Repository: TeamNewPipe/NewPipe Branch: dev Commit: 515bb6e94de5 Files: 3907 Total size: 9.8 MB Directory structure: gitextract_066mfw7c/ ├── .editorconfig ├── .github/ │ ├── CONTRIBUTING.md │ ├── DISCUSSION_TEMPLATE/ │ │ └── questions.yml │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ ├── PULL_REQUEST_TEMPLATE.md │ ├── changed-lines-count-labeler.yml │ └── workflows/ │ ├── backport-pr.yml │ ├── build-release-apk.yml │ ├── ci.yml │ ├── image-minimizer.js │ ├── image-minimizer.yml │ ├── no-response.yml │ └── pr-labeler.yml ├── .gitignore ├── LICENSE ├── README.md ├── app/ │ ├── build.gradle.kts │ ├── lint.xml │ ├── proguard-rules.pro │ ├── sampledata/ │ │ └── channels.json │ ├── schemas/ │ │ └── org.schabi.newpipe.database.AppDatabase/ │ │ ├── 2.json │ │ ├── 3.json │ │ ├── 4.json │ │ ├── 5.json │ │ ├── 6.json │ │ ├── 7.json │ │ ├── 8.json │ │ └── 9.json │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── org/ │ │ └── schabi/ │ │ └── newpipe/ │ │ ├── database/ │ │ │ ├── DatabaseMigrationTest.kt │ │ │ └── FeedDAOTest.kt │ │ ├── error/ │ │ │ └── ErrorInfoTest.java │ │ ├── local/ │ │ │ ├── history/ │ │ │ │ └── HistoryRecordManagerTest.kt │ │ │ ├── playlist/ │ │ │ │ └── LocalPlaylistManagerTest.kt │ │ │ └── subscription/ │ │ │ └── SubscriptionManagerTest.java │ │ ├── testUtil/ │ │ │ ├── TestDatabase.kt │ │ │ └── TrampolineSchedulerRule.kt │ │ └── util/ │ │ └── StreamItemAdapterTest.kt │ ├── debug/ │ │ ├── AndroidManifest.xml │ │ └── java/ │ │ └── org/ │ │ └── schabi/ │ │ └── newpipe/ │ │ ├── DebugApp.kt │ │ └── settings/ │ │ └── DebugSettingsBVDLeakCanary.java │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── assets/ │ │ │ ├── apache2.html │ │ │ ├── epl1.html │ │ │ ├── gpl_3.html │ │ │ ├── mit.html │ │ │ ├── mpl2.html │ │ │ └── po_token.html │ │ ├── java/ │ │ │ ├── androidx/ │ │ │ │ └── fragment/ │ │ │ │ └── app/ │ │ │ │ └── FragmentStatePagerAdapterMenuWorkaround.java │ │ │ ├── com/ │ │ │ │ └── google/ │ │ │ │ └── android/ │ │ │ │ └── material/ │ │ │ │ └── appbar/ │ │ │ │ └── FlingBehavior.java │ │ │ ├── org/ │ │ │ │ ├── apache/ │ │ │ │ │ └── commons/ │ │ │ │ │ └── text/ │ │ │ │ │ └── similarity/ │ │ │ │ │ └── FuzzyScore.java │ │ │ │ └── schabi/ │ │ │ │ └── newpipe/ │ │ │ │ ├── App.kt │ │ │ │ ├── BaseFragment.java │ │ │ │ ├── DownloaderImpl.java │ │ │ │ ├── ExitActivity.kt │ │ │ │ ├── MainActivity.java │ │ │ │ ├── NewPipeDatabase.kt │ │ │ │ ├── NewVersionWorker.kt │ │ │ │ ├── PanicResponderActivity.java │ │ │ │ ├── QueueItemMenuUtil.java │ │ │ │ ├── RouterActivity.java │ │ │ │ ├── about/ │ │ │ │ │ ├── AboutActivity.kt │ │ │ │ │ ├── License.kt │ │ │ │ │ ├── LicenseFragment.kt │ │ │ │ │ ├── LicenseFragmentHelper.kt │ │ │ │ │ ├── SoftwareComponent.kt │ │ │ │ │ └── StandardLicenses.kt │ │ │ │ ├── database/ │ │ │ │ │ ├── AppDatabase.kt │ │ │ │ │ ├── BasicDAO.kt │ │ │ │ │ ├── Converters.kt │ │ │ │ │ ├── LocalItem.kt │ │ │ │ │ ├── Migrations.kt │ │ │ │ │ ├── feed/ │ │ │ │ │ │ ├── dao/ │ │ │ │ │ │ │ ├── FeedDAO.kt │ │ │ │ │ │ │ └── FeedGroupDAO.kt │ │ │ │ │ │ └── model/ │ │ │ │ │ │ ├── FeedEntity.kt │ │ │ │ │ │ ├── FeedGroupEntity.kt │ │ │ │ │ │ ├── FeedGroupSubscriptionEntity.kt │ │ │ │ │ │ └── FeedLastUpdatedEntity.kt │ │ │ │ │ ├── history/ │ │ │ │ │ │ ├── dao/ │ │ │ │ │ │ │ ├── SearchHistoryDAO.kt │ │ │ │ │ │ │ └── StreamHistoryDAO.kt │ │ │ │ │ │ └── model/ │ │ │ │ │ │ ├── SearchHistoryEntry.kt │ │ │ │ │ │ ├── StreamHistoryEntity.kt │ │ │ │ │ │ └── StreamHistoryEntry.kt │ │ │ │ │ ├── playlist/ │ │ │ │ │ │ ├── PlaylistDuplicatesEntry.kt │ │ │ │ │ │ ├── PlaylistLocalItem.kt │ │ │ │ │ │ ├── PlaylistMetadataEntry.kt │ │ │ │ │ │ ├── PlaylistStreamEntry.kt │ │ │ │ │ │ ├── dao/ │ │ │ │ │ │ │ ├── PlaylistDAO.kt │ │ │ │ │ │ │ ├── PlaylistRemoteDAO.kt │ │ │ │ │ │ │ └── PlaylistStreamDAO.kt │ │ │ │ │ │ └── model/ │ │ │ │ │ │ ├── PlaylistEntity.kt │ │ │ │ │ │ ├── PlaylistRemoteEntity.kt │ │ │ │ │ │ └── PlaylistStreamEntity.kt │ │ │ │ │ ├── stream/ │ │ │ │ │ │ ├── StreamStatisticsEntry.kt │ │ │ │ │ │ ├── StreamWithState.kt │ │ │ │ │ │ ├── dao/ │ │ │ │ │ │ │ ├── StreamDAO.kt │ │ │ │ │ │ │ └── StreamStateDAO.kt │ │ │ │ │ │ └── model/ │ │ │ │ │ │ ├── StreamEntity.kt │ │ │ │ │ │ └── StreamStateEntity.kt │ │ │ │ │ └── subscription/ │ │ │ │ │ ├── NotificationMode.kt │ │ │ │ │ ├── SubscriptionDAO.kt │ │ │ │ │ └── SubscriptionEntity.kt │ │ │ │ ├── download/ │ │ │ │ │ ├── DownloadActivity.java │ │ │ │ │ ├── DownloadDialog.java │ │ │ │ │ └── LoadingDialog.java │ │ │ │ ├── error/ │ │ │ │ │ ├── AcraReportSender.java │ │ │ │ │ ├── AcraReportSenderFactory.java │ │ │ │ │ ├── ErrorActivity.kt │ │ │ │ │ ├── ErrorInfo.kt │ │ │ │ │ ├── ErrorPanelHelper.kt │ │ │ │ │ ├── ErrorUtil.kt │ │ │ │ │ ├── ReCaptchaActivity.java │ │ │ │ │ └── UserAction.kt │ │ │ │ ├── fragments/ │ │ │ │ │ ├── BackPressable.java │ │ │ │ │ ├── BaseStateFragment.java │ │ │ │ │ ├── BlankFragment.java │ │ │ │ │ ├── EmptyFragment.java │ │ │ │ │ ├── MainFragment.java │ │ │ │ │ ├── OnScrollBelowItemsListener.java │ │ │ │ │ ├── ViewContract.java │ │ │ │ │ ├── detail/ │ │ │ │ │ │ ├── BaseDescriptionFragment.java │ │ │ │ │ │ ├── DescriptionFragment.java │ │ │ │ │ │ ├── StackItem.java │ │ │ │ │ │ ├── TabAdapter.java │ │ │ │ │ │ ├── VideoDetailFragment.java │ │ │ │ │ │ └── VideoDetailPlayerCrasher.java │ │ │ │ │ └── list/ │ │ │ │ │ ├── BaseListFragment.java │ │ │ │ │ ├── BaseListInfoFragment.java │ │ │ │ │ ├── ListViewContract.java │ │ │ │ │ ├── channel/ │ │ │ │ │ │ ├── ChannelAboutFragment.java │ │ │ │ │ │ ├── ChannelFragment.java │ │ │ │ │ │ └── ChannelTabFragment.java │ │ │ │ │ ├── comments/ │ │ │ │ │ │ ├── CommentRepliesFragment.java │ │ │ │ │ │ ├── CommentRepliesInfo.java │ │ │ │ │ │ └── CommentsFragment.java │ │ │ │ │ ├── kiosk/ │ │ │ │ │ │ ├── DefaultKioskFragment.java │ │ │ │ │ │ └── KioskFragment.java │ │ │ │ │ ├── playlist/ │ │ │ │ │ │ ├── PlaylistControlViewHolder.java │ │ │ │ │ │ └── PlaylistFragment.java │ │ │ │ │ ├── search/ │ │ │ │ │ │ ├── SearchFragment.java │ │ │ │ │ │ ├── SuggestionItem.kt │ │ │ │ │ │ └── SuggestionListAdapter.kt │ │ │ │ │ └── videos/ │ │ │ │ │ ├── RelatedItemsFragment.java │ │ │ │ │ └── RelatedItemsInfo.java │ │ │ │ ├── info_list/ │ │ │ │ │ ├── InfoItemBuilder.kt │ │ │ │ │ ├── InfoListAdapter.java │ │ │ │ │ ├── ItemViewMode.kt │ │ │ │ │ ├── StreamSegmentAdapter.kt │ │ │ │ │ ├── StreamSegmentItem.kt │ │ │ │ │ ├── dialog/ │ │ │ │ │ │ ├── InfoItemDialog.java │ │ │ │ │ │ ├── StreamDialogDefaultEntry.java │ │ │ │ │ │ └── StreamDialogEntry.java │ │ │ │ │ └── holder/ │ │ │ │ │ ├── ChannelCardInfoItemHolder.java │ │ │ │ │ ├── ChannelGridInfoItemHolder.java │ │ │ │ │ ├── ChannelInfoItemHolder.java │ │ │ │ │ ├── ChannelMiniInfoItemHolder.java │ │ │ │ │ ├── CommentInfoItemHolder.java │ │ │ │ │ ├── InfoItemHolder.java │ │ │ │ │ ├── PlaylistCardInfoItemHolder.java │ │ │ │ │ ├── PlaylistGridInfoItemHolder.java │ │ │ │ │ ├── PlaylistInfoItemHolder.java │ │ │ │ │ ├── PlaylistMiniInfoItemHolder.java │ │ │ │ │ ├── StreamCardInfoItemHolder.java │ │ │ │ │ ├── StreamGridInfoItemHolder.java │ │ │ │ │ ├── StreamInfoItemHolder.java │ │ │ │ │ └── StreamMiniInfoItemHolder.java │ │ │ │ ├── ktx/ │ │ │ │ │ ├── Bitmap.kt │ │ │ │ │ ├── Bundle.kt │ │ │ │ │ ├── SharedPreferences.kt │ │ │ │ │ ├── TextView.kt │ │ │ │ │ ├── Throwable.kt │ │ │ │ │ └── View.kt │ │ │ │ ├── local/ │ │ │ │ │ ├── BaseLocalListFragment.java │ │ │ │ │ ├── HeaderFooterHolder.java │ │ │ │ │ ├── LocalItemBuilder.java │ │ │ │ │ ├── LocalItemListAdapter.java │ │ │ │ │ ├── bookmark/ │ │ │ │ │ │ ├── BookmarkFragment.java │ │ │ │ │ │ └── MergedPlaylistManager.java │ │ │ │ │ ├── dialog/ │ │ │ │ │ │ ├── PlaylistAppendDialog.java │ │ │ │ │ │ ├── PlaylistCreationDialog.java │ │ │ │ │ │ └── PlaylistDialog.java │ │ │ │ │ ├── feed/ │ │ │ │ │ │ ├── FeedDatabaseManager.kt │ │ │ │ │ │ ├── FeedFragment.kt │ │ │ │ │ │ ├── FeedState.kt │ │ │ │ │ │ ├── FeedViewModel.kt │ │ │ │ │ │ ├── item/ │ │ │ │ │ │ │ └── StreamItem.kt │ │ │ │ │ │ ├── notifications/ │ │ │ │ │ │ │ ├── NotificationHelper.kt │ │ │ │ │ │ │ ├── NotificationWorker.kt │ │ │ │ │ │ │ └── ScheduleOptions.kt │ │ │ │ │ │ └── service/ │ │ │ │ │ │ ├── FeedEventManager.kt │ │ │ │ │ │ ├── FeedLoadManager.kt │ │ │ │ │ │ ├── FeedLoadService.kt │ │ │ │ │ │ ├── FeedLoadState.kt │ │ │ │ │ │ ├── FeedResultsHolder.kt │ │ │ │ │ │ └── FeedUpdateInfo.kt │ │ │ │ │ ├── history/ │ │ │ │ │ │ ├── HistoryRecordManager.java │ │ │ │ │ │ └── StatisticsPlaylistFragment.java │ │ │ │ │ ├── holder/ │ │ │ │ │ │ ├── LocalBookmarkPlaylistItemHolder.java │ │ │ │ │ │ ├── LocalItemHolder.java │ │ │ │ │ │ ├── LocalPlaylistCardItemHolder.java │ │ │ │ │ │ ├── LocalPlaylistGridItemHolder.java │ │ │ │ │ │ ├── LocalPlaylistItemHolder.java │ │ │ │ │ │ ├── LocalPlaylistStreamCardItemHolder.java │ │ │ │ │ │ ├── LocalPlaylistStreamGridItemHolder.java │ │ │ │ │ │ ├── LocalPlaylistStreamItemHolder.java │ │ │ │ │ │ ├── LocalStatisticStreamCardItemHolder.java │ │ │ │ │ │ ├── LocalStatisticStreamGridItemHolder.java │ │ │ │ │ │ ├── LocalStatisticStreamItemHolder.java │ │ │ │ │ │ ├── PlaylistItemHolder.java │ │ │ │ │ │ ├── RemoteBookmarkPlaylistItemHolder.java │ │ │ │ │ │ ├── RemotePlaylistCardItemHolder.java │ │ │ │ │ │ ├── RemotePlaylistGridItemHolder.java │ │ │ │ │ │ └── RemotePlaylistItemHolder.java │ │ │ │ │ ├── playlist/ │ │ │ │ │ │ ├── ExportPlaylist.kt │ │ │ │ │ │ ├── LocalPlaylistFragment.java │ │ │ │ │ │ ├── LocalPlaylistManager.java │ │ │ │ │ │ ├── PlayListShareMode.kt │ │ │ │ │ │ └── RemotePlaylistManager.kt │ │ │ │ │ └── subscription/ │ │ │ │ │ ├── FeedGroupIcon.kt │ │ │ │ │ ├── ImportConfirmationDialog.java │ │ │ │ │ ├── SubscriptionFragment.kt │ │ │ │ │ ├── SubscriptionManager.kt │ │ │ │ │ ├── SubscriptionViewModel.kt │ │ │ │ │ ├── SubscriptionsImportExportHelper.kt │ │ │ │ │ ├── SubscriptionsImportFragment.java │ │ │ │ │ ├── dialog/ │ │ │ │ │ │ ├── FeedGroupDialog.kt │ │ │ │ │ │ ├── FeedGroupDialogViewModel.kt │ │ │ │ │ │ ├── FeedGroupReorderDialog.kt │ │ │ │ │ │ └── FeedGroupReorderDialogViewModel.kt │ │ │ │ │ ├── item/ │ │ │ │ │ │ ├── ChannelItem.kt │ │ │ │ │ │ ├── FeedGroupAddNewGridItem.kt │ │ │ │ │ │ ├── FeedGroupAddNewItem.kt │ │ │ │ │ │ ├── FeedGroupCardGridItem.kt │ │ │ │ │ │ ├── FeedGroupCardItem.kt │ │ │ │ │ │ ├── FeedGroupCarouselItem.kt │ │ │ │ │ │ ├── FeedGroupReorderItem.kt │ │ │ │ │ │ ├── GroupsHeader.kt │ │ │ │ │ │ ├── Header.kt │ │ │ │ │ │ ├── ImportSubscriptionsHintPlaceholderItem.kt │ │ │ │ │ │ ├── PickerIconItem.kt │ │ │ │ │ │ └── PickerSubscriptionItem.kt │ │ │ │ │ └── workers/ │ │ │ │ │ ├── ImportExportJsonHelper.kt │ │ │ │ │ ├── SubscriptionData.kt │ │ │ │ │ ├── SubscriptionExportWorker.kt │ │ │ │ │ └── SubscriptionImportWorker.kt │ │ │ │ ├── player/ │ │ │ │ │ ├── AudioServiceLeakFix.java │ │ │ │ │ ├── PlayQueueActivity.java │ │ │ │ │ ├── Player.java │ │ │ │ │ ├── PlayerIntentType.kt │ │ │ │ │ ├── PlayerService.java │ │ │ │ │ ├── PlayerType.kt │ │ │ │ │ ├── datasource/ │ │ │ │ │ │ ├── NonUriHlsDataSourceFactory.java │ │ │ │ │ │ └── YoutubeHttpDataSource.java │ │ │ │ │ ├── event/ │ │ │ │ │ │ ├── OnKeyDownListener.java │ │ │ │ │ │ ├── PlayerEventListener.java │ │ │ │ │ │ ├── PlayerServiceEventListener.java │ │ │ │ │ │ └── PlayerServiceExtendedEventListener.java │ │ │ │ │ ├── gesture/ │ │ │ │ │ │ ├── BasePlayerGestureListener.kt │ │ │ │ │ │ ├── CustomBottomSheetBehavior.java │ │ │ │ │ │ ├── DisplayPortion.kt │ │ │ │ │ │ ├── DoubleTapListener.kt │ │ │ │ │ │ ├── MainPlayerGestureListener.kt │ │ │ │ │ │ └── PopupPlayerGestureListener.kt │ │ │ │ │ ├── helper/ │ │ │ │ │ │ ├── AudioReactor.java │ │ │ │ │ │ ├── CacheFactory.java │ │ │ │ │ │ ├── CustomMediaCodecVideoRenderer.java │ │ │ │ │ │ ├── CustomRenderersFactory.java │ │ │ │ │ │ ├── LoadController.java │ │ │ │ │ │ ├── LockManager.java │ │ │ │ │ │ ├── PlaybackParameterDialog.java │ │ │ │ │ │ ├── PlayerDataSource.java │ │ │ │ │ │ ├── PlayerHelper.java │ │ │ │ │ │ ├── PlayerHolder.java │ │ │ │ │ │ ├── PlayerSemitoneHelper.java │ │ │ │ │ │ └── YoutubeDashLiveManifestParser.java │ │ │ │ │ ├── mediabrowser/ │ │ │ │ │ │ ├── MediaBrowserCommon.kt │ │ │ │ │ │ ├── MediaBrowserImpl.kt │ │ │ │ │ │ ├── MediaBrowserPlaybackPreparer.kt │ │ │ │ │ │ └── PackageValidator.kt │ │ │ │ │ ├── mediaitem/ │ │ │ │ │ │ ├── ExceptionTag.java │ │ │ │ │ │ ├── MediaItemTag.java │ │ │ │ │ │ ├── PlaceholderTag.java │ │ │ │ │ │ └── StreamInfoTag.java │ │ │ │ │ ├── mediasession/ │ │ │ │ │ │ ├── MediaSessionPlayerUi.java │ │ │ │ │ │ ├── PlayQueueNavigator.java │ │ │ │ │ │ └── SessionConnectorActionProvider.java │ │ │ │ │ ├── mediasource/ │ │ │ │ │ │ ├── FailedMediaSource.java │ │ │ │ │ │ ├── LoadedMediaSource.java │ │ │ │ │ │ ├── ManagedMediaSource.java │ │ │ │ │ │ ├── ManagedMediaSourcePlaylist.java │ │ │ │ │ │ └── PlaceholderMediaSource.java │ │ │ │ │ ├── notification/ │ │ │ │ │ │ ├── NotificationActionData.java │ │ │ │ │ │ ├── NotificationConstants.java │ │ │ │ │ │ ├── NotificationPlayerUi.java │ │ │ │ │ │ └── NotificationUtil.java │ │ │ │ │ ├── playback/ │ │ │ │ │ │ ├── MediaSourceManager.java │ │ │ │ │ │ ├── PlaybackListener.java │ │ │ │ │ │ └── SurfaceHolderCallback.java │ │ │ │ │ ├── playqueue/ │ │ │ │ │ │ ├── AbstractInfoPlayQueue.java │ │ │ │ │ │ ├── ChannelTabPlayQueue.java │ │ │ │ │ │ ├── PlayQueue.java │ │ │ │ │ │ ├── PlayQueueAdapter.java │ │ │ │ │ │ ├── PlayQueueEvent.kt │ │ │ │ │ │ ├── PlayQueueItem.java │ │ │ │ │ │ ├── PlayQueueItemBuilder.java │ │ │ │ │ │ ├── PlayQueueItemHolder.java │ │ │ │ │ │ ├── PlayQueueItemTouchCallback.java │ │ │ │ │ │ ├── PlaylistPlayQueue.java │ │ │ │ │ │ └── SinglePlayQueue.java │ │ │ │ │ ├── resolver/ │ │ │ │ │ │ ├── AudioPlaybackResolver.java │ │ │ │ │ │ ├── PlaybackResolver.java │ │ │ │ │ │ ├── Resolver.java │ │ │ │ │ │ └── VideoPlaybackResolver.java │ │ │ │ │ ├── seekbarpreview/ │ │ │ │ │ │ ├── SeekbarPreviewThumbnailHelper.java │ │ │ │ │ │ └── SeekbarPreviewThumbnailHolder.java │ │ │ │ │ └── ui/ │ │ │ │ │ ├── BackgroundPlayerUi.java │ │ │ │ │ ├── MainPlayerUi.java │ │ │ │ │ ├── PlayerUi.java │ │ │ │ │ ├── PlayerUiList.java │ │ │ │ │ ├── PopupPlayerUi.java │ │ │ │ │ └── VideoPlayerUi.java │ │ │ │ ├── settings/ │ │ │ │ │ ├── AppearanceSettingsFragment.java │ │ │ │ │ ├── BackupRestoreSettingsFragment.java │ │ │ │ │ ├── BasePreferenceFragment.java │ │ │ │ │ ├── ContentSettingsFragment.java │ │ │ │ │ ├── DebugSettingsFragment.java │ │ │ │ │ ├── DownloadSettingsFragment.java │ │ │ │ │ ├── ExoPlayerSettingsFragment.java │ │ │ │ │ ├── HistorySettingsFragment.java │ │ │ │ │ ├── MainSettingsFragment.java │ │ │ │ │ ├── NewPipeSettings.java │ │ │ │ │ ├── NotificationSettingsFragment.kt │ │ │ │ │ ├── NotificationsSettingsFragment.kt │ │ │ │ │ ├── PeertubeInstanceListFragment.java │ │ │ │ │ ├── PlayerNotificationSettingsFragment.kt │ │ │ │ │ ├── SelectChannelFragment.java │ │ │ │ │ ├── SelectFeedGroupFragment.java │ │ │ │ │ ├── SelectKioskFragment.java │ │ │ │ │ ├── SelectPlaylistFragment.java │ │ │ │ │ ├── SettingsActivity.java │ │ │ │ │ ├── SettingsResourceRegistry.java │ │ │ │ │ ├── UpdateSettingsFragment.java │ │ │ │ │ ├── VideoAudioSettingsFragment.java │ │ │ │ │ ├── custom/ │ │ │ │ │ │ ├── DurationListPreference.kt │ │ │ │ │ │ ├── NotificationActionsPreference.java │ │ │ │ │ │ └── NotificationSlot.java │ │ │ │ │ ├── export/ │ │ │ │ │ │ ├── BackupFileLocator.kt │ │ │ │ │ │ ├── ImportExportManager.kt │ │ │ │ │ │ └── PreferencesObjectInputStream.kt │ │ │ │ │ ├── migration/ │ │ │ │ │ │ ├── MigrationManager.java │ │ │ │ │ │ └── SettingMigrations.java │ │ │ │ │ ├── notifications/ │ │ │ │ │ │ ├── NotificationModeConfigAdapter.kt │ │ │ │ │ │ └── NotificationModeConfigFragment.kt │ │ │ │ │ ├── preferencesearch/ │ │ │ │ │ │ ├── PreferenceFuzzySearchFunction.java │ │ │ │ │ │ ├── PreferenceParser.java │ │ │ │ │ │ ├── PreferenceSearchAdapter.java │ │ │ │ │ │ ├── PreferenceSearchConfiguration.java │ │ │ │ │ │ ├── PreferenceSearchFragment.java │ │ │ │ │ │ ├── PreferenceSearchItem.kt │ │ │ │ │ │ ├── PreferenceSearchResultHighlighter.java │ │ │ │ │ │ ├── PreferenceSearchResultListener.kt │ │ │ │ │ │ ├── PreferenceSearcher.java │ │ │ │ │ │ └── package-info.java │ │ │ │ │ └── tabs/ │ │ │ │ │ ├── AddTabDialog.java │ │ │ │ │ ├── ChooseTabsFragment.java │ │ │ │ │ ├── Tab.java │ │ │ │ │ ├── TabsJsonHelper.java │ │ │ │ │ └── TabsManager.java │ │ │ │ ├── streams/ │ │ │ │ │ ├── DataReader.java │ │ │ │ │ ├── Mp4DashReader.java │ │ │ │ │ ├── Mp4FromDashWriter.java │ │ │ │ │ ├── OggFromWebMWriter.java │ │ │ │ │ ├── SrtFromTtmlWriter.java │ │ │ │ │ ├── WebMReader.java │ │ │ │ │ ├── WebMWriter.java │ │ │ │ │ └── io/ │ │ │ │ │ ├── NoFileManagerSafeGuard.java │ │ │ │ │ ├── SharpInputStream.java │ │ │ │ │ ├── SharpOutputStream.java │ │ │ │ │ ├── SharpStream.java │ │ │ │ │ ├── StoredDirectoryHelper.java │ │ │ │ │ └── StoredFileHelper.java │ │ │ │ ├── util/ │ │ │ │ │ ├── AudioTrackAdapter.java │ │ │ │ │ ├── BridgeStateSaverInitializer.java │ │ │ │ │ ├── ChannelTabHelper.java │ │ │ │ │ ├── Constants.kt │ │ │ │ │ ├── DependentPreferenceHelper.kt │ │ │ │ │ ├── DeviceUtils.java │ │ │ │ │ ├── ExtractorHelper.java │ │ │ │ │ ├── FallbackViewHolder.java │ │ │ │ │ ├── FilePickerActivityHelper.java │ │ │ │ │ ├── FilenameUtils.kt │ │ │ │ │ ├── InfoCache.java │ │ │ │ │ ├── KeyboardUtil.java │ │ │ │ │ ├── KioskTranslator.kt │ │ │ │ │ ├── ListHelper.java │ │ │ │ │ ├── Localization.java │ │ │ │ │ ├── NavigationHelper.java │ │ │ │ │ ├── NewPipeTextViewHelper.kt │ │ │ │ │ ├── OnClickGesture.java │ │ │ │ │ ├── PeertubeHelper.kt │ │ │ │ │ ├── PermissionHelper.java │ │ │ │ │ ├── PlayButtonHelper.kt │ │ │ │ │ ├── ReleaseVersionUtil.kt │ │ │ │ │ ├── SavedState.kt │ │ │ │ │ ├── SecondaryStreamHelper.java │ │ │ │ │ ├── SerializedCache.java │ │ │ │ │ ├── ServiceHelper.kt │ │ │ │ │ ├── SimpleOnSeekBarChangeListener.kt │ │ │ │ │ ├── SliderStrategy.java │ │ │ │ │ ├── SparseItemUtil.java │ │ │ │ │ ├── StateSaver.java │ │ │ │ │ ├── StreamItemAdapter.java │ │ │ │ │ ├── StreamTypeUtil.kt │ │ │ │ │ ├── ThemeHelper.java │ │ │ │ │ ├── ZipHelper.java │ │ │ │ │ ├── debounce/ │ │ │ │ │ │ ├── DebounceSavable.java │ │ │ │ │ │ └── DebounceSaver.java │ │ │ │ │ ├── external_communication/ │ │ │ │ │ │ ├── KoreUtils.java │ │ │ │ │ │ └── ShareUtils.java │ │ │ │ │ ├── image/ │ │ │ │ │ │ ├── CoilHelper.kt │ │ │ │ │ │ ├── ImageStrategy.kt │ │ │ │ │ │ └── PreferredImageQuality.kt │ │ │ │ │ ├── potoken/ │ │ │ │ │ │ ├── JavaScriptUtil.kt │ │ │ │ │ │ ├── PoTokenException.kt │ │ │ │ │ │ ├── PoTokenGenerator.kt │ │ │ │ │ │ ├── PoTokenProviderImpl.kt │ │ │ │ │ │ └── PoTokenWebView.kt │ │ │ │ │ ├── text/ │ │ │ │ │ │ ├── HashtagLongPressClickableSpan.java │ │ │ │ │ │ ├── InternalUrlsHandler.java │ │ │ │ │ │ ├── LongPressClickableSpan.java │ │ │ │ │ │ ├── LongPressLinkMovementMethod.java │ │ │ │ │ │ ├── TextEllipsizer.java │ │ │ │ │ │ ├── TextLinkifier.java │ │ │ │ │ │ ├── TextViewExtensions.kt │ │ │ │ │ │ ├── TimestampExtractor.java │ │ │ │ │ │ ├── TimestampLongPressClickableSpan.kt │ │ │ │ │ │ ├── TouchUtils.java │ │ │ │ │ │ └── UrlLongPressClickableSpan.java │ │ │ │ │ └── urlfinder/ │ │ │ │ │ ├── PatternsCompat.java │ │ │ │ │ └── UrlFinder.kt │ │ │ │ └── views/ │ │ │ │ ├── AnimatedProgressBar.java │ │ │ │ ├── CustomCollapsingToolbarLayout.java │ │ │ │ ├── ExpandableSurfaceView.java │ │ │ │ ├── FocusAwareCoordinator.java │ │ │ │ ├── FocusAwareDrawerLayout.java │ │ │ │ ├── FocusAwareSeekBar.java │ │ │ │ ├── FocusOverlayView.java │ │ │ │ ├── NewPipeEditText.java │ │ │ │ ├── NewPipeRecyclerView.java │ │ │ │ ├── NewPipeTextView.java │ │ │ │ ├── ScrollableTabLayout.java │ │ │ │ ├── SimpleWindowCallback.kt │ │ │ │ ├── SuperScrollLayoutManager.java │ │ │ │ └── player/ │ │ │ │ ├── CircleClipTapView.kt │ │ │ │ ├── PlayerFastSeekOverlay.kt │ │ │ │ └── SecondsView.kt │ │ │ └── us/ │ │ │ └── shandian/ │ │ │ └── giga/ │ │ │ ├── get/ │ │ │ │ ├── DownloadInitializer.java │ │ │ │ ├── DownloadMission.java │ │ │ │ ├── DownloadMissionRecover.java │ │ │ │ ├── DownloadRunnable.java │ │ │ │ ├── DownloadRunnableFallback.java │ │ │ │ ├── FinishedMission.java │ │ │ │ ├── Mission.java │ │ │ │ ├── MissionRecoveryInfo.kt │ │ │ │ └── sqlite/ │ │ │ │ └── FinishedMissionStore.java │ │ │ ├── io/ │ │ │ │ ├── ChunkFileInputStream.java │ │ │ │ ├── CircularFileWriter.java │ │ │ │ ├── FileStream.java │ │ │ │ ├── FileStreamSAF.java │ │ │ │ └── ProgressReport.java │ │ │ ├── postprocessing/ │ │ │ │ ├── M4aNoDash.java │ │ │ │ ├── Mp4FromDashMuxer.java │ │ │ │ ├── OggFromWebmDemuxer.java │ │ │ │ ├── Postprocessing.java │ │ │ │ ├── TtmlConverter.java │ │ │ │ └── WebMMuxer.java │ │ │ ├── service/ │ │ │ │ ├── DownloadManager.java │ │ │ │ ├── DownloadManagerService.java │ │ │ │ └── MissionState.java │ │ │ ├── ui/ │ │ │ │ ├── adapter/ │ │ │ │ │ └── MissionAdapter.java │ │ │ │ ├── common/ │ │ │ │ │ ├── Deleter.java │ │ │ │ │ ├── ProgressDrawable.java │ │ │ │ │ └── ToolbarActivity.java │ │ │ │ └── fragment/ │ │ │ │ └── MissionsFragment.java │ │ │ └── util/ │ │ │ └── Utility.java │ │ └── res/ │ │ ├── animator/ │ │ │ ├── custom_fade_in.xml │ │ │ └── custom_fade_out.xml │ │ ├── drawable/ │ │ │ ├── background_oval_black_transparent.xml │ │ │ ├── dashed_border_black.xml │ │ │ ├── dashed_border_dark.xml │ │ │ ├── dashed_border_light.xml │ │ │ ├── drawer_header_bottom_background.xml │ │ │ ├── ic_add.xml │ │ │ ├── ic_add_circle_outline.xml │ │ │ ├── ic_apps.xml │ │ │ ├── ic_arrow_back.xml │ │ │ ├── ic_arrow_drop_down.xml │ │ │ ├── ic_arrow_drop_up.xml │ │ │ ├── ic_art_track.xml │ │ │ ├── ic_asterisk.xml │ │ │ ├── ic_attach_money.xml │ │ │ ├── ic_backup.xml │ │ │ ├── ic_bookmark.xml │ │ │ ├── ic_bookmark_white.xml │ │ │ ├── ic_brightness_high.xml │ │ │ ├── ic_brightness_low.xml │ │ │ ├── ic_brightness_medium.xml │ │ │ ├── ic_bug_report.xml │ │ │ ├── ic_campaign.xml │ │ │ ├── ic_cast.xml │ │ │ ├── ic_checklist.xml │ │ │ ├── ic_child_care.xml │ │ │ ├── ic_circle.xml │ │ │ ├── ic_close.xml │ │ │ ├── ic_cloud.xml │ │ │ ├── ic_cloud_download.xml │ │ │ ├── ic_comment.xml │ │ │ ├── ic_computer.xml │ │ │ ├── ic_crop_portrait.xml │ │ │ ├── ic_delete.xml │ │ │ ├── ic_description.xml │ │ │ ├── ic_directions_bike.xml │ │ │ ├── ic_directions_car.xml │ │ │ ├── ic_done.xml │ │ │ ├── ic_drag_handle.xml │ │ │ ├── ic_expand_more.xml │ │ │ ├── ic_explore.xml │ │ │ ├── ic_fastfood.xml │ │ │ ├── ic_favorite.xml │ │ │ ├── ic_file_download.xml │ │ │ ├── ic_filter_list.xml │ │ │ ├── ic_fitness_center.xml │ │ │ ├── ic_fullscreen.xml │ │ │ ├── ic_fullscreen_exit.xml │ │ │ ├── ic_headset.xml │ │ │ ├── ic_headset_shadow.xml │ │ │ ├── ic_heart.xml │ │ │ ├── ic_help.xml │ │ │ ├── ic_history.xml │ │ │ ├── ic_history_white.xml │ │ │ ├── ic_home.xml │ │ │ ├── ic_hourglass_top.xml │ │ │ ├── ic_info_outline.xml │ │ │ ├── ic_insert_emoticon.xml │ │ │ ├── ic_language.xml │ │ │ ├── ic_list.xml │ │ │ ├── ic_live_tv.xml │ │ │ ├── ic_menu_book.xml │ │ │ ├── ic_mic.xml │ │ │ ├── ic_more_vert.xml │ │ │ ├── ic_motorcycle.xml │ │ │ ├── ic_movie.xml │ │ │ ├── ic_music_note.xml │ │ │ ├── ic_next.xml │ │ │ ├── ic_notifications.xml │ │ │ ├── ic_palette.xml │ │ │ ├── ic_pause.xml │ │ │ ├── ic_people.xml │ │ │ ├── ic_person.xml │ │ │ ├── ic_pets.xml │ │ │ ├── ic_picture_in_picture.xml │ │ │ ├── ic_pin.xml │ │ │ ├── ic_placeholder_bandcamp.xml │ │ │ ├── ic_placeholder_media_ccc.xml │ │ │ ├── ic_placeholder_peertube.xml │ │ │ ├── ic_play_arrow.xml │ │ │ ├── ic_play_arrow_shadow.xml │ │ │ ├── ic_play_seek_triangle.xml │ │ │ ├── ic_playlist_add.xml │ │ │ ├── ic_playlist_add_check.xml │ │ │ ├── ic_playlist_play.xml │ │ │ ├── ic_podcasts.xml │ │ │ ├── ic_previous.xml │ │ │ ├── ic_public.xml │ │ │ ├── ic_radio.xml │ │ │ ├── ic_refresh.xml │ │ │ ├── ic_repeat.xml │ │ │ ├── ic_replay.xml │ │ │ ├── ic_restaurant.xml │ │ │ ├── ic_rss_feed.xml │ │ │ ├── ic_save.xml │ │ │ ├── ic_school.xml │ │ │ ├── ic_search.xml │ │ │ ├── ic_search_add.xml │ │ │ ├── ic_select_all.xml │ │ │ ├── ic_settings.xml │ │ │ ├── ic_settings_backup_restore.xml │ │ │ ├── ic_share.xml │ │ │ ├── ic_shopping_cart.xml │ │ │ ├── ic_shuffle.xml │ │ │ ├── ic_smart_display.xml │ │ │ ├── ic_sort.xml │ │ │ ├── ic_stars.xml │ │ │ ├── ic_subscriptions.xml │ │ │ ├── ic_subtitles.xml │ │ │ ├── ic_telescope.xml │ │ │ ├── ic_thumb_down.xml │ │ │ ├── ic_thumb_up.xml │ │ │ ├── ic_trending_up.xml │ │ │ ├── ic_tv.xml │ │ │ ├── ic_videogame_asset.xml │ │ │ ├── ic_visibility_on.xml │ │ │ ├── ic_volume_down.xml │ │ │ ├── ic_volume_mute.xml │ │ │ ├── ic_volume_off.xml │ │ │ ├── ic_volume_up.xml │ │ │ ├── ic_watch_later.xml │ │ │ ├── ic_wb_sunny.xml │ │ │ ├── ic_whatshot.xml │ │ │ ├── ic_work.xml │ │ │ ├── not_available_monkey.xml │ │ │ ├── placeholder_person.xml │ │ │ ├── placeholder_thumbnail_playlist.xml │ │ │ ├── placeholder_thumbnail_video.xml │ │ │ ├── player_controls_background.xml │ │ │ ├── player_controls_top_background.xml │ │ │ ├── progress_circular_white.xml │ │ │ ├── progress_soundcloud_horizontal_dark.xml │ │ │ ├── progress_soundcloud_horizontal_light.xml │ │ │ ├── progress_youtube_horizontal_dark.xml │ │ │ ├── progress_youtube_horizontal_light.xml │ │ │ ├── selector_checked_dark.xml │ │ │ ├── selector_checked_light.xml │ │ │ ├── selector_dark.xml │ │ │ ├── selector_focused_dark.xml │ │ │ ├── selector_focused_light.xml │ │ │ ├── selector_light.xml │ │ │ ├── splash_background.xml │ │ │ ├── splash_foreground.xml │ │ │ ├── toolbar_shadow_dark.xml │ │ │ └── toolbar_shadow_light.xml │ │ ├── drawable-mdpi/ │ │ │ └── volunteer_activism_ic.xml │ │ ├── drawable-night/ │ │ │ ├── ic_heart.xml │ │ │ └── splash_background.xml │ │ ├── drawable-night-v23/ │ │ │ └── splash_background.xml │ │ ├── drawable-v23/ │ │ │ └── splash_background.xml │ │ ├── layout/ │ │ │ ├── activity_about.xml │ │ │ ├── activity_downloader.xml │ │ │ ├── activity_error.xml │ │ │ ├── activity_main.xml │ │ │ ├── activity_player_queue_control.xml │ │ │ ├── activity_recaptcha.xml │ │ │ ├── chip.xml │ │ │ ├── comment_replies_header.xml │ │ │ ├── dialog_edit_text.xml │ │ │ ├── dialog_feed_group_create.xml │ │ │ ├── dialog_feed_group_reorder.xml │ │ │ ├── dialog_playback_parameter.xml │ │ │ ├── dialog_playlists.xml │ │ │ ├── dialog_title.xml │ │ │ ├── download_dialog.xml │ │ │ ├── download_loading_dialog.xml │ │ │ ├── drawer_header.xml │ │ │ ├── drawer_layout.xml │ │ │ ├── error_panel.xml │ │ │ ├── feed_group_add_new_grid_item.xml │ │ │ ├── feed_group_add_new_item.xml │ │ │ ├── feed_group_card_grid_item.xml │ │ │ ├── feed_group_card_item.xml │ │ │ ├── feed_group_reorder_item.xml │ │ │ ├── feed_item_carousel.xml │ │ │ ├── fragment_about.xml │ │ │ ├── fragment_blank.xml │ │ │ ├── fragment_bookmarks.xml │ │ │ ├── fragment_channel.xml │ │ │ ├── fragment_channel_tab.xml │ │ │ ├── fragment_channel_videos.xml │ │ │ ├── fragment_channels_notifications.xml │ │ │ ├── fragment_choose_tabs.xml │ │ │ ├── fragment_comments.xml │ │ │ ├── fragment_description.xml │ │ │ ├── fragment_empty.xml │ │ │ ├── fragment_feed.xml │ │ │ ├── fragment_import.xml │ │ │ ├── fragment_instance_list.xml │ │ │ ├── fragment_kiosk.xml │ │ │ ├── fragment_licenses.xml │ │ │ ├── fragment_main.xml │ │ │ ├── fragment_playlist.xml │ │ │ ├── fragment_related_items.xml │ │ │ ├── fragment_search.xml │ │ │ ├── fragment_subscription.xml │ │ │ ├── fragment_video_detail.xml │ │ │ ├── instance_spinner_item.xml │ │ │ ├── instance_spinner_layout.xml │ │ │ ├── item_instance.xml │ │ │ ├── item_metadata.xml │ │ │ ├── item_metadata_tags.xml │ │ │ ├── item_notification_config.xml │ │ │ ├── item_search_suggestion.xml │ │ │ ├── item_software_component.xml │ │ │ ├── item_stream_segment.xml │ │ │ ├── list_channel_card_item.xml │ │ │ ├── list_channel_grid_item.xml │ │ │ ├── list_channel_item.xml │ │ │ ├── list_channel_mini_item.xml │ │ │ ├── list_choose_tabs.xml │ │ │ ├── list_choose_tabs_dialog.xml │ │ │ ├── list_comment_item.xml │ │ │ ├── list_empty_view.xml │ │ │ ├── list_empty_view_subscriptions.xml │ │ │ ├── list_playlist_bookmark_item.xml │ │ │ ├── list_playlist_card_item.xml │ │ │ ├── list_playlist_grid_item.xml │ │ │ ├── list_playlist_item.xml │ │ │ ├── list_playlist_mini_item.xml │ │ │ ├── list_radio_icon_item.xml │ │ │ ├── list_stream_card_item.xml │ │ │ ├── list_stream_grid_item.xml │ │ │ ├── list_stream_item.xml │ │ │ ├── list_stream_mini_item.xml │ │ │ ├── list_stream_playlist_card_item.xml │ │ │ ├── list_stream_playlist_grid_item.xml │ │ │ ├── list_stream_playlist_item.xml │ │ │ ├── local_playlist_header.xml │ │ │ ├── main_bg.xml │ │ │ ├── mission_item.xml │ │ │ ├── mission_item_linear.xml │ │ │ ├── missions.xml │ │ │ ├── missions_header.xml │ │ │ ├── picker_icon_item.xml │ │ │ ├── picker_subscription_item.xml │ │ │ ├── pignate_footer.xml │ │ │ ├── play_queue_item.xml │ │ │ ├── player.xml │ │ │ ├── player_fast_seek_overlay.xml │ │ │ ├── player_fast_seek_seconds_view.xml │ │ │ ├── player_popup_close_overlay.xml │ │ │ ├── playlist_control.xml │ │ │ ├── playlist_header.xml │ │ │ ├── related_items_header.xml │ │ │ ├── select_channel_fragment.xml │ │ │ ├── select_channel_item.xml │ │ │ ├── select_feed_group_fragment.xml │ │ │ ├── select_feed_group_item.xml │ │ │ ├── select_kiosk_fragment.xml │ │ │ ├── select_kiosk_item.xml │ │ │ ├── select_playlist_fragment.xml │ │ │ ├── settings_category_header_layout.xml │ │ │ ├── settings_category_header_title.xml │ │ │ ├── settings_layout.xml │ │ │ ├── settings_notification.xml │ │ │ ├── settings_notification_action.xml │ │ │ ├── settings_preferencesearch_fragment.xml │ │ │ ├── settings_preferencesearch_list_item_result.xml │ │ │ ├── single_choice_dialog_view.xml │ │ │ ├── statistic_playlist_control.xml │ │ │ ├── stream_quality_item.xml │ │ │ ├── subscription_groups_header.xml │ │ │ ├── subscription_header.xml │ │ │ ├── toolbar_layout.xml │ │ │ └── toolbar_search_layout.xml │ │ ├── layout-land/ │ │ │ ├── activity_player_queue_control.xml │ │ │ └── list_stream_card_item.xml │ │ ├── layout-large-land/ │ │ │ └── fragment_video_detail.xml │ │ ├── menu/ │ │ │ ├── dialog_url.xml │ │ │ ├── download_menu.xml │ │ │ ├── drawer_items.xml │ │ │ ├── error_menu.xml │ │ │ ├── menu_channel.xml │ │ │ ├── menu_chooser_fragment.xml │ │ │ ├── menu_feed_fragment.xml │ │ │ ├── menu_feed_group_dialog.xml │ │ │ ├── menu_history.xml │ │ │ ├── menu_local_playlist.xml │ │ │ ├── menu_main_fragment.xml │ │ │ ├── menu_notifications_channels.xml │ │ │ ├── menu_play_queue.xml │ │ │ ├── menu_play_queue_bg.xml │ │ │ ├── menu_play_queue_item.xml │ │ │ ├── menu_playlist.xml │ │ │ ├── menu_recaptcha.xml │ │ │ ├── menu_settings_main_fragment.xml │ │ │ └── mission.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ └── ic_launcher.xml │ │ ├── resources.properties │ │ ├── values/ │ │ │ ├── attrs.xml │ │ │ ├── bools.xml │ │ │ ├── colors.xml │ │ │ ├── colors_services.xml │ │ │ ├── dimens.xml │ │ │ ├── donottranslate.xml │ │ │ ├── settings_keys.xml │ │ │ ├── strings.xml │ │ │ ├── styles.xml │ │ │ ├── styles_misc.xml │ │ │ └── styles_services.xml │ │ ├── values-ace/ │ │ │ └── strings.xml │ │ ├── values-aeb/ │ │ │ └── strings.xml │ │ ├── values-af/ │ │ │ └── strings.xml │ │ ├── values-ang/ │ │ │ └── strings.xml │ │ ├── values-ar/ │ │ │ └── strings.xml │ │ ├── values-ar-rLY/ │ │ │ └── strings.xml │ │ ├── values-ars/ │ │ │ └── strings.xml │ │ ├── values-as/ │ │ │ └── strings.xml │ │ ├── values-ay/ │ │ │ └── strings.xml │ │ ├── values-ayc/ │ │ │ └── strings.xml │ │ ├── values-az/ │ │ │ └── strings.xml │ │ ├── values-azb/ │ │ │ └── strings.xml │ │ ├── values-b+ast/ │ │ │ └── strings.xml │ │ ├── values-b+uz+Latn/ │ │ │ └── strings.xml │ │ ├── values-bar/ │ │ │ └── strings.xml │ │ ├── values-be/ │ │ │ └── strings.xml │ │ ├── values-ber/ │ │ │ └── strings.xml │ │ ├── values-bg/ │ │ │ └── strings.xml │ │ ├── values-bm/ │ │ │ └── strings.xml │ │ ├── values-bn/ │ │ │ └── strings.xml │ │ ├── values-bn-rBD/ │ │ │ └── strings.xml │ │ ├── values-bn-rIN/ │ │ │ └── strings.xml │ │ ├── values-bqi/ │ │ │ └── strings.xml │ │ ├── values-br/ │ │ │ └── strings.xml │ │ ├── values-bs/ │ │ │ └── strings.xml │ │ ├── values-ca/ │ │ │ └── strings.xml │ │ ├── values-ckb/ │ │ │ └── strings.xml │ │ ├── values-cs/ │ │ │ └── strings.xml │ │ ├── values-cy/ │ │ │ └── strings.xml │ │ ├── values-da/ │ │ │ └── strings.xml │ │ ├── values-de/ │ │ │ └── strings.xml │ │ ├── values-dum/ │ │ │ └── strings.xml │ │ ├── values-el/ │ │ │ └── strings.xml │ │ ├── values-en-rGB/ │ │ │ └── strings.xml │ │ ├── values-enm/ │ │ │ └── strings.xml │ │ ├── values-eo/ │ │ │ └── strings.xml │ │ ├── values-es/ │ │ │ └── strings.xml │ │ ├── values-et/ │ │ │ └── strings.xml │ │ ├── values-eu/ │ │ │ └── strings.xml │ │ ├── values-fa/ │ │ │ └── strings.xml │ │ ├── values-fi/ │ │ │ └── strings.xml │ │ ├── values-fil/ │ │ │ └── strings.xml │ │ ├── values-fr/ │ │ │ └── strings.xml │ │ ├── values-frc/ │ │ │ └── strings.xml │ │ ├── values-gd/ │ │ │ └── strings.xml │ │ ├── values-gl/ │ │ │ └── strings.xml │ │ ├── values-gu/ │ │ │ └── strings.xml │ │ ├── values-he/ │ │ │ └── strings.xml │ │ ├── values-hi/ │ │ │ └── strings.xml │ │ ├── values-hr/ │ │ │ └── strings.xml │ │ ├── values-hu/ │ │ │ └── strings.xml │ │ ├── values-hy/ │ │ │ └── strings.xml │ │ ├── values-ia/ │ │ │ └── strings.xml │ │ ├── values-in/ │ │ │ └── strings.xml │ │ ├── values-is/ │ │ │ └── strings.xml │ │ ├── values-it/ │ │ │ └── strings.xml │ │ ├── values-ja/ │ │ │ └── strings.xml │ │ ├── values-ji/ │ │ │ └── strings.xml │ │ ├── values-jv/ │ │ │ └── strings.xml │ │ ├── values-ka/ │ │ │ └── strings.xml │ │ ├── values-kab/ │ │ │ └── strings.xml │ │ ├── values-kk/ │ │ │ └── strings.xml │ │ ├── values-kmr/ │ │ │ └── strings.xml │ │ ├── values-kn/ │ │ │ └── strings.xml │ │ ├── values-ko/ │ │ │ └── strings.xml │ │ ├── values-ks/ │ │ │ └── strings.xml │ │ ├── values-ku/ │ │ │ └── strings.xml │ │ ├── values-la/ │ │ │ └── strings.xml │ │ ├── values-land/ │ │ │ └── dimens.xml │ │ ├── values-lmo/ │ │ │ └── strings.xml │ │ ├── values-lt/ │ │ │ └── strings.xml │ │ ├── values-lv/ │ │ │ └── strings.xml │ │ ├── values-mk/ │ │ │ └── strings.xml │ │ ├── values-ml/ │ │ │ └── strings.xml │ │ ├── values-mn/ │ │ │ └── strings.xml │ │ ├── values-mr/ │ │ │ └── strings.xml │ │ ├── values-ms/ │ │ │ └── strings.xml │ │ ├── values-my/ │ │ │ └── strings.xml │ │ ├── values-nap/ │ │ │ └── strings.xml │ │ ├── values-nb-rNO/ │ │ │ └── strings.xml │ │ ├── values-nds/ │ │ │ └── strings.xml │ │ ├── values-ne/ │ │ │ └── strings.xml │ │ ├── values-night/ │ │ │ ├── colors.xml │ │ │ └── styles.xml │ │ ├── values-nl/ │ │ │ └── strings.xml │ │ ├── values-nl-rBE/ │ │ │ └── strings.xml │ │ ├── values-nn/ │ │ │ └── strings.xml │ │ ├── values-nqo/ │ │ │ └── strings.xml │ │ ├── values-oc/ │ │ │ └── strings.xml │ │ ├── values-or/ │ │ │ └── strings.xml │ │ ├── values-pa/ │ │ │ └── strings.xml │ │ ├── values-pa-rPK/ │ │ │ └── strings.xml │ │ ├── values-pl/ │ │ │ └── strings.xml │ │ ├── values-pt/ │ │ │ └── strings.xml │ │ ├── values-pt-rBR/ │ │ │ └── strings.xml │ │ ├── values-pt-rPT/ │ │ │ └── strings.xml │ │ ├── values-ro/ │ │ │ └── strings.xml │ │ ├── values-rom/ │ │ │ └── strings.xml │ │ ├── values-ru/ │ │ │ └── strings.xml │ │ ├── values-ryu/ │ │ │ └── strings.xml │ │ ├── values-sat/ │ │ │ └── strings.xml │ │ ├── values-sc/ │ │ │ └── strings.xml │ │ ├── values-scn/ │ │ │ └── strings.xml │ │ ├── values-si/ │ │ │ └── strings.xml │ │ ├── values-sk/ │ │ │ └── strings.xml │ │ ├── values-sl/ │ │ │ └── strings.xml │ │ ├── values-so/ │ │ │ └── strings.xml │ │ ├── values-sq/ │ │ │ └── strings.xml │ │ ├── values-sr/ │ │ │ └── strings.xml │ │ ├── values-sv/ │ │ │ └── strings.xml │ │ ├── values-sw/ │ │ │ └── strings.xml │ │ ├── values-sw600dp/ │ │ │ └── dimens.xml │ │ ├── values-sw600dp-land/ │ │ │ └── dimens.xml │ │ ├── values-ta/ │ │ │ └── strings.xml │ │ ├── values-te/ │ │ │ └── strings.xml │ │ ├── values-th/ │ │ │ └── strings.xml │ │ ├── values-ti/ │ │ │ └── strings.xml │ │ ├── values-tl/ │ │ │ └── strings.xml │ │ ├── values-tok/ │ │ │ └── strings.xml │ │ ├── values-tr/ │ │ │ └── strings.xml │ │ ├── values-tt/ │ │ │ └── strings.xml │ │ ├── values-tzm/ │ │ │ └── strings.xml │ │ ├── values-uk/ │ │ │ └── strings.xml │ │ ├── values-und/ │ │ │ └── strings.xml │ │ ├── values-ur/ │ │ │ └── strings.xml │ │ ├── values-v27/ │ │ │ └── styles.xml │ │ ├── values-v29/ │ │ │ └── styles.xml │ │ ├── values-v35/ │ │ │ └── styles.xml │ │ ├── values-vi/ │ │ │ └── strings.xml │ │ ├── values-vmf/ │ │ │ └── strings.xml │ │ ├── values-w820dp/ │ │ │ └── dimens.xml │ │ ├── values-zh-rCN/ │ │ │ └── strings.xml │ │ ├── values-zh-rHK/ │ │ │ └── strings.xml │ │ ├── values-zh-rTW/ │ │ │ └── strings.xml │ │ └── xml/ │ │ ├── appearance_settings.xml │ │ ├── automotive_app_desc.xml │ │ ├── backup_restore_settings.xml │ │ ├── content_settings.xml │ │ ├── debug_settings.xml │ │ ├── download_settings.xml │ │ ├── exoplayer_settings.xml │ │ ├── history_settings.xml │ │ ├── main_settings.xml │ │ ├── notifications_settings.xml │ │ ├── player_notification_settings.xml │ │ ├── update_settings.xml │ │ └── video_audio_settings.xml │ └── test/ │ ├── java/ │ │ └── org/ │ │ └── schabi/ │ │ └── newpipe/ │ │ ├── NewVersionManagerTest.kt │ │ ├── database/ │ │ │ └── playlist/ │ │ │ └── PlaylistLocalItemTest.kt │ │ ├── error/ │ │ │ └── ReCaptchaActivityTest.kt │ │ ├── ktx/ │ │ │ └── ThrowableExtensionsTest.kt │ │ ├── local/ │ │ │ ├── playlist/ │ │ │ │ └── ExportPlaylistTest.kt │ │ │ └── subscription/ │ │ │ ├── FeedGroupIconTest.kt │ │ │ └── services/ │ │ │ └── ImportExportJsonHelperTest.java │ │ ├── player/ │ │ │ └── playqueue/ │ │ │ ├── PlayQueueItemTest.java │ │ │ └── PlayQueueTest.java │ │ ├── settings/ │ │ │ ├── ImportAllCombinationsTest.kt │ │ │ ├── ImportExportManagerTest.kt │ │ │ └── tabs/ │ │ │ ├── TabTest.java │ │ │ └── TabsJsonHelperTest.java │ │ ├── streams/ │ │ │ └── SrtFromTtmlWriterTest.java │ │ └── util/ │ │ ├── ListHelperTest.java │ │ ├── LocalizationTest.kt │ │ ├── QuadraticSliderStrategyTest.java │ │ ├── external_communication/ │ │ │ └── TimestampExtractorTest.java │ │ ├── image/ │ │ │ └── ImageStrategyTest.java │ │ └── urlfinder/ │ │ └── UrlFinderTest.kt │ └── resources/ │ ├── import_export_test.json │ └── settings/ │ └── README.md ├── build.gradle.kts ├── buildSrc/ │ ├── build.gradle.kts │ └── src/ │ └── main/ │ └── kotlin/ │ └── CheckDependenciesOrder.kt ├── checkstyle/ │ ├── checkstyle.xml │ └── suppressions.xml ├── doc/ │ ├── README.ar.md │ ├── README.asm.md │ ├── README.de.md │ ├── README.es.md │ ├── README.fr.md │ ├── README.hi.md │ ├── README.it.md │ ├── README.ja.md │ ├── README.ko.md │ ├── README.pa.md │ ├── README.pl.md │ ├── README.pt_BR.md │ ├── README.ro.md │ ├── README.ru.md │ ├── README.ryu.md │ ├── README.so.md │ ├── README.sr.md │ ├── README.tr.md │ ├── README.zh_TW.md │ └── gradle.md ├── fastlane/ │ └── metadata/ │ └── android/ │ ├── ar/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── ar_LY/ │ │ ├── changelogs/ │ │ │ ├── 63.txt │ │ │ └── 64.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── ast/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── az/ │ │ ├── changelogs/ │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1007.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 991.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── azb/ │ │ └── short_description.txt │ ├── bar/ │ │ └── short_description.txt │ ├── be/ │ │ ├── changelogs/ │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ └── 992.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── bg/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ └── 64.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── bm/ │ │ └── short_description.txt │ ├── bn/ │ │ ├── changelogs/ │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 730.txt │ │ │ ├── 770.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 850.txt │ │ │ ├── 953.txt │ │ │ ├── 956.txt │ │ │ ├── 962.txt │ │ │ └── 963.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── bn_BD/ │ │ ├── changelogs/ │ │ │ ├── 63.txt │ │ │ └── 64.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── bs/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1007.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── ca/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── ckb/ │ │ ├── changelogs/ │ │ │ └── 960.txt │ │ └── short_description.txt │ ├── cs/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 1008.txt │ │ │ ├── 1009.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── cy/ │ │ ├── changelogs/ │ │ │ └── 63.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── da/ │ │ ├── changelogs/ │ │ │ └── 63.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── de/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 1008.txt │ │ │ ├── 1009.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── el/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 770.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 850.txt │ │ │ ├── 910.txt │ │ │ ├── 950.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 996.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── en-US/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 1008.txt │ │ │ ├── 1009.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── en_GB/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 63.txt │ │ │ └── 64.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── eo/ │ │ ├── changelogs/ │ │ │ └── 63.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── es/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── et/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1005.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 962.txt │ │ │ ├── 967.txt │ │ │ ├── 969.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── eu/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── fa/ │ │ ├── changelogs/ │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1007.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 730.txt │ │ │ ├── 770.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 850.txt │ │ │ ├── 870.txt │ │ │ ├── 910.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 985.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── fi/ │ │ ├── changelogs/ │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 830.txt │ │ │ ├── 957.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ └── 975.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── fil/ │ │ ├── changelogs/ │ │ │ ├── 63.txt │ │ │ └── 64.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── fr/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 1008.txt │ │ │ ├── 1009.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── gl/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── he/ │ │ ├── changelogs/ │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1007.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 973.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 988.txt │ │ │ ├── 995.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── hi/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── hr/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── hu/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── ia/ │ │ └── short_description.txt │ ├── id/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1007.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── is/ │ │ ├── changelogs/ │ │ │ └── 997.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── it/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── ja/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ └── 960.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── jv/ │ │ ├── changelogs/ │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 850.txt │ │ │ └── 860.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── ka/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 1008.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 996.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── kab/ │ │ └── short_description.txt │ ├── kk/ │ │ ├── changelogs/ │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ └── 65.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── kn-IN/ │ │ ├── changelogs/ │ │ │ ├── 830.txt │ │ │ └── 850.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── ko/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── ku/ │ │ └── short_description.txt │ ├── lmo/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── lt/ │ │ ├── changelogs/ │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 820.txt │ │ │ └── 830.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── lv/ │ │ ├── changelogs/ │ │ │ ├── 1001.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 730.txt │ │ │ ├── 770.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 950.txt │ │ │ ├── 956.txt │ │ │ ├── 963.txt │ │ │ ├── 982.txt │ │ │ ├── 985.txt │ │ │ ├── 989.txt │ │ │ ├── 996.txt │ │ │ └── 998.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── mk/ │ │ ├── changelogs/ │ │ │ ├── 1001.txt │ │ │ ├── 850.txt │ │ │ └── 982.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── ml/ │ │ ├── changelogs/ │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ └── 968.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── ms/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 790.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ └── 954.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── nb-NO/ │ │ ├── changelogs/ │ │ │ ├── 954.txt │ │ │ ├── 956.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 962.txt │ │ │ ├── 964.txt │ │ │ ├── 986.txt │ │ │ └── 992.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── ne/ │ │ └── short_description.txt │ ├── nl/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1007.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 770.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 950.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 985.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── nl-BE/ │ │ ├── changelogs/ │ │ │ ├── 63.txt │ │ │ ├── 730.txt │ │ │ ├── 770.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 850.txt │ │ │ ├── 910.txt │ │ │ ├── 950.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ └── 957.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── nqo/ │ │ ├── changelogs/ │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ └── 68.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── or/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── pa/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── pa-PK/ │ │ └── short_description.txt │ ├── pl/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1009.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 730.txt │ │ │ ├── 770.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 950.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ └── 997.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── pt/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1007.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── pt-BR/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 1008.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 971.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 976.txt │ │ │ ├── 978.txt │ │ │ ├── 982.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 989.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── pt-PT/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1007.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── ro/ │ │ ├── changelogs/ │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 770.txt │ │ │ └── 953.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── ru/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1007.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── sat/ │ │ └── short_description.txt │ ├── sc/ │ │ ├── changelogs/ │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 959.txt │ │ │ └── 960.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── si/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── sk/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 1008.txt │ │ │ ├── 1009.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── sl/ │ │ ├── changelogs/ │ │ │ └── 991.txt │ │ └── short_description.txt │ ├── so/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── sq/ │ │ ├── changelogs/ │ │ │ └── 1000.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── sr/ │ │ ├── changelogs/ │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 956.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ └── 996.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── sv/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 1008.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── ta/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 1008.txt │ │ │ ├── 1009.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── te/ │ │ ├── changelogs/ │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ └── 65.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── th/ │ │ ├── changelogs/ │ │ │ └── 1000.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── ti/ │ │ ├── changelogs/ │ │ │ └── 850.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── tl/ │ │ └── short_description.txt │ ├── tok/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── tr/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 910.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 982.txt │ │ │ ├── 985.txt │ │ │ ├── 987.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── uk/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── und/ │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── ur/ │ │ ├── changelogs/ │ │ │ ├── 63.txt │ │ │ └── 956.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── uz-Latn/ │ │ ├── changelogs/ │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 730.txt │ │ │ ├── 770.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ └── 957.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── vi/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 1008.txt │ │ │ ├── 1009.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 950.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── zh-Hans/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1005.txt │ │ │ ├── 1006.txt │ │ │ ├── 1007.txt │ │ │ ├── 1008.txt │ │ │ ├── 1009.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 66.txt │ │ │ ├── 68.txt │ │ │ ├── 69.txt │ │ │ ├── 70.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 740.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 963.txt │ │ │ ├── 964.txt │ │ │ ├── 965.txt │ │ │ ├── 966.txt │ │ │ ├── 967.txt │ │ │ ├── 968.txt │ │ │ ├── 969.txt │ │ │ ├── 970.txt │ │ │ ├── 971.txt │ │ │ ├── 972.txt │ │ │ ├── 973.txt │ │ │ ├── 974.txt │ │ │ ├── 975.txt │ │ │ ├── 976.txt │ │ │ ├── 977.txt │ │ │ ├── 978.txt │ │ │ ├── 979.txt │ │ │ ├── 980.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 983.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 994.txt │ │ │ ├── 995.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ ├── zh-Hant/ │ │ ├── changelogs/ │ │ │ ├── 1000.txt │ │ │ ├── 1001.txt │ │ │ ├── 1002.txt │ │ │ ├── 1003.txt │ │ │ ├── 1004.txt │ │ │ ├── 1007.txt │ │ │ ├── 63.txt │ │ │ ├── 64.txt │ │ │ ├── 65.txt │ │ │ ├── 71.txt │ │ │ ├── 730.txt │ │ │ ├── 750.txt │ │ │ ├── 760.txt │ │ │ ├── 770.txt │ │ │ ├── 780.txt │ │ │ ├── 790.txt │ │ │ ├── 800.txt │ │ │ ├── 810.txt │ │ │ ├── 820.txt │ │ │ ├── 830.txt │ │ │ ├── 840.txt │ │ │ ├── 850.txt │ │ │ ├── 860.txt │ │ │ ├── 870.txt │ │ │ ├── 900.txt │ │ │ ├── 910.txt │ │ │ ├── 920.txt │ │ │ ├── 930.txt │ │ │ ├── 940.txt │ │ │ ├── 950.txt │ │ │ ├── 951.txt │ │ │ ├── 952.txt │ │ │ ├── 953.txt │ │ │ ├── 954.txt │ │ │ ├── 955.txt │ │ │ ├── 956.txt │ │ │ ├── 957.txt │ │ │ ├── 958.txt │ │ │ ├── 959.txt │ │ │ ├── 960.txt │ │ │ ├── 961.txt │ │ │ ├── 962.txt │ │ │ ├── 964.txt │ │ │ ├── 981.txt │ │ │ ├── 982.txt │ │ │ ├── 984.txt │ │ │ ├── 985.txt │ │ │ ├── 986.txt │ │ │ ├── 987.txt │ │ │ ├── 988.txt │ │ │ ├── 989.txt │ │ │ ├── 990.txt │ │ │ ├── 991.txt │ │ │ ├── 992.txt │ │ │ ├── 993.txt │ │ │ ├── 996.txt │ │ │ ├── 997.txt │ │ │ ├── 998.txt │ │ │ └── 999.txt │ │ ├── full_description.txt │ │ └── short_description.txt │ └── zh_Hant_HK/ │ ├── changelogs/ │ │ ├── 1000.txt │ │ ├── 1001.txt │ │ ├── 1002.txt │ │ ├── 1003.txt │ │ ├── 1004.txt │ │ ├── 1005.txt │ │ ├── 1007.txt │ │ ├── 63.txt │ │ ├── 64.txt │ │ ├── 65.txt │ │ ├── 66.txt │ │ ├── 68.txt │ │ ├── 981.txt │ │ ├── 983.txt │ │ ├── 984.txt │ │ ├── 985.txt │ │ ├── 986.txt │ │ ├── 987.txt │ │ ├── 988.txt │ │ ├── 989.txt │ │ ├── 990.txt │ │ ├── 991.txt │ │ ├── 992.txt │ │ ├── 993.txt │ │ ├── 994.txt │ │ ├── 995.txt │ │ ├── 996.txt │ │ ├── 997.txt │ │ ├── 998.txt │ │ └── 999.txt │ ├── full_description.txt │ └── short_description.txt ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat └── settings.gradle.kts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # # SPDX-FileCopyrightText: 2025 NewPipe e.V. # SPDX-License-Identifier: GPL-3.0-or-later # root = true [*.{kt,kts}] ktlint_code_style = android_studio # https://pinterest.github.io/ktlint/latest/rules/standard/#function-naming ktlint_function_naming_ignore_when_annotated_with = Composable ktlint_standard_class-signature = disabled ktlint_standard_function-expression-body = disabled ktlint_standard_max-line-length = disabled ktlint_standard_mixed-condition-operators = disabled ktlint_standard_package-name = disabled ktlint_standard_property-naming = disabled ================================================ FILE: .github/CONTRIBUTING.md ================================================ ### Please do **not** open pull requests for *new features* now, as we are planning to rewrite large chunks of the code. Only bugfix PRs will be accepted. More details will be announced soon! NewPipe contribution guidelines =============================== ## AI policy * Using generative AI to develop new features or making larger code changes is generally prohibited. Please refrain from contributions which are heavily depending on AI generated source code because they are usually lacking a fundamental understanding of the overall project structure and thus come with poor quality. However, you are allowed to use gen. AI if you * are aware of the project structure, * ensure that the generated code follows the project structure, * fully understand the generated code, and * review the generated code completely. * Using AI to find the root cause of bugs and generating small fixes might be acceptable. However, gen. AI often does not fix the underlying problem but is trying to fix the symptoms. If you are using AI to fix bugs, ensure that the root cause is tackled. * The use of AI to generate documentation is allowed. We ask you to thoroughly check the quality of generated documentation – wrong, misleading or uninformative documentation is useless and wastes the reader's time. Ensure that reasoning is documented. * Using generative AI to write or fill in PR or issue templates is prohibited. Those texts are often lengthy and miss critical information. * PRs and issues that do not follow this AI policy can be closed without further explanation. ## Crash reporting Report crashes through the **automated crash report system** of NewPipe. This way all the data needed for debugging is included in your bug report for GitHub. You'll see *exactly* what is sent, be able to add **your comments**, and then send it. ## Issue reporting/feature requests * **Already reported**? Browse the [existing issues](https://github.com/TeamNewPipe/NewPipe/issues) to make sure your issue/feature hasn't been reported/requested. * **Already fixed**? Check whether your issue/feature is already fixed/implemented. * **Still relevant**? Check if the issue still exists in the latest release/beta version. * **Can you fix it**? If you are an Android/Java developer, you are always welcome to fix an issue or implement a feature yourself. PRs welcome! See [Code contribution](#code-contribution) for more info. * **Is it in English**? Issues in other languages will be ignored unless someone translates them. * **Is it one issue**? Multiple issues require multiple reports, that can be linked to track their statuses. * **The template**: Fill it out, everyone wins. Your issue has a chance of getting fixed. ## Translation * NewPipe is translated via [Weblate](https://hosted.weblate.org/projects/newpipe/strings/). Log in there with your GitHub account, or register. * Add the language you want to translate if it is not there already: see [How to add a new language](https://github.com/TeamNewPipe/NewPipe/wiki/How-to-add-a-new-language-to-NewPipe) in the wiki. * NewPipe uses the [PrettyTime](https://github.com/ocpsoft/prettytime) library to display localized versions of dates and times. It needs to be translated, too. Read [these instructions to add a new language](https://www.ocpsoft.org/prettytime/#section-14) and [this issue](https://github.com/TeamNewPipe/NewPipe/issues/9134) for more info. ## Code contribution ### Guidelines * Stick to NewPipe's *style conventions* of [checkStyle](https://github.com/checkstyle/checkstyle) and [ktlint](https://github.com/pinterest/ktlint). They run each time you build the project. * Stick to [F-Droid contribution guidelines](https://f-droid.org/wiki/page/Inclusion_Policy). * In particular **do not bring non-free software** (e.g. binary blobs) into the project. Make sure you do not introduce any closed-source library from Google. ### Before starting development * If you want to help out with an existing bug report or feature request, **leave a comment** on that issue saying you want to try your hand at it. * If there is no existing issue for what you want to work on, **open a new one** describing the changes you are planning to introduce. This gives the team and the community a chance to give **feedback** before you spend time on something that is already in development, should be done differently, or should be avoided completely. * Please show **intention to maintain your features** and code after you contribute a PR. Unmaintained code is a hassle for core developers. If you do not intend to maintain features you plan to contribute, please rethink your submission, or clearly state that in the PR description. * Create PRs that cover only **one specific issue/solution/bug**. Do not create PRs that are huge monoliths and could have been split into multiple independent contributions. * NewPipe uses [NewPipeExtractor](https://github.com/TeamNewPipe/NewPipeExtractor) to fetch data from services. If you need to change something there, you must test your changes in NewPipe. Telling NewPipe to use your extractor version can be accomplished by editing the `app/build.gradle` file: the comments under the "NewPipe libraries" section of `dependencies` will help you out. ### Creating a Pull Request (PR) * Make changes on a **separate branch** with a meaningful name, not on the _master_ branch or the _dev_ branch. This is commonly known as *feature branch workflow*. You may then send your changes as a pull request (PR) on GitHub. * Please **test** (compile and run) your code before submitting changes! Ideally, provide test feedback in the PR description. Untested code will **not** be merged! * Respond if someone requests changes or otherwise raises issues about your PRs. * Try to figure out yourself why builds on our CI fail. * Make sure your PR is **up-to-date** with the rest of the code. Often, a simple click on "Update branch" will do the job, but if not, you must *rebase* your branch on the `dev` branch manually and resolve the conflicts on your own. You can find help [on the wiki](https://github.com/TeamNewPipe/NewPipe/wiki/How-to-merge-a-PR). Doing this makes the maintainers' job way easier. ## IDE setup & building the app ### Basic setup NewPipe is developed using [Android Studio](https://developer.android.com/studio/). Learn more about how to install it and how it works in the [official documentation](https://developer.android.com/studio/intro). In particular, make sure you have accepted Android Studio's SDK licences. Once Android Studio is ready, setting up the NewPipe project is fairly simple: - Clone the NewPipe repository with `git clone https://github.com/TeamNewPipe/NewPipe.git` (or use the link from your own fork, if you want to open a PR). - Open the folder you just cloned with Android Studio. - Build and run it just like you would do with any other app, with the green triangle in the top bar. You may find [SonarLint](https://www.sonarlint.org/intellij)'s **inspections** useful in helping you to write good code and prevent bugs. ### checkStyle setup The [checkStyle](https://github.com/checkstyle/checkstyle) plugin verifies that Java code abides by the project style. It runs automatically each time you build the project. If you want to view errors directly in the editor, instead of having to skim through the build output, you can install an Android Studio plugin: - Go to `File -> Settings -> Plugins`, search for `checkstyle` and install `CheckStyle-IDEA`. - Go to `File -> Settings -> Tools -> Checkstyle`. - Add NewPipe's configuration file by clicking the `+` in the right toolbar of the "Configuration File" list. - Under the "Use a local Checkstyle file" bullet, click on `Browse` and, enter `checkstyle` folder under the project's root path and pick the file named `checkstyle.xml`. - Enable "Store relative to project location" so that moving the directory around does not create issues. - Insert a description in the top bar, then click `Next` and then `Finish`. - Activate the configuration file you just added by enabling the checkbox on the left. - Click `Ok` and you are done. ### ktlint setup The [ktlint](https://github.com/pinterest/ktlint) plugin does the same job as checkStyle for Kotlin files. Installing the related plugin is as simple as going to `File -> Settings -> Plugins`, searching for `ktlint` and installing `Ktlint (unofficial)`. ## Communication * You can use a Matrix account to join the NewPipe channel at [#newpipe:matrix.newpipe-ev.de](https://matrix.to/#/#newpipe:matrix.newpipe-ev.de). Some convenient clients, available both for phone and desktop, are listed at that link. * Alternatively, the #newpipe channel on Libera Chat (`ircs://irc.libera.chat:6697/newpipe`) can also be joined, as it is bridged to the Matrix room. [Click here for webchat](https://web.libera.chat/#newpipe)! * You can post your suggestions, changes, ideas etc. on either GitHub or Matrix (including via IRC). ================================================ FILE: .github/DISCUSSION_TEMPLATE/questions.yml ================================================ body: - type: markdown attributes: value: | Thanks for taking the time to fill out this form! :hugs: Note that you can also ask questions on our [IRC channel](https://web.libera.chat/#newpipe). - type: checkboxes id: checklist attributes: label: "Checklist" options: - label: "I made sure that there are *no existing issues or discussions* - [open](https://github.com/TeamNewPipe/NewPipe/issues) or [closed](https://github.com/TeamNewPipe/NewPipe/issues?q=is%3Aissue+is%3Aclosed) - which I could contribute my information to." required: true - label: "I have read the [FAQ](https://newpipe.net/FAQ/) and my question isn't listed." required: true - label: "I have taken the time to fill in all the required details. I understand that the question will be dismissed otherwise." required: true - label: "I have read and understood the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/dev/.github/CONTRIBUTING.md)." required: true - type: textarea id: what-is-the-question attributes: label: What is/are your question(s)? validations: required: true - type: textarea id: additional-information attributes: label: Additional information description: Any other information you'd like to include, for instance sketches, mockups, pictures of cats, etc. ================================================ FILE: .github/FUNDING.yml ================================================ liberapay: TeamNewPipe custom: 'https://newpipe.net/donate/' ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug report description: Create a bug report to help us improve labels: [bug, needs triage] body: - type: markdown attributes: value: | Thank you for helping to make NewPipe better by reporting a bug. :hugs: Please fill in as much information as possible about your bug so that we don't have to play "information ping-pong" and can help you immediately. - type: checkboxes id: checklist attributes: label: "Checklist" options: - label: "I am able to reproduce the bug with the latest version given here: [CLICK THIS LINK](https://github.com/TeamNewPipe/NewPipe/releases/latest)." required: true - label: "I made sure that there are *no existing issues* - [open](https://github.com/TeamNewPipe/NewPipe/issues) or [closed](https://github.com/TeamNewPipe/NewPipe/issues?q=is%3Aissue+is%3Aclosed) - which I could contribute my information to." required: true - label: "I have read the [FAQ](https://newpipe.net/FAQ/) and my problem isn't listed." required: true - label: "I have taken the time to fill in all the required details. I understand that the bug report will be dismissed otherwise." required: true - label: "This issue contains only one bug." required: true - label: "I have read and understood the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/dev/.github/CONTRIBUTING.md)." required: true - label: "I have read and understood the [AI policy](https://github.com/TeamNewPipe/NewPipe/blob/dev/.github/CONTRIBUTING.md#ai-policy). The content of this bug report is not generated by AI." required: true - type: input id: app-version attributes: label: Affected version description: "In which NewPipe version did you encounter the bug?" placeholder: "x.xx.x - Can be seen in the app from the 'About' section in the sidebar" validations: required: true - type: textarea id: steps-to-reproduce attributes: label: Steps to reproduce the bug description: | What did you do for the bug to show up? If you can't cause the bug to show up again reliably (and hence don't have a proper set of steps to give us), please still try to give as many details as possible on how you think you encountered the bug. placeholder: | 1. Go to '...' 2. Press on '....' 3. Swipe down to '....' validations: required: true - type: textarea id: expected-behavior attributes: label: Expected behavior description: | Tell us what you expect to happen. - type: textarea id: actual-behavior attributes: label: Actual behavior description: | Tell us what happens with the steps given above. - type: textarea id: screen-media attributes: label: Screenshots/Screen recordings description: | A picture or video is worth a thousand words. If applicable, add screenshots or a screen recording to help explain your problem. GitHub supports uploading them directly in the text box. If your file is too big for Github to accept, try to compress it (ZIP-file) or feel free to paste a link to an image/video hoster here instead. :heavy_exclamation_mark: DON'T POST SCREENSHOTS OF THE ERROR PAGE. Instead, follow the instructions in the "Logs" section below. - type: textarea id: logs attributes: label: Logs description: | If your bug includes a crash (where you're shown the Error Report page with a bunch of info), tap on "Copy formatted report" at the bottom and paste it here. - type: input id: device-os-info attributes: label: Affected Android/Custom ROM version description: | With what operating system (+ version) did you encounter the bug? placeholder: "Example: Android 12 / LineageOS 18.1" - type: input id: device-model-info attributes: label: Affected device model description: | On what device did you encounter the bug? placeholder: "Example: Huawei P20 lite (ANE-LX1) / Samsung Galaxy S20" - type: textarea id: additional-information attributes: label: Additional information description: | Any other information you'd like to include, for instance that * the affected device is foldable or a TV * you have disabled all animations on your device * your cat disabled your network connection * ... ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: ❓ Question url: https://github.com/TeamNewPipe/NewPipe/discussions/new?category=questions about: Ask about anything NewPipe-related - name: 💬 Matrix url: https://matrix.to/#/#newpipe:matrix.newpipe-ev.de about: Chat with us via Matrix for quick Q/A - name: 💬 IRC url: https://web.libera.chat/#newpipe about: Chat with us via IRC for quick Q/A ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: Feature request description: Suggest an idea for this project labels: [feature request, needs triage] body: - type: markdown attributes: value: | Thank you for helping to make NewPipe better by suggesting a feature. :hugs: Your ideas are highly welcome! The app is made for you, the users, after all. - type: checkboxes id: checklist attributes: label: "Checklist" options: - label: "I made sure that there are *no existing issues* - [open](https://github.com/TeamNewPipe/NewPipe/issues) or [closed](https://github.com/TeamNewPipe/NewPipe/issues?q=is%3Aissue+is%3Aclosed) - which I could contribute my information to." required: true - label: "I have read the [FAQ](https://newpipe.net/FAQ/) and my problem isn't listed." required: true - label: "I'm aware that this is a request for NewPipe itself and that requests for adding a new service need to be made at [NewPipeExtractor](https://github.com/TeamNewPipe/NewPipeExtractor/issues)." required: true - label: "I have taken the time to fill in all the required details. I understand that the feature request will be dismissed otherwise." required: true - label: "This issue contains only one feature request." required: true - label: "I have read and understood the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/dev/.github/CONTRIBUTING.md)." required: true - label: "I have read and understood the [AI policy](https://github.com/TeamNewPipe/NewPipe/blob/dev/.github/CONTRIBUTING.md#ai-policy). The content of this request is not generated by AI." required: true - type: textarea id: feature-description attributes: label: Feature description description: | Explain how you want the app's look or behavior to change to suit your needs. validations: required: true - type: textarea id: why-is-the-feature-requested attributes: label: Why do you want this feature? description: | Describe any problem or limitation you come across while using the app which would be solved by this feature. validations: required: true - type: textarea id: additional-information attributes: label: Additional information description: Any other information you'd like to include, for instance sketches, mockups, pictures of cats, etc. ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ #### What is it? - [ ] Bugfix (user facing) - [ ] Feature (user facing) ⚠️ **Your PR must target the [`refactor`](https://github.com/TeamNewPipe/NewPipe/tree/refactor) branch** - [ ] Codebase improvement (dev facing) - [ ] Meta improvement to the project (dev facing) #### Description of the changes in your PR - record videos - create clones - take over the world #### Before/After Screenshots/Screen Record - Before: - After: #### Fixes the following issue(s) - Fixes # #### Relies on the following changes - #### APK testing The APK can be found by going to the "Checks" tab below the title. On the left pane, click on "CI", scroll down to "artifacts" and click "app" to download the zip file which contains the debug APK of this PR. You can find more info and a video demonstration [on this wiki page](https://github.com/TeamNewPipe/NewPipe/wiki/Download-APK-for-PR). #### Due diligence - [ ] I read the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md). - [ ] The proposed changes follow the [AI policy](https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md#ai-policy). - [ ] I tested the changes using an emulator or a physical device. ================================================ FILE: .github/changed-lines-count-labeler.yml ================================================ # Add 'size/small' label to any changes with less than 50 lines size/small: max: 49 # Add 'size/medium' label to any changes between 50 and 249 lines size/medium: min: 50 max: 249 # Add 'size/large' label to any changes between 250 and 749 lines size/large: min: 250 max: 749 # Add 'size/giant' label to any changes for more than 749 lines size/giant: min: 750 ================================================ FILE: .github/workflows/backport-pr.yml ================================================ name: Backport merged pull request on: issue_comment: types: [created] permissions: contents: write # for comment creation on original PR pull-requests: write jobs: backport: name: Backport pull request runs-on: ubuntu-latest # Only run when the comment starts with the `/backport` command on a PR and # the commenter has write access to the repository. We do not want to allow # everybody to trigger backports and create branches in our repository. if: > github.event.issue.pull_request && startsWith(github.event.comment.body, '/backport ') && ( github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'MEMBER' ) steps: - uses: actions/checkout@v6 - name: Get backport metadata # the target branch is the first argument after `/backport` env: COMMENT_BODY: ${{ github.event.comment.body }} run: | set -euo pipefail body="$COMMENT_BODY" line=${body%%$'\n'*} # Get the first line if [[ $line =~ ^/backport[[:space:]]+([^[:space:]]+) ]]; then echo "BACKPORT_TARGET=${BASH_REMATCH[1]}" >> "$GITHUB_ENV" else echo "Usage: /backport " >&2 exit 1 fi - name: Create backport pull request uses: korthout/backport-action@v4 with: add_labels: 'backport' copy_labels_pattern: '.*' label_pattern: '' target_branches: ${{ env.BACKPORT_TARGET }} ================================================ FILE: .github/workflows/build-release-apk.yml ================================================ name: "Build unsigned release APK on master" on: workflow_dispatch: jobs: release: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: ref: 'master' - uses: actions/setup-java@v5 with: distribution: 'temurin' java-version: '21' cache: 'gradle' - name: "Build release APK" run: ./gradlew assembleRelease --stacktrace - name: "Rename APK" run: | VERSION_NAME="$(jq -r ".elements[0].versionName" "app/build/outputs/apk/release/output-metadata.json")" echo "Version name: $VERSION_NAME" >> "$GITHUB_STEP_SUMMARY" echo '```json' >> "$GITHUB_STEP_SUMMARY" cat "app/build/outputs/apk/release/output-metadata.json" >> "$GITHUB_STEP_SUMMARY" echo >> "$GITHUB_STEP_SUMMARY" echo '```' >> "$GITHUB_STEP_SUMMARY" # assume there is only one APK in that folder mv app/build/outputs/apk/release/*.apk "app/build/outputs/apk/release/NewPipe_v$VERSION_NAME.apk" - name: "Upload APK" uses: actions/upload-artifact@v6 with: name: app path: app/build/outputs/apk/release/*.apk ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: workflow_dispatch: pull_request: branches: - dev - master - refactor - release** paths-ignore: - 'README.md' - 'doc/**' - 'fastlane/**' - 'assets/**' - '.github/**/*.md' - '.github/FUNDING.yml' - '.github/ISSUE_TEMPLATE/**' push: branches: - dev - master paths-ignore: - 'README.md' - 'doc/**' - 'fastlane/**' - 'assets/**' - '.github/**/*.md' - '.github/FUNDING.yml' - '.github/ISSUE_TEMPLATE/**' jobs: build-and-test-jvm: runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@v6 - uses: gradle/actions/wrapper-validation@v5 - name: create and checkout branch # push events already checked out the branch if: github.event_name == 'pull_request' env: BRANCH: ${{ github.head_ref }} run: git checkout -B "$BRANCH" - name: set up JDK uses: actions/setup-java@v5 with: java-version: 21 distribution: "temurin" cache: 'gradle' - name: Build debug APK and run jvm tests run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint - name: Upload APK uses: actions/upload-artifact@v6 with: name: app path: app/build/outputs/apk/debug/*.apk test-android: runs-on: ubuntu-latest timeout-minutes: 20 strategy: matrix: include: - api-level: 21 target: default arch: x86 - api-level: 35 target: default arch: x86_64 permissions: contents: read steps: - uses: actions/checkout@v6 - name: Enable KVM run: | echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - name: set up JDK uses: actions/setup-java@v5 with: java-version: 21 distribution: "temurin" cache: 'gradle' - name: Run android tests uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} target: ${{ matrix.target }} arch: ${{ matrix.arch }} script: ./gradlew connectedCheck --stacktrace - name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553 uses: actions/upload-artifact@v6 if: failure() with: name: android-test-report-api${{ matrix.api-level }} path: app/build/reports/androidTests/connected/** sonar: if: ${{ false }} # the key has expired and needs to be regenerated by the sonar admins runs-on: ubuntu-latest permissions: contents: read steps: - uses: actions/checkout@v6 with: fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis - name: Set up JDK uses: actions/setup-java@v5 with: java-version: 21 distribution: "temurin" cache: 'gradle' - name: Cache SonarCloud packages uses: actions/cache@v5 with: path: ~/.sonar/cache key: ${{ runner.os }}-sonar restore-keys: ${{ runner.os }}-sonar - name: Build and analyze env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} run: ./gradlew build sonar --info ================================================ FILE: .github/workflows/image-minimizer.js ================================================ /* * Script for minimizing big images (jpg,gif,png) when they are uploaded to GitHub and not edited otherwise */ module.exports = async ({github, context}) => { const IGNORE_KEY = ''; const IGNORE_ALT_NAME_END = 'ignoreImageMinify'; // Targeted maximum height const IMG_MAX_HEIGHT_PX = 600; // maximum width of GitHub issues/comments const IMG_MAX_WIDTH_PX = 800; // all images that have a lower aspect ratio (-> have a smaller width) than this will be minimized const MIN_ASPECT_RATIO = IMG_MAX_WIDTH_PX / IMG_MAX_HEIGHT_PX // Get the body of the image let initialBody = null; if (context.eventName == 'issue_comment') { initialBody = context.payload.comment.body; } else if (context.eventName == 'issues') { initialBody = context.payload.issue.body; } else if (context.eventName == 'pull_request') { initialBody = context.payload.pull_request.body; } else { console.log('Aborting: No body found'); return; } console.log(`Found body: \n${initialBody}\n`); // Check if we should ignore the currently processing element if (initialBody.includes(IGNORE_KEY)) { console.log('Ignoring: Body contains IGNORE_KEY'); return; } // Regex for finding images (simple variant) ![ALT_TEXT](https://*.githubusercontent.com//.) const REGEX_USER_CONTENT_IMAGE_LOOKUP = /\!\[([^\]]*)\]\((https:\/\/[-a-z0-9]+\.githubusercontent\.com\/\d+\/[-0-9a-f]{32,512}\.(jpg|gif|png))\)/gm; const REGEX_ASSETS_IMAGE_LOOKUP = /\!\[([^\]]*)\]\((https:\/\/github\.com\/(?:user-attachments\/assets|[-\w\d]+\/[-\w\d]+\/assets\/\d+)\/[\-0-9a-f]{32,512})\)/gm; // Check if we found something let foundSimpleImages = REGEX_USER_CONTENT_IMAGE_LOOKUP.test(initialBody) || REGEX_ASSETS_IMAGE_LOOKUP.test(initialBody); if (!foundSimpleImages) { console.log('Found no simple images to process'); return; } console.log('Found at least one simple image to process'); // Require the probe lib for getting the image dimensions const probe = require('probe-image-size'); var wasMatchModified = false; // Try to find and replace the images with minimized ones let newBody = await replaceAsync(initialBody, REGEX_USER_CONTENT_IMAGE_LOOKUP, minimizeAsync); newBody = await replaceAsync(newBody, REGEX_ASSETS_IMAGE_LOOKUP, minimizeAsync); if (!wasMatchModified) { console.log('Nothing was modified. Skipping update'); return; } // Update the corresponding element if (context.eventName == 'issue_comment') { console.log('Updating comment with id', context.payload.comment.id); await github.rest.issues.updateComment({ comment_id: context.payload.comment.id, owner: context.repo.owner, repo: context.repo.repo, body: newBody }) } else if (context.eventName == 'issues') { console.log('Updating issue', context.payload.issue.number); await github.rest.issues.update({ issue_number: context.payload.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: newBody }); } else if (context.eventName == 'pull_request') { console.log('Updating pull request', context.payload.pull_request.number); await github.rest.pulls.update({ pull_number: context.payload.pull_request.number, owner: context.repo.owner, repo: context.repo.repo, body: newBody }); } // Async replace function from https://stackoverflow.com/a/48032528 async function replaceAsync(str, regex, asyncFn) { const promises = []; str.replace(regex, (match, ...args) => { const promise = asyncFn(match, ...args); promises.push(promise); }); const data = await Promise.all(promises); return str.replace(regex, () => data.shift()); } async function minimizeAsync(match, g1, g2) { console.log(`Found match '${match}'`); if (g1.endsWith(IGNORE_ALT_NAME_END)) { console.log(`Ignoring match '${match}': IGNORE_ALT_NAME_END`); return match; } let probeAspectRatio = 0; let shouldModify = false; try { console.log(`Probing ${g2}`); let probeResult = await probe(g2); if (probeResult == null) { throw 'No probeResult'; } if (probeResult.hUnits != 'px') { throw `Unexpected probeResult.hUnits (expected px but got ${probeResult.hUnits})`; } if (probeResult.height <= 0) { throw `Unexpected probeResult.height (height is invalid: ${probeResult.height})`; } if (probeResult.wUnits != 'px') { throw `Unexpected probeResult.wUnits (expected px but got ${probeResult.wUnits})`; } if (probeResult.width <= 0) { throw `Unexpected probeResult.width (width is invalid: ${probeResult.width})`; } console.log(`Probing resulted in ${probeResult.width}x${probeResult.height}px`); probeAspectRatio = probeResult.width / probeResult.height; shouldModify = probeResult.height > IMG_MAX_HEIGHT_PX && probeAspectRatio < MIN_ASPECT_RATIO; } catch(e) { console.log('Probing failed:', e); // Immediately abort return match; } if (shouldModify) { wasMatchModified = true; console.log(`Modifying match '${match}'`); return `${g1}`; } console.log(`Match '${match}' is ok/will not be modified`); return match; } } ================================================ FILE: .github/workflows/image-minimizer.yml ================================================ name: Image Minimizer on: issue_comment: types: [created, edited] issues: types: [opened, edited] pull_request: types: [opened, edited] permissions: issues: write pull-requests: write jobs: try-minimize: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: 16 - name: Install probe-image-size run: npm i probe-image-size@7.2.3 --ignore-scripts - name: Minimize simple images uses: actions/github-script@v8 timeout-minutes: 3 with: script: | const script = require('.github/workflows/image-minimizer.js'); await script({github, context}); ================================================ FILE: .github/workflows/no-response.yml ================================================ name: No Response # Both `issue_comment` and `scheduled` event types are required for this Action # to work properly. on: issue_comment: types: [created] schedule: # Run daily at midnight. - cron: '0 0 * * *' permissions: issues: write pull-requests: write jobs: noResponse: runs-on: ubuntu-latest steps: - uses: lee-dohm/no-response@v0.5.0 with: token: ${{ github.token }} daysUntilClose: 14 responseRequiredLabel: waiting for author ================================================ FILE: .github/workflows/pr-labeler.yml ================================================ name: "PR size labeler" on: [pull_request_target] permissions: contents: read pull-requests: write jobs: changed-lines-count-labeler: runs-on: ubuntu-latest name: Automatically labelling pull requests based on the changed lines count permissions: pull-requests: write steps: - name: Set a label uses: TeamNewPipe/changed-lines-count-labeler@main with: repo-token: ${{ secrets.GITHUB_TOKEN }} configuration-path: .github/changed-lines-count-labeler.yml ================================================ FILE: .gitignore ================================================ .gradle/ local.properties .DS_Store build/ captures/ .idea/ *.iml *~ .weblate .kotlin *.class app/debug/ app/release/ # vscode / eclipse files *.classpath *.project *.settings bin/ .vscode/ *.code-workspace ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================

We are rewriting large chunks of the codebase, to bring about a modern and stable NewPipe! You can download nightly builds here.

Please work on the refactor branch if you want to contribute new features. The current codebase is in maintenance mode and will only receive bugfixes.

NewPipe

A libre lightweight streaming front-end for Android.

Get it on F-Droid


ScreenshotsSupported ServicesDescriptionFeaturesInstallation and updatesContributionDonateLicense

WebsiteBlogFAQPress


*Read this document in other languages: [Deutsch](doc/README.de.md), [English](README.md), [Español](doc/README.es.md), [Français](doc/README.fr.md), [हिन्दी](doc/README.hi.md), [Italiano](doc/README.it.md), [한국어](doc/README.ko.md), [Português Brasil](doc/README.pt_BR.md), [Polski](doc/README.pl.md), [ਪੰਜਾਬੀ ](doc/README.pa.md), [日本語](doc/README.ja.md), [Română](doc/README.ro.md), [Soomaali](doc/README.so.md), [Türkçe](doc/README.tr.md), [正體中文](doc/README.zh_TW.md), [অসমীয়া](doc/README.asm.md), [Српски](doc/README.sr.md), [العربية](README.ar.md)* > [!warning] > THIS APP IS IN BETA, SO YOU MAY ENCOUNTER BUGS. IF YOU DO, OPEN AN ISSUE IN OUR GITHUB REPOSITORY BY FILLING OUT THE ISSUE TEMPLATE. > > PUTTING NEWPIPE, OR ANY FORK OF IT, INTO THE GOOGLE PLAY STORE VIOLATES THEIR TERMS AND CONDITIONS. ## Screenshots [](fastlane/metadata/android/en-US/images/phoneScreenshots/00.png) [](fastlane/metadata/android/en-US/images/phoneScreenshots/01.png) [](fastlane/metadata/android/en-US/images/phoneScreenshots/02.png) [](fastlane/metadata/android/en-US/images/phoneScreenshots/03.png) [](fastlane/metadata/android/en-US/images/phoneScreenshots/04.png) [](fastlane/metadata/android/en-US/images/phoneScreenshots/05.png) [](fastlane/metadata/android/en-US/images/phoneScreenshots/06.png) [](fastlane/metadata/android/en-US/images/phoneScreenshots/07.png) [](fastlane/metadata/android/en-US/images/phoneScreenshots/08.png)

[](fastlane/metadata/android/en-US/images/tenInchScreenshots/09.png) [](fastlane/metadata/android/en-US/images/tenInchScreenshots/10.png) ### Supported Services NewPipe currently supports these services: * YouTube ([website](https://www.youtube.com/)) and YouTube Music ([website](https://music.youtube.com/)) ([wiki](https://en.wikipedia.org/wiki/YouTube)) * PeerTube ([website](https://joinpeertube.org/)) and all its instances (open the website to know what that means!) ([wiki](https://en.wikipedia.org/wiki/PeerTube)) * Bandcamp ([website](https://bandcamp.com/)) ([wiki](https://en.wikipedia.org/wiki/Bandcamp)) * SoundCloud ([website](https://soundcloud.com/)) ([wiki](https://en.wikipedia.org/wiki/SoundCloud)) * media.ccc.de ([website](https://media.ccc.de/)) ([wiki](https://en.wikipedia.org/wiki/Chaos_Computer_Club)) As you can see, NewPipe supports multiple video and audio services. Though it started off with YouTube, other people have added more services over the years, making NewPipe more and more versatile! Partially due to circumstance, and partially due to its popularity, YouTube is the best supported out of these services. If you use or are familiar with any of these other services, please help us improve support for them! We're looking for maintainers for SoundCloud and PeerTube. If you intend to add a new service, please get in touch with us first! Our [docs](https://teamnewpipe.github.io/documentation/) provide more information on how a new service can be added to the app and to the [NewPipe Extractor](https://github.com/TeamNewPipe/NewPipeExtractor). ## Description NewPipe works by fetching the required data from the official API (e.g. PeerTube) of the service you're using. If the official API is restricted (e.g. YouTube) for our purposes, or is proprietary, the app parses the website or uses an internal API instead. This means that you don't need an account on any service to use NewPipe. Also, since they are free and open source software, neither the app nor the Extractor use any proprietary libraries or frameworks, such as Google Play Services. This means you can use NewPipe on devices or custom ROMs that do not have Google apps installed. ### Features * Watch videos at resolutions up to 4K * Listen to audio in the background, only loading the audio stream to save data * Popup mode (floating player, aka Picture-in-Picture) * Watch live streams * Show/hide subtitles/closed captions * Search videos and audios (on YouTube, you can specify the content language as well) * Enqueue videos (and optionally save them as local playlists) * Show/hide general information about videos (such as description and tags) * Show/hide next/related videos * Show/hide comments * Search videos, audios, channels, playlists and albums * Browse videos and audios within a channel * Subscribe to channels (yes, without logging into any account!) * Get notifications about new videos from channels you're subscribed to * Create and edit channel groups (for easier browsing and management) * Browse video feeds generated from your channel groups * View and search your watch history * Search and watch playlists (these are remote playlists, which means they're fetched from the service you're browsing) * Create and edit local playlists (these are created and saved within the app, and have nothing to do with any service) * Download videos/audios/subtitles (closed captions) * Open in Kodi * Watch/Block age-restricted material ## Installation and updates You can install NewPipe using one of the following methods: 1. Add our custom repo to F-Droid and install it from there. The instructions are here: https://newpipe.net/FAQ/tutorials/install-add-fdroid-repo/ 2. Download the APK from [GitHub Releases](https://github.com/TeamNewPipe/NewPipe/releases), [compare the signing key](#apk-info) and install it. 3. Update via F-Droid. This is the slowest method of getting updates, as F-Droid must recognize changes, build the APK itself, sign it, and then push the update to users. 4. Build a debug APK yourself. This is the fastest way to get new features on your device, but is much more complicated, so we recommend using one of the other methods. 5. If you're interested in a specific feature or bugfix provided in a Pull Request in this repo, you can also download its APK from within the PR. Read the PR description for instructions. The great thing about PR-specific APKs is that they're installed side-by-side the official app, so you don't have to worry about losing your data or messing anything up. We recommend method 1 for most users. APKs installed using method 1 or 2 are compatible with each other (meaning that if you installed NewPipe using either method 1 or 2, you can also update NewPipe using the other), but not with those installed using method 3. This is due to the same signing key (ours) being used for 1 and 2, but a different signing key (F-Droid's) being used for 3. Building a debug APK using method 4 excludes a key entirely. Signing keys help ensure that a user isn't tricked into installing a malicious update to an app. When using method 5, each APK is signed with a different random key supplied by GitHub Actions, so you cannot even update it. You will have to backup and restore the app data each time you wish to use a new APK. In the meanwhile, if you want to switch sources for some reason (e.g. NewPipe's core functionality breaks and F-Droid doesn't have the latest update yet), we recommend following this procedure: 1. Back up your data via Settings > Backup and Restore > Export Database so you keep your history, subscriptions, and playlists 2. Uninstall NewPipe 3. Download the APK from the new source and install it 4. Import the data from step 1 via Settings > Backup and Restore > Import Database > [!Note] > When you're importing a database into the official app, always make sure that it is the one you exported _from_ the official app. If you import a database exported from an APK other than the official app, it may break things. Such an action is unsupported, and you should only do so when you're absolutely certain you know what you're doing. ### APK Info This is the SHA fingerprint of NewPipe's signing key to verify downloaded APKs which are signed by us. The fingerprint is also available on [NewPipe's website](https://newpipe.net#download). This is relevant for method 2. ``` CB:84:06:9B:D6:81:16:BA:FA:E5:EE:4E:E5:B0:8A:56:7A:A6:D8:98:40:4E:7C:B1:2F:9E:75:6D:F5:CF:5C:AB ``` ## Contribution Whether you have ideas, translations, design changes, code cleaning, or even major code changes, help is always welcome. The app gets better and better with each contribution, no matter how big or small! If you'd like to get involved, check our [contribution notes](.github/CONTRIBUTING.md). Translation status ## Donate If you like NewPipe, you're welcome to send a donation. We prefer Liberapay, as it is both open-source and non-profit. For further info on donating to NewPipe, please visit our [website](https://newpipe.net/donate).
Liberapay Visit NewPipe at liberapay.com Donate via Liberapay
## Privacy Policy The NewPipe project aims to provide a private, anonymous experience for using web-based media services. Therefore, the app does not collect any data without your consent. NewPipe's privacy policy explains in detail what data is sent and stored when you send a crash report, or leave a comment in our blog. You can find the document [here](https://newpipe.net/legal/privacy/). ## License [![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](https://www.gnu.org/licenses/gpl-3.0.en.html) NewPipe is Free Software: You can use, study, share, and improve it at will. Specifically you can redistribute and/or modify it under the terms of the [GNU General Public License](https://www.gnu.org/licenses/gpl.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. ================================================ FILE: app/build.gradle.kts ================================================ /* * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ import com.android.build.api.dsl.ApplicationExtension plugins { alias(libs.plugins.android.application) alias(libs.plugins.jetbrains.kotlin.android) alias(libs.plugins.jetbrains.kotlin.kapt) alias(libs.plugins.google.ksp) alias(libs.plugins.jetbrains.kotlin.parcelize) alias(libs.plugins.jetbrains.kotlinx.serialization) alias(libs.plugins.sonarqube) checkstyle } val gitWorkingBranch = providers.exec { commandLine("git", "rev-parse", "--abbrev-ref", "HEAD") }.standardOutput.asText.map { it.trim() } java { toolchain { languageVersion = JavaLanguageVersion.of(17) } } kotlin { compilerOptions { // TODO: Drop annotation default target when it is stable freeCompilerArgs.addAll( "-Xannotation-default-target=param-property" ) } } configure { compileSdk = 36 namespace = "org.schabi.newpipe" defaultConfig { applicationId = "org.schabi.newpipe" resValue("string", "app_name", "NewPipe") minSdk = 21 targetSdk = 35 versionCode = System.getProperty("versionCodeOverride")?.toInt() ?: 1009 versionName = "0.28.4" System.getProperty("versionNameSuffix")?.let { versionNameSuffix = it } testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { debug { isDebuggable = true // suffix the app id and the app name with git branch name val defaultBranches = listOf("master", "dev") val workingBranch = gitWorkingBranch.getOrElse("") val normalizedWorkingBranch = workingBranch .replaceFirst("^[^A-Za-z]+".toRegex(), "") .replace("[^0-9A-Za-z]+".toRegex(), "") if (normalizedWorkingBranch.isEmpty() || workingBranch in defaultBranches) { // default values when branch name could not be determined or is master or dev applicationIdSuffix = ".debug" resValue("string", "app_name", "NewPipe Debug") } else { applicationIdSuffix = ".debug.$normalizedWorkingBranch" resValue("string", "app_name", "NewPipe $workingBranch") } } release { System.getProperty("packageSuffix")?.let { suffix -> applicationIdSuffix = suffix resValue("string", "app_name", "NewPipe $suffix") } isMinifyEnabled = true isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } lint { lintConfig = file("lint.xml") // Continue the debug build even when errors are found abortOnError = false } compileOptions { // Flag to enable support for the new language APIs isCoreLibraryDesugaringEnabled = true encoding = "utf-8" } sourceSets { getByName("androidTest") { assets.directories += "$projectDir/schemas" } } androidResources { generateLocaleConfig = true } buildFeatures { viewBinding = true buildConfig = true resValues = true } packaging { resources { // remove two files which belong to jsoup // no idea how they ended up in the META-INF dir... excludes += setOf( "META-INF/README.md", "META-INF/CHANGES", "META-INF/COPYRIGHT" // "COPYRIGHT" belongs to RxJava... ) } } } ksp { arg("room.schemaLocation", "$projectDir/schemas") } // Custom dependency configuration for ktlint val ktlint by configurations.creating // https://checkstyle.org/#JRE_and_JDK tasks.withType().configureEach { javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(21) } } checkstyle { configDirectory = rootProject.file("checkstyle") isIgnoreFailures = false isShowViolations = true toolVersion = libs.versions.checkstyle.get() } tasks.register("runCheckstyle") { source("src") include("**/*.java") exclude("**/gen/**") exclude("**/R.java") exclude("**/BuildConfig.java") exclude("main/java/us/shandian/giga/**") classpath = configurations.getByName("checkstyle") isShowViolations = true reports { xml.required = true html.required = true } } val outputDir = project.layout.buildDirectory.dir("reports/ktlint/") val inputFiles = fileTree("src") { include("**/*.kt") } tasks.register("runKtlint") { inputs.files(inputFiles) outputs.dir(outputDir) mainClass.set("com.pinterest.ktlint.Main") classpath = configurations.getByName("ktlint") args = listOf("--editorconfig=../.editorconfig", "src/**/*.kt") jvmArgs = listOf("--add-opens", "java.base/java.lang=ALL-UNNAMED") } tasks.register("formatKtlint") { inputs.files(inputFiles) outputs.dir(outputDir) mainClass.set("com.pinterest.ktlint.Main") classpath = configurations.getByName("ktlint") args = listOf("--editorconfig=../.editorconfig", "-F", "src/**/*.kt") jvmArgs = listOf("--add-opens", "java.base/java.lang=ALL-UNNAMED") } tasks.register("checkDependenciesOrder") { tomlFile = layout.projectDirectory.file("../gradle/libs.versions.toml") } afterEvaluate { tasks.named("preDebugBuild").configure { if (!System.getProperties().containsKey("skipFormatKtlint")) { dependsOn("formatKtlint") } dependsOn("runCheckstyle", "runKtlint", "checkDependenciesOrder") } } sonar { properties { property("sonar.projectKey", "TeamNewPipe_NewPipe") property("sonar.organization", "teamnewpipe") property("sonar.host.url", "https://sonarcloud.io") } } dependencies { /** Desugaring **/ coreLibraryDesugaring(libs.android.desugar) /** NewPipe libraries **/ implementation(libs.newpipe.nanojson) implementation(libs.newpipe.extractor) implementation(libs.newpipe.filepicker) /** Checkstyle **/ checkstyle(libs.puppycrawl.checkstyle) ktlint(libs.pinterest.ktlint) /** AndroidX **/ implementation(libs.androidx.appcompat) implementation(libs.androidx.cardview) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.core) implementation(libs.androidx.documentfile) implementation(libs.androidx.fragment) implementation(libs.androidx.lifecycle.livedata) implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.localbroadcastmanager) implementation(libs.androidx.media) implementation(libs.androidx.preference) implementation(libs.androidx.recyclerview) implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.rxjava3) ksp(libs.androidx.room.compiler) implementation(libs.androidx.swiperefreshlayout) implementation(libs.androidx.viewpager2) implementation(libs.androidx.work.runtime) implementation(libs.androidx.work.rxjava3) implementation(libs.google.android.material) implementation(libs.androidx.webkit) // Coroutines interop implementation(libs.kotlinx.coroutines.rx3) // Kotlinx Serialization implementation(libs.kotlinx.serialization.json) /** Third-party libraries **/ implementation(libs.livefront.bridge) implementation(libs.evernote.statesaver.core) kapt(libs.evernote.statesaver.compiler) // HTML parser implementation(libs.jsoup) // HTTP client implementation(libs.squareup.okhttp) // Media player implementation(libs.google.exoplayer.core) implementation(libs.google.exoplayer.dash) implementation(libs.google.exoplayer.database) implementation(libs.google.exoplayer.datasource) implementation(libs.google.exoplayer.hls) implementation(libs.google.exoplayer.mediasession) implementation(libs.google.exoplayer.smoothstreaming) implementation(libs.google.exoplayer.ui) // Manager for complex RecyclerView layouts implementation(libs.lisawray.groupie.core) implementation(libs.lisawray.groupie.viewbinding) // Image loading implementation(libs.coil.compose) implementation(libs.coil.network.okhttp) // Markdown library for Android implementation(libs.noties.markwon.core) implementation(libs.noties.markwon.linkify) // Crash reporting implementation(libs.acra.core) compileOnly(libs.google.autoservice.annotations) ksp(libs.zacsweers.autoservice.compiler) // Properly restarting implementation(libs.jakewharton.phoenix) // Reactive extensions for Java VM implementation(libs.reactivex.rxjava) implementation(libs.reactivex.rxandroid) // RxJava binding APIs for Android UI widgets implementation(libs.jakewharton.rxbinding) // Date and time formatting implementation(libs.ocpsoft.prettytime) /** Debugging **/ // Memory leak detection debugImplementation(libs.squareup.leakcanary.watcher) debugImplementation(libs.squareup.leakcanary.plumber) debugImplementation(libs.squareup.leakcanary.core) // Debug bridge for Android debugImplementation(libs.facebook.stetho.core) debugImplementation(libs.facebook.stetho.okhttp3) /** Testing **/ testImplementation(libs.junit) testImplementation(libs.mockito.core) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.runner) androidTestImplementation(libs.androidx.room.testing) androidTestImplementation(libs.assertj.core) } ================================================ FILE: app/lint.xml ================================================ ================================================ FILE: app/proguard-rules.pro ================================================ # https://developer.android.com/build/shrink-code ## Helps debug release versions -dontobfuscate ## Rules for NewPipeExtractor -keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; } ## Rules for Rhino and Rhino Engine -keep class org.mozilla.javascript.* { *; } -keep class org.mozilla.javascript.** { *; } -keep class org.mozilla.javascript.engine.** { *; } -keep class org.mozilla.classfile.ClassFileWriter -dontwarn org.mozilla.javascript.JavaToJSONConverters -dontwarn org.mozilla.javascript.tools.** -keep class javax.script.** { *; } -dontwarn javax.script.** -keep class jdk.dynalink.** { *; } -dontwarn jdk.dynalink.** # Rules for jsoup # Ignore intended-to-be-optional re2j classes - only needed if using re2j for jsoup regex # jsoup safely falls back to JDK regex if re2j not on classpath, but has concrete re2j refs # See https://github.com/jhy/jsoup/issues/2459 - may be resolved in future, then this may be removed -dontwarn com.google.re2j.** ## Rules for ExoPlayer -keep class com.google.android.exoplayer2.** { *; } ## Rules for OkHttp. Copy pasted from https://github.com/square/okhttp -dontwarn okhttp3.** -dontwarn okio.** ## See https://github.com/TeamNewPipe/NewPipe/pull/1441 -keepclassmembers class * implements java.io.Serializable { static final long serialVersionUID; !static !transient ; private void writeObject(java.io.ObjectOutputStream); private void readObject(java.io.ObjectInputStream); } ## For some reason NotificationModeConfigFragment wasn't kept (only referenced in a preference xml) -keep class org.schabi.newpipe.settings.notifications.** { *; } # Prevent R8 from stripping or renaming Protobuf internal fields -keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { ; } ## Keep Kotlinx Serialization classes -keepclassmembers class kotlinx.serialization.json.** { *** Companion; } -keepclasseswithmembers class kotlinx.serialization.json.** { kotlinx.serialization.KSerializer serializer(...); } -keep,includedescriptorclasses class org.schabi.newpipe.**$$serializer { *; } -keepclassmembers class org.schabi.newpipe.** { *** Companion; } -keepclasseswithmembers class org.schabi.newpipe.** { kotlinx.serialization.KSerializer serializer(...); } ================================================ FILE: app/sampledata/channels.json ================================================ { "data": [ { "name": "BBC", "additional": "12K subscribers•233 videos", "description": "The BBC is the world’s leading public service broadcaster. We’re impartial and independent, and every day we create distinctive, world-class programmes and content which inform, educate and entertain millions of people in the UK and around the world. SUBSCRIBE to our YouTube channel to get the best of BBC entertainment and comedy programmes, stories from science and nature documentaries, and much more! https://bit.ly/2IXqEIn Get ALL your fresh TV, and sofa-hugging box sets on iPlayer https://bbc.in/2J18jYJ" }, { "name": "Linus Tech Tips", "additional": "1M subscribers•233 videos", "description": "Looking for a Tech YouTuber?\n\nLinus Tech Tips is a passionate team of \"professionally curious\" experts in consumer technology and video production which aims to inform and educate people of all ages through our entertaining videos. We create product reviews, step-by-step computer build guides, and a variety of other tech-focused content.\n\nSchedule:\nNew videos every Saturday to Thursday @ 10:00am Pacific\nLive WAN Show podcasts every Friday @ ~5:00pm Pacific" }, { "name": "Marques Brownlee", "additional": "13 subscribers•12K videos", "description": "MKBHD: Quality Tech Videos | YouTuber | Geek | Consumer Electronics | Tech Head | Internet Personality!\n\nbusiness@MKBHD.com\n\nNYC" } ] } ================================================ FILE: app/schemas/org.schabi.newpipe.database.AppDatabase/2.json ================================================ { "formatVersion": 1, "database": { "version": 2, "identityHash": "b7856223e2595ddf20a3ce6243ce9527", "entities": [ { "tableName": "subscriptions", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "avatarUrl", "columnName": "avatar_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "subscriberCount", "columnName": "subscriber_count", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_subscriptions_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "createSql": "CREATE UNIQUE INDEX `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "search_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "creationDate", "columnName": "creation_date", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "search", "columnName": "search", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_search_history_search", "unique": false, "columnNames": [ "search" ], "createSql": "CREATE INDEX `index_search_history_search` ON `${TABLE_NAME}` (`search`)" } ], "foreignKeys": [] }, { "tableName": "streams", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `title` TEXT, `stream_type` TEXT, `duration` INTEGER, `uploader` TEXT, `thumbnail_url` TEXT)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": false }, { "fieldPath": "streamType", "columnName": "stream_type", "affinity": "TEXT", "notNull": false }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "uploader", "columnName": "uploader", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_streams_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "createSql": "CREATE UNIQUE INDEX `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "stream_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", "fields": [ { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "accessDate", "columnName": "access_date", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "repeatCount", "columnName": "repeat_count", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "stream_id", "access_date" ], "autoGenerate": false }, "indices": [ { "name": "index_stream_history_stream_id", "unique": false, "columnNames": [ "stream_id" ], "createSql": "CREATE INDEX `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)" } ], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "stream_state", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", "fields": [ { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "progressTime", "columnName": "progress_time", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "stream_id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "playlists", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `thumbnail_url` TEXT)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_playlists_name", "unique": false, "columnNames": [ "name" ], "createSql": "CREATE INDEX `index_playlists_name` ON `${TABLE_NAME}` (`name`)" } ], "foreignKeys": [] }, { "tableName": "playlist_stream_join", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "playlistUid", "columnName": "playlist_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "index", "columnName": "join_index", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "playlist_id", "join_index" ], "autoGenerate": false }, "indices": [ { "name": "index_playlist_stream_join_playlist_id_join_index", "unique": true, "columnNames": [ "playlist_id", "join_index" ], "createSql": "CREATE UNIQUE INDEX `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)" }, { "name": "index_playlist_stream_join_stream_id", "unique": false, "columnNames": [ "stream_id" ], "createSql": "CREATE INDEX `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)" } ], "foreignKeys": [ { "table": "playlists", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "playlist_id" ], "referencedColumns": [ "uid" ] }, { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "remote_playlists", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "uploader", "columnName": "uploader", "affinity": "TEXT", "notNull": false }, { "fieldPath": "streamCount", "columnName": "stream_count", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_remote_playlists_name", "unique": false, "columnNames": [ "name" ], "createSql": "CREATE INDEX `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)" }, { "name": "index_remote_playlists_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "createSql": "CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] } ], "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, \"b7856223e2595ddf20a3ce6243ce9527\")" ] } } ================================================ FILE: app/schemas/org.schabi.newpipe.database.AppDatabase/3.json ================================================ { "formatVersion": 1, "database": { "version": 3, "identityHash": "9f825b1ee281480bedd38b971feac327", "entities": [ { "tableName": "subscriptions", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "avatarUrl", "columnName": "avatar_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "subscriberCount", "columnName": "subscriber_count", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_subscriptions_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "createSql": "CREATE UNIQUE INDEX `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "search_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "creationDate", "columnName": "creation_date", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "search", "columnName": "search", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_search_history_search", "unique": false, "columnNames": [ "search" ], "createSql": "CREATE INDEX `index_search_history_search` ON `${TABLE_NAME}` (`search`)" } ], "foreignKeys": [] }, { "tableName": "streams", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "streamType", "columnName": "stream_type", "affinity": "TEXT", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uploader", "columnName": "uploader", "affinity": "TEXT", "notNull": true }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "viewCount", "columnName": "view_count", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "textualUploadDate", "columnName": "textual_upload_date", "affinity": "TEXT", "notNull": false }, { "fieldPath": "uploadDate", "columnName": "upload_date", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "isUploadDateApproximation", "columnName": "is_upload_date_approximation", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_streams_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "createSql": "CREATE UNIQUE INDEX `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "stream_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", "fields": [ { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "accessDate", "columnName": "access_date", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "repeatCount", "columnName": "repeat_count", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "stream_id", "access_date" ], "autoGenerate": false }, "indices": [ { "name": "index_stream_history_stream_id", "unique": false, "columnNames": [ "stream_id" ], "createSql": "CREATE INDEX `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)" } ], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "stream_state", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", "fields": [ { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "progressTime", "columnName": "progress_time", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "stream_id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "playlists", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `thumbnail_url` TEXT)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_playlists_name", "unique": false, "columnNames": [ "name" ], "createSql": "CREATE INDEX `index_playlists_name` ON `${TABLE_NAME}` (`name`)" } ], "foreignKeys": [] }, { "tableName": "playlist_stream_join", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "playlistUid", "columnName": "playlist_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "index", "columnName": "join_index", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "playlist_id", "join_index" ], "autoGenerate": false }, "indices": [ { "name": "index_playlist_stream_join_playlist_id_join_index", "unique": true, "columnNames": [ "playlist_id", "join_index" ], "createSql": "CREATE UNIQUE INDEX `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)" }, { "name": "index_playlist_stream_join_stream_id", "unique": false, "columnNames": [ "stream_id" ], "createSql": "CREATE INDEX `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)" } ], "foreignKeys": [ { "table": "playlists", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "playlist_id" ], "referencedColumns": [ "uid" ] }, { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "remote_playlists", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "uploader", "columnName": "uploader", "affinity": "TEXT", "notNull": false }, { "fieldPath": "streamCount", "columnName": "stream_count", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_remote_playlists_name", "unique": false, "columnNames": [ "name" ], "createSql": "CREATE INDEX `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)" }, { "name": "index_remote_playlists_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "createSql": "CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "feed", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "streamId", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "stream_id", "subscription_id" ], "autoGenerate": false }, "indices": [ { "name": "index_feed_subscription_id", "unique": false, "columnNames": [ "subscription_id" ], "createSql": "CREATE INDEX `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" } ], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] }, { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "feed_group", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "icon", "columnName": "icon_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortOrder", "columnName": "sort_order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_feed_group_sort_order", "unique": false, "columnNames": [ "sort_order" ], "createSql": "CREATE INDEX `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)" } ], "foreignKeys": [] }, { "tableName": "feed_group_subscription_join", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "feedGroupId", "columnName": "group_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "group_id", "subscription_id" ], "autoGenerate": false }, "indices": [ { "name": "index_feed_group_subscription_join_subscription_id", "unique": false, "columnNames": [ "subscription_id" ], "createSql": "CREATE INDEX `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" } ], "foreignKeys": [ { "table": "feed_group", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "group_id" ], "referencedColumns": [ "uid" ] }, { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "feed_last_updated", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastUpdated", "columnName": "last_updated", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "subscription_id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [ { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] } ], "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, '9f825b1ee281480bedd38b971feac327')" ] } } ================================================ FILE: app/schemas/org.schabi.newpipe.database.AppDatabase/4.json ================================================ { "formatVersion": 1, "database": { "version": 4, "identityHash": "d8070091972a7011bce18aed62f80b90", "entities": [ { "tableName": "subscriptions", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "avatarUrl", "columnName": "avatar_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "subscriberCount", "columnName": "subscriber_count", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_subscriptions_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "search_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "creationDate", "columnName": "creation_date", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "search", "columnName": "search", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_search_history_search", "unique": false, "columnNames": [ "search" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)" } ], "foreignKeys": [] }, { "tableName": "streams", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "streamType", "columnName": "stream_type", "affinity": "TEXT", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uploader", "columnName": "uploader", "affinity": "TEXT", "notNull": true }, { "fieldPath": "uploaderUrl", "columnName": "uploader_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "viewCount", "columnName": "view_count", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "textualUploadDate", "columnName": "textual_upload_date", "affinity": "TEXT", "notNull": false }, { "fieldPath": "uploadDate", "columnName": "upload_date", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "isUploadDateApproximation", "columnName": "is_upload_date_approximation", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_streams_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "stream_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", "fields": [ { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "accessDate", "columnName": "access_date", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "repeatCount", "columnName": "repeat_count", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "stream_id", "access_date" ], "autoGenerate": false }, "indices": [ { "name": "index_stream_history_stream_id", "unique": false, "columnNames": [ "stream_id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)" } ], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "stream_state", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", "fields": [ { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "progressMillis", "columnName": "progress_time", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "stream_id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "playlists", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `thumbnail_url` TEXT)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_playlists_name", "unique": false, "columnNames": [ "name" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`name`)" } ], "foreignKeys": [] }, { "tableName": "playlist_stream_join", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "playlistUid", "columnName": "playlist_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "index", "columnName": "join_index", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "playlist_id", "join_index" ], "autoGenerate": false }, "indices": [ { "name": "index_playlist_stream_join_playlist_id_join_index", "unique": true, "columnNames": [ "playlist_id", "join_index" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)" }, { "name": "index_playlist_stream_join_stream_id", "unique": false, "columnNames": [ "stream_id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)" } ], "foreignKeys": [ { "table": "playlists", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "playlist_id" ], "referencedColumns": [ "uid" ] }, { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "remote_playlists", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "uploader", "columnName": "uploader", "affinity": "TEXT", "notNull": false }, { "fieldPath": "streamCount", "columnName": "stream_count", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_remote_playlists_name", "unique": false, "columnNames": [ "name" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)" }, { "name": "index_remote_playlists_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "feed", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "streamId", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "stream_id", "subscription_id" ], "autoGenerate": false }, "indices": [ { "name": "index_feed_subscription_id", "unique": false, "columnNames": [ "subscription_id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" } ], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] }, { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "feed_group", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "icon", "columnName": "icon_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortOrder", "columnName": "sort_order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_feed_group_sort_order", "unique": false, "columnNames": [ "sort_order" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)" } ], "foreignKeys": [] }, { "tableName": "feed_group_subscription_join", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "feedGroupId", "columnName": "group_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "group_id", "subscription_id" ], "autoGenerate": false }, "indices": [ { "name": "index_feed_group_subscription_join_subscription_id", "unique": false, "columnNames": [ "subscription_id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" } ], "foreignKeys": [ { "table": "feed_group", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "group_id" ], "referencedColumns": [ "uid" ] }, { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "feed_last_updated", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastUpdated", "columnName": "last_updated", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "subscription_id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [ { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] } ], "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, 'd8070091972a7011bce18aed62f80b90')" ] } } ================================================ FILE: app/schemas/org.schabi.newpipe.database.AppDatabase/5.json ================================================ { "formatVersion": 1, "database": { "version": 5, "identityHash": "096731b513bb71dd44517639f4a2c1e3", "entities": [ { "tableName": "subscriptions", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "avatarUrl", "columnName": "avatar_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "subscriberCount", "columnName": "subscriber_count", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "notificationMode", "columnName": "notification_mode", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_subscriptions_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "search_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT)", "fields": [ { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "creationDate", "columnName": "creation_date", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "search", "columnName": "search", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_search_history_search", "unique": false, "columnNames": [ "search" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)" } ], "foreignKeys": [] }, { "tableName": "streams", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "streamType", "columnName": "stream_type", "affinity": "TEXT", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uploader", "columnName": "uploader", "affinity": "TEXT", "notNull": true }, { "fieldPath": "uploaderUrl", "columnName": "uploader_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "viewCount", "columnName": "view_count", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "textualUploadDate", "columnName": "textual_upload_date", "affinity": "TEXT", "notNull": false }, { "fieldPath": "uploadDate", "columnName": "upload_date", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "isUploadDateApproximation", "columnName": "is_upload_date_approximation", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_streams_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "stream_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", "fields": [ { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "accessDate", "columnName": "access_date", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "repeatCount", "columnName": "repeat_count", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "stream_id", "access_date" ], "autoGenerate": false }, "indices": [ { "name": "index_stream_history_stream_id", "unique": false, "columnNames": [ "stream_id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)" } ], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "stream_state", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", "fields": [ { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "progressMillis", "columnName": "progress_time", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "stream_id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "playlists", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `thumbnail_url` TEXT)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_playlists_name", "unique": false, "columnNames": [ "name" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`name`)" } ], "foreignKeys": [] }, { "tableName": "playlist_stream_join", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "playlistUid", "columnName": "playlist_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "index", "columnName": "join_index", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "playlist_id", "join_index" ], "autoGenerate": false }, "indices": [ { "name": "index_playlist_stream_join_playlist_id_join_index", "unique": true, "columnNames": [ "playlist_id", "join_index" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)" }, { "name": "index_playlist_stream_join_stream_id", "unique": false, "columnNames": [ "stream_id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)" } ], "foreignKeys": [ { "table": "playlists", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "playlist_id" ], "referencedColumns": [ "uid" ] }, { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "remote_playlists", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "uploader", "columnName": "uploader", "affinity": "TEXT", "notNull": false }, { "fieldPath": "streamCount", "columnName": "stream_count", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_remote_playlists_name", "unique": false, "columnNames": [ "name" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)" }, { "name": "index_remote_playlists_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "feed", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "streamId", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "stream_id", "subscription_id" ], "autoGenerate": false }, "indices": [ { "name": "index_feed_subscription_id", "unique": false, "columnNames": [ "subscription_id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" } ], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] }, { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "feed_group", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "icon", "columnName": "icon_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortOrder", "columnName": "sort_order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_feed_group_sort_order", "unique": false, "columnNames": [ "sort_order" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)" } ], "foreignKeys": [] }, { "tableName": "feed_group_subscription_join", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "feedGroupId", "columnName": "group_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "group_id", "subscription_id" ], "autoGenerate": false }, "indices": [ { "name": "index_feed_group_subscription_join_subscription_id", "unique": false, "columnNames": [ "subscription_id" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" } ], "foreignKeys": [ { "table": "feed_group", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "group_id" ], "referencedColumns": [ "uid" ] }, { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "feed_last_updated", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastUpdated", "columnName": "last_updated", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "subscription_id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [ { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] } ], "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, '096731b513bb71dd44517639f4a2c1e3')" ] } } ================================================ FILE: app/schemas/org.schabi.newpipe.database.AppDatabase/6.json ================================================ { "formatVersion": 1, "database": { "version": 6, "identityHash": "4084aa342aef315dc7b558770a7755a9", "entities": [ { "tableName": "subscriptions", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "avatarUrl", "columnName": "avatar_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "subscriberCount", "columnName": "subscriber_count", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "notificationMode", "columnName": "notification_mode", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_subscriptions_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "search_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", "fields": [ { "fieldPath": "creationDate", "columnName": "creation_date", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "search", "columnName": "search", "affinity": "TEXT", "notNull": false }, { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_search_history_search", "unique": false, "columnNames": [ "search" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)" } ], "foreignKeys": [] }, { "tableName": "streams", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "streamType", "columnName": "stream_type", "affinity": "TEXT", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uploader", "columnName": "uploader", "affinity": "TEXT", "notNull": true }, { "fieldPath": "uploaderUrl", "columnName": "uploader_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "viewCount", "columnName": "view_count", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "textualUploadDate", "columnName": "textual_upload_date", "affinity": "TEXT", "notNull": false }, { "fieldPath": "uploadDate", "columnName": "upload_date", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "isUploadDateApproximation", "columnName": "is_upload_date_approximation", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_streams_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "stream_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", "fields": [ { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "accessDate", "columnName": "access_date", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "repeatCount", "columnName": "repeat_count", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "stream_id", "access_date" ], "autoGenerate": false }, "indices": [ { "name": "index_stream_history_stream_id", "unique": false, "columnNames": [ "stream_id" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)" } ], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "stream_state", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", "fields": [ { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "progressMillis", "columnName": "progress_time", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "stream_id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "playlists", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `thumbnail_url` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isThumbnailPermanent", "columnName": "is_thumbnail_permanent", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_playlists_name", "unique": false, "columnNames": [ "name" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`name`)" } ], "foreignKeys": [] }, { "tableName": "playlist_stream_join", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "playlistUid", "columnName": "playlist_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "index", "columnName": "join_index", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "playlist_id", "join_index" ], "autoGenerate": false }, "indices": [ { "name": "index_playlist_stream_join_playlist_id_join_index", "unique": true, "columnNames": [ "playlist_id", "join_index" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)" }, { "name": "index_playlist_stream_join_stream_id", "unique": false, "columnNames": [ "stream_id" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)" } ], "foreignKeys": [ { "table": "playlists", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "playlist_id" ], "referencedColumns": [ "uid" ] }, { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "remote_playlists", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "uploader", "columnName": "uploader", "affinity": "TEXT", "notNull": false }, { "fieldPath": "streamCount", "columnName": "stream_count", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_remote_playlists_name", "unique": false, "columnNames": [ "name" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)" }, { "name": "index_remote_playlists_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "feed", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "streamId", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "stream_id", "subscription_id" ], "autoGenerate": false }, "indices": [ { "name": "index_feed_subscription_id", "unique": false, "columnNames": [ "subscription_id" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" } ], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] }, { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "feed_group", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "icon", "columnName": "icon_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortOrder", "columnName": "sort_order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_feed_group_sort_order", "unique": false, "columnNames": [ "sort_order" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)" } ], "foreignKeys": [] }, { "tableName": "feed_group_subscription_join", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "feedGroupId", "columnName": "group_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "group_id", "subscription_id" ], "autoGenerate": false }, "indices": [ { "name": "index_feed_group_subscription_join_subscription_id", "unique": false, "columnNames": [ "subscription_id" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" } ], "foreignKeys": [ { "table": "feed_group", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "group_id" ], "referencedColumns": [ "uid" ] }, { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "feed_last_updated", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastUpdated", "columnName": "last_updated", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "subscription_id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [ { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] } ], "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, '4084aa342aef315dc7b558770a7755a9')" ] } } ================================================ FILE: app/schemas/org.schabi.newpipe.database.AppDatabase/7.json ================================================ { "formatVersion": 1, "database": { "version": 7, "identityHash": "012fc8e7ad3333f1597347f34e76a513", "entities": [ { "tableName": "subscriptions", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "avatarUrl", "columnName": "avatar_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "subscriberCount", "columnName": "subscriber_count", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "notificationMode", "columnName": "notification_mode", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_subscriptions_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "search_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", "fields": [ { "fieldPath": "creationDate", "columnName": "creation_date", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "search", "columnName": "search", "affinity": "TEXT", "notNull": false }, { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "id" ], "autoGenerate": true }, "indices": [ { "name": "index_search_history_search", "unique": false, "columnNames": [ "search" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)" } ], "foreignKeys": [] }, { "tableName": "streams", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "streamType", "columnName": "stream_type", "affinity": "TEXT", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uploader", "columnName": "uploader", "affinity": "TEXT", "notNull": true }, { "fieldPath": "uploaderUrl", "columnName": "uploader_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "viewCount", "columnName": "view_count", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "textualUploadDate", "columnName": "textual_upload_date", "affinity": "TEXT", "notNull": false }, { "fieldPath": "uploadDate", "columnName": "upload_date", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "isUploadDateApproximation", "columnName": "is_upload_date_approximation", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_streams_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "stream_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", "fields": [ { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "accessDate", "columnName": "access_date", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "repeatCount", "columnName": "repeat_count", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "stream_id", "access_date" ], "autoGenerate": false }, "indices": [ { "name": "index_stream_history_stream_id", "unique": false, "columnNames": [ "stream_id" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)" } ], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "stream_state", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", "fields": [ { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "progressMillis", "columnName": "progress_time", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "stream_id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "playlists", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, `thumbnail_stream_id` INTEGER NOT NULL)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isThumbnailPermanent", "columnName": "is_thumbnail_permanent", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "thumbnailStreamId", "columnName": "thumbnail_stream_id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_playlists_name", "unique": false, "columnNames": [ "name" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`name`)" } ], "foreignKeys": [] }, { "tableName": "playlist_stream_join", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "playlistUid", "columnName": "playlist_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "index", "columnName": "join_index", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "playlist_id", "join_index" ], "autoGenerate": false }, "indices": [ { "name": "index_playlist_stream_join_playlist_id_join_index", "unique": true, "columnNames": [ "playlist_id", "join_index" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)" }, { "name": "index_playlist_stream_join_stream_id", "unique": false, "columnNames": [ "stream_id" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)" } ], "foreignKeys": [ { "table": "playlists", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "playlist_id" ], "referencedColumns": [ "uid" ] }, { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "remote_playlists", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "uploader", "columnName": "uploader", "affinity": "TEXT", "notNull": false }, { "fieldPath": "streamCount", "columnName": "stream_count", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_remote_playlists_name", "unique": false, "columnNames": [ "name" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)" }, { "name": "index_remote_playlists_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "feed", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "streamId", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "stream_id", "subscription_id" ], "autoGenerate": false }, "indices": [ { "name": "index_feed_subscription_id", "unique": false, "columnNames": [ "subscription_id" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" } ], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] }, { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "feed_group", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "icon", "columnName": "icon_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortOrder", "columnName": "sort_order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "uid" ], "autoGenerate": true }, "indices": [ { "name": "index_feed_group_sort_order", "unique": false, "columnNames": [ "sort_order" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)" } ], "foreignKeys": [] }, { "tableName": "feed_group_subscription_join", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "feedGroupId", "columnName": "group_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "columnNames": [ "group_id", "subscription_id" ], "autoGenerate": false }, "indices": [ { "name": "index_feed_group_subscription_join_subscription_id", "unique": false, "columnNames": [ "subscription_id" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" } ], "foreignKeys": [ { "table": "feed_group", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "group_id" ], "referencedColumns": [ "uid" ] }, { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "feed_last_updated", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastUpdated", "columnName": "last_updated", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "columnNames": [ "subscription_id" ], "autoGenerate": false }, "indices": [], "foreignKeys": [ { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] } ], "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')" ] } } ================================================ FILE: app/schemas/org.schabi.newpipe.database.AppDatabase/8.json ================================================ { "formatVersion": 1, "database": { "version": 8, "identityHash": "012fc8e7ad3333f1597347f34e76a513", "entities": [ { "tableName": "subscriptions", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "avatarUrl", "columnName": "avatar_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "subscriberCount", "columnName": "subscriber_count", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "notificationMode", "columnName": "notification_mode", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "uid" ] }, "indices": [ { "name": "index_subscriptions_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "search_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", "fields": [ { "fieldPath": "creationDate", "columnName": "creation_date", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "search", "columnName": "search", "affinity": "TEXT", "notNull": false }, { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_search_history_search", "unique": false, "columnNames": [ "search" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)" } ], "foreignKeys": [] }, { "tableName": "streams", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "streamType", "columnName": "stream_type", "affinity": "TEXT", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uploader", "columnName": "uploader", "affinity": "TEXT", "notNull": true }, { "fieldPath": "uploaderUrl", "columnName": "uploader_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "viewCount", "columnName": "view_count", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "textualUploadDate", "columnName": "textual_upload_date", "affinity": "TEXT", "notNull": false }, { "fieldPath": "uploadDate", "columnName": "upload_date", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "isUploadDateApproximation", "columnName": "is_upload_date_approximation", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "uid" ] }, "indices": [ { "name": "index_streams_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "stream_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", "fields": [ { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "accessDate", "columnName": "access_date", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "repeatCount", "columnName": "repeat_count", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "stream_id", "access_date" ] }, "indices": [ { "name": "index_stream_history_stream_id", "unique": false, "columnNames": [ "stream_id" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)" } ], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "stream_state", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", "fields": [ { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "progressMillis", "columnName": "progress_time", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "stream_id" ] }, "indices": [], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "playlists", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, `thumbnail_stream_id` INTEGER NOT NULL)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isThumbnailPermanent", "columnName": "is_thumbnail_permanent", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "thumbnailStreamId", "columnName": "thumbnail_stream_id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "uid" ] }, "indices": [ { "name": "index_playlists_name", "unique": false, "columnNames": [ "name" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`name`)" } ], "foreignKeys": [] }, { "tableName": "playlist_stream_join", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "playlistUid", "columnName": "playlist_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "index", "columnName": "join_index", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "playlist_id", "join_index" ] }, "indices": [ { "name": "index_playlist_stream_join_playlist_id_join_index", "unique": true, "columnNames": [ "playlist_id", "join_index" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)" }, { "name": "index_playlist_stream_join_stream_id", "unique": false, "columnNames": [ "stream_id" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)" } ], "foreignKeys": [ { "table": "playlists", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "playlist_id" ], "referencedColumns": [ "uid" ] }, { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "remote_playlists", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "uploader", "columnName": "uploader", "affinity": "TEXT", "notNull": false }, { "fieldPath": "streamCount", "columnName": "stream_count", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "uid" ] }, "indices": [ { "name": "index_remote_playlists_name", "unique": false, "columnNames": [ "name" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)" }, { "name": "index_remote_playlists_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "feed", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "streamId", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "stream_id", "subscription_id" ] }, "indices": [ { "name": "index_feed_subscription_id", "unique": false, "columnNames": [ "subscription_id" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" } ], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] }, { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "feed_group", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "icon", "columnName": "icon_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortOrder", "columnName": "sort_order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "uid" ] }, "indices": [ { "name": "index_feed_group_sort_order", "unique": false, "columnNames": [ "sort_order" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)" } ], "foreignKeys": [] }, { "tableName": "feed_group_subscription_join", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "feedGroupId", "columnName": "group_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "group_id", "subscription_id" ] }, "indices": [ { "name": "index_feed_group_subscription_join_subscription_id", "unique": false, "columnNames": [ "subscription_id" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" } ], "foreignKeys": [ { "table": "feed_group", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "group_id" ], "referencedColumns": [ "uid" ] }, { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "feed_last_updated", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastUpdated", "columnName": "last_updated", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "subscription_id" ] }, "indices": [], "foreignKeys": [ { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] } ], "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')" ] } } ================================================ FILE: app/schemas/org.schabi.newpipe.database.AppDatabase/9.json ================================================ { "formatVersion": 1, "database": { "version": 9, "identityHash": "7591e8039faa74d8c0517dc867af9d3e", "entities": [ { "tableName": "subscriptions", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "avatarUrl", "columnName": "avatar_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "subscriberCount", "columnName": "subscriber_count", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "description", "columnName": "description", "affinity": "TEXT", "notNull": false }, { "fieldPath": "notificationMode", "columnName": "notification_mode", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "uid" ] }, "indices": [ { "name": "index_subscriptions_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "search_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", "fields": [ { "fieldPath": "creationDate", "columnName": "creation_date", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "search", "columnName": "search", "affinity": "TEXT", "notNull": false }, { "fieldPath": "id", "columnName": "id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "id" ] }, "indices": [ { "name": "index_search_history_search", "unique": false, "columnNames": [ "search" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)" } ], "foreignKeys": [] }, { "tableName": "streams", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": true }, { "fieldPath": "title", "columnName": "title", "affinity": "TEXT", "notNull": true }, { "fieldPath": "streamType", "columnName": "stream_type", "affinity": "TEXT", "notNull": true }, { "fieldPath": "duration", "columnName": "duration", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "uploader", "columnName": "uploader", "affinity": "TEXT", "notNull": true }, { "fieldPath": "uploaderUrl", "columnName": "uploader_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "viewCount", "columnName": "view_count", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "textualUploadDate", "columnName": "textual_upload_date", "affinity": "TEXT", "notNull": false }, { "fieldPath": "uploadDate", "columnName": "upload_date", "affinity": "INTEGER", "notNull": false }, { "fieldPath": "isUploadDateApproximation", "columnName": "is_upload_date_approximation", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "uid" ] }, "indices": [ { "name": "index_streams_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "stream_history", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", "fields": [ { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "accessDate", "columnName": "access_date", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "repeatCount", "columnName": "repeat_count", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "stream_id", "access_date" ] }, "indices": [ { "name": "index_stream_history_stream_id", "unique": false, "columnNames": [ "stream_id" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)" } ], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "stream_state", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", "fields": [ { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "progressMillis", "columnName": "progress_time", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "stream_id" ] }, "indices": [], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "playlists", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, `thumbnail_stream_id` INTEGER NOT NULL, `display_index` INTEGER NOT NULL)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "isThumbnailPermanent", "columnName": "is_thumbnail_permanent", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "thumbnailStreamId", "columnName": "thumbnail_stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "displayIndex", "columnName": "display_index", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "uid" ] }, "indices": [], "foreignKeys": [] }, { "tableName": "playlist_stream_join", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "playlistUid", "columnName": "playlist_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "streamUid", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "index", "columnName": "join_index", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "playlist_id", "join_index" ] }, "indices": [ { "name": "index_playlist_stream_join_playlist_id_join_index", "unique": true, "columnNames": [ "playlist_id", "join_index" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)" }, { "name": "index_playlist_stream_join_stream_id", "unique": false, "columnNames": [ "stream_id" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)" } ], "foreignKeys": [ { "table": "playlists", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "playlist_id" ], "referencedColumns": [ "uid" ] }, { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "remote_playlists", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `display_index` INTEGER NOT NULL, `stream_count` INTEGER)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "serviceId", "columnName": "service_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "orderingName", "columnName": "name", "affinity": "TEXT", "notNull": false }, { "fieldPath": "url", "columnName": "url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "thumbnailUrl", "columnName": "thumbnail_url", "affinity": "TEXT", "notNull": false }, { "fieldPath": "uploader", "columnName": "uploader", "affinity": "TEXT", "notNull": false }, { "fieldPath": "displayIndex", "columnName": "display_index", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "streamCount", "columnName": "stream_count", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "uid" ] }, "indices": [ { "name": "index_remote_playlists_service_id_url", "unique": true, "columnNames": [ "service_id", "url" ], "orders": [], "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" } ], "foreignKeys": [] }, { "tableName": "feed", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "streamId", "columnName": "stream_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "stream_id", "subscription_id" ] }, "indices": [ { "name": "index_feed_subscription_id", "unique": false, "columnNames": [ "subscription_id" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" } ], "foreignKeys": [ { "table": "streams", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "stream_id" ], "referencedColumns": [ "uid" ] }, { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "feed_group", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)", "fields": [ { "fieldPath": "uid", "columnName": "uid", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "name", "columnName": "name", "affinity": "TEXT", "notNull": true }, { "fieldPath": "icon", "columnName": "icon_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "sortOrder", "columnName": "sort_order", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": true, "columnNames": [ "uid" ] }, "indices": [ { "name": "index_feed_group_sort_order", "unique": false, "columnNames": [ "sort_order" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)" } ], "foreignKeys": [] }, { "tableName": "feed_group_subscription_join", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "feedGroupId", "columnName": "group_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "group_id", "subscription_id" ] }, "indices": [ { "name": "index_feed_group_subscription_join_subscription_id", "unique": false, "columnNames": [ "subscription_id" ], "orders": [], "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" } ], "foreignKeys": [ { "table": "feed_group", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "group_id" ], "referencedColumns": [ "uid" ] }, { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] }, { "tableName": "feed_last_updated", "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "subscriptionId", "columnName": "subscription_id", "affinity": "INTEGER", "notNull": true }, { "fieldPath": "lastUpdated", "columnName": "last_updated", "affinity": "INTEGER", "notNull": false } ], "primaryKey": { "autoGenerate": false, "columnNames": [ "subscription_id" ] }, "indices": [], "foreignKeys": [ { "table": "subscriptions", "onDelete": "CASCADE", "onUpdate": "CASCADE", "columns": [ "subscription_id" ], "referencedColumns": [ "uid" ] } ] } ], "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, '7591e8039faa74d8c0517dc867af9d3e')" ] } } ================================================ FILE: app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt ================================================ package org.schabi.newpipe.database import android.content.ContentValues import android.database.sqlite.SQLiteDatabase import androidx.room.Room import androidx.room.testing.MigrationTestHelper import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNull import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.schabi.newpipe.database.playlist.model.PlaylistEntity import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.stream.StreamType @RunWith(AndroidJUnit4::class) class DatabaseMigrationTest { companion object { private const val DEFAULT_SERVICE_ID = 0 private const val DEFAULT_URL = "https://www.youtube.com/watch?v=cDphUib5iG4" private const val DEFAULT_TITLE = "Test Title" private const val DEFAULT_NAME = "Test Name" private val DEFAULT_TYPE = StreamType.VIDEO_STREAM private const val DEFAULT_DURATION = 480L private const val DEFAULT_UPLOADER_NAME = "Uploader Test" private const val DEFAULT_THUMBNAIL = "https://example.com/example.jpg" private const val DEFAULT_SECOND_SERVICE_ID = 1 private const val DEFAULT_SECOND_URL = "https://www.youtube.com/watch?v=ncQU6iBn5Fc" private const val DEFAULT_THIRD_SERVICE_ID = 2 private const val DEFAULT_THIRD_URL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ" } @get:Rule val testHelper = MigrationTestHelper( InstrumentationRegistry.getInstrumentation(), AppDatabase::class.java ) @Test fun migrateDatabaseFrom2to3() { val databaseInV2 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_2) databaseInV2.run { insert( "streams", SQLiteDatabase.CONFLICT_FAIL, ContentValues().apply { put("service_id", DEFAULT_SERVICE_ID) put("url", DEFAULT_URL) put("title", DEFAULT_TITLE) put("stream_type", DEFAULT_TYPE.name) put("duration", DEFAULT_DURATION) put("uploader", DEFAULT_UPLOADER_NAME) put("thumbnail_url", DEFAULT_THUMBNAIL) } ) insert( "streams", SQLiteDatabase.CONFLICT_FAIL, ContentValues().apply { put("service_id", DEFAULT_SECOND_SERVICE_ID) put("url", DEFAULT_SECOND_URL) } ) insert( "streams", SQLiteDatabase.CONFLICT_FAIL, ContentValues().apply { put("service_id", DEFAULT_SERVICE_ID) } ) close() } testHelper.runMigrationsAndValidate( AppDatabase.DATABASE_NAME, Migrations.DB_VER_3, true, Migrations.MIGRATION_2_3 ) testHelper.runMigrationsAndValidate( AppDatabase.DATABASE_NAME, Migrations.DB_VER_4, true, Migrations.MIGRATION_3_4 ) testHelper.runMigrationsAndValidate( AppDatabase.DATABASE_NAME, Migrations.DB_VER_5, true, Migrations.MIGRATION_4_5 ) testHelper.runMigrationsAndValidate( AppDatabase.DATABASE_NAME, Migrations.DB_VER_6, true, Migrations.MIGRATION_5_6 ) testHelper.runMigrationsAndValidate( AppDatabase.DATABASE_NAME, Migrations.DB_VER_7, true, Migrations.MIGRATION_6_7 ) testHelper.runMigrationsAndValidate( AppDatabase.DATABASE_NAME, Migrations.DB_VER_8, true, Migrations.MIGRATION_7_8 ) testHelper.runMigrationsAndValidate( AppDatabase.DATABASE_NAME, Migrations.DB_VER_9, true, Migrations.MIGRATION_8_9 ) val migratedDatabaseV3 = getMigratedDatabase() val listFromDB = migratedDatabaseV3.streamDAO().getAll().blockingFirst() // Only expect 2, the one with the null url will be ignored assertEquals(2, listFromDB.size) val streamFromMigratedDatabase = listFromDB[0] assertEquals(DEFAULT_SERVICE_ID, streamFromMigratedDatabase.serviceId) assertEquals(DEFAULT_URL, streamFromMigratedDatabase.url) assertEquals(DEFAULT_TITLE, streamFromMigratedDatabase.title) assertEquals(DEFAULT_TYPE, streamFromMigratedDatabase.streamType) assertEquals(DEFAULT_DURATION, streamFromMigratedDatabase.duration) assertEquals(DEFAULT_UPLOADER_NAME, streamFromMigratedDatabase.uploader) assertEquals(DEFAULT_THUMBNAIL, streamFromMigratedDatabase.thumbnailUrl) assertNull(streamFromMigratedDatabase.viewCount) assertNull(streamFromMigratedDatabase.textualUploadDate) assertNull(streamFromMigratedDatabase.uploadDate) assertNull(streamFromMigratedDatabase.isUploadDateApproximation) val secondStreamFromMigratedDatabase = listFromDB[1] assertEquals(DEFAULT_SECOND_SERVICE_ID, secondStreamFromMigratedDatabase.serviceId) assertEquals(DEFAULT_SECOND_URL, secondStreamFromMigratedDatabase.url) assertEquals("", secondStreamFromMigratedDatabase.title) // Should fallback to VIDEO_STREAM assertEquals(StreamType.VIDEO_STREAM, secondStreamFromMigratedDatabase.streamType) assertEquals(0, secondStreamFromMigratedDatabase.duration) assertEquals("", secondStreamFromMigratedDatabase.uploader) assertEquals("", secondStreamFromMigratedDatabase.thumbnailUrl) assertNull(secondStreamFromMigratedDatabase.viewCount) assertNull(secondStreamFromMigratedDatabase.textualUploadDate) assertNull(secondStreamFromMigratedDatabase.uploadDate) assertNull(secondStreamFromMigratedDatabase.isUploadDateApproximation) } @Test fun migrateDatabaseFrom7to8() { val databaseInV7 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_7) val defaultSearch1 = " abc " val defaultSearch2 = " abc" val serviceId = DEFAULT_SERVICE_ID // YouTube // Use id different to YouTube because two searches with the same query // but different service are considered not equal. val otherServiceId = ServiceList.SoundCloud.serviceId databaseInV7.run { insert( "search_history", SQLiteDatabase.CONFLICT_FAIL, ContentValues().apply { put("service_id", serviceId) put("search", defaultSearch1) } ) insert( "search_history", SQLiteDatabase.CONFLICT_FAIL, ContentValues().apply { put("service_id", serviceId) put("search", defaultSearch2) } ) insert( "search_history", SQLiteDatabase.CONFLICT_FAIL, ContentValues().apply { put("service_id", otherServiceId) put("search", defaultSearch1) } ) insert( "search_history", SQLiteDatabase.CONFLICT_FAIL, ContentValues().apply { put("service_id", otherServiceId) put("search", defaultSearch2) } ) close() } testHelper.runMigrationsAndValidate( AppDatabase.DATABASE_NAME, Migrations.DB_VER_8, true, Migrations.MIGRATION_7_8 ) testHelper.runMigrationsAndValidate( AppDatabase.DATABASE_NAME, Migrations.DB_VER_9, true, Migrations.MIGRATION_8_9 ) val migratedDatabaseV8 = getMigratedDatabase() val listFromDB = migratedDatabaseV8.searchHistoryDAO().getAll().blockingFirst() assertEquals(2, listFromDB.size) assertEquals("abc", listFromDB[0].search) assertEquals("abc", listFromDB[1].search) assertNotEquals(listFromDB[0].serviceId, listFromDB[1].serviceId) } @Test fun migrateDatabaseFrom8to9() { val databaseInV8 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_8) val localUid1: Long val localUid2: Long val remoteUid1: Long val remoteUid2: Long databaseInV8.run { localUid1 = insert( "playlists", SQLiteDatabase.CONFLICT_FAIL, ContentValues().apply { put("name", DEFAULT_NAME + "1") put("is_thumbnail_permanent", false) put("thumbnail_stream_id", -1) } ) localUid2 = insert( "playlists", SQLiteDatabase.CONFLICT_FAIL, ContentValues().apply { put("name", DEFAULT_NAME + "2") put("is_thumbnail_permanent", false) put("thumbnail_stream_id", -1) } ) delete( "playlists", "uid = ?", Array(1) { localUid1 } ) remoteUid1 = insert( "remote_playlists", SQLiteDatabase.CONFLICT_FAIL, ContentValues().apply { put("service_id", DEFAULT_SERVICE_ID) put("url", DEFAULT_URL) } ) remoteUid2 = insert( "remote_playlists", SQLiteDatabase.CONFLICT_FAIL, ContentValues().apply { put("service_id", DEFAULT_SECOND_SERVICE_ID) put("url", DEFAULT_SECOND_URL) } ) delete( "remote_playlists", "uid = ?", Array(1) { remoteUid2 } ) close() } testHelper.runMigrationsAndValidate( AppDatabase.DATABASE_NAME, Migrations.DB_VER_9, true, Migrations.MIGRATION_8_9 ) val migratedDatabaseV9 = getMigratedDatabase() var localListFromDB = migratedDatabaseV9.playlistDAO().getAll().blockingFirst() var remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().getAll().blockingFirst() assertEquals(1, localListFromDB.size) assertEquals(localUid2, localListFromDB[0].uid) assertEquals(-1, localListFromDB[0].displayIndex) assertEquals(1, remoteListFromDB.size) assertEquals(remoteUid1, remoteListFromDB[0].uid) assertEquals(-1, remoteListFromDB[0].displayIndex) val localUid3 = migratedDatabaseV9.playlistDAO().insert( PlaylistEntity( name = "${DEFAULT_NAME}3", isThumbnailPermanent = false, thumbnailStreamId = -1, displayIndex = -1 ) ) val remoteUid3 = migratedDatabaseV9.playlistRemoteDAO().insert( PlaylistRemoteEntity( serviceId = DEFAULT_THIRD_SERVICE_ID, orderingName = DEFAULT_NAME, url = DEFAULT_THIRD_URL, thumbnailUrl = DEFAULT_THUMBNAIL, uploader = DEFAULT_UPLOADER_NAME, displayIndex = -1, streamCount = 10 ) ) localListFromDB = migratedDatabaseV9.playlistDAO().getAll().blockingFirst() remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().getAll().blockingFirst() assertEquals(2, localListFromDB.size) assertEquals(localUid3, localListFromDB[1].uid) assertEquals(-1, localListFromDB[1].displayIndex) assertEquals(2, remoteListFromDB.size) assertEquals(remoteUid3, remoteListFromDB[1].uid) assertEquals(-1, remoteListFromDB[1].displayIndex) } private fun getMigratedDatabase(): AppDatabase { val database: AppDatabase = Room.databaseBuilder( ApplicationProvider.getApplicationContext(), AppDatabase::class.java, AppDatabase.DATABASE_NAME ) .build() testHelper.closeWhenFinished(database) return database } } ================================================ FILE: app/src/androidTest/java/org/schabi/newpipe/database/FeedDAOTest.kt ================================================ package org.schabi.newpipe.database import android.content.Context import androidx.room.Room import androidx.test.core.app.ApplicationProvider import io.reactivex.rxjava3.core.Single import java.io.IOException import java.time.OffsetDateTime import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Before import org.junit.Test import org.schabi.newpipe.database.feed.dao.FeedDAO import org.schabi.newpipe.database.feed.model.FeedEntity import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.stream.StreamWithState import org.schabi.newpipe.database.stream.dao.StreamDAO import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.subscription.SubscriptionDAO import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.channel.ChannelInfo import org.schabi.newpipe.extractor.stream.StreamType class FeedDAOTest { private lateinit var db: AppDatabase private lateinit var feedDAO: FeedDAO private lateinit var streamDAO: StreamDAO private lateinit var subscriptionDAO: SubscriptionDAO private val serviceId = ServiceList.YouTube.serviceId private val stream1 = StreamEntity(1, serviceId, "https://youtube.com/watch?v=1", "stream 1", StreamType.VIDEO_STREAM, 1000, "channel-1", "https://youtube.com/channel/1", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-01-01", OffsetDateTime.parse("2023-01-01T00:00:00Z")) private val stream2 = StreamEntity(2, serviceId, "https://youtube.com/watch?v=2", "stream 2", StreamType.VIDEO_STREAM, 1000, "channel-1", "https://youtube.com/channel/1", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-01-02", OffsetDateTime.parse("2023-01-02T00:00:00Z")) private val stream3 = StreamEntity(3, serviceId, "https://youtube.com/watch?v=3", "stream 3", StreamType.LIVE_STREAM, 1000, "channel-1", "https://youtube.com/channel/1", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-01-03", OffsetDateTime.parse("2023-01-03T00:00:00Z")) private val stream4 = StreamEntity(4, serviceId, "https://youtube.com/watch?v=4", "stream 4", StreamType.VIDEO_STREAM, 1000, "channel-2", "https://youtube.com/channel/2", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-08-10", OffsetDateTime.parse("2023-08-10T00:00:00Z")) private val stream5 = StreamEntity(5, serviceId, "https://youtube.com/watch?v=5", "stream 5", StreamType.VIDEO_STREAM, 1000, "channel-2", "https://youtube.com/channel/2", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-08-20", OffsetDateTime.parse("2023-08-20T00:00:00Z")) private val stream6 = StreamEntity(6, serviceId, "https://youtube.com/watch?v=6", "stream 6", StreamType.VIDEO_STREAM, 1000, "channel-3", "https://youtube.com/channel/3", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-09-01", OffsetDateTime.parse("2023-09-01T00:00:00Z")) private val stream7 = StreamEntity(7, serviceId, "https://youtube.com/watch?v=7", "stream 7", StreamType.VIDEO_STREAM, 1000, "channel-4", "https://youtube.com/channel/4", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-08-10", OffsetDateTime.parse("2023-08-10T00:00:00Z")) private val allStreams = listOf( stream1, stream2, stream3, stream4, stream5, stream6, stream7 ) @Before fun createDb() { val context = ApplicationProvider.getApplicationContext() db = Room.inMemoryDatabaseBuilder( context, AppDatabase::class.java ).build() feedDAO = db.feedDAO() streamDAO = db.streamDAO() subscriptionDAO = db.subscriptionDAO() } @After @Throws(IOException::class) fun closeDb() { db.close() } @Test fun testUnlinkStreamsOlderThan_KeepOne() { setupUnlinkDelete("2023-08-15T00:00:00Z") val streams = feedDAO.getStreams( FeedGroupEntity.GROUP_ALL_ID, includePlayed = true, includePartiallyPlayed = true, null ) .blockingGet() val allowedStreams = listOf(stream3, stream5, stream6, stream7) assertEqual(streams, allowedStreams) } @Test fun testUnlinkStreamsOlderThan_KeepMultiple() { setupUnlinkDelete("2023-08-01T00:00:00Z") val streams = feedDAO.getStreams( FeedGroupEntity.GROUP_ALL_ID, includePlayed = true, includePartiallyPlayed = true, null ) .blockingGet() val allowedStreams = listOf(stream3, stream4, stream5, stream6, stream7) assertEqual(streams, allowedStreams) } private fun assertEqual(streams: List?, allowedStreams: List) { assertNotNull(streams) assertEquals( allowedStreams, streams!! .map { it.stream } .sortedBy { it.uid } .toList() ) } private fun setupUnlinkDelete(time: String) { clearAndFillTables() Single.fromCallable { feedDAO.unlinkStreamsOlderThan(OffsetDateTime.parse(time)) }.blockingSubscribe() Single.fromCallable { streamDAO.deleteOrphans() }.blockingSubscribe() } private fun clearAndFillTables() { db.clearAllTables() streamDAO.insertAll(allStreams) subscriptionDAO.insertAll( listOf( SubscriptionEntity.from(ChannelInfo(serviceId, "1", "https://youtube.com/channel/1", "https://youtube.com/channel/1", "channel-1")), SubscriptionEntity.from(ChannelInfo(serviceId, "2", "https://youtube.com/channel/2", "https://youtube.com/channel/2", "channel-2")), SubscriptionEntity.from(ChannelInfo(serviceId, "3", "https://youtube.com/channel/3", "https://youtube.com/channel/3", "channel-3")), SubscriptionEntity.from(ChannelInfo(serviceId, "4", "https://youtube.com/channel/4", "https://youtube.com/channel/4", "channel-4")) ) ) feedDAO.insertAll( listOf( FeedEntity(1, 1), FeedEntity(2, 1), FeedEntity(3, 1), FeedEntity(4, 2), FeedEntity(5, 2), FeedEntity(6, 3), FeedEntity(7, 4) ) ) } } ================================================ FILE: app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.java ================================================ package org.schabi.newpipe.error; import android.os.Parcel; import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.filters.LargeTest; import org.junit.Test; import org.junit.runner.RunWith; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.exceptions.ParsingException; import java.util.Arrays; import java.util.Objects; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; /** * Instrumented tests for {@link ErrorInfo}. */ @RunWith(AndroidJUnit4.class) @LargeTest public class ErrorInfoTest { /** * @param errorInfo the error info to access * @return the private field errorInfo.message.stringRes using reflection */ private int getMessageFromErrorInfo(final ErrorInfo errorInfo) throws NoSuchFieldException, IllegalAccessException { final var message = ErrorInfo.class.getDeclaredField("message"); message.setAccessible(true); final var messageValue = (ErrorInfo.Companion.ErrorMessage) message.get(errorInfo); final var stringRes = ErrorInfo.Companion.ErrorMessage.class.getDeclaredField("stringRes"); stringRes.setAccessible(true); return (int) Objects.requireNonNull(stringRes.get(messageValue)); } @Test public void errorInfoTestParcelable() throws NoSuchFieldException, IllegalAccessException { final ErrorInfo info = new ErrorInfo(new ParsingException("Hello"), UserAction.USER_REPORT, "request", ServiceList.YouTube.getServiceId()); // Obtain a Parcel object and write the parcelable object to it: final Parcel parcel = Parcel.obtain(); info.writeToParcel(parcel, 0); parcel.setDataPosition(0); final ErrorInfo infoFromParcel = (ErrorInfo) ErrorInfo.CREATOR.createFromParcel(parcel); assertTrue(Arrays.toString(infoFromParcel.getStackTraces()) .contains(ErrorInfoTest.class.getSimpleName())); assertEquals(UserAction.USER_REPORT, infoFromParcel.getUserAction()); assertEquals(ServiceList.YouTube.getServiceInfo().getName(), infoFromParcel.getServiceName()); assertEquals("request", infoFromParcel.getRequest()); assertEquals(R.string.parsing_error, getMessageFromErrorInfo(infoFromParcel)); parcel.recycle(); } } ================================================ FILE: app/src/androidTest/java/org/schabi/newpipe/local/history/HistoryRecordManagerTest.kt ================================================ package org.schabi.newpipe.local.history import androidx.test.core.app.ApplicationProvider import java.time.LocalDateTime import java.time.OffsetDateTime import java.time.ZoneOffset import org.assertj.core.api.Assertions.assertThat import org.junit.After import org.junit.Assert.assertEquals import org.junit.Before import org.junit.Rule import org.junit.Test import org.schabi.newpipe.database.AppDatabase import org.schabi.newpipe.database.history.model.SearchHistoryEntry import org.schabi.newpipe.testUtil.TestDatabase import org.schabi.newpipe.testUtil.TrampolineSchedulerRule class HistoryRecordManagerTest { private lateinit var manager: HistoryRecordManager private lateinit var database: AppDatabase @get:Rule val trampolineScheduler = TrampolineSchedulerRule() @Before fun setup() { database = TestDatabase.createReplacingNewPipeDatabase() manager = HistoryRecordManager(ApplicationProvider.getApplicationContext()) } @After fun cleanUp() { database.close() } @Test fun onSearched() { manager.onSearched(0, "Hello").test().await().assertValue(1) // For some reason the Flowable returned by getAll() never completes, so we can't assert // that the number of Lists it returns is exactly 1, we can only check if the first List is // correct. Why on earth has a Flowable been used instead of a Single for getAll()?!? val entities = database.searchHistoryDAO().getAll().blockingFirst() assertThat(entities).hasSize(1) assertThat(entities[0].id).isEqualTo(1) assertThat(entities[0].serviceId).isEqualTo(0) assertThat(entities[0].search).isEqualTo("Hello") } @Test fun deleteSearchHistory() { val entries = listOf( SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 0, search = "A"), SearchHistoryEntry(creationDate = time.minusSeconds(2), serviceId = 2, search = "A"), SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 1, search = "B"), SearchHistoryEntry(creationDate = time.minusSeconds(4), serviceId = 0, search = "B") ) // make sure all 4 were inserted database.searchHistoryDAO().insertAll(entries) assertThat(database.searchHistoryDAO().getAll().blockingFirst()).hasSameSizeAs(entries) // try to delete only "A" entries, "B" entries should be untouched manager.deleteSearchHistory("A").test().await().assertValue(2) val entities = database.searchHistoryDAO().getAll().blockingFirst() assertThat(entities).hasSize(2) assertThat(entities).usingElementComparator { o1, o2 -> if (o1.hasEqualValues(o2)) 0 else 1 } .containsExactly(*entries.subList(2, 4).toTypedArray()) // assert that nothing happens if we delete a search query that does exist in the db manager.deleteSearchHistory("A").test().await().assertValue(0) val entities2 = database.searchHistoryDAO().getAll().blockingFirst() assertThat(entities2).hasSize(2) assertThat(entities2).usingElementComparator { o1, o2 -> if (o1.hasEqualValues(o2)) 0 else 1 } .containsExactly(*entries.subList(2, 4).toTypedArray()) // delete all remaining entries manager.deleteSearchHistory("B").test().await().assertValue(2) assertThat(database.searchHistoryDAO().getAll().blockingFirst()).isEmpty() } @Test fun deleteCompleteSearchHistory() { val entries = listOf( SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 1, search = "A"), SearchHistoryEntry(creationDate = time.minusSeconds(2), serviceId = 2, search = "B"), SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 0, search = "C") ) // make sure all 3 were inserted database.searchHistoryDAO().insertAll(entries) assertThat(database.searchHistoryDAO().getAll().blockingFirst()).hasSameSizeAs(entries) // should remove everything manager.deleteCompleteSearchHistory().test().await().assertValue(entries.size) assertThat(database.searchHistoryDAO().getAll().blockingFirst()).isEmpty() } private fun insertShuffledRelatedSearches(relatedSearches: Collection) { // shuffle to make sure the order of items returned by queries depends only on // SearchHistoryEntry.creationDate, not on the actual insertion time, so that we can // verify that the `ORDER BY` clause does its job database.searchHistoryDAO().insertAll(relatedSearches.shuffled()) // make sure all entries were inserted assertEquals( relatedSearches.size, database.searchHistoryDAO().getAll().blockingFirst().size ) } @Test fun getRelatedSearches_emptyQuery() { insertShuffledRelatedSearches(RELATED_SEARCHES_ENTRIES) // make sure correct number of searches is returned and in correct order val searches = manager.getRelatedSearches("", 6, 4).blockingFirst() assertThat(searches).containsExactly( RELATED_SEARCHES_ENTRIES[6].search, // A (even if in two places) RELATED_SEARCHES_ENTRIES[4].search, // B RELATED_SEARCHES_ENTRIES[5].search, // AA RELATED_SEARCHES_ENTRIES[2].search // BA ) } @Test fun getRelatedSearches_emptyQuery_manyDuplicates() { val relatedSearches = listOf( SearchHistoryEntry(creationDate = time.minusSeconds(9), serviceId = 3, search = "A"), SearchHistoryEntry(creationDate = time.minusSeconds(8), serviceId = 3, search = "AB"), SearchHistoryEntry(creationDate = time.minusSeconds(7), serviceId = 3, search = "A"), SearchHistoryEntry(creationDate = time.minusSeconds(6), serviceId = 3, search = "A"), SearchHistoryEntry(creationDate = time.minusSeconds(5), serviceId = 3, search = "BA"), SearchHistoryEntry(creationDate = time.minusSeconds(4), serviceId = 3, search = "A"), SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 3, search = "A"), SearchHistoryEntry(creationDate = time.minusSeconds(2), serviceId = 0, search = "A"), SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 2, search = "AA") ) insertShuffledRelatedSearches(relatedSearches) val searches = manager.getRelatedSearches("", 9, 3).blockingFirst() assertThat(searches).containsExactly("AA", "A", "BA") } @Test fun getRelatedSearched_nonEmptyQuery() { insertShuffledRelatedSearches(RELATED_SEARCHES_ENTRIES) // make sure correct number of searches is returned and in correct order val searches = manager.getRelatedSearches("A", 3, 5).blockingFirst() assertThat(searches).containsExactly( RELATED_SEARCHES_ENTRIES[6].search, // A (even if in two places) RELATED_SEARCHES_ENTRIES[5].search, // AA RELATED_SEARCHES_ENTRIES[1].search // BA ) // also make sure that the string comparison is case insensitive val searches2 = manager.getRelatedSearches("a", 3, 5).blockingFirst() assertThat(searches).isEqualTo(searches2) } companion object { private val time = OffsetDateTime.of(LocalDateTime.of(2000, 1, 1, 1, 1), ZoneOffset.UTC) private val RELATED_SEARCHES_ENTRIES = listOf( SearchHistoryEntry(creationDate = time.minusSeconds(7), serviceId = 2, search = "AC"), SearchHistoryEntry(creationDate = time.minusSeconds(6), serviceId = 0, search = "ABC"), SearchHistoryEntry(creationDate = time.minusSeconds(5), serviceId = 1, search = "BA"), SearchHistoryEntry(creationDate = time.minusSeconds(4), serviceId = 3, search = "A"), SearchHistoryEntry(creationDate = time.minusSeconds(2), serviceId = 0, search = "B"), SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 2, search = "AA"), SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 1, search = "A") ) } } ================================================ FILE: app/src/androidTest/java/org/schabi/newpipe/local/playlist/LocalPlaylistManagerTest.kt ================================================ package org.schabi.newpipe.local.playlist import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test import org.schabi.newpipe.database.AppDatabase import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.testUtil.TestDatabase import org.schabi.newpipe.testUtil.TrampolineSchedulerRule class LocalPlaylistManagerTest { private lateinit var manager: LocalPlaylistManager private lateinit var database: AppDatabase @get:Rule val trampolineScheduler = TrampolineSchedulerRule() @Before fun setup() { database = TestDatabase.createReplacingNewPipeDatabase() manager = LocalPlaylistManager(database) } @After fun cleanUp() { database.close() } @Test fun createPlaylist() { val NEWPIPE_URL = "https://newpipe.net/" val stream = StreamEntity( serviceId = 1, url = NEWPIPE_URL, title = "title", streamType = StreamType.VIDEO_STREAM, duration = 1, uploader = "uploader", uploaderUrl = NEWPIPE_URL ) val result = manager.createPlaylist("name", listOf(stream)) // This should not behave like this. // Currently list of all stream ids is returned instead of playlist id result.test().await().assertValue(listOf(1L)) } @Test fun createPlaylist_emptyPlaylistMustReturnEmpty() { val result = manager.createPlaylist("name", emptyList()) // This should not behave like this. // It should throw an error because currently the result is null result.test().await().assertComplete() manager.playlists.test().awaitCount(1).assertValue(emptyList()) } @Test() fun createPlaylist_nonExistentStreamsAreUpserted() { val stream = StreamEntity( serviceId = 1, url = "https://newpipe.net/", title = "title", streamType = StreamType.VIDEO_STREAM, duration = 1, uploader = "uploader", uploaderUrl = "https://newpipe.net/" ) database.streamDAO().insert(stream) val upserted = StreamEntity( serviceId = 1, url = "https://newpipe.net/2", title = "title2", streamType = StreamType.VIDEO_STREAM, duration = 1, uploader = "uploader", uploaderUrl = "https://newpipe.net/" ) val result = manager.createPlaylist("name", listOf(stream, upserted)) result.test().await().assertComplete() database.streamDAO().getAll().test().awaitCount(1).assertValue(listOf(stream, upserted)) } } ================================================ FILE: app/src/androidTest/java/org/schabi/newpipe/local/subscription/SubscriptionManagerTest.java ================================================ package org.schabi.newpipe.local.subscription; import static org.junit.Assert.assertEquals; import androidx.test.core.app.ApplicationProvider; import org.junit.After; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.schabi.newpipe.database.AppDatabase; import org.schabi.newpipe.database.feed.model.FeedGroupEntity; import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.testUtil.TestDatabase; import org.schabi.newpipe.testUtil.TrampolineSchedulerRule; import java.io.IOException; import java.util.List; public class SubscriptionManagerTest { private AppDatabase database; private SubscriptionManager manager; @Rule public TrampolineSchedulerRule trampolineScheduler = new TrampolineSchedulerRule(); private SubscriptionEntity getAssertOneSubscriptionEntity() { final List entities = manager .getSubscriptions(FeedGroupEntity.GROUP_ALL_ID, "", false) .blockingFirst(); assertEquals(1, entities.size()); return entities.get(0); } @Before public void setup() { database = TestDatabase.Companion.createReplacingNewPipeDatabase(); manager = new SubscriptionManager(ApplicationProvider.getApplicationContext()); } @After public void cleanUp() { database.close(); } @Test public void testInsert() throws ExtractionException, IOException { final ChannelInfo info = ChannelInfo.getInfo("https://www.youtube.com/c/3blue1brown"); final SubscriptionEntity subscription = SubscriptionEntity.from(info); manager.insertSubscription(subscription); final SubscriptionEntity readSubscription = getAssertOneSubscriptionEntity(); // the uid has changed, since the uid is chosen upon inserting, but the rest should match assertEquals(subscription.getServiceId(), readSubscription.getServiceId()); assertEquals(subscription.getUrl(), readSubscription.getUrl()); assertEquals(subscription.getName(), readSubscription.getName()); assertEquals(subscription.getAvatarUrl(), readSubscription.getAvatarUrl()); assertEquals(subscription.getSubscriberCount(), readSubscription.getSubscriberCount()); assertEquals(subscription.getDescription(), readSubscription.getDescription()); } @Test public void testUpdateNotificationMode() throws ExtractionException, IOException { final ChannelInfo info = ChannelInfo.getInfo("https://www.youtube.com/c/veritasium"); final SubscriptionEntity subscription = SubscriptionEntity.from(info); subscription.setNotificationMode(0); manager.insertSubscription(subscription); manager.updateNotificationMode(subscription.getServiceId(), subscription.getUrl(), 1) .blockingAwait(); final SubscriptionEntity anotherSubscription = getAssertOneSubscriptionEntity(); assertEquals(0, subscription.getNotificationMode()); assertEquals(subscription.getUrl(), anotherSubscription.getUrl()); assertEquals(1, anotherSubscription.getNotificationMode()); } } ================================================ FILE: app/src/androidTest/java/org/schabi/newpipe/testUtil/TestDatabase.kt ================================================ package org.schabi.newpipe.testUtil import androidx.room.Room import androidx.test.core.app.ApplicationProvider import org.junit.Assert.assertSame import org.schabi.newpipe.NewPipeDatabase import org.schabi.newpipe.database.AppDatabase class TestDatabase { companion object { fun createReplacingNewPipeDatabase(): AppDatabase { val database = Room.inMemoryDatabaseBuilder( ApplicationProvider.getApplicationContext(), AppDatabase::class.java ) .allowMainThreadQueries() .build() val databaseField = NewPipeDatabase::class.java.getDeclaredField("databaseInstance") databaseField.isAccessible = true databaseField.set(NewPipeDatabase::class, database) assertSame( "Mocking database failed!", database, NewPipeDatabase.getInstance(ApplicationProvider.getApplicationContext()) ) return database } } } ================================================ FILE: app/src/androidTest/java/org/schabi/newpipe/testUtil/TrampolineSchedulerRule.kt ================================================ package org.schabi.newpipe.testUtil import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins import io.reactivex.rxjava3.plugins.RxJavaPlugins import io.reactivex.rxjava3.schedulers.Schedulers import org.junit.rules.TestRule import org.junit.runner.Description import org.junit.runners.model.Statement /** * Always run on [Schedulers.trampoline]. * This executes the task in the current thread in FIFO manner. * This ensures that tasks are run quickly inside the tests * and not scheduled away to another thread for later execution */ class TrampolineSchedulerRule : TestRule { private val scheduler = Schedulers.trampoline() override fun apply(base: Statement, description: Description): Statement = object : Statement() { override fun evaluate() { try { RxJavaPlugins.setComputationSchedulerHandler { scheduler } RxJavaPlugins.setIoSchedulerHandler { scheduler } RxJavaPlugins.setNewThreadSchedulerHandler { scheduler } RxJavaPlugins.setSingleSchedulerHandler { scheduler } RxAndroidPlugins.setInitMainThreadSchedulerHandler { scheduler } base.evaluate() } finally { RxJavaPlugins.reset() RxAndroidPlugins.reset() } } } } ================================================ FILE: app/src/androidTest/java/org/schabi/newpipe/util/StreamItemAdapterTest.kt ================================================ package org.schabi.newpipe.util import android.content.Context import android.view.View import android.view.View.GONE import android.view.View.INVISIBLE import android.view.View.VISIBLE import android.widget.Spinner import androidx.collection.SparseArrayCompat import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import androidx.test.internal.runner.junit4.statement.UiThreadStatement import org.junit.Assert import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.schabi.newpipe.R import org.schabi.newpipe.extractor.MediaFormat import org.schabi.newpipe.extractor.downloader.Response import org.schabi.newpipe.extractor.stream.AudioStream import org.schabi.newpipe.extractor.stream.Stream import org.schabi.newpipe.extractor.stream.SubtitlesStream import org.schabi.newpipe.extractor.stream.VideoStream import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper @MediumTest @RunWith(AndroidJUnit4::class) class StreamItemAdapterTest { private lateinit var context: Context private lateinit var spinner: Spinner @Before fun setUp() { context = ApplicationProvider.getApplicationContext() UiThreadStatement.runOnUiThread { spinner = Spinner(context) } } @Test fun videoStreams_noSecondaryStream() { val adapter = StreamItemAdapter( getVideoStreams(true, true, true, true) ) spinner.adapter = adapter assertIconVisibility(spinner, 0, VISIBLE, VISIBLE) assertIconVisibility(spinner, 1, VISIBLE, VISIBLE) assertIconVisibility(spinner, 2, VISIBLE, VISIBLE) assertIconVisibility(spinner, 3, VISIBLE, VISIBLE) } @Test fun videoStreams_hasSecondaryStream() { val adapter = StreamItemAdapter( getVideoStreams(false, true, false, true), getAudioStreams(false, true, false, true) ) spinner.adapter = adapter assertIconVisibility(spinner, 0, GONE, GONE) assertIconVisibility(spinner, 1, GONE, GONE) assertIconVisibility(spinner, 2, GONE, GONE) assertIconVisibility(spinner, 3, GONE, GONE) } @Test fun videoStreams_Mixed() { val adapter = StreamItemAdapter( getVideoStreams(true, true, true, true, true, false, true, true), getAudioStreams(false, true, false, false, false, true, true, true) ) spinner.adapter = adapter assertIconVisibility(spinner, 0, VISIBLE, VISIBLE) assertIconVisibility(spinner, 1, GONE, INVISIBLE) assertIconVisibility(spinner, 2, VISIBLE, VISIBLE) assertIconVisibility(spinner, 3, VISIBLE, VISIBLE) assertIconVisibility(spinner, 4, VISIBLE, VISIBLE) assertIconVisibility(spinner, 5, GONE, INVISIBLE) assertIconVisibility(spinner, 6, GONE, INVISIBLE) assertIconVisibility(spinner, 7, GONE, INVISIBLE) } @Test fun subtitleStreams_noIcon() { val adapter = StreamItemAdapter( StreamItemAdapter.StreamInfoWrapper( (0 until 5).map { SubtitlesStream.Builder() .setContent("https://example.com", true) .setMediaFormat(MediaFormat.SRT) .setLanguageCode("pt-BR") .setAutoGenerated(false) .build() }, context ) ) spinner.adapter = adapter for (i in 0 until spinner.count) { assertIconVisibility(spinner, i, GONE, GONE) } } @Test fun audioStreams_noIcon() { val adapter = StreamItemAdapter( StreamItemAdapter.StreamInfoWrapper( (0 until 5).map { AudioStream.Builder() .setId(Stream.ID_UNKNOWN) .setContent("https://example.com/$it", true) .setMediaFormat(MediaFormat.OPUS) .setAverageBitrate(192) .build() }, context ) ) spinner.adapter = adapter for (i in 0 until spinner.count) { assertIconVisibility(spinner, i, GONE, GONE) } } @Test fun retrieveMediaFormatFromFileTypeHeaders() { val streams = getIncompleteAudioStreams(5) val wrapper = StreamInfoWrapper(streams, context) val retrieveMediaFormat = { stream: AudioStream, response: Response -> StreamInfoWrapper.retrieveMediaFormatFromFileTypeHeaders(stream, wrapper, response) } val helper = AssertionHelper(streams, wrapper, retrieveMediaFormat) helper.assertInvalidResponse(getResponse(mapOf(Pair("content-length", "mp3"))), 0) helper.assertInvalidResponse(getResponse(mapOf(Pair("file-type", "mp0"))), 1) helper.assertValidResponse(getResponse(mapOf(Pair("x-amz-meta-file-type", "aiff"))), 2, MediaFormat.AIFF) helper.assertValidResponse(getResponse(mapOf(Pair("file-type", "mp3"))), 3, MediaFormat.MP3) } @Test fun retrieveMediaFormatFromContentDispositionHeader() { val streams = getIncompleteAudioStreams(11) val wrapper = StreamInfoWrapper(streams, context) val retrieveMediaFormat = { stream: AudioStream, response: Response -> StreamInfoWrapper.retrieveMediaFormatFromContentDispositionHeader(stream, wrapper, response) } val helper = AssertionHelper(streams, wrapper, retrieveMediaFormat) helper.assertInvalidResponse(getResponse(mapOf(Pair("content-length", "mp3"))), 0) helper.assertInvalidResponse( getResponse(mapOf(Pair("Content-Disposition", "filename=\"train.png\""))), 1 ) helper.assertInvalidResponse( getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"data.csv\""))), 2 ) helper.assertInvalidResponse( getResponse(mapOf(Pair("Content-Disposition", "form-data; filename=\"data.csv\""))), 3 ) helper.assertInvalidResponse( getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"fieldName\"; filename*=\"filename.jpg\""))), 4 ) helper.assertValidResponse( getResponse(mapOf(Pair("Content-Disposition", "filename=\"train.ogg\""))), 5, MediaFormat.OGG ) helper.assertValidResponse( getResponse(mapOf(Pair("Content-Disposition", "some-form-data; filename=\"audio.flac\""))), 6, MediaFormat.FLAC ) helper.assertValidResponse( getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.aiff\"; filename=\"audio.aiff\""))), 7, MediaFormat.AIFF ) helper.assertValidResponse( getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"alien?\"; filename*=UTF-8''%CE%B1%CE%BB%CE%B9%CF%B5%CE%BD.m4a"))), 8, MediaFormat.M4A ) helper.assertValidResponse( getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.mp3\"; filename=\"audio.opus\"; filename*=UTF-8''alien.opus"))), 9, MediaFormat.OPUS ) helper.assertValidResponse( getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.mp3\"; filename=\"audio.opus\"; filename*=\"UTF-8''alien.opus\""))), 10, MediaFormat.OPUS ) } @Test fun retrieveMediaFormatFromContentTypeHeader() { val streams = getIncompleteAudioStreams(12) val wrapper = StreamInfoWrapper(streams, context) val retrieveMediaFormat = { stream: AudioStream, response: Response -> StreamInfoWrapper.retrieveMediaFormatFromContentTypeHeader(stream, wrapper, response) } val helper = AssertionHelper(streams, wrapper, retrieveMediaFormat) helper.assertInvalidResponse(getResponse(mapOf(Pair("content-length", "984501"))), 0) helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "audio/xyz"))), 1) helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "mp3"))), 2) helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "mp3"))), 3) helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "audio/mpeg"))), 4) helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "audio/aif"))), 5) helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "whatever"))), 6) helper.assertInvalidResponse(getResponse(mapOf()), 7) helper.assertValidResponse( getResponse(mapOf(Pair("Content-Type", "audio/flac"))), 8, MediaFormat.FLAC ) helper.assertValidResponse( getResponse(mapOf(Pair("Content-Type", "audio/wav"))), 9, MediaFormat.WAV ) helper.assertValidResponse( getResponse(mapOf(Pair("Content-Type", "audio/opus"))), 10, MediaFormat.OPUS ) helper.assertValidResponse( getResponse(mapOf(Pair("Content-Type", "audio/aiff"))), 11, MediaFormat.AIFF ) } /** * @return a list of video streams, in which their video only property mirrors the provided * [videoOnly] vararg. */ private fun getVideoStreams(vararg videoOnly: Boolean) = StreamInfoWrapper( videoOnly.map { VideoStream.Builder() .setId(Stream.ID_UNKNOWN) .setContent("https://example.com", true) .setMediaFormat(MediaFormat.MPEG_4) .setResolution("720p") .setIsVideoOnly(it) .build() }, context ) /** * @return a list of audio streams, containing valid and null elements mirroring the provided * [shouldBeValid] vararg. */ private fun getAudioStreams(vararg shouldBeValid: Boolean) = getSecondaryStreamsFromList( shouldBeValid.map { if (it) { AudioStream.Builder() .setId(Stream.ID_UNKNOWN) .setContent("https://example.com", true) .setMediaFormat(MediaFormat.OPUS) .setAverageBitrate(192) .build() } else { null } } ) private fun getIncompleteAudioStreams(size: Int): List { val list = ArrayList(size) for (i in 1..size) { list.add( AudioStream.Builder() .setId(Stream.ID_UNKNOWN) .setContent("https://example.com/$i", true) .build() ) } return list } /** * Checks whether the item at [position] in the [spinner] has the correct icon visibility when * it is shown in normal mode (selected) and in dropdown mode (user is choosing one of a list). */ private fun assertIconVisibility( spinner: Spinner, position: Int, normalVisibility: Int, dropDownVisibility: Int ) { spinner.setSelection(position) spinner.adapter.getView(position, null, spinner).run { Assert.assertEquals( "normal visibility (pos=[$position]) is not correct", findViewById(R.id.wo_sound_icon).visibility, normalVisibility ) } spinner.adapter.getDropDownView(position, null, spinner).run { Assert.assertEquals( "drop down visibility (pos=[$position]) is not correct", findViewById(R.id.wo_sound_icon).visibility, dropDownVisibility ) } } /** * Helper function that builds a secondary stream list. */ private fun getSecondaryStreamsFromList(streams: List) = SparseArrayCompat?>(streams.size).apply { streams.forEachIndexed { index, stream -> val secondaryStreamHelper: SecondaryStreamHelper? = stream?.let { SecondaryStreamHelper( StreamItemAdapter.StreamInfoWrapper(streams, context), it ) } put(index, secondaryStreamHelper) } } private fun getResponse(headers: Map): Response { val listHeaders = HashMap>() headers.forEach { entry -> listHeaders[entry.key] = listOf(entry.value) } return Response(200, null, listHeaders, "", "") } /** * Helper class for assertion related to extractions of [MediaFormat]s. */ class AssertionHelper( private val streams: List, private val wrapper: StreamInfoWrapper, private val retrieveMediaFormat: (stream: T, response: Response) -> Boolean ) { /** * Assert that an invalid response does not result in wrongly extracted [MediaFormat]. */ fun assertInvalidResponse( response: Response, index: Int ) { assertFalse( "invalid header returns valid value", retrieveMediaFormat(streams[index], response) ) assertNull("Media format extracted although stated otherwise", wrapper.getFormat(index)) } /** * Assert that a valid response results in correctly extracted and handled [MediaFormat]. */ fun assertValidResponse( response: Response, index: Int, format: MediaFormat ) { assertTrue( "header was not recognized", retrieveMediaFormat(streams[index], response) ) assertEquals("Wrong media format extracted", format, wrapper.getFormat(index)) } } } ================================================ FILE: app/src/debug/AndroidManifest.xml ================================================ ================================================ FILE: app/src/debug/java/org/schabi/newpipe/DebugApp.kt ================================================ package org.schabi.newpipe import androidx.preference.PreferenceManager import com.facebook.stetho.Stetho import com.facebook.stetho.okhttp3.StethoInterceptor import leakcanary.LeakCanary import okhttp3.OkHttpClient import org.schabi.newpipe.extractor.downloader.Downloader class DebugApp : App() { override fun onCreate() { super.onCreate() initStetho() LeakCanary.config = LeakCanary.config.copy( dumpHeap = PreferenceManager .getDefaultSharedPreferences(this).getBoolean( getString( R.string.allow_heap_dumping_key ), false ) ) } override fun getDownloader(): Downloader { val downloader = DownloaderImpl.init( OkHttpClient.Builder() .addNetworkInterceptor(StethoInterceptor()) ) setCookiesToDownloader(downloader) return downloader } private fun initStetho() { // Create an InitializerBuilder val initializerBuilder = Stetho.newInitializerBuilder(this) // Enable Chrome DevTools initializerBuilder.enableWebKitInspector(Stetho.defaultInspectorModulesProvider(this)) // Enable command line interface initializerBuilder.enableDumpapp( Stetho.defaultDumperPluginsProvider(applicationContext) ) // Use the InitializerBuilder to generate an Initializer val initializer = initializerBuilder.build() // Initialize Stetho with the Initializer Stetho.initialize(initializer) } override fun isDisposedRxExceptionsReported(): Boolean { return PreferenceManager.getDefaultSharedPreferences(this) .getBoolean(getString(R.string.allow_disposed_exceptions_key), false) } } ================================================ FILE: app/src/debug/java/org/schabi/newpipe/settings/DebugSettingsBVDLeakCanary.java ================================================ package org.schabi.newpipe.settings; import android.content.Intent; import leakcanary.LeakCanary; /** * Build variant dependent (BVD) leak canary API implementation for the debug settings fragment. * This class is loaded via reflection by * {@link DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI}. */ @SuppressWarnings("unused") // Class is used but loaded via reflection public class DebugSettingsBVDLeakCanary implements DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI { @Override public Intent getNewLeakDisplayActivityIntent() { return LeakCanary.INSTANCE.newLeakDisplayActivityIntent(); } } ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/assets/apache2.html ================================================ Apache License - Version 2.0, January 2004

Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/

TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION

1. Definitions.

"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.

"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.

"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.

"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.

"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.

"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.

"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).

"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.

"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."

"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.

2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.

3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.

4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:

  1. You must give any other recipients of the Work or Derivative Works a copy of this License; and
  2. You must cause any modified files to carry prominent notices stating that You changed the files; and
  3. You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
  4. If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.

    You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.

5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.

6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.

7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.

8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.

9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.

================================================ FILE: app/src/main/assets/epl1.html ================================================ Eclipse Public License - Version 1.0

Eclipse Public License - v 1.0

THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.

1. DEFINITIONS

"Contribution" means:

a) in the case of the initial Contributor, the initial code and documentation distributed under this Agreement, and

b) in the case of each subsequent Contributor:

i) changes to the Program, and

ii) additions to the Program;

where such changes and/or additions to the Program originate from and are distributed by that particular Contributor. A Contribution 'originates' from a Contributor if it was added to the Program by such Contributor itself or anyone acting on such Contributor's behalf. Contributions do not include additions to the Program which: (i) are separate modules of software distributed in conjunction with the Program under their own license agreement, and (ii) are not derivative works of the Program.

"Contributor" means any person or entity that distributes the Program.

"Licensed Patents" mean patent claims licensable by a Contributor which are necessarily infringed by the use or sale of its Contribution alone or when combined with the Program.

"Program" means the Contributions distributed in accordance with this Agreement.

"Recipient" means anyone who receives the Program under this Agreement, including all Contributors.

2. GRANT OF RIGHTS

a) Subject to the terms of this Agreement, each Contributor hereby grants Recipient a non-exclusive, worldwide, royalty-free copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, distribute and sublicense the Contribution of such Contributor, if any, and such derivative works, in source code and object code form.

b) Subject to the terms of this Agreement, each Contributor hereby grants Recipient a non-exclusive, worldwide, royalty-free patent license under Licensed Patents to make, use, sell, offer to sell, import and otherwise transfer the Contribution of such Contributor, if any, in source code and object code form. This patent license shall apply to the combination of the Contribution and the Program if, at the time the Contribution is added by the Contributor, such addition of the Contribution causes such combination to be covered by the Licensed Patents. The patent license shall not apply to any other combinations which include the Contribution. No hardware per se is licensed hereunder.

c) Recipient understands that although each Contributor grants the licenses to its Contributions set forth herein, no assurances are provided by any Contributor that the Program does not infringe the patent or other intellectual property rights of any other entity. Each Contributor disclaims any liability to Recipient for claims brought by any other entity based on infringement of intellectual property rights or otherwise. As a condition to exercising the rights and licenses granted hereunder, each Recipient hereby assumes sole responsibility to secure any other intellectual property rights needed, if any. For example, if a third party patent license is required to allow Recipient to distribute the Program, it is Recipient's responsibility to acquire that license before distributing the Program.

d) Each Contributor represents that to its knowledge it has sufficient copyright rights in its Contribution, if any, to grant the copyright license set forth in this Agreement.

3. REQUIREMENTS

A Contributor may choose to distribute the Program in object code form under its own license agreement, provided that:

a) it complies with the terms and conditions of this Agreement; and

b) its license agreement:

i) effectively disclaims on behalf of all Contributors all warranties and conditions, express and implied, including warranties or conditions of title and non-infringement, and implied warranties or conditions of merchantability and fitness for a particular purpose;

ii) effectively excludes on behalf of all Contributors all liability for damages, including direct, indirect, special, incidental and consequential damages, such as lost profits;

iii) states that any provisions which differ from this Agreement are offered by that Contributor alone and not by any other party; and

iv) states that source code for the Program is available from such Contributor, and informs licensees how to obtain it in a reasonable manner on or through a medium customarily used for software exchange.

When the Program is made available in source code form:

a) it must be made available under this Agreement; and

b) a copy of this Agreement must be included with each copy of the Program.

Contributors may not remove or alter any copyright notices contained within the Program.

Each Contributor must identify itself as the originator of its Contribution, if any, in a manner that reasonably allows subsequent Recipients to identify the originator of the Contribution.

4. COMMERCIAL DISTRIBUTION

Commercial distributors of software may accept certain responsibilities with respect to end users, business partners and the like. While this license is intended to facilitate the commercial use of the Program, the Contributor who includes the Program in a commercial product offering should do so in a manner which does not create potential liability for other Contributors. Therefore, if a Contributor includes the Program in a commercial product offering, such Contributor ("Commercial Contributor") hereby agrees to defend and indemnify every other Contributor ("Indemnified Contributor") against any losses, damages and costs (collectively "Losses") arising from claims, lawsuits and other legal actions brought by a third party against the Indemnified Contributor to the extent caused by the acts or omissions of such Commercial Contributor in connection with its distribution of the Program in a commercial product offering. The obligations in this section do not apply to any claims or Losses relating to any actual or alleged intellectual property infringement. In order to qualify, an Indemnified Contributor must: a) promptly notify the Commercial Contributor in writing of such claim, and b) allow the Commercial Contributor to control, and cooperate with the Commercial Contributor in, the defense and any related settlement negotiations. The Indemnified Contributor may participate in any such claim at its own expense.

For example, a Contributor might include the Program in a commercial product offering, Product X. That Contributor is then a Commercial Contributor. If that Commercial Contributor then makes performance claims, or offers warranties related to Product X, those performance claims and warranties are such Commercial Contributor's responsibility alone. Under this section, the Commercial Contributor would have to defend claims against the other Contributors related to those performance claims and warranties, and if a court requires any other Contributor to pay any damages as a result, the Commercial Contributor must pay those damages.

5. NO WARRANTY

EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, THE PROGRAM IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. Each Recipient is solely responsible for determining the appropriateness of using and distributing the Program and assumes all risks associated with its exercise of rights under this Agreement , including but not limited to the risks and costs of program errors, compliance with applicable laws, damage to or loss of data, programs or equipment, and unavailability or interruption of operations.

6. DISCLAIMER OF LIABILITY

EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, NEITHER RECIPIENT NOR ANY CONTRIBUTORS SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.

7. GENERAL

If any provision of this Agreement is invalid or unenforceable under applicable law, it shall not affect the validity or enforceability of the remainder of the terms of this Agreement, and without further action by the parties hereto, such provision shall be reformed to the minimum extent necessary to make such provision valid and enforceable.

If Recipient institutes patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Program itself (excluding combinations of the Program with other software or hardware) infringes such Recipient's patent(s), then such Recipient's rights granted under Section 2(b) shall terminate as of the date such litigation is filed.

All Recipient's rights under this Agreement shall terminate if it fails to comply with any of the material terms or conditions of this Agreement and does not cure such failure in a reasonable period of time after becoming aware of such noncompliance. If all Recipient's rights under this Agreement terminate, Recipient agrees to cease use and distribution of the Program as soon as reasonably practicable. However, Recipient's obligations under this Agreement and any licenses granted by Recipient relating to the Program shall continue and survive.

Everyone is permitted to copy and distribute copies of this Agreement, but in order to avoid inconsistency the Agreement is copyrighted and may only be modified in the following manner. The Agreement Steward reserves the right to publish new versions (including revisions) of this Agreement from time to time. No one other than the Agreement Steward has the right to modify this Agreement. The Eclipse Foundation is the initial Agreement Steward. The Eclipse Foundation may assign the responsibility to serve as the Agreement Steward to a suitable separate entity. Each new version of the Agreement will be given a distinguishing version number. The Program (including Contributions) may always be distributed subject to the version of the Agreement under which it was received. In addition, after a new version of the Agreement is published, Contributor may elect to distribute the Program (including its Contributions) under the new version. Except as expressly stated in Sections 2(a) and 2(b) above, Recipient receives no rights or licenses to the intellectual property of any Contributor under this Agreement, whether expressly, by implication, estoppel or otherwise. All rights in the Program not expressly granted under this Agreement are reserved.

This Agreement is governed by the laws of the State of New York and the intellectual property laws of the United States of America. No party to this Agreement will bring a legal action under this Agreement more than one year after the cause of action arose. Each party waives its rights to a jury trial in any resulting litigation.

================================================ FILE: app/src/main/assets/gpl_3.html ================================================ GNU General Public License v3.0 - GNU Project - Free Software Foundation (FSF)

GNU GENERAL PUBLIC LICENSE

Version 3, 29 June 2007

Copyright © 2007 Free Software Foundation, Inc. <http://fsf.org/>

Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.

Preamble

The GNU General Public License is a free, copyleft license for software and other kinds of works.

The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.

When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.

To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.

For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.

Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.

For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.

Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.

Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.

The precise terms and conditions for copying, distribution and modification follow.

TERMS AND CONDITIONS

0. Definitions.

“This License” refers to version 3 of the GNU General Public License.

“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.

“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.

To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.

A “covered work” means either the unmodified Program or a work based on the Program.

To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.

To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.

An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.

1. Source Code.

The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.

A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.

The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.

The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.

The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.

The Corresponding Source for a work in source code form is that same work.

2. Basic Permissions.

All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.

You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.

Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.

3. Protecting Users' Legal Rights From Anti-Circumvention Law.

No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.

When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.

4. Conveying Verbatim Copies.

You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.

You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.

5. Conveying Modified Source Versions.

You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:

  • a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
  • b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
  • c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
  • d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.

A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.

6. Conveying Non-Source Forms.

You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:

  • a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
  • b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
  • c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
  • d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
  • e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.

A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.

A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.

“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.

If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).

The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.

Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.

7. Additional Terms.

“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.

When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.

Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:

  • a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
  • b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
  • c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
  • d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
  • e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
  • f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.

All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.

If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.

Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.

8. Termination.

You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).

However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.

Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.

Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.

9. Acceptance Not Required for Having Copies.

You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.

10. Automatic Licensing of Downstream Recipients.

Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.

An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.

You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.

11. Patents.

A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.

A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.

Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.

In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.

If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.

If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.

A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.

Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.

12. No Surrender of Others' Freedom.

If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.

13. Use with the GNU Affero General Public License.

Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.

14. Revised Versions of this License.

The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.

Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.

If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.

Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.

15. Disclaimer of Warranty.

THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.

16. Limitation of Liability.

IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.

17. Interpretation of Sections 15 and 16.

If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.

================================================ FILE: app/src/main/assets/mit.html ================================================

Copyright (c) <year> <copyright holders>

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

================================================ FILE: app/src/main/assets/mpl2.html ================================================ Mozilla Public License, version 2.0

Mozilla Public License
Version 2.0

1. Definitions

1.1. “Contributor”

means each individual or legal entity that creates, contributes to the creation of, or owns Covered Software.

1.2. “Contributor Version”

means the combination of the Contributions of others (if any) used by a Contributor and that particular Contributor’s Contribution.

1.3. “Contribution”

means Covered Software of a particular Contributor.

1.4. “Covered Software”

means Source Code Form to which the initial Contributor has attached the notice in Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source Code Form, in each case including portions thereof.

1.5. “Incompatible With Secondary Licenses”

means

  1. that the initial Contributor has attached the notice described in Exhibit B to the Covered Software; or

  2. that the Covered Software was made available under the terms of version 1.1 or earlier of the License, but not also under the terms of a Secondary License.

1.6. “Executable Form”

means any form of the work other than Source Code Form.

1.7. “Larger Work”

means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software.

1.8. “License”

means this document.

1.9. “Licensable”

means having the right to grant, to the maximum extent possible, whether at the time of the initial grant or subsequently, any and all of the rights conveyed by this License.

1.10. “Modifications”

means any of the following:

  1. any file in Source Code Form that results from an addition to, deletion from, or modification of the contents of Covered Software; or

  2. any new file in Source Code Form that contains any Covered Software.

1.11. “Patent Claims” of a Contributor

means any patent claim(s), including without limitation, method, process, and apparatus claims, in any patent Licensable by such Contributor that would be infringed, but for the grant of the License, by the making, using, selling, offering for sale, having made, import, or transfer of either its Contributions or its Contributor Version.

1.12. “Secondary License”

means either the GNU General Public License, Version 2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later versions of those licenses.

1.13. “Source Code Form”

means the form of the work preferred for making modifications.

1.14. “You” (or “Your”)

means an individual or a legal entity exercising rights under this License. For legal entities, “You” includes any entity that controls, is controlled by, or is under common control with You. For purposes of this definition, “control” means (a) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (b) ownership of more than fifty percent (50%) of the outstanding shares or beneficial ownership of such entity.

2. License Grants and Conditions

2.1. Grants

Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license:

  1. under intellectual property rights (other than patent or trademark) Licensable by such Contributor to use, reproduce, make available, modify, display, perform, distribute, and otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or as part of a Larger Work; and

  2. under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, import, and otherwise transfer either its Contributions or its Contributor Version.

2.2. Effective Date

The licenses granted in Section 2.1 with respect to any Contribution become effective for each Contribution on the date the Contributor first distributes such Contribution.

2.3. Limitations on Grant Scope

The licenses granted in this Section 2 are the only rights granted under this License. No additional rights or licenses will be implied from the distribution or licensing of Covered Software under this License. Notwithstanding Section 2.1(b) above, no patent license is granted by a Contributor:

  1. for any code that a Contributor has removed from Covered Software; or

  2. for infringements caused by: (i) Your and any other third party’s modifications of Covered Software, or (ii) the combination of its Contributions with other software (except as part of its Contributor Version); or

  3. under Patent Claims infringed by Covered Software in the absence of its Contributions.

This License does not grant any rights in the trademarks, service marks, or logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 3.4).

2.4. Subsequent Licenses

No Contributor makes additional grants as a result of Your choice to distribute the Covered Software under a subsequent version of this License (see Section 10.2) or under the terms of a Secondary License (if permitted under the terms of Section 3.3).

2.5. Representation

Each Contributor represents that the Contributor believes its Contributions are its original creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by this License.

2.6. Fair Use

This License is not intended to limit any rights You have under applicable copyright doctrines of fair use, fair dealing, or other equivalents.

2.7. Conditions

Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1.

3. Responsibilities

3.1. Distribution of Source Form

All distribution of Covered Software in Source Code Form, including any Modifications that You create or to which You contribute, must be under the terms of this License. You must inform recipients that the Source Code Form of the Covered Software is governed by the terms of this License, and how they can obtain a copy of this License. You may not attempt to alter or restrict the recipients’ rights in the Source Code Form.

3.2. Distribution of Executable Form

If You distribute Covered Software in Executable Form then:

  1. such Covered Software must also be made available in Source Code Form, as described in Section 3.1, and You must inform recipients of the Executable Form how they can obtain a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more than the cost of distribution to the recipient; and

  2. You may distribute such Executable Form under the terms of this License, or sublicense it under different terms, provided that the license for the Executable Form does not attempt to limit or alter the recipients’ rights in the Source Code Form under this License.

3.3. Distribution of a Larger Work

You may create and distribute a Larger Work under terms of Your choice, provided that You also comply with the requirements of this License for the Covered Software. If the Larger Work is a combination of Covered Software with a work governed by one or more Secondary Licenses, and the Covered Software is not Incompatible With Secondary Licenses, this License permits You to additionally distribute such Covered Software under the terms of such Secondary License(s), so that the recipient of the Larger Work may, at their option, further distribute the Covered Software under the terms of either this License or such Secondary License(s).

3.4. Notices

You may not remove or alter the substance of any license notices (including copyright notices, patent notices, disclaimers of warranty, or limitations of liability) contained within the Source Code Form of the Covered Software, except that You may alter any license notices to the extent required to remedy known factual inaccuracies.

3.5. Application of Additional Terms

You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations to one or more recipients of Covered Software. However, You may do so only on Your own behalf, and not on behalf of any Contributor. You must make it absolutely clear that any such warranty, support, indemnity, or liability obligation is offered by You alone, and You hereby agree to indemnify every Contributor for any liability incurred by such Contributor as a result of warranty, support, indemnity or liability terms You offer. You may include additional disclaimers of warranty and limitations of liability specific to any jurisdiction.

4. Inability to Comply Due to Statute or Regulation

If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Covered Software due to statute, judicial order, or regulation then You must: (a) comply with the terms of this License to the maximum extent possible; and (b) describe the limitations and the code they affect. Such description must be placed in a text file included with all distributions of the Covered Software under this License. Except to the extent prohibited by statute or regulation, such description must be sufficiently detailed for a recipient of ordinary skill to be able to understand it.

5. Termination

5.1. The rights granted under this License will terminate automatically if You fail to comply with any of its terms. However, if You become compliant, then the rights granted under this License from a particular Contributor are reinstated (a) provisionally, unless and until such Contributor explicitly and finally terminates Your grants, and (b) on an ongoing basis, if such Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days after You have come back into compliance. Moreover, Your grants from a particular Contributor are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by some reasonable means, this is the first time You have received notice of non-compliance with this License from such Contributor, and You become compliant prior to 30 days after Your receipt of the notice.

5.2. If You initiate litigation against any entity by asserting a patent infringement claim (excluding declaratory judgment actions, counter-claims, and cross-claims) alleging that a Contributor Version directly or indirectly infringes any patent, then the rights granted to You by any and all Contributors for the Covered Software under Section 2.1 of this License shall terminate.

5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user license agreements (excluding distributors and resellers) which have been validly granted by You or Your distributors under this License prior to termination shall survive termination.

6. Disclaimer of Warranty

Covered Software is provided under this License on an “as is” basis, without warranty of any kind, either expressed, implied, or statutory, including, without limitation, warranties that the Covered Software is free of defects, merchantable, fit for a particular purpose or non-infringing. The entire risk as to the quality and performance of the Covered Software is with You. Should any Covered Software prove defective in any respect, You (not any Contributor) assume the cost of any necessary servicing, repair, or correction. This disclaimer of warranty constitutes an essential part of this License. No use of any Covered Software is authorized under this License except under this disclaimer.

7. Limitation of Liability

Under no circumstances and under no legal theory, whether tort (including negligence), contract, or otherwise, shall any Contributor, or anyone who distributes Covered Software as permitted above, be liable to You for any direct, indirect, special, incidental, or consequential damages of any character including, without limitation, damages for lost profits, loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses, even if such party shall have been informed of the possibility of such damages. This limitation of liability shall not apply to liability for death or personal injury resulting from such party’s negligence to the extent applicable law prohibits such limitation. Some jurisdictions do not allow the exclusion or limitation of incidental or consequential damages, so this exclusion and limitation may not apply to You.

8. Litigation

Any litigation relating to this License may be brought only in the courts of a jurisdiction where the defendant maintains its principal place of business and such litigation shall be governed by laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this Section shall prevent a party’s ability to bring cross-claims or counter-claims.

9. Miscellaneous

This License represents the complete agreement concerning the subject matter hereof. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. Any law or regulation which provides that the language of a contract shall be construed against the drafter shall not be used to construe this License against a Contributor.

10. Versions of the License

10.1. New Versions

Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other than the license steward has the right to modify or publish new versions of this License. Each version will be given a distinguishing version number.

10.2. Effect of New Versions

You may distribute the Covered Software under the terms of the version of the License under which You originally received the Covered Software, or under the terms of any subsequent version published by the license steward.

10.3. Modified Versions

If you create software not governed by this License, and you want to create a new license for such software, you may create and use a modified version of this License if you rename the license and remove any references to the name of the license steward (except to note that such modified license differs from this License).

10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses

If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under the terms of this version of the License, the notice described in Exhibit B of this License must be attached.

Exhibit A - Source Code Form License Notice

This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at https://mozilla.org/MPL/2.0/.

If it is not possible or desirable to put the notice in a particular file, then You may include the notice in a location (such as a LICENSE file in a relevant directory) where a recipient would be likely to look for such a notice.

You may add additional accurate notices of copyright ownership.

Exhibit B - “Incompatible With Secondary Licenses” Notice

This Source Code Form is “Incompatible With Secondary Licenses”, as defined by the Mozilla Public License, v. 2.0.

================================================ FILE: app/src/main/assets/po_token.html ================================================ ================================================ FILE: app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.java ================================================ /* * Copyright 2018 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package androidx.fragment.app; import android.os.Bundle; import android.os.Parcelable; import android.util.Log; import android.view.View; import android.view.ViewGroup; import androidx.annotation.IntDef; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.os.BundleCompat; import androidx.lifecycle.Lifecycle; import androidx.viewpager.widget.PagerAdapter; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.ArrayList; // TODO: Replace this deprecated class with its ViewPager2 counterpart /** * This is a copy from {@link androidx.fragment.app.FragmentStatePagerAdapter}. *

* It includes a workaround to fix the menu visibility when the adapter is restored. *

*

* When restoring the state of this adapter, all the fragments' menu visibility were set to false, * effectively disabling the menu from the user until he switched pages or another event * that triggered the menu to be visible again happened. *

*

* Check out the changes in: *

*
    *
  • {@link #saveState()}
  • *
  • {@link #restoreState(Parcelable, ClassLoader)}
  • *
* * @deprecated Switch to {@link androidx.viewpager2.widget.ViewPager2} and use * {@link androidx.viewpager2.adapter.FragmentStateAdapter} instead. */ @SuppressWarnings("deprecation") @Deprecated public abstract class FragmentStatePagerAdapterMenuWorkaround extends PagerAdapter { private static final String TAG = "FragmentStatePagerAdapt"; private static final boolean DEBUG = false; @Retention(RetentionPolicy.SOURCE) @IntDef({BEHAVIOR_SET_USER_VISIBLE_HINT, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT}) private @interface Behavior { } /** * Indicates that {@link Fragment#setUserVisibleHint(boolean)} will be called when the current * fragment changes. * * @deprecated This behavior relies on the deprecated * {@link Fragment#setUserVisibleHint(boolean)} API. Use * {@link #BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT} to switch to its replacement, * {@link FragmentTransaction#setMaxLifecycle}. * @see #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int) */ @Deprecated public static final int BEHAVIOR_SET_USER_VISIBLE_HINT = 0; /** * Indicates that only the current fragment will be in the {@link Lifecycle.State#RESUMED} * state. All other Fragments are capped at {@link Lifecycle.State#STARTED}. * * @see #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int) */ public static final int BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT = 1; private final FragmentManager mFragmentManager; private final int mBehavior; private FragmentTransaction mCurTransaction = null; private final ArrayList mSavedState = new ArrayList<>(); private final ArrayList mFragments = new ArrayList<>(); private Fragment mCurrentPrimaryItem = null; private boolean mExecutingFinishUpdate; /** * Constructor for {@link FragmentStatePagerAdapterMenuWorkaround} * that sets the fragment manager for the adapter. This is the equivalent of calling * {@link #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int)} and passing in * {@link #BEHAVIOR_SET_USER_VISIBLE_HINT}. * *

Fragments will have {@link Fragment#setUserVisibleHint(boolean)} called whenever the * current Fragment changes.

* * @param fm fragment manager that will interact with this adapter * @deprecated use {@link #FragmentStatePagerAdapterMenuWorkaround(FragmentManager, int)} with * {@link #BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT} */ @Deprecated public FragmentStatePagerAdapterMenuWorkaround(@NonNull final FragmentManager fm) { this(fm, BEHAVIOR_SET_USER_VISIBLE_HINT); } /** * Constructor for {@link FragmentStatePagerAdapterMenuWorkaround}. * * If {@link #BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT} is passed in, then only the current * Fragment is in the {@link Lifecycle.State#RESUMED} state, while all other fragments are * capped at {@link Lifecycle.State#STARTED}. If {@link #BEHAVIOR_SET_USER_VISIBLE_HINT} is * passed, all fragments are in the {@link Lifecycle.State#RESUMED} state and there will be * callbacks to {@link Fragment#setUserVisibleHint(boolean)}. * * @param fm fragment manager that will interact with this adapter * @param behavior determines if only current fragments are in a resumed state */ public FragmentStatePagerAdapterMenuWorkaround(@NonNull final FragmentManager fm, @Behavior final int behavior) { mFragmentManager = fm; mBehavior = behavior; } /** * @param position the position of the item you want * @return the {@link Fragment} associated with a specified position */ @NonNull public abstract Fragment getItem(int position); @Override public void startUpdate(@NonNull final ViewGroup container) { if (container.getId() == View.NO_ID) { throw new IllegalStateException("ViewPager with adapter " + this + " requires a view id"); } } @SuppressWarnings("deprecation") @NonNull @Override public Object instantiateItem(@NonNull final ViewGroup container, final int position) { // If we already have this item instantiated, there is nothing // to do. This can happen when we are restoring the entire pager // from its saved state, where the fragment manager has already // taken care of restoring the fragments we previously had instantiated. if (mFragments.size() > position) { final Fragment f = mFragments.get(position); if (f != null) { return f; } } if (mCurTransaction == null) { mCurTransaction = mFragmentManager.beginTransaction(); } final Fragment fragment = getItem(position); if (DEBUG) { Log.v(TAG, "Adding item #" + position + ": f=" + fragment); } if (mSavedState.size() > position) { final Fragment.SavedState fss = mSavedState.get(position); if (fss != null) { fragment.setInitialSavedState(fss); } } while (mFragments.size() <= position) { mFragments.add(null); } fragment.setMenuVisibility(false); if (mBehavior == BEHAVIOR_SET_USER_VISIBLE_HINT) { fragment.setUserVisibleHint(false); } mFragments.set(position, fragment); mCurTransaction.add(container.getId(), fragment); if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { mCurTransaction.setMaxLifecycle(fragment, Lifecycle.State.STARTED); } return fragment; } @Override public void destroyItem(@NonNull final ViewGroup container, final int position, @NonNull final Object object) { final Fragment fragment = (Fragment) object; if (mCurTransaction == null) { mCurTransaction = mFragmentManager.beginTransaction(); } if (DEBUG) { Log.v(TAG, "Removing item #" + position + ": f=" + object + " v=" + ((Fragment) object).getView()); } while (mSavedState.size() <= position) { mSavedState.add(null); } mSavedState.set(position, fragment.isAdded() ? mFragmentManager.saveFragmentInstanceState(fragment) : null); mFragments.set(position, null); mCurTransaction.remove(fragment); if (fragment.equals(mCurrentPrimaryItem)) { mCurrentPrimaryItem = null; } } @Override @SuppressWarnings({"ReferenceEquality", "deprecation"}) public void setPrimaryItem(@NonNull final ViewGroup container, final int position, @NonNull final Object object) { final Fragment fragment = (Fragment) object; if (fragment != mCurrentPrimaryItem) { if (mCurrentPrimaryItem != null) { mCurrentPrimaryItem.setMenuVisibility(false); if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { if (mCurTransaction == null) { mCurTransaction = mFragmentManager.beginTransaction(); } mCurTransaction.setMaxLifecycle(mCurrentPrimaryItem, Lifecycle.State.STARTED); } else { mCurrentPrimaryItem.setUserVisibleHint(false); } } fragment.setMenuVisibility(true); if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { if (mCurTransaction == null) { mCurTransaction = mFragmentManager.beginTransaction(); } mCurTransaction.setMaxLifecycle(fragment, Lifecycle.State.RESUMED); } else { fragment.setUserVisibleHint(true); } mCurrentPrimaryItem = fragment; } } @Override public void finishUpdate(@NonNull final ViewGroup container) { if (mCurTransaction != null) { // We drop any transactions that attempt to be committed // from a re-entrant call to finishUpdate(). We need to // do this as a workaround for Robolectric running measure/layout // calls inline rather than allowing them to be posted // as they would on a real device. if (!mExecutingFinishUpdate) { try { mExecutingFinishUpdate = true; mCurTransaction.commitNowAllowingStateLoss(); } finally { mExecutingFinishUpdate = false; } } mCurTransaction = null; } } @Override public boolean isViewFromObject(@NonNull final View view, @NonNull final Object object) { return ((Fragment) object).getView() == view; } //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! private final String selectedFragment = "selected_fragment"; //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! @Override @Nullable public Parcelable saveState() { Bundle state = null; if (!mSavedState.isEmpty()) { state = new Bundle(); state.putParcelableArrayList("states", mSavedState); } for (int i = 0; i < mFragments.size(); i++) { final Fragment f = mFragments.get(i); if (f != null && f.isAdded()) { if (state == null) { state = new Bundle(); } final String key = "f" + i; mFragmentManager.putFragment(state, key, f); //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! // Check if it's the same fragment instance if (f == mCurrentPrimaryItem) { state.putString(selectedFragment, key); } //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! } } return state; } @Override public void restoreState(@Nullable final Parcelable state, @Nullable final ClassLoader loader) { if (state != null) { final Bundle bundle = (Bundle) state; bundle.setClassLoader(loader); final var states = BundleCompat.getParcelableArrayList(bundle, "states", Fragment.SavedState.class); mSavedState.clear(); mFragments.clear(); if (states != null) { mSavedState.addAll(states); } final Iterable keys = bundle.keySet(); for (final String key : keys) { if (key.startsWith("f")) { final int index = Integer.parseInt(key.substring(1)); final Fragment f = mFragmentManager.getFragment(bundle, key); if (f != null) { while (mFragments.size() <= index) { mFragments.add(null); } //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! final boolean wasSelected = bundle.getString(selectedFragment, "") .equals(key); f.setMenuVisibility(wasSelected); //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! mFragments.set(index, f); } else { Log.w(TAG, "Bad fragment at key " + key); } } } } } } ================================================ FILE: app/src/main/java/com/google/android/material/appbar/FlingBehavior.java ================================================ package com.google.android.material.appbar; import android.content.Context; import android.graphics.Rect; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import android.widget.OverScroller; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.coordinatorlayout.widget.CoordinatorLayout; import org.schabi.newpipe.R; import java.lang.reflect.Field; import java.util.List; // See https://stackoverflow.com/questions/56849221#57997489 public final class FlingBehavior extends AppBarLayout.Behavior { private final Rect focusScrollRect = new Rect(); public FlingBehavior(final Context context, final AttributeSet attrs) { super(context, attrs); } private boolean allowScroll = true; private final Rect globalRect = new Rect(); private final List skipInterceptionOfElements = List.of( R.id.itemsListPanel, R.id.playbackSeekBar, R.id.playPauseButton, R.id.playPreviousButton, R.id.playNextButton); @Override public boolean onRequestChildRectangleOnScreen( @NonNull final CoordinatorLayout coordinatorLayout, @NonNull final AppBarLayout child, @NonNull final Rect rectangle, final boolean immediate) { focusScrollRect.set(rectangle); coordinatorLayout.offsetDescendantRectToMyCoords(child, focusScrollRect); final int height = coordinatorLayout.getHeight(); if (focusScrollRect.top <= 0 && focusScrollRect.bottom >= height) { // the child is too big to fit inside ourselves completely, ignore request return false; } final int dy; if (focusScrollRect.bottom > height) { dy = focusScrollRect.top; } else if (focusScrollRect.top < 0) { // scrolling up dy = -(height - focusScrollRect.bottom); } else { // nothing to do return false; } final int consumed = scroll(coordinatorLayout, child, dy, getMaxDragOffset(child), 0); return consumed == dy; } @Override public boolean onInterceptTouchEvent(@NonNull final CoordinatorLayout parent, @NonNull final AppBarLayout child, @NonNull final MotionEvent ev) { for (final int element : skipInterceptionOfElements) { final View view = child.findViewById(element); if (view != null) { final boolean visible = view.getGlobalVisibleRect(globalRect); if (visible && globalRect.contains((int) ev.getRawX(), (int) ev.getRawY())) { allowScroll = false; return false; } } } allowScroll = true; switch (ev.getActionMasked()) { case MotionEvent.ACTION_DOWN: // remove reference to old nested scrolling child resetNestedScrollingChild(); // Stop fling when your finger touches the screen stopAppBarLayoutFling(); break; default: break; } return super.onInterceptTouchEvent(parent, child, ev); } @Override public boolean onStartNestedScroll(@NonNull final CoordinatorLayout parent, @NonNull final AppBarLayout child, @NonNull final View directTargetChild, final View target, final int nestedScrollAxes, final int type) { return allowScroll && super.onStartNestedScroll( parent, child, directTargetChild, target, nestedScrollAxes, type); } @Override public boolean onNestedFling(@NonNull final CoordinatorLayout coordinatorLayout, @NonNull final AppBarLayout child, @NonNull final View target, final float velocityX, final float velocityY, final boolean consumed) { return allowScroll && super.onNestedFling( coordinatorLayout, child, target, velocityX, velocityY, consumed); } @Nullable private OverScroller getScrollerField() { try { final Class headerBehaviorType = this.getClass() .getSuperclass().getSuperclass().getSuperclass(); if (headerBehaviorType != null) { final Field field = headerBehaviorType.getDeclaredField("scroller"); field.setAccessible(true); return ((OverScroller) field.get(this)); } } catch (final NoSuchFieldException | IllegalAccessException e) { // ? } return null; } @Nullable private Field getLastNestedScrollingChildRefField() { try { final Class headerBehaviorType = this.getClass().getSuperclass().getSuperclass(); if (headerBehaviorType != null) { final Field field = headerBehaviorType.getDeclaredField("lastNestedScrollingChildRef"); field.setAccessible(true); return field; } } catch (final NoSuchFieldException e) { // ? } return null; } private void resetNestedScrollingChild() { final Field field = getLastNestedScrollingChildRefField(); if (field != null) { try { final Object value = field.get(this); if (value != null) { field.set(this, null); } } catch (final IllegalAccessException e) { // ? } } } private void stopAppBarLayoutFling() { final OverScroller scroller = getScrollerField(); if (scroller != null) { scroller.forceFinished(true); } } } ================================================ FILE: app/src/main/java/org/apache/commons/text/similarity/FuzzyScore.java ================================================ /* * Licensed to the Apache Software Foundation (ASF) under one or more * contributor license agreements. See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.apache.commons.text.similarity; import java.util.Locale; /** * A matching algorithm that is similar to the searching algorithms implemented in editors such * as Sublime Text, TextMate, Atom and others. * *

* One point is given for every matched character. Subsequent matches yield two bonus points. * A higher score indicates a higher similarity. *

* *

* This code has been adapted from Apache Commons Lang 3.3. *

* * @since 1.0 * * Note: This class was forked from * * apache/commons-text (8cfdafc) FuzzyScore.java * */ public class FuzzyScore { /** * Locale used to change the case of text. */ private final Locale locale; /** * This returns a {@link Locale}-specific {@link FuzzyScore}. * * @param locale The string matching logic is case insensitive. A {@link Locale} is necessary to normalize both Strings to lower case. * @throws IllegalArgumentException * This is thrown if the {@link Locale} parameter is {@code null}. */ public FuzzyScore(final Locale locale) { if (locale == null) { throw new IllegalArgumentException("Locale must not be null"); } this.locale = locale; } /** * Find the Fuzzy Score which indicates the similarity score between two * Strings. * *
     * score.fuzzyScore(null, null)                          = IllegalArgumentException
     * score.fuzzyScore("not null", null)                    = IllegalArgumentException
     * score.fuzzyScore(null, "not null")                    = IllegalArgumentException
     * score.fuzzyScore("", "")                              = 0
     * score.fuzzyScore("Workshop", "b")                     = 0
     * score.fuzzyScore("Room", "o")                         = 1
     * score.fuzzyScore("Workshop", "w")                     = 1
     * score.fuzzyScore("Workshop", "ws")                    = 2
     * score.fuzzyScore("Workshop", "wo")                    = 4
     * score.fuzzyScore("Apache Software Foundation", "asf") = 3
     * 
* * @param term a full term that should be matched against, must not be null * @param query the query that will be matched against a term, must not be * null * @return result score * @throws IllegalArgumentException if the term or query is {@code null} */ public Integer fuzzyScore(final CharSequence term, final CharSequence query) { if (term == null || query == null) { throw new IllegalArgumentException("CharSequences must not be null"); } // fuzzy logic is case insensitive. We normalize the Strings to lower // case right from the start. Turning characters to lower case // via Character.toLowerCase(char) is unfortunately insufficient // as it does not accept a locale. final String termLowerCase = term.toString().toLowerCase(locale); final String queryLowerCase = query.toString().toLowerCase(locale); // the resulting score int score = 0; // the position in the term which will be scanned next for potential // query character matches int termIndex = 0; // index of the previously matched character in the term int previousMatchingCharacterIndex = Integer.MIN_VALUE; for (int queryIndex = 0; queryIndex < queryLowerCase.length(); queryIndex++) { final char queryChar = queryLowerCase.charAt(queryIndex); boolean termCharacterMatchFound = false; for (; termIndex < termLowerCase.length() && !termCharacterMatchFound; termIndex++) { final char termChar = termLowerCase.charAt(termIndex); if (queryChar == termChar) { // simple character matches result in one point score++; // subsequent character matches further improve // the score. if (previousMatchingCharacterIndex + 1 == termIndex) { score += 2; } previousMatchingCharacterIndex = termIndex; // we can leave the nested loop. Every character in the // query can match at most one character in the term. termCharacterMatchFound = true; } } } return score; } /** * Gets the locale. * * @return The locale */ public Locale getLocale() { return locale; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/App.kt ================================================ package org.schabi.newpipe import android.app.ActivityManager import android.app.Application import android.content.Context import android.util.Log import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.getSystemService import androidx.preference.PreferenceManager import coil3.ImageLoader import coil3.SingletonImageLoader import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.allowRgb565 import coil3.request.crossfade import coil3.util.DebugLogger import com.jakewharton.processphoenix.ProcessPhoenix import io.reactivex.rxjava3.exceptions.CompositeException import io.reactivex.rxjava3.exceptions.MissingBackpressureException import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException import io.reactivex.rxjava3.exceptions.UndeliverableException import io.reactivex.rxjava3.functions.Consumer import io.reactivex.rxjava3.plugins.RxJavaPlugins import java.io.IOException import java.io.InterruptedIOException import java.net.SocketException import org.acra.ACRA.init import org.acra.ACRA.isACRASenderServiceProcess import org.acra.config.CoreConfigurationBuilder import org.schabi.newpipe.error.ReCaptchaActivity import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.downloader.Downloader import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor import org.schabi.newpipe.ktx.hasAssignableCause import org.schabi.newpipe.settings.NewPipeSettings import org.schabi.newpipe.util.BridgeStateSaverInitializer import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.ServiceHelper import org.schabi.newpipe.util.StateSaver import org.schabi.newpipe.util.image.ImageStrategy import org.schabi.newpipe.util.image.PreferredImageQuality import org.schabi.newpipe.util.potoken.PoTokenProviderImpl /* * Copyright (C) Hans-Christoph Steiner 2016 * App.kt is part of NewPipe. * * NewPipe is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * NewPipe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ open class App : Application(), SingletonImageLoader.Factory { var isFirstRun = false private set var notificationsRequested = false private set fun setNotificationsRequested() { notificationsRequested = true } override fun attachBaseContext(base: Context?) { super.attachBaseContext(base) initACRA() } override fun onCreate() { super.onCreate() instance = this if (ProcessPhoenix.isPhoenixProcess(this)) { Log.i(TAG, "This is a phoenix process! Aborting initialization of App[onCreate]") return } // check if the last used preference version is set // to determine whether this is the first app run val lastUsedPrefVersion = PreferenceManager .getDefaultSharedPreferences(this) .getInt(getString(R.string.last_used_preferences_version), -1) isFirstRun = lastUsedPrefVersion == -1 // Initialize settings first because other initializations can use its values NewPipeSettings.initSettings(this) NewPipe.init( getDownloader(), Localization.getPreferredLocalization(this), Localization.getPreferredContentCountry(this) ) Localization.initPrettyTime(Localization.resolvePrettyTime()) BridgeStateSaverInitializer.init(this) StateSaver.init(this) initNotificationChannels() ServiceHelper.initServices(this) // Initialize image loader val prefs = PreferenceManager.getDefaultSharedPreferences(this) ImageStrategy.setPreferredImageQuality( PreferredImageQuality.fromPreferenceKey( this, prefs.getString( getString(R.string.image_quality_key), getString(R.string.image_quality_default) ) ) ) configureRxJavaErrorHandler() YoutubeStreamExtractor.setPoTokenProvider(PoTokenProviderImpl) } override fun newImageLoader(context: Context): ImageLoader = ImageLoader .Builder(this) .logger(if (BuildConfig.DEBUG) DebugLogger() else null) .allowRgb565(getSystemService()!!.isLowRamDevice) .crossfade(true) .components { add(OkHttpNetworkFetcherFactory(callFactory = DownloaderImpl.getInstance().client)) }.build() protected open fun getDownloader(): Downloader { val downloader = DownloaderImpl.init(null) setCookiesToDownloader(downloader) return downloader } protected fun setCookiesToDownloader(downloader: DownloaderImpl) { val prefs = PreferenceManager.getDefaultSharedPreferences(this) val key = getString(R.string.recaptcha_cookies_key) downloader.setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, prefs.getString(key, null)) downloader.updateYoutubeRestrictedModeCookies(this) } private fun configureRxJavaErrorHandler() { // https://github.com/ReactiveX/RxJava/wiki/What's-different-in-2.0#error-handling RxJavaPlugins.setErrorHandler( object : Consumer { override fun accept(throwable: Throwable) { Log.e(TAG, "RxJavaPlugins.ErrorHandler called with -> : throwable = [${throwable.javaClass.getName()}]") // As UndeliverableException is a wrapper, // get the cause of it to get the "real" exception val actualThrowable = (throwable as? UndeliverableException)?.cause ?: throwable val errors = (actualThrowable as? CompositeException)?.exceptions ?: listOf(actualThrowable) for (error in errors) { if (isThrowableIgnored(error)) { return } if (isThrowableCritical(error)) { reportException(error) return } } // Out-of-lifecycle exceptions should only be reported if a debug user wishes so, // When exception is not reported, log it if (isDisposedRxExceptionsReported()) { reportException(actualThrowable) } else { Log.e(TAG, "RxJavaPlugin: Undeliverable Exception received: ", actualThrowable) } } fun isThrowableIgnored(throwable: Throwable): Boolean { // Don't crash the application over a simple network problem return throwable // network api cancellation .hasAssignableCause( IOException::class.java, SocketException::class.java, // blocking code disposed InterruptedException::class.java, InterruptedIOException::class.java ) } fun isThrowableCritical(throwable: Throwable): Boolean { // Though these exceptions cannot be ignored return throwable .hasAssignableCause( // bug in app NullPointerException::class.java, IllegalArgumentException::class.java, OnErrorNotImplementedException::class.java, MissingBackpressureException::class.java, // bug in operator IllegalStateException::class.java ) } fun reportException(throwable: Throwable) { // Throw uncaught exception that will trigger the report system Thread .currentThread() .uncaughtExceptionHandler .uncaughtException(Thread.currentThread(), throwable) } } ) } /** * Called in [.attachBaseContext] after calling the `super` method. * Should be overridden if MultiDex is enabled, since it has to be initialized before ACRA. */ protected fun initACRA() { if (isACRASenderServiceProcess()) { return } val acraConfig = CoreConfigurationBuilder() .withBuildConfigClass(BuildConfig::class.java) init(this, acraConfig) } private fun initNotificationChannels() { // Keep the importance below DEFAULT to avoid making noise on every notification update for // the main and update channels val mainChannel = NotificationChannelCompat .Builder( getString(R.string.notification_channel_id), NotificationManagerCompat.IMPORTANCE_LOW ).setName(getString(R.string.notification_channel_name)) .setDescription(getString(R.string.notification_channel_description)) .build() val appUpdateChannel = NotificationChannelCompat .Builder( getString(R.string.app_update_notification_channel_id), NotificationManagerCompat.IMPORTANCE_LOW ).setName(getString(R.string.app_update_notification_channel_name)) .setDescription(getString(R.string.app_update_notification_channel_description)) .build() val hashChannel = NotificationChannelCompat .Builder( getString(R.string.hash_channel_id), NotificationManagerCompat.IMPORTANCE_HIGH ).setName(getString(R.string.hash_channel_name)) .setDescription(getString(R.string.hash_channel_description)) .build() val errorReportChannel = NotificationChannelCompat .Builder( getString(R.string.error_report_channel_id), NotificationManagerCompat.IMPORTANCE_LOW ).setName(getString(R.string.error_report_channel_name)) .setDescription(getString(R.string.error_report_channel_description)) .build() val newStreamChannel = NotificationChannelCompat .Builder( getString(R.string.streams_notification_channel_id), NotificationManagerCompat.IMPORTANCE_DEFAULT ).setName(getString(R.string.streams_notification_channel_name)) .setDescription(getString(R.string.streams_notification_channel_description)) .build() val channels = listOf(mainChannel, appUpdateChannel, hashChannel, errorReportChannel, newStreamChannel) NotificationManagerCompat.from(this).createNotificationChannelsCompat(channels) } protected open fun isDisposedRxExceptionsReported(): Boolean = false companion object { const val PACKAGE_NAME: String = BuildConfig.APPLICATION_ID private val TAG = App::class.java.toString() @JvmStatic lateinit var instance: App private set } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/BaseFragment.java ================================================ package org.schabi.newpipe; import android.content.Context; import android.os.Bundle; import android.util.Log; import android.view.View; import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import com.evernote.android.state.State; import com.livefront.bridge.Bridge; public abstract class BaseFragment extends Fragment { protected final String TAG = getClass().getSimpleName() + "@" + Integer.toHexString(hashCode()); protected static final boolean DEBUG = MainActivity.DEBUG; protected AppCompatActivity activity; //These values are used for controlling fragments when they are part of the frontpage @State protected boolean useAsFrontPage = false; public void useAsFrontPage(final boolean value) { useAsFrontPage = value; } /*////////////////////////////////////////////////////////////////////////// // Fragment's Lifecycle //////////////////////////////////////////////////////////////////////////*/ @Override public void onAttach(@NonNull final Context context) { super.onAttach(context); activity = (AppCompatActivity) context; } @Override public void onDetach() { super.onDetach(); activity = null; } @Override public void onCreate(final Bundle savedInstanceState) { if (DEBUG) { Log.d(TAG, "onCreate() called with: " + "savedInstanceState = [" + savedInstanceState + "]"); } super.onCreate(savedInstanceState); Bridge.restoreInstanceState(this, savedInstanceState); if (savedInstanceState != null) { onRestoreInstanceState(savedInstanceState); } } @Override public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { super.onViewCreated(rootView, savedInstanceState); if (DEBUG) { Log.d(TAG, "onViewCreated() called with: " + "rootView = [" + rootView + "], " + "savedInstanceState = [" + savedInstanceState + "]"); } initViews(rootView, savedInstanceState); initListeners(); } @Override public void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); Bridge.saveInstanceState(this, outState); } protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) { } /*////////////////////////////////////////////////////////////////////////// // Init //////////////////////////////////////////////////////////////////////////*/ /** * This method is called in {@link #onViewCreated(View, Bundle)} to initialize the views. * *

* {@link #initListeners()} is called after this method to initialize the corresponding * listeners. *

* @param rootView The inflated view for this fragment * (provided by {@link #onViewCreated(View, Bundle)}) * @param savedInstanceState The saved state of this fragment * (provided by {@link #onViewCreated(View, Bundle)}) */ protected void initViews(final View rootView, final Bundle savedInstanceState) { } /** * Initialize the listeners for this fragment. * *

* This method is called after {@link #initViews(View, Bundle)} * in {@link #onViewCreated(View, Bundle)}. *

*/ protected void initListeners() { } /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ public void setTitle(final String title) { if (DEBUG) { Log.d(TAG, "setTitle() called with: title = [" + title + "]"); } if (!useAsFrontPage && activity != null && activity.getSupportActionBar() != null) { activity.getSupportActionBar().setDisplayShowTitleEnabled(true); activity.getSupportActionBar().setTitle(title); } } /** * Finds the root fragment by looping through all of the parent fragments. The root fragment * is supposed to be {@link org.schabi.newpipe.fragments.MainFragment}, and is the fragment that * handles keeping the backstack of opened fragments in NewPipe, and also the player bottom * sheet. This function therefore returns the fragment manager of said fragment. * * @return the fragment manager of the root fragment, i.e. * {@link org.schabi.newpipe.fragments.MainFragment} */ protected FragmentManager getFM() { Fragment current = this; while (current.getParentFragment() != null) { current = current.getParentFragment(); } return current.getFragmentManager(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/DownloaderImpl.java ================================================ package org.schabi.newpipe; import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.PreferenceManager; import org.schabi.newpipe.error.ReCaptchaActivity; import org.schabi.newpipe.extractor.downloader.Downloader; import org.schabi.newpipe.extractor.downloader.Request; import org.schabi.newpipe.extractor.downloader.Response; import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; import org.schabi.newpipe.util.InfoCache; import java.io.IOException; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import java.util.stream.Stream; import okhttp3.OkHttpClient; import okhttp3.RequestBody; import okhttp3.ResponseBody; public final class DownloaderImpl extends Downloader { public static final String USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0"; public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY = "youtube_restricted_mode_key"; public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000"; public static final String YOUTUBE_DOMAIN = "youtube.com"; private static DownloaderImpl instance; private final Map mCookies; private final OkHttpClient client; private DownloaderImpl(final OkHttpClient.Builder builder) { this.client = builder .readTimeout(30, TimeUnit.SECONDS) // .cache(new Cache(new File(context.getExternalCacheDir(), "okhttp"), // 16 * 1024 * 1024)) .build(); this.mCookies = new HashMap<>(); } @NonNull public OkHttpClient getClient() { return client; } /** * It's recommended to call exactly once in the entire lifetime of the application. * * @param builder if null, default builder will be used * @return a new instance of {@link DownloaderImpl} */ public static DownloaderImpl init(@Nullable final OkHttpClient.Builder builder) { instance = new DownloaderImpl( builder != null ? builder : new OkHttpClient.Builder()); return instance; } public static DownloaderImpl getInstance() { return instance; } public String getCookies(final String url) { final String youtubeCookie = url.contains(YOUTUBE_DOMAIN) ? getCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY) : null; // Recaptcha cookie is always added TODO: not sure if this is necessary return Stream.of(youtubeCookie, getCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY)) .filter(Objects::nonNull) .flatMap(cookies -> Arrays.stream(cookies.split("; *"))) .distinct() .collect(Collectors.joining("; ")); } public String getCookie(final String key) { return mCookies.get(key); } public void setCookie(final String key, final String cookie) { mCookies.put(key, cookie); } public void removeCookie(final String key) { mCookies.remove(key); } public void updateYoutubeRestrictedModeCookies(final Context context) { final String restrictedModeEnabledKey = context.getString(R.string.youtube_restricted_mode_enabled); final boolean restrictedModeEnabled = PreferenceManager.getDefaultSharedPreferences(context) .getBoolean(restrictedModeEnabledKey, false); updateYoutubeRestrictedModeCookies(restrictedModeEnabled); } public void updateYoutubeRestrictedModeCookies(final boolean youtubeRestrictedModeEnabled) { if (youtubeRestrictedModeEnabled) { setCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY, YOUTUBE_RESTRICTED_MODE_COOKIE); } else { removeCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY); } InfoCache.getInstance().clearCache(); } /** * Get the size of the content that the url is pointing by firing a HEAD request. * * @param url an url pointing to the content * @return the size of the content, in bytes */ public long getContentLength(final String url) throws IOException { try { final Response response = head(url); return Long.parseLong(response.getHeader("Content-Length")); } catch (final NumberFormatException e) { throw new IOException("Invalid content length", e); } catch (final ReCaptchaException e) { throw new IOException(e); } } @Override public Response execute(@NonNull final Request request) throws IOException, ReCaptchaException { final String httpMethod = request.httpMethod(); final String url = request.url(); final Map> headers = request.headers(); final byte[] dataToSend = request.dataToSend(); RequestBody requestBody = null; if (dataToSend != null) { requestBody = RequestBody.create(dataToSend); } final okhttp3.Request.Builder requestBuilder = new okhttp3.Request.Builder() .method(httpMethod, requestBody) .url(url) .addHeader("User-Agent", USER_AGENT); final String cookies = getCookies(url); if (!cookies.isEmpty()) { requestBuilder.addHeader("Cookie", cookies); } headers.forEach((headerName, headerValueList) -> { requestBuilder.removeHeader(headerName); headerValueList.forEach(headerValue -> requestBuilder.addHeader(headerName, headerValue)); }); try ( okhttp3.Response response = client.newCall(requestBuilder.build()).execute() ) { if (response.code() == 429) { throw new ReCaptchaException("reCaptcha Challenge requested", url); } String responseBodyToReturn = null; try (ResponseBody body = response.body()) { responseBodyToReturn = body.string(); } final String latestUrl = response.request().url().toString(); return new Response( response.code(), response.message(), response.headers().toMultimap(), responseBodyToReturn, latestUrl); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/ExitActivity.kt ================================================ /* * SPDX-FileCopyrightText: 2016-2026 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe import android.annotation.SuppressLint import android.app.Activity import android.content.Intent import android.os.Bundle import org.schabi.newpipe.util.NavigationHelper class ExitActivity : Activity() { @SuppressLint("NewApi") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) finishAndRemoveTask() NavigationHelper.restartApp(this) } companion object { @JvmStatic fun exitAndRemoveFromRecentApps(activity: Activity) { val intent = Intent(activity, ExitActivity::class.java) intent.addFlags( Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS or Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NO_ANIMATION ) activity.startActivity(intent) } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/MainActivity.java ================================================ /* * Created by Christian Schabesberger on 02.08.16. *

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

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

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

* You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ package org.schabi.newpipe; import android.app.AlertDialog; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.util.Log; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.webkit.WebView; import android.widget.AdapterView; import android.widget.ArrayAdapter; import android.widget.FrameLayout; import android.widget.Spinner; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBarDrawerToggle; import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import androidx.core.view.GravityCompat; import androidx.drawerlayout.widget.DrawerLayout; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentContainerView; import androidx.fragment.app.FragmentManager; import androidx.preference.PreferenceManager; import com.google.android.material.bottomsheet.BottomSheetBehavior; import org.schabi.newpipe.databinding.ActivityMainBinding; import org.schabi.newpipe.databinding.DrawerHeaderBinding; import org.schabi.newpipe.databinding.DrawerLayoutBinding; import org.schabi.newpipe.databinding.InstanceSpinnerLayoutBinding; import org.schabi.newpipe.databinding.ToolbarLayoutBinding; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; import org.schabi.newpipe.fragments.BackPressable; import org.schabi.newpipe.fragments.MainFragment; import org.schabi.newpipe.fragments.detail.VideoDetailFragment; import org.schabi.newpipe.fragments.list.comments.CommentRepliesFragment; import org.schabi.newpipe.fragments.list.search.SearchFragment; import org.schabi.newpipe.local.feed.notifications.NotificationWorker; import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.event.OnKeyDownListener; import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.settings.UpdateSettingsFragment; import org.schabi.newpipe.settings.migration.MigrationManager; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.KioskTranslator; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PeertubeHelper; import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.ReleaseVersionUtil; import org.schabi.newpipe.util.SerializedCache; import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.StateSaver; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.views.FocusOverlayView; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.List; import java.util.Objects; public class MainActivity extends AppCompatActivity { private static final String TAG = "MainActivity"; @SuppressWarnings("ConstantConditions") public static final boolean DEBUG = !BuildConfig.BUILD_TYPE.equals("release"); private ActivityMainBinding mainBinding; private DrawerHeaderBinding drawerHeaderBinding; private DrawerLayoutBinding drawerLayoutBinding; private ToolbarLayoutBinding toolbarLayoutBinding; private ActionBarDrawerToggle toggle; private boolean servicesShown = false; private BroadcastReceiver broadcastReceiver; private static final int ITEM_ID_SUBSCRIPTIONS = -1; private static final int ITEM_ID_FEED = -2; private static final int ITEM_ID_BOOKMARKS = -3; private static final int ITEM_ID_DOWNLOADS = -4; private static final int ITEM_ID_HISTORY = -5; private static final int ITEM_ID_SETTINGS = 0; private static final int ITEM_ID_DONATION = 1; private static final int ITEM_ID_ABOUT = 2; private static final int ORDER = 0; public static final String KEY_IS_IN_BACKGROUND = "is_in_background"; private SharedPreferences sharedPreferences; private SharedPreferences.Editor sharedPrefEditor; /*////////////////////////////////////////////////////////////////////////// // Activity's LifeCycle //////////////////////////////////////////////////////////////////////////*/ @Override protected void onCreate(final Bundle savedInstanceState) { if (DEBUG) { Log.d(TAG, "onCreate() called with: " + "savedInstanceState = [" + savedInstanceState + "]"); } Localization.migrateAppLanguageSettingIfNecessary(getApplicationContext()); ThemeHelper.setDayNightMode(this); ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this)); // Fixes text color turning black in dark/black mode: // https://github.com/TeamNewPipe/NewPipe/issues/12016 // For further reference see: https://issuetracker.google.com/issues/37124582 if (DeviceUtils.supportsWebView()) { try { new WebView(this); } catch (final Throwable e) { if (DEBUG) { Log.e(TAG, "Failed to create WebView", e); } } } super.onCreate(savedInstanceState); sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); sharedPrefEditor = sharedPreferences.edit(); mainBinding = ActivityMainBinding.inflate(getLayoutInflater()); drawerLayoutBinding = mainBinding.drawerLayout; drawerHeaderBinding = DrawerHeaderBinding.bind(drawerLayoutBinding.navigation .getHeaderView(0)); toolbarLayoutBinding = mainBinding.toolbarLayout; setContentView(mainBinding.getRoot()); if (getSupportFragmentManager().getBackStackEntryCount() == 0) { initFragments(); } setSupportActionBar(toolbarLayoutBinding.toolbar); try { setupDrawer(); } catch (final Exception e) { ErrorUtil.showUiErrorSnackbar(this, "Setting up drawer", e); } if (DeviceUtils.isTv(this)) { FocusOverlayView.setupFocusObserver(this); } openMiniPlayerUponPlayerStarted(); if (PermissionHelper.checkPostNotificationsPermission(this, PermissionHelper.POST_NOTIFICATIONS_REQUEST_CODE)) { // Schedule worker for checking for new streams and creating corresponding notifications // if this is enabled by the user. NotificationWorker.initialize(this); } if (!UpdateSettingsFragment.wasUserAskedForConsent(this) && !App.getInstance().isFirstRun() && ReleaseVersionUtil.INSTANCE.isReleaseApk()) { UpdateSettingsFragment.askForConsentToUpdateChecks(this); } // ReleaseVersionUtil.INSTANCE.isReleaseApk() will be true only for main official build // We want every release build (nightly, nightly-refactor) to show the popup if (!DEBUG) { showKeepAndroidDialog(); } MigrationManager.showUserInfoIfPresent(this); } @Override protected void onPostCreate(final Bundle savedInstanceState) { super.onPostCreate(savedInstanceState); final App app = App.getInstance(); if (sharedPreferences.getBoolean(app.getString(R.string.update_app_key), false) && sharedPreferences .getBoolean(app.getString(R.string.update_check_consent_key), false)) { // Start the worker which is checking all conditions // and eventually searching for a new version. NewVersionWorker.enqueueNewVersionCheckingWork(app, false); } } @Override protected void onStart() { super.onStart(); sharedPrefEditor.putBoolean(KEY_IS_IN_BACKGROUND, false).apply(); Log.d(TAG, "App moved to foreground"); } @Override protected void onStop() { super.onStop(); sharedPrefEditor.putBoolean(KEY_IS_IN_BACKGROUND, true).apply(); Log.d(TAG, "App moved to background"); } private void setupDrawer() throws ExtractionException { addDrawerMenuForCurrentService(); toggle = new ActionBarDrawerToggle(this, mainBinding.getRoot(), toolbarLayoutBinding.toolbar, R.string.drawer_open, R.string.drawer_close); toggle.syncState(); mainBinding.getRoot().addDrawerListener(toggle); mainBinding.getRoot().addDrawerListener(new DrawerLayout.SimpleDrawerListener() { private int lastService; @Override public void onDrawerOpened(final View drawerView) { lastService = ServiceHelper.getSelectedServiceId(MainActivity.this); } @Override public void onDrawerClosed(final View drawerView) { if (servicesShown) { toggleServices(); } if (lastService != ServiceHelper.getSelectedServiceId(MainActivity.this)) { ActivityCompat.recreate(MainActivity.this); } } }); drawerLayoutBinding.navigation.setNavigationItemSelectedListener(this::drawerItemSelected); setupDrawerHeader(); } /** * Builds the drawer menu for the current service. * * @throws ExtractionException if the service didn't provide available kiosks */ private void addDrawerMenuForCurrentService() throws ExtractionException { //Tabs drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER, R.string.tab_subscriptions) .setIcon(R.drawable.ic_tv); drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title) .setIcon(R.drawable.ic_subscriptions); drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks) .setIcon(R.drawable.ic_bookmark); drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_tabs_group, ITEM_ID_DOWNLOADS, ORDER, R.string.downloads) .setIcon(R.drawable.ic_file_download); drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_tabs_group, ITEM_ID_HISTORY, ORDER, R.string.action_history) .setIcon(R.drawable.ic_history); //Kiosks final int currentServiceId = ServiceHelper.getSelectedServiceId(this); final StreamingService service = NewPipe.getService(currentServiceId); int kioskMenuItemId = 0; for (final String ks : service.getKioskList().getAvailableKiosks()) { drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_kiosks_group, kioskMenuItemId, 0, KioskTranslator .getTranslatedKioskName(ks, this)) .setIcon(KioskTranslator.getKioskIcon(ks)); kioskMenuItemId++; } //Settings and About drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_options_about_group, ITEM_ID_SETTINGS, ORDER, R.string.settings) .setIcon(R.drawable.ic_settings); drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_options_about_group, ITEM_ID_DONATION, ORDER, R.string.donation_title) .setIcon(R.drawable.volunteer_activism_ic); drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_options_about_group, ITEM_ID_ABOUT, ORDER, R.string.tab_about) .setIcon(R.drawable.ic_info_outline); } private boolean drawerItemSelected(final MenuItem item) { final int groupId = item.getGroupId(); if (groupId == R.id.menu_services_group) { changeService(item); } else if (groupId == R.id.menu_tabs_group) { tabSelected(item); } else if (groupId == R.id.menu_kiosks_group) { try { kioskSelected(item); } catch (final Exception e) { ErrorUtil.showUiErrorSnackbar(this, "Selecting drawer kiosk", e); } } else if (groupId == R.id.menu_options_about_group) { optionsAboutSelected(item); } else { return false; } mainBinding.getRoot().closeDrawers(); return true; } private void changeService(final MenuItem item) { drawerLayoutBinding.navigation.getMenu() .getItem(ServiceHelper.getSelectedServiceId(this)) .setChecked(false); ServiceHelper.setSelectedServiceId(this, item.getItemId()); drawerLayoutBinding.navigation.getMenu() .getItem(ServiceHelper.getSelectedServiceId(this)) .setChecked(true); } private void tabSelected(final MenuItem item) { switch (item.getItemId()) { case ITEM_ID_SUBSCRIPTIONS: NavigationHelper.openSubscriptionFragment(getSupportFragmentManager()); break; case ITEM_ID_FEED: NavigationHelper.openFeedFragment(getSupportFragmentManager()); break; case ITEM_ID_BOOKMARKS: NavigationHelper.openBookmarksFragment(getSupportFragmentManager()); break; case ITEM_ID_DOWNLOADS: NavigationHelper.openDownloads(this); break; case ITEM_ID_HISTORY: NavigationHelper.openStatisticFragment(getSupportFragmentManager()); break; } } private void kioskSelected(final MenuItem item) throws ExtractionException { final StreamingService currentService = ServiceHelper.getSelectedService(this); int kioskMenuItemId = 0; for (final String kioskId : currentService.getKioskList().getAvailableKiosks()) { if (kioskMenuItemId == item.getItemId()) { NavigationHelper.openKioskFragment(getSupportFragmentManager(), currentService.getServiceId(), kioskId); break; } kioskMenuItemId++; } } private void optionsAboutSelected(final MenuItem item) { switch (item.getItemId()) { case ITEM_ID_SETTINGS: NavigationHelper.openSettings(this); break; case ITEM_ID_DONATION: ShareUtils.openUrlInBrowser(this, getString(R.string.donation_url)); break; case ITEM_ID_ABOUT: NavigationHelper.openAbout(this); break; } } private void setupDrawerHeader() { drawerHeaderBinding.drawerHeaderActionButton.setOnClickListener(view -> toggleServices()); // If the current app name is bigger than the default "NewPipe" (7 chars), // let the text view grow a little more as well. if (getString(R.string.app_name).length() > "NewPipe".length()) { final ViewGroup.LayoutParams layoutParams = drawerHeaderBinding.drawerHeaderNewpipeTitle.getLayoutParams(); layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT; drawerHeaderBinding.drawerHeaderNewpipeTitle.setLayoutParams(layoutParams); drawerHeaderBinding.drawerHeaderNewpipeTitle.setMaxLines(2); drawerHeaderBinding.drawerHeaderNewpipeTitle.setMinWidth(getResources() .getDimensionPixelSize(R.dimen.drawer_header_newpipe_title_default_width)); drawerHeaderBinding.drawerHeaderNewpipeTitle.setMaxWidth(getResources() .getDimensionPixelSize(R.dimen.drawer_header_newpipe_title_max_width)); } } private void toggleServices() { servicesShown = !servicesShown; drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_services_group); drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_tabs_group); drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_kiosks_group); drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_options_about_group); // Show up or down arrow drawerHeaderBinding.drawerArrow.setImageResource( servicesShown ? R.drawable.ic_arrow_drop_up : R.drawable.ic_arrow_drop_down); if (servicesShown) { showServices(); } else { try { addDrawerMenuForCurrentService(); } catch (final Exception e) { ErrorUtil.showUiErrorSnackbar(this, "Showing main page tabs", e); } } } private void showServices() { for (final StreamingService s : NewPipe.getServices()) { final String title = s.getServiceInfo().getName(); final MenuItem menuItem = drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_services_group, s.getServiceId(), ORDER, title) .setIcon(ServiceHelper.getIcon(s.getServiceId())); // peertube specifics if (s.getServiceId() == 3) { enhancePeertubeMenu(menuItem); } } drawerLayoutBinding.navigation.getMenu() .getItem(ServiceHelper.getSelectedServiceId(this)) .setChecked(true); } private void enhancePeertubeMenu(final MenuItem menuItem) { final PeertubeInstance currentInstance = PeertubeHelper.getCurrentInstance(); menuItem.setTitle(currentInstance.getName()); final Spinner spinner = InstanceSpinnerLayoutBinding.inflate(LayoutInflater.from(this)) .getRoot(); final List instances = PeertubeHelper.getInstanceList(this); final List items = new ArrayList<>(); int defaultSelect = 0; for (final PeertubeInstance instance : instances) { items.add(instance.getName()); if (instance.getUrl().equals(currentInstance.getUrl())) { defaultSelect = items.size() - 1; } } final ArrayAdapter adapter = new ArrayAdapter<>(this, R.layout.instance_spinner_item, items); adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); spinner.setAdapter(adapter); spinner.setSelection(defaultSelect, false); spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(final AdapterView parent, final View view, final int position, final long id) { final PeertubeInstance newInstance = instances.get(position); if (newInstance.getUrl().equals(PeertubeHelper.getCurrentInstance().getUrl())) { return; } PeertubeHelper.selectInstance(newInstance, getApplicationContext()); changeService(menuItem); mainBinding.getRoot().closeDrawers(); new Handler(Looper.getMainLooper()).postDelayed(() -> { getSupportFragmentManager().popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE); ActivityCompat.recreate(MainActivity.this); }, 300); } @Override public void onNothingSelected(final AdapterView parent) { } }); menuItem.setActionView(spinner); } @Override protected void onDestroy() { super.onDestroy(); if (!isChangingConfigurations()) { StateSaver.clearStateFiles(); } if (broadcastReceiver != null) { unregisterReceiver(broadcastReceiver); } } @Override protected void onResume() { // Change the date format to match the selected language on resume Localization.initPrettyTime(Localization.resolvePrettyTime()); super.onResume(); // Close drawer on return, and don't show animation, // so it looks like the drawer isn't open when the user returns to MainActivity mainBinding.getRoot().closeDrawer(GravityCompat.START, false); try { final int selectedServiceId = ServiceHelper.getSelectedServiceId(this); final String selectedServiceName = NewPipe.getService(selectedServiceId) .getServiceInfo().getName(); drawerHeaderBinding.drawerHeaderServiceView.setText(selectedServiceName); drawerHeaderBinding.drawerHeaderServiceIcon.setImageResource(ServiceHelper .getIcon(selectedServiceId)); drawerHeaderBinding.drawerHeaderServiceView.post(() -> drawerHeaderBinding .drawerHeaderServiceView.setSelected(true)); drawerHeaderBinding.drawerHeaderActionButton.setContentDescription( getString(R.string.drawer_header_description) + selectedServiceName); } catch (final Exception e) { ErrorUtil.showUiErrorSnackbar(this, "Setting up service toggle", e); } if (sharedPreferences.getBoolean(Constants.KEY_THEME_CHANGE, false)) { if (DEBUG) { Log.d(TAG, "Theme has changed, recreating activity..."); } sharedPrefEditor.putBoolean(Constants.KEY_THEME_CHANGE, false).apply(); ActivityCompat.recreate(this); } if (sharedPreferences.getBoolean(Constants.KEY_MAIN_PAGE_CHANGE, false)) { if (DEBUG) { Log.d(TAG, "main page has changed, recreating main fragment..."); } sharedPrefEditor.putBoolean(Constants.KEY_MAIN_PAGE_CHANGE, false).apply(); NavigationHelper.openMainActivity(this); } final boolean isHistoryEnabled = sharedPreferences.getBoolean( getString(R.string.enable_watch_history_key), true); drawerLayoutBinding.navigation.getMenu().findItem(ITEM_ID_HISTORY) .setVisible(isHistoryEnabled); } @Override protected void onNewIntent(final Intent intent) { if (DEBUG) { Log.d(TAG, "onNewIntent() called with: intent = [" + intent + "]"); } if (intent != null) { // Return if launched from a launcher (e.g. Nova Launcher, Pixel Launcher ...) // to not destroy the already created backstack final String action = intent.getAction(); if ((action != null && action.equals(Intent.ACTION_MAIN)) && intent.hasCategory(Intent.CATEGORY_LAUNCHER)) { return; } } super.onNewIntent(intent); setIntent(intent); handleIntent(intent); } @Override public boolean onKeyDown(final int keyCode, final KeyEvent event) { final Fragment fragment = getSupportFragmentManager() .findFragmentById(R.id.fragment_player_holder); if (fragment instanceof OnKeyDownListener && !bottomSheetHiddenOrCollapsed()) { // Provide keyDown event to fragment which then sends this event // to the main player service return ((OnKeyDownListener) fragment).onKeyDown(keyCode) || super.onKeyDown(keyCode, event); } return super.onKeyDown(keyCode, event); } @Override public void onBackPressed() { if (DEBUG) { Log.d(TAG, "onBackPressed() called"); } if (DeviceUtils.isTv(this)) { if (mainBinding.getRoot().isDrawerOpen(drawerLayoutBinding.navigation)) { mainBinding.getRoot().closeDrawers(); return; } } // In case bottomSheet is not visible on the screen or collapsed we can assume that the user // interacts with a fragment inside fragment_holder so all back presses should be // handled by it if (bottomSheetHiddenOrCollapsed()) { final FragmentManager fm = getSupportFragmentManager(); final Fragment fragment = fm.findFragmentById(R.id.fragment_holder); // If current fragment implements BackPressable (i.e. can/wanna handle back press) // delegate the back press to it if (fragment instanceof BackPressable) { if (((BackPressable) fragment).onBackPressed()) { return; } } else if (fragment instanceof CommentRepliesFragment) { // expand DetailsFragment if CommentRepliesFragment was opened // to show the top level comments again // Expand DetailsFragment if CommentRepliesFragment was opened // and no other CommentRepliesFragments are on top of the back stack // to show the top level comments again. openDetailFragmentFromCommentReplies(fm, false); } } else { final Fragment fragmentPlayer = getSupportFragmentManager() .findFragmentById(R.id.fragment_player_holder); // If current fragment implements BackPressable (i.e. can/wanna handle back press) // delegate the back press to it if (fragmentPlayer instanceof BackPressable) { if (!((BackPressable) fragmentPlayer).onBackPressed()) { BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder) .setState(BottomSheetBehavior.STATE_COLLAPSED); } return; } } if (getSupportFragmentManager().getBackStackEntryCount() == 1) { finish(); } else { super.onBackPressed(); } } @Override public void onRequestPermissionsResult(final int requestCode, @NonNull final String[] permissions, @NonNull final int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); for (final int i : grantResults) { if (i == PackageManager.PERMISSION_DENIED) { return; } } switch (requestCode) { case PermissionHelper.DOWNLOADS_REQUEST_CODE: NavigationHelper.openDownloads(this); break; case PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE: final Fragment fragment = getSupportFragmentManager() .findFragmentById(R.id.fragment_player_holder); if (fragment instanceof VideoDetailFragment) { ((VideoDetailFragment) fragment).openDownloadDialog(); } break; case PermissionHelper.POST_NOTIFICATIONS_REQUEST_CODE: NotificationWorker.initialize(this); break; } } /** * Implement the following diagram behavior for the up button: *

     *              +---------------+
     *              |  Main Screen  +----+
     *              +-------+-------+    |
     *                      |            |
     *                      ▲ Up         | Search Button
     *                      |            |
     *                 +----+-----+      |
     *    +------------+  Search  |◄-----+
     *    |            +----+-----+
     *    |   Open          |
     *    |  something      ▲ Up
     *    |                 |
     *    |    +------------+-------------+
     *    |    |                          |
     *    |    |  Video    <->  Channel   |
     *    +---►|  Channel  <->  Playlist  |
     *         |  Video    <->  ....      |
     *         |                          |
     *         +--------------------------+
     * 
*/ private void onHomeButtonPressed() { final FragmentManager fm = getSupportFragmentManager(); final Fragment fragment = fm.findFragmentById(R.id.fragment_holder); if (fragment instanceof CommentRepliesFragment) { // Expand DetailsFragment if CommentRepliesFragment was opened // and no other CommentRepliesFragments are on top of the back stack // to show the top level comments again. openDetailFragmentFromCommentReplies(fm, true); } else if (!NavigationHelper.tryGotoSearchFragment(fm)) { // If search fragment wasn't found in the backstack go to the main fragment NavigationHelper.gotoMainFragment(fm); } } /*////////////////////////////////////////////////////////////////////////// // Menu //////////////////////////////////////////////////////////////////////////*/ @Override public boolean onCreateOptionsMenu(final Menu menu) { if (DEBUG) { Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu + "]"); } super.onCreateOptionsMenu(menu); final Fragment fragment = getSupportFragmentManager().findFragmentById(R.id.fragment_holder); if (!(fragment instanceof SearchFragment)) { toolbarLayoutBinding.toolbarSearchContainer.getRoot().setVisibility(View.GONE); } final ActionBar actionBar = getSupportActionBar(); if (actionBar != null) { actionBar.setDisplayHomeAsUpEnabled(false); } updateDrawerNavigation(); return true; } @Override public boolean onOptionsItemSelected(@NonNull final MenuItem item) { if (DEBUG) { Log.d(TAG, "onOptionsItemSelected() called with: item = [" + item + "]"); } if (item.getItemId() == android.R.id.home) { onHomeButtonPressed(); return true; } return super.onOptionsItemSelected(item); } /*////////////////////////////////////////////////////////////////////////// // Init //////////////////////////////////////////////////////////////////////////*/ private void initFragments() { if (DEBUG) { Log.d(TAG, "initFragments() called"); } StateSaver.clearStateFiles(); if (getIntent() != null && getIntent().hasExtra(Constants.KEY_LINK_TYPE)) { // When user watch a video inside popup and then tries to open the video in main player // while the app is closed he will see a blank fragment on place of kiosk. // Let's open it first if (getSupportFragmentManager().getBackStackEntryCount() == 0) { NavigationHelper.openMainFragment(getSupportFragmentManager()); } handleIntent(getIntent()); } else { NavigationHelper.gotoMainFragment(getSupportFragmentManager()); } } /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ private void updateDrawerNavigation() { if (getSupportActionBar() == null) { return; } final Fragment fragment = getSupportFragmentManager() .findFragmentById(R.id.fragment_holder); if (fragment instanceof MainFragment) { getSupportActionBar().setDisplayHomeAsUpEnabled(false); if (toggle != null) { toggle.syncState(); toolbarLayoutBinding.toolbar.setNavigationOnClickListener(v -> mainBinding.getRoot() .open()); mainBinding.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED); } } else { mainBinding.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED); getSupportActionBar().setDisplayHomeAsUpEnabled(true); toolbarLayoutBinding.toolbar.setNavigationOnClickListener(v -> onHomeButtonPressed()); } } private void handleIntent(final Intent intent) { try { if (DEBUG) { Log.d(TAG, "handleIntent() called with: intent = [" + intent + "]"); } if (intent.hasExtra(Constants.KEY_LINK_TYPE)) { final String url = intent.getStringExtra(Constants.KEY_URL); final int serviceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, 0); String title = intent.getStringExtra(Constants.KEY_TITLE); if (title == null) { title = ""; } final StreamingService.LinkType linkType = ((StreamingService.LinkType) intent .getSerializableExtra(Constants.KEY_LINK_TYPE)); assert linkType != null; switch (linkType) { case STREAM: final String intentCacheKey = intent.getStringExtra( Player.PLAY_QUEUE_KEY); final PlayQueue playQueue = intentCacheKey != null ? SerializedCache.getInstance() .take(intentCacheKey, PlayQueue.class) : null; final boolean switchingPlayers = intent.getBooleanExtra( VideoDetailFragment.KEY_SWITCHING_PLAYERS, false); NavigationHelper.openVideoDetailFragment( getApplicationContext(), getSupportFragmentManager(), serviceId, url, title, playQueue, switchingPlayers); break; case CHANNEL: NavigationHelper.openChannelFragment(getSupportFragmentManager(), serviceId, url, title); break; case PLAYLIST: NavigationHelper.openPlaylistFragment(getSupportFragmentManager(), serviceId, url, title); break; } } else if (intent.hasExtra(Constants.KEY_OPEN_SEARCH)) { String searchString = intent.getStringExtra(Constants.KEY_SEARCH_STRING); if (searchString == null) { searchString = ""; } final int serviceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, 0); NavigationHelper.openSearchFragment( getSupportFragmentManager(), serviceId, searchString); } else { NavigationHelper.gotoMainFragment(getSupportFragmentManager()); } } catch (final Exception e) { ErrorUtil.showUiErrorSnackbar(this, "Handling intent", e); } } private void openMiniPlayerIfMissing() { final Fragment fragmentPlayer = getSupportFragmentManager() .findFragmentById(R.id.fragment_player_holder); if (fragmentPlayer == null) { // We still don't have a fragment attached to the activity. It can happen when a user // started popup or background players without opening a stream inside the fragment. // Adding it in a collapsed state (only mini player will be visible). NavigationHelper.showMiniPlayer(getSupportFragmentManager()); } } private void openMiniPlayerUponPlayerStarted() { if (getIntent().getSerializableExtra(Constants.KEY_LINK_TYPE) == StreamingService.LinkType.STREAM) { // handleIntent() already takes care of opening video detail fragment // due to an intent containing a STREAM link return; } if (PlayerHolder.getInstance().isPlayerOpen()) { // if the player is already open, no need for a broadcast receiver openMiniPlayerIfMissing(); } else { // listen for player start intent being sent around broadcastReceiver = new BroadcastReceiver() { @Override public void onReceive(final Context context, final Intent intent) { if (Objects.equals(intent.getAction(), VideoDetailFragment.ACTION_PLAYER_STARTED) && PlayerHolder.getInstance().isPlayerOpen()) { openMiniPlayerIfMissing(); // At this point the player is added 100%, we can unregister. Other actions // are useless since the fragment will not be removed after that. unregisterReceiver(broadcastReceiver); broadcastReceiver = null; } } }; final IntentFilter intentFilter = new IntentFilter(); intentFilter.addAction(VideoDetailFragment.ACTION_PLAYER_STARTED); ContextCompat.registerReceiver(this, broadcastReceiver, intentFilter, ContextCompat.RECEIVER_EXPORTED); // If the PlayerHolder is not bound yet, but the service is running, try to bind to it. // Once the connection is established, the ACTION_PLAYER_STARTED will be sent. PlayerHolder.getInstance().tryBindIfNeeded(this); } } private void openDetailFragmentFromCommentReplies( @NonNull final FragmentManager fm, final boolean popBackStack ) { // obtain the name of the fragment under the replies fragment that's going to be popped @Nullable final String fragmentUnderEntryName; if (fm.getBackStackEntryCount() < 2) { fragmentUnderEntryName = null; } else { fragmentUnderEntryName = fm.getBackStackEntryAt(fm.getBackStackEntryCount() - 2) .getName(); } // the root comment is the comment for which the user opened the replies page @Nullable final CommentRepliesFragment repliesFragment = (CommentRepliesFragment) fm.findFragmentByTag(CommentRepliesFragment.TAG); @Nullable final CommentsInfoItem rootComment = repliesFragment == null ? null : repliesFragment.getCommentsInfoItem(); // sometimes this function pops the backstack, other times it's handled by the system if (popBackStack) { fm.popBackStackImmediate(); } // only expand the bottom sheet back if there are no more nested comment replies fragments // stacked under the one that is currently being popped if (CommentRepliesFragment.TAG.equals(fragmentUnderEntryName)) { return; } final BottomSheetBehavior behavior = BottomSheetBehavior .from(mainBinding.fragmentPlayerHolder); // do not return to the comment if the details fragment was closed if (behavior.getState() == BottomSheetBehavior.STATE_HIDDEN) { return; } // scroll to the root comment once the bottom sheet expansion animation is finished behavior.addBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() { @Override public void onStateChanged(@NonNull final View bottomSheet, final int newState) { if (newState == BottomSheetBehavior.STATE_EXPANDED) { final Fragment detailFragment = fm.findFragmentById( R.id.fragment_player_holder); if (detailFragment instanceof VideoDetailFragment && rootComment != null) { // should always be the case ((VideoDetailFragment) detailFragment).scrollToComment(rootComment); } behavior.removeBottomSheetCallback(this); } } @Override public void onSlide(@NonNull final View bottomSheet, final float slideOffset) { // not needed, listener is removed once the sheet is expanded } }); behavior.setState(BottomSheetBehavior.STATE_EXPANDED); } private boolean bottomSheetHiddenOrCollapsed() { final BottomSheetBehavior bottomSheetBehavior = BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder); final int sheetState = bottomSheetBehavior.getState(); return sheetState == BottomSheetBehavior.STATE_HIDDEN || sheetState == BottomSheetBehavior.STATE_COLLAPSED; } private void showKeepAndroidDialog() { final var prefs = PreferenceManager.getDefaultSharedPreferences(this); final var now = Instant.now(); final var kaoLastCheck = Instant.ofEpochMilli(prefs.getLong( getString(R.string.kao_last_checked_key), 0 )); final var supportedLannguages = List.of("fr", "de", "ca", "es", "id", "it", "pl", "pt", "cs", "sk", "fa", "ar", "tr", "el", "th", "ru", "uk", "ko", "zh", "ja"); final var locale = Localization.getAppLocale(); final String kaoBaseUrl = "https://keepandroidopen.org/"; final String kaoURI; if (supportedLannguages.contains(locale.getLanguage())) { if ("zh".equals(locale.getLanguage())) { kaoURI = kaoBaseUrl + ("TW".equals(locale.getCountry()) ? "zh-TW" : "zh-CN"); } else { kaoURI = kaoBaseUrl + locale.getLanguage(); } } else { kaoURI = kaoBaseUrl; } final var solutionURI = "https://github.com/woheller69/FreeDroidWarn?tab=readme-ov-file#solutions"; if (kaoLastCheck.plus(30, ChronoUnit.DAYS).isBefore(now)) { final var dialog = new AlertDialog.Builder(this) .setTitle("Keep Android Open") .setCancelable(false) .setMessage(this.getString(R.string.kao_dialog_warning)) .setPositiveButton(this.getString(android.R.string.ok), (d, w) -> { prefs.edit() .putLong( getString(R.string.kao_last_checked_key), now.toEpochMilli() ) .apply(); }) .setNeutralButton(this.getString(R.string.kao_solution), null) .setNegativeButton(this.getString(R.string.kao_dialog_more_info), null) .show(); // If we use setNeutralButton and etc. dialog will close after pressing the buttons, // but we want it to close only when positive button is pressed dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener(v -> ShareUtils.openUrlInBrowser(this, kaoURI) ); dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener(v -> ShareUtils.openUrlInBrowser(this, solutionURI) ); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/NewPipeDatabase.kt ================================================ /* * SPDX-FileCopyrightText: 2017-2024 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe import android.content.Context import androidx.room.Room.databaseBuilder import kotlin.concurrent.Volatile import org.schabi.newpipe.database.AppDatabase import org.schabi.newpipe.database.Migrations.MIGRATION_1_2 import org.schabi.newpipe.database.Migrations.MIGRATION_2_3 import org.schabi.newpipe.database.Migrations.MIGRATION_3_4 import org.schabi.newpipe.database.Migrations.MIGRATION_4_5 import org.schabi.newpipe.database.Migrations.MIGRATION_5_6 import org.schabi.newpipe.database.Migrations.MIGRATION_6_7 import org.schabi.newpipe.database.Migrations.MIGRATION_7_8 import org.schabi.newpipe.database.Migrations.MIGRATION_8_9 object NewPipeDatabase { @Volatile private var databaseInstance: AppDatabase? = null private fun getDatabase(context: Context): AppDatabase { return databaseBuilder( context.applicationContext, AppDatabase::class.java, AppDatabase.Companion.DATABASE_NAME ).addMigrations( MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9 ).build() } @JvmStatic fun getInstance(context: Context): AppDatabase { var result = databaseInstance if (result == null) { synchronized(NewPipeDatabase::class.java) { result = databaseInstance if (result == null) { databaseInstance = getDatabase(context) result = databaseInstance } } } return result!! } @JvmStatic fun checkpoint() { checkNotNull(databaseInstance) { "database is not initialized" } val c = databaseInstance!!.query("pragma wal_checkpoint(full)", null) if (c.moveToFirst() && c.getInt(0) == 1) { throw RuntimeException("Checkpoint was blocked from completing") } } @JvmStatic fun close() { if (databaseInstance != null) { synchronized(NewPipeDatabase::class.java) { if (databaseInstance != null) { databaseInstance!!.close() databaseInstance = null } } } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt ================================================ package org.schabi.newpipe import android.content.Context import android.content.Intent import android.util.Log import android.widget.Toast import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat import androidx.core.content.ContextCompat import androidx.core.content.edit import androidx.core.net.toUri import androidx.preference.PreferenceManager import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager import androidx.work.Worker import androidx.work.WorkerParameters import androidx.work.workDataOf import com.grack.nanojson.JsonParser import com.grack.nanojson.JsonParserException import java.io.IOException import org.schabi.newpipe.extractor.downloader.Response import org.schabi.newpipe.extractor.exceptions.ReCaptchaException import org.schabi.newpipe.util.ReleaseVersionUtil class NewVersionWorker( context: Context, workerParams: WorkerParameters ) : Worker(context, workerParams) { /** * Method to compare the current and latest available app version. * If a newer version is available, we show the update notification. * * @param versionName Name of new version * @param apkLocationUrl Url with the new apk * @param versionCode Code of new version */ private fun compareAppVersionAndShowNotification( versionName: String, apkLocationUrl: String?, versionCode: Int ) { if (BuildConfig.VERSION_CODE >= versionCode) { if (inputData.getBoolean(IS_MANUAL, false)) { // Show toast stating that the app is up-to-date if the update check was manual. ContextCompat.getMainExecutor(applicationContext).execute { Toast.makeText( applicationContext, R.string.app_update_unavailable_toast, Toast.LENGTH_SHORT ).show() } } return } // A pending intent to open the apk location url in the browser. val intent = Intent(Intent.ACTION_VIEW, apkLocationUrl?.toUri()) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) val pendingIntent = PendingIntentCompat.getActivity( applicationContext, 0, intent, 0, false ) val channelId = applicationContext.getString(R.string.app_update_notification_channel_id) val notificationBuilder = NotificationCompat.Builder(applicationContext, channelId) .setSmallIcon(R.drawable.ic_newpipe_update) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setAutoCancel(true) .setContentIntent(pendingIntent) .setContentTitle( applicationContext.getString(R.string.app_update_available_notification_title) ) .setContentText( applicationContext.getString( R.string.app_update_available_notification_text, versionName ) ) val notificationManager = NotificationManagerCompat.from(applicationContext) if (notificationManager.areNotificationsEnabled()) { notificationManager.notify(2000, notificationBuilder.build()) } } @Throws(IOException::class, ReCaptchaException::class) private fun checkNewVersion() { // Check if the current apk is a github one or not. if (!ReleaseVersionUtil.isReleaseApk) { return } if (!inputData.getBoolean(IS_MANUAL, false)) { val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) // Check if the last request has happened a certain time ago // to reduce the number of API requests. val expiry = prefs.getLong(applicationContext.getString(R.string.update_expiry_key), 0) if (!ReleaseVersionUtil.isLastUpdateCheckExpired(expiry)) { return } } // Make a network request to get latest NewPipe data. val response = DownloaderImpl.getInstance().get(NEWPIPE_API_URL) handleResponse(response) } private fun handleResponse(response: Response) { val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) try { // Store a timestamp which needs to be exceeded, // before a new request to the API is made. val newExpiry = ReleaseVersionUtil.coerceUpdateCheckExpiry(response.getHeader("expires")) prefs.edit { putLong(applicationContext.getString(R.string.update_expiry_key), newExpiry) } } catch (e: Exception) { if (DEBUG) { Log.w(TAG, "Could not extract and save new expiry date", e) } } // Parse the json from the response. try { val newpipeVersionInfo = JsonParser.`object`() .from(response.responseBody()).getObject("flavors") .getObject("newpipe") val versionName = newpipeVersionInfo.getString("version") val versionCode = newpipeVersionInfo.getInt("version_code") val apkLocationUrl = newpipeVersionInfo.getString("apk") compareAppVersionAndShowNotification(versionName, apkLocationUrl, versionCode) } catch (e: JsonParserException) { // Most likely something is wrong in data received from NEWPIPE_API_URL. // Do not alarm user and fail silently. if (DEBUG) { Log.w(TAG, "Could not get NewPipe API: invalid json", e) } } } override fun doWork(): Result { return try { checkNewVersion() Result.success() } catch (e: IOException) { Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e) Result.failure() } catch (e: ReCaptchaException) { Log.e(TAG, "ReCaptchaException should never happen here.", e) Result.failure() } } companion object { private val DEBUG = MainActivity.DEBUG private val TAG = NewVersionWorker::class.java.simpleName private const val NEWPIPE_API_URL = "https://newpipe.net/api/data.json" private const val IS_MANUAL = "isManual" /** * Start a new worker which checks if all conditions for performing a version check are met, * fetches the API endpoint [.NEWPIPE_API_URL] containing info about the latest NewPipe * version and displays a notification about an available update if one is available. *

* Following conditions need to be met, before data is requested from the server: * * * The app is signed with the correct signing key (by TeamNewPipe / schabi). * If the signing key differs from the one used upstream, the update cannot be installed. * * The user enabled searching for and notifying about updates in the settings. * * The app did not recently check for updates. * We do not want to make unnecessary connections and DOS our servers. */ @JvmStatic fun enqueueNewVersionCheckingWork(context: Context, isManual: Boolean) { val workRequest = OneTimeWorkRequestBuilder() .setInputData(workDataOf(IS_MANUAL to isManual)) .build() WorkManager.getInstance(context).enqueue(workRequest) } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/PanicResponderActivity.java ================================================ package org.schabi.newpipe; import android.annotation.SuppressLint; import android.app.Activity; import android.content.Intent; import android.os.Bundle; /* * Copyright (C) Hans-Christoph Steiner 2016 * PanicResponderActivity.java is part of NewPipe. * * NewPipe is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * NewPipe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ public class PanicResponderActivity extends Activity { public static final String PANIC_TRIGGER_ACTION = "info.guardianproject.panic.action.TRIGGER"; @SuppressLint("NewApi") @Override protected void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); final Intent intent = getIntent(); if (intent != null && PANIC_TRIGGER_ACTION.equals(intent.getAction())) { // TODO: Explicitly clear the search results // once they are restored when the app restarts // or if the app reloads the current video after being killed, // that should be cleared also ExitActivity.exitAndRemoveFromRecentApps(this); } finishAndRemoveTask(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java ================================================ package org.schabi.newpipe; import static org.schabi.newpipe.util.SparseItemUtil.fetchStreamInfoAndSaveToDatabase; import static org.schabi.newpipe.util.external_communication.ShareUtils.shareText; import android.content.Context; import android.view.ContextThemeWrapper; import android.view.View; import android.widget.PopupMenu; import androidx.fragment.app.FragmentManager; import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.download.DownloadDialog; import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.SparseItemUtil; import java.util.List; public final class QueueItemMenuUtil { private QueueItemMenuUtil() { } public static void openPopupMenu(final PlayQueue playQueue, final PlayQueueItem item, final View view, final boolean hideDetails, final FragmentManager fragmentManager, final Context context) { final ContextThemeWrapper themeWrapper = new ContextThemeWrapper(context, R.style.DarkPopupMenu); final PopupMenu popupMenu = new PopupMenu(themeWrapper, view); popupMenu.inflate(R.menu.menu_play_queue_item); if (hideDetails) { popupMenu.getMenu().findItem(R.id.menu_item_details).setVisible(false); } popupMenu.setOnMenuItemClickListener(menuItem -> { final int itemId = menuItem.getItemId(); if (itemId == R.id.menu_item_remove) { final int index = playQueue.indexOf(item); playQueue.remove(index); return true; } else if (itemId == R.id.menu_item_details) { // playQueue is null since we don't want any queue change NavigationHelper.openVideoDetail(context, item.getServiceId(), item.getUrl(), item.getTitle(), null, false); return true; } else if (itemId == R.id.menu_item_append_playlist) { PlaylistDialog.createCorrespondingDialog( context, List.of(new StreamEntity(item)), dialog -> dialog.show( fragmentManager, "QueueItemMenuUtil@append_playlist" ) ); return true; } else if (itemId == R.id.menu_item_channel_details) { SparseItemUtil.fetchUploaderUrlIfSparse(context, item.getServiceId(), item.getUrl(), item.getUploaderUrl(), // An intent must be used here. // Opening with FragmentManager transactions is not working, // as PlayQueueActivity doesn't use fragments. uploaderUrl -> NavigationHelper.openChannelFragmentUsingIntent( context, item.getServiceId(), uploaderUrl, item.getUploader() )); return true; } else if (itemId == R.id.menu_item_share) { shareText(context, item.getTitle(), item.getUrl(), item.getThumbnails()); return true; } else if (itemId == R.id.menu_item_download) { fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(), info -> { final DownloadDialog downloadDialog = new DownloadDialog(context, info); downloadDialog.show(fragmentManager, "downloadDialog"); }); return true; } return false; }); popupMenu.show(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/RouterActivity.java ================================================ package org.schabi.newpipe; import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.AUDIO; import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.VIDEO; import android.annotation.SuppressLint; import android.app.IntentService; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.os.Build; import android.os.Bundle; import android.text.TextUtils; import android.view.ContextThemeWrapper; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.WindowManager; import android.widget.Button; import android.widget.RadioButton; import android.widget.RadioGroup; import android.widget.Toast; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.content.res.AppCompatResources; import androidx.core.app.NotificationCompat; import androidx.core.app.ServiceCompat; import androidx.core.math.MathUtils; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.lifecycle.DefaultLifecycleObserver; import androidx.lifecycle.Lifecycle; import androidx.lifecycle.LifecycleOwner; import androidx.preference.PreferenceManager; import com.evernote.android.state.State; import com.livefront.bridge.Bridge; import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.databinding.ListRadioIconItemBinding; import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding; import org.schabi.newpipe.download.DownloadDialog; import org.schabi.newpipe.download.LoadingDialog; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.ReCaptchaActivity; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.Info; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.StreamingService.LinkType; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.player.PlayerType; import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.util.ChannelTabHelper; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.urlfinder.UrlFinder; import org.schabi.newpipe.views.FocusOverlayView; import java.io.Serializable; import java.lang.ref.Reference; import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Optional; import java.util.function.Consumer; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; /** * Get the url from the intent and open it in the chosen preferred player. */ public class RouterActivity extends AppCompatActivity { protected final CompositeDisposable disposables = new CompositeDisposable(); @State protected int currentServiceId = -1; @State protected LinkType currentLinkType; @State protected int selectedRadioPosition = -1; protected int selectedPreviously = -1; protected String currentUrl; private StreamingService currentService; private boolean selectionIsDownload = false; private boolean selectionIsAddToPlaylist = false; private AlertDialog alertDialogChoice = null; private FragmentManager.FragmentLifecycleCallbacks dismissListener = null; @Override protected void onCreate(final Bundle savedInstanceState) { ThemeHelper.setDayNightMode(this); setTheme(ThemeHelper.isLightThemeSelected(this) ? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark); // Pass-through touch events to background activities // so that our transparent window won't lock UI in the mean time // network request is underway before showing PlaylistDialog or DownloadDialog // (ref: https://stackoverflow.com/a/10606141) getWindow().addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE); // Android never fails to impress us with a list of new restrictions per API. // Starting with S (Android 12) one of the prerequisite conditions has to be met // before the FLAG_NOT_TOUCHABLE flag is allowed to kick in: // @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE // For our present purpose it seems we can just set LayoutParams.alpha to 0 // on the strength of "4. Fully transparent windows" without affecting the scrim of dialogs final WindowManager.LayoutParams params = getWindow().getAttributes(); params.alpha = 0f; getWindow().setAttributes(params); super.onCreate(savedInstanceState); Bridge.restoreInstanceState(this, savedInstanceState); // FragmentManager will take care to recreate (Playlist|Download)Dialog when screen rotates // We used to .setOnDismissListener(dialog -> finish()); when creating these DialogFragments // but those callbacks won't survive a config change // Try an alternate approach to hook into FragmentManager instead, to that effect // (ref: https://stackoverflow.com/a/44028453) final FragmentManager fm = getSupportFragmentManager(); if (dismissListener == null) { dismissListener = new FragmentManager.FragmentLifecycleCallbacks() { @Override public void onFragmentDestroyed(@NonNull final FragmentManager fm, @NonNull final Fragment f) { super.onFragmentDestroyed(fm, f); if (f instanceof DialogFragment && fm.getFragments().isEmpty()) { // No more DialogFragments, we're done finish(); } } }; } fm.registerFragmentLifecycleCallbacks(dismissListener, false); if (TextUtils.isEmpty(currentUrl)) { currentUrl = getUrl(getIntent()); if (TextUtils.isEmpty(currentUrl)) { handleText(); finish(); } } } @Override protected void onStop() { super.onStop(); // we need to dismiss the dialog before leaving the activity or we get leaks if (alertDialogChoice != null) { alertDialogChoice.dismiss(); } } @Override protected void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); Bridge.saveInstanceState(this, outState); } @Override protected void onStart() { super.onStart(); // Don't overlap the DialogFragment after rotating the screen // If there's no DialogFragment, we're either starting afresh // or we didn't make it to PlaylistDialog or DownloadDialog before the orientation change if (getSupportFragmentManager().getFragments().isEmpty()) { // Start over from scratch handleUrl(currentUrl); } } @Override protected void onDestroy() { super.onDestroy(); if (dismissListener != null) { getSupportFragmentManager().unregisterFragmentLifecycleCallbacks(dismissListener); } disposables.clear(); } @Override public void finish() { // allow the activity to recreate in case orientation changes if (!isChangingConfigurations()) { super.finish(); } } private void handleUrl(final String url) { disposables.add(Observable .fromCallable(() -> { try { if (currentServiceId == -1) { currentService = NewPipe.getServiceByUrl(url); currentServiceId = currentService.getServiceId(); currentLinkType = currentService.getLinkTypeByUrl(url); currentUrl = url; } else { currentService = NewPipe.getService(currentServiceId); } // return whether the url was found to be supported or not return currentLinkType != LinkType.NONE; } catch (final ExtractionException e) { // this can be reached only when the url is completely unsupported return false; } }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(isUrlSupported -> { if (isUrlSupported) { onSuccess(); } else { showUnsupportedUrlDialog(url); } }, throwable -> handleError(this, new ErrorInfo(throwable, UserAction.SHARE_TO_NEWPIPE, "Getting service from url: " + url, null, url)))); } /** * @param context the context. It will be {@code finish()}ed at the end of the handling if it is * an instance of {@link RouterActivity}. * @param errorInfo the error information */ private static void handleError(final Context context, final ErrorInfo errorInfo) { if (errorInfo.getRecaptchaUrl() != null) { Toast.makeText(context, R.string.recaptcha_request_toast, Toast.LENGTH_LONG).show(); // Starting ReCaptcha Challenge Activity final Intent intent = new Intent(context, ReCaptchaActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.putExtra(ReCaptchaActivity.RECAPTCHA_URL_EXTRA, errorInfo.getRecaptchaUrl()); context.startActivity(intent); } else if (errorInfo.isReportable()) { ErrorUtil.createNotification(context, errorInfo); } else { // this exception does not usually indicate a problem that should be reported, // so just show a toast instead of the notification Toast.makeText(context, errorInfo.getMessage(context), Toast.LENGTH_LONG).show(); } if (context instanceof RouterActivity) { ((RouterActivity) context).finish(); } } protected void showUnsupportedUrlDialog(final String url) { final Context context = getThemeWrapperContext(); new AlertDialog.Builder(context) .setTitle(R.string.unsupported_url) .setMessage(R.string.unsupported_url_dialog_message) .setIcon(R.drawable.ic_share) .setPositiveButton(R.string.open_in_browser, (dialog, which) -> ShareUtils.openUrlInBrowser(this, url)) .setNegativeButton(R.string.share, (dialog, which) -> ShareUtils.shareText(this, "", url)) // no subject .setNeutralButton(R.string.cancel, null) .setOnDismissListener(dialog -> finish()) .show(); } protected void onSuccess() { final SharedPreferences preferences = PreferenceManager .getDefaultSharedPreferences(this); final ChoiceAvailabilityChecker choiceChecker = new ChoiceAvailabilityChecker( getChoicesForService(currentService, currentLinkType), preferences.getString(getString(R.string.preferred_open_action_key), getString(R.string.preferred_open_action_default))); // Check for non-player related choices if (choiceChecker.isAvailableAndSelected( R.string.show_info_key, R.string.download_key, R.string.add_to_playlist_key)) { handleChoice(choiceChecker.getSelectedChoiceKey()); return; } // Check if the choice is player related if (choiceChecker.isAvailableAndSelected( R.string.video_player_key, R.string.background_player_key, R.string.popup_player_key, R.string.enqueue_key)) { final String selectedChoice = choiceChecker.getSelectedChoiceKey(); final boolean isExtVideoEnabled = preferences.getBoolean( getString(R.string.use_external_video_player_key), false); final boolean isExtAudioEnabled = preferences.getBoolean( getString(R.string.use_external_audio_player_key), false); final boolean isVideoPlayerSelected = selectedChoice.equals(getString(R.string.video_player_key)) || selectedChoice.equals(getString(R.string.popup_player_key)); final boolean isAudioPlayerSelected = selectedChoice.equals(getString(R.string.background_player_key)); final boolean isEnqueueSelected = selectedChoice.equals(getString(R.string.enqueue_key)); if (currentLinkType != LinkType.STREAM && ((isExtAudioEnabled && isAudioPlayerSelected) || (isExtVideoEnabled && isVideoPlayerSelected)) ) { Toast.makeText(this, R.string.external_player_unsupported_link_type, Toast.LENGTH_LONG).show(); handleChoice(getString(R.string.show_info_key)); return; } final var capabilities = currentService.getServiceInfo().getMediaCapabilities(); // Check if the service supports the choice if ((isVideoPlayerSelected && capabilities.contains(VIDEO)) || (isAudioPlayerSelected && capabilities.contains(AUDIO)) || (isEnqueueSelected && (capabilities.contains(VIDEO) || capabilities.contains(AUDIO)))) { handleChoice(selectedChoice); } else { handleChoice(getString(R.string.show_info_key)); } return; } // Default / Ask always final List availableChoices = choiceChecker.getAvailableChoices(); switch (availableChoices.size()) { case 1: handleChoice(availableChoices.get(0).key); break; case 0: handleChoice(getString(R.string.show_info_key)); break; default: showDialog(availableChoices); break; } } /** * This is a helper class for checking if the choices are available and/or selected. */ class ChoiceAvailabilityChecker { private final List availableChoices; private final String selectedChoiceKey; ChoiceAvailabilityChecker( @NonNull final List availableChoices, @NonNull final String selectedChoiceKey) { this.availableChoices = availableChoices; this.selectedChoiceKey = selectedChoiceKey; } public List getAvailableChoices() { return availableChoices; } public String getSelectedChoiceKey() { return selectedChoiceKey; } public boolean isAvailableAndSelected(@StringRes final int... wantedKeys) { return Arrays.stream(wantedKeys).anyMatch(this::isAvailableAndSelected); } public boolean isAvailableAndSelected(@StringRes final int wantedKey) { final String wanted = getString(wantedKey); // Check if the wanted option is selected if (!selectedChoiceKey.equals(wanted)) { return false; } // Check if it's available return availableChoices.stream().anyMatch(item -> wanted.equals(item.key)); } } private void showDialog(final List choices) { final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); final Context themeWrapperContext = getThemeWrapperContext(); final LayoutInflater layoutInflater = LayoutInflater.from(themeWrapperContext); final SingleChoiceDialogViewBinding binding = SingleChoiceDialogViewBinding.inflate(layoutInflater); final RadioGroup radioGroup = binding.list; final DialogInterface.OnClickListener dialogButtonsClickListener = (dialog, which) -> { final int indexOfChild = radioGroup.indexOfChild( radioGroup.findViewById(radioGroup.getCheckedRadioButtonId())); final AdapterChoiceItem choice = choices.get(indexOfChild); handleChoice(choice.key); // open future streams always like this one, because "always" button was used by user if (which == DialogInterface.BUTTON_POSITIVE) { preferences.edit() .putString(getString(R.string.preferred_open_action_key), choice.key) .apply(); } }; alertDialogChoice = new AlertDialog.Builder(themeWrapperContext) .setTitle(R.string.preferred_open_action_share_menu_title) .setView(binding.getRoot()) .setCancelable(true) .setNegativeButton(R.string.just_once, dialogButtonsClickListener) .setPositiveButton(R.string.always, dialogButtonsClickListener) .setOnDismissListener(dialog -> { if (!selectionIsDownload && !selectionIsAddToPlaylist) { finish(); } }) .create(); alertDialogChoice.setOnShowListener(dialog -> setDialogButtonsState( alertDialogChoice, radioGroup.getCheckedRadioButtonId() != -1)); radioGroup.setOnCheckedChangeListener((group, checkedId) -> setDialogButtonsState(alertDialogChoice, true)); final View.OnClickListener radioButtonsClickListener = v -> { final int indexOfChild = radioGroup.indexOfChild(v); if (indexOfChild == -1) { return; } selectedPreviously = selectedRadioPosition; selectedRadioPosition = indexOfChild; if (selectedPreviously == selectedRadioPosition) { handleChoice(choices.get(selectedRadioPosition).key); } }; int id = 12345; for (final AdapterChoiceItem item : choices) { final RadioButton radioButton = ListRadioIconItemBinding.inflate(layoutInflater) .getRoot(); radioButton.setText(item.description); radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds( AppCompatResources.getDrawable(themeWrapperContext, item.icon), null, null, null); radioButton.setChecked(false); radioButton.setId(id++); radioButton.setLayoutParams(new RadioGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); radioButton.setOnClickListener(radioButtonsClickListener); radioGroup.addView(radioButton); } if (selectedRadioPosition == -1) { final String lastSelectedPlayer = preferences.getString( getString(R.string.preferred_open_action_last_selected_key), null); if (!TextUtils.isEmpty(lastSelectedPlayer)) { for (int i = 0; i < choices.size(); i++) { final AdapterChoiceItem c = choices.get(i); if (lastSelectedPlayer.equals(c.key)) { selectedRadioPosition = i; break; } } } } selectedRadioPosition = MathUtils.clamp(selectedRadioPosition, -1, choices.size() - 1); if (selectedRadioPosition != -1) { ((RadioButton) radioGroup.getChildAt(selectedRadioPosition)).setChecked(true); } selectedPreviously = selectedRadioPosition; alertDialogChoice.show(); if (DeviceUtils.isTv(this)) { FocusOverlayView.setupFocusObserver(alertDialogChoice); } } private List getChoicesForService(final StreamingService service, final LinkType linkType) { final AdapterChoiceItem showInfo = new AdapterChoiceItem( getString(R.string.show_info_key), getString(R.string.show_info), R.drawable.ic_info_outline); final AdapterChoiceItem videoPlayer = new AdapterChoiceItem( getString(R.string.video_player_key), getString(R.string.video_player), R.drawable.ic_play_arrow); final AdapterChoiceItem backgroundPlayer = new AdapterChoiceItem( getString(R.string.background_player_key), getString(R.string.background_player), R.drawable.ic_headset); final AdapterChoiceItem popupPlayer = new AdapterChoiceItem( getString(R.string.popup_player_key), getString(R.string.popup_player), R.drawable.ic_picture_in_picture); final List returnedItems = new ArrayList<>(); returnedItems.add(showInfo); // Always present final var capabilities = service.getServiceInfo().getMediaCapabilities(); if (linkType == LinkType.STREAM || linkType == LinkType.PLAYLIST) { if (capabilities.contains(VIDEO)) { returnedItems.add(videoPlayer); returnedItems.add(popupPlayer); } if (capabilities.contains(AUDIO)) { returnedItems.add(backgroundPlayer); } // Enqueue is only shown if the current queue is not empty. // However, if the playqueue or the player is cleared after this item was chosen and // while the item is extracted, it will automatically fall back to background player. if (PlayerHolder.getInstance().getQueueSize() > 0) { returnedItems.add(new AdapterChoiceItem(getString(R.string.enqueue_key), getString(R.string.enqueue_stream), R.drawable.ic_add)); } if (linkType == LinkType.STREAM) { // download is redundant for linkType CHANNEL AND PLAYLIST // (till playlist downloading is not supported ) returnedItems.add(new AdapterChoiceItem(getString(R.string.download_key), getString(R.string.download), R.drawable.ic_file_download)); // Add to playlist is not necessary for CHANNEL and PLAYLIST linkType // since those can not be added to a playlist returnedItems.add(new AdapterChoiceItem(getString(R.string.add_to_playlist_key), getString(R.string.add_to_playlist), R.drawable.ic_playlist_add)); } } else { // LinkType.NONE is never present because it's filtered out before // channels and playlist can be played as they contain a list of videos final SharedPreferences preferences = PreferenceManager .getDefaultSharedPreferences(this); final boolean isExtVideoEnabled = preferences.getBoolean( getString(R.string.use_external_video_player_key), false); final boolean isExtAudioEnabled = preferences.getBoolean( getString(R.string.use_external_audio_player_key), false); if (capabilities.contains(VIDEO) && !isExtVideoEnabled) { returnedItems.add(videoPlayer); returnedItems.add(popupPlayer); } if (capabilities.contains(AUDIO) && !isExtAudioEnabled) { returnedItems.add(backgroundPlayer); } } return returnedItems; } protected Context getThemeWrapperContext() { return new ContextThemeWrapper(this, ThemeHelper.isLightThemeSelected(this) ? R.style.LightTheme : R.style.DarkTheme); } private void setDialogButtonsState(final AlertDialog dialog, final boolean state) { final Button negativeButton = dialog.getButton(DialogInterface.BUTTON_NEGATIVE); final Button positiveButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE); if (negativeButton == null || positiveButton == null) { return; } negativeButton.setEnabled(state); positiveButton.setEnabled(state); } private void handleText() { final String searchString = getIntent().getStringExtra(Intent.EXTRA_TEXT); final int serviceId = getIntent().getIntExtra(Constants.KEY_SERVICE_ID, 0); final Intent intent = new Intent(getThemeWrapperContext(), MainActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); startActivity(intent); NavigationHelper.openSearch(getThemeWrapperContext(), serviceId, searchString); } private void handleChoice(final String selectedChoiceKey) { final List validChoicesList = Arrays.asList(getResources() .getStringArray(R.array.preferred_open_action_values_list)); if (validChoicesList.contains(selectedChoiceKey)) { PreferenceManager.getDefaultSharedPreferences(this).edit() .putString(getString( R.string.preferred_open_action_last_selected_key), selectedChoiceKey) .apply(); } if (selectedChoiceKey.equals(getString(R.string.popup_player_key)) && !PermissionHelper.isPopupEnabledElseAsk(this)) { finish(); return; } if (selectedChoiceKey.equals(getString(R.string.download_key))) { if (PermissionHelper.checkStoragePermissions(this, PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { selectionIsDownload = true; openDownloadDialog(); } return; } if (selectedChoiceKey.equals(getString(R.string.add_to_playlist_key))) { selectionIsAddToPlaylist = true; openAddToPlaylistDialog(); return; } // stop and bypass FetcherService if InfoScreen was selected since // StreamDetailFragment can fetch data itself if (selectedChoiceKey.equals(getString(R.string.show_info_key)) || canHandleChoiceLikeShowInfo(selectedChoiceKey)) { disposables.add(Observable .fromCallable(() -> NavigationHelper.getIntentByLink(this, currentUrl)) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(intent -> { startActivity(intent); finish(); }, throwable -> handleError(this, new ErrorInfo(throwable, UserAction.SHARE_TO_NEWPIPE, "Starting info activity: " + currentUrl, null, currentUrl))) ); return; } final Intent intent = new Intent(this, FetcherService.class); final Choice choice = new Choice(currentService.getServiceId(), currentLinkType, currentUrl, selectedChoiceKey); intent.putExtra(FetcherService.KEY_CHOICE, choice); startService(intent); finish(); } private boolean canHandleChoiceLikeShowInfo(final String selectedChoiceKey) { if (!selectedChoiceKey.equals(getString(R.string.video_player_key))) { return false; } // "video player" can be handled like "show info" (because VideoDetailFragment can load // the stream instead of FetcherService) when... // ...Autoplay is enabled if (!PlayerHelper.isAutoplayAllowedByUser(getThemeWrapperContext())) { return false; } final boolean isExtVideoEnabled = PreferenceManager.getDefaultSharedPreferences(this) .getBoolean(getString(R.string.use_external_video_player_key), false); // ...it's not done via an external player if (isExtVideoEnabled) { return false; } // ...the player is not running or in normal Video-mode/type final PlayerType playerType = PlayerHolder.getInstance().getType(); return playerType == null || playerType == PlayerType.MAIN; } public static class PersistentFragment extends Fragment { private WeakReference weakContext; private final CompositeDisposable disposables = new CompositeDisposable(); private int running = 0; private synchronized void inFlight(final boolean started) { if (started) { running++; } else { running--; if (running <= 0) { getActivityContext().ifPresent(context -> context.getSupportFragmentManager() .beginTransaction().remove(this).commit()); } } } @Override public void onAttach(@NonNull final Context activityContext) { super.onAttach(activityContext); weakContext = new WeakReference<>((AppCompatActivity) activityContext); } @Override public void onDetach() { super.onDetach(); weakContext = null; } @SuppressWarnings("deprecation") @Override public void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); setRetainInstance(true); } @Override public void onDestroy() { super.onDestroy(); disposables.clear(); } /** * @return the activity context, if there is one and the activity is not finishing */ private Optional getActivityContext() { return Optional.ofNullable(weakContext) .map(Reference::get) .filter(context -> !context.isFinishing()); } // guard against IllegalStateException in calling DialogFragment.show() whilst in background // (which could happen, say, when the user pressed the home button while waiting for // the network request to return) when it internally calls FragmentTransaction.commit() // after the FragmentManager has saved its states (isStateSaved() == true) // (ref: https://stackoverflow.com/a/39813506) private void runOnVisible(final Consumer runnable) { getActivityContext().ifPresentOrElse(context -> { if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) { context.runOnUiThread(() -> { runnable.accept(context); inFlight(false); }); } else { getLifecycle().addObserver(new DefaultLifecycleObserver() { @Override public void onResume(@NonNull final LifecycleOwner owner) { getLifecycle().removeObserver(this); getActivityContext().ifPresentOrElse(context -> context.runOnUiThread(() -> { runnable.accept(context); inFlight(false); }), () -> inFlight(false) ); } }); // this trick doesn't seem to work on Android 10+ (API 29) // which places restrictions on starting activities from the background if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && !context.isChangingConfigurations()) { // try to bring the activity back to front if minimised final Intent i = new Intent(context, RouterActivity.class); i.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); startActivity(i); } } }, () -> // this branch is executed if there is no activity context inFlight(false) ); } Single pleaseWait(final Single single) { // 'abuse' ambWith() here to cancel the toast for us when the wait is over return single.ambWith(Single.create(emitter -> getActivityContext().ifPresent(context -> context.runOnUiThread(() -> { // Getting the stream info usually takes a moment // Notifying the user here to ensure that no confusion arises final Toast toast = Toast.makeText(context, getString(R.string.processing_may_take_a_moment), Toast.LENGTH_LONG); toast.show(); emitter.setCancellable(toast::cancel); })))); } @SuppressLint("CheckResult") private void openDownloadDialog(final int currentServiceId, final String currentUrl) { inFlight(true); final LoadingDialog loadingDialog = new LoadingDialog(R.string.loading_metadata_title); loadingDialog.show(getParentFragmentManager(), "loadingDialog"); disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .compose(this::pleaseWait) .subscribe(result -> runOnVisible(ctx -> { loadingDialog.dismiss(); final FragmentManager fm = ctx.getSupportFragmentManager(); final DownloadDialog downloadDialog = new DownloadDialog(ctx, result); // dismiss listener to be handled by FragmentManager downloadDialog.show(fm, "downloadDialog"); } ), throwable -> runOnVisible(ctx -> { loadingDialog.dismiss(); ((RouterActivity) ctx).showUnsupportedUrlDialog(currentUrl); }))); } private void openAddToPlaylistDialog(final int currentServiceId, final String currentUrl) { inFlight(true); disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, false) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .compose(this::pleaseWait) .subscribe( info -> getActivityContext().ifPresent(context -> PlaylistDialog.createCorrespondingDialog(context, List.of(new StreamEntity(info)), playlistDialog -> runOnVisible(ctx -> { // dismiss listener to be handled by FragmentManager final FragmentManager fm = ctx.getSupportFragmentManager(); playlistDialog.show(fm, "addToPlaylistDialog"); }) )), throwable -> runOnVisible(ctx -> handleError(ctx, new ErrorInfo( throwable, UserAction.REQUESTED_STREAM, "Tried to add " + currentUrl + " to a playlist", ((RouterActivity) ctx).currentService.getServiceId(), currentUrl) )) ) ); } } private void openAddToPlaylistDialog() { getPersistFragment().openAddToPlaylistDialog(currentServiceId, currentUrl); } private void openDownloadDialog() { getPersistFragment().openDownloadDialog(currentServiceId, currentUrl); } private PersistentFragment getPersistFragment() { final FragmentManager fm = getSupportFragmentManager(); PersistentFragment persistFragment = (PersistentFragment) fm.findFragmentByTag("PERSIST_FRAGMENT"); if (persistFragment == null) { persistFragment = new PersistentFragment(); fm.beginTransaction() .add(persistFragment, "PERSIST_FRAGMENT") .commitNow(); } return persistFragment; } @Override public void onRequestPermissionsResult(final int requestCode, @NonNull final String[] permissions, @NonNull final int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); for (final int i : grantResults) { if (i == PackageManager.PERMISSION_DENIED) { finish(); return; } } if (requestCode == PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE) { openDownloadDialog(); } } private static class AdapterChoiceItem { final String description; final String key; @DrawableRes final int icon; AdapterChoiceItem(final String key, final String description, final int icon) { this.key = key; this.description = description; this.icon = icon; } } private static class Choice implements Serializable { final int serviceId; final String url; final String playerChoice; final LinkType linkType; Choice(final int serviceId, final LinkType linkType, final String url, final String playerChoice) { this.serviceId = serviceId; this.linkType = linkType; this.url = url; this.playerChoice = playerChoice; } @NonNull @Override public String toString() { return serviceId + ":" + url + " > " + linkType + " ::: " + playerChoice; } } public static class FetcherService extends IntentService { public static final String KEY_CHOICE = "key_choice"; private static final int ID = 456; private Disposable fetcher; public FetcherService() { super(FetcherService.class.getSimpleName()); } @Override public void onCreate() { super.onCreate(); startForeground(ID, createNotification().build()); } @Override protected void onHandleIntent(@Nullable final Intent intent) { if (intent == null) { return; } final Serializable serializable = intent.getSerializableExtra(KEY_CHOICE); if (!(serializable instanceof Choice)) { return; } final Choice playerChoice = (Choice) serializable; handleChoice(playerChoice); } public void handleChoice(final Choice choice) { Single single = null; UserAction userAction = UserAction.SOMETHING_ELSE; switch (choice.linkType) { case STREAM: single = ExtractorHelper.getStreamInfo(choice.serviceId, choice.url, false); userAction = UserAction.REQUESTED_STREAM; break; case CHANNEL: single = ExtractorHelper.getChannelInfo(choice.serviceId, choice.url, false); userAction = UserAction.REQUESTED_CHANNEL; break; case PLAYLIST: single = ExtractorHelper.getPlaylistInfo(choice.serviceId, choice.url, false); userAction = UserAction.REQUESTED_PLAYLIST; break; } if (single != null) { final UserAction finalUserAction = userAction; final Consumer resultHandler = getResultHandler(choice); fetcher = single .observeOn(AndroidSchedulers.mainThread()) .subscribe(info -> { resultHandler.accept(info); if (fetcher != null) { fetcher.dispose(); } }, throwable -> handleError(this, new ErrorInfo(throwable, finalUserAction, choice.url + " opened with " + choice.playerChoice, choice.serviceId, choice.url))); } } public Consumer getResultHandler(final Choice choice) { return info -> { final String videoPlayerKey = getString(R.string.video_player_key); final String backgroundPlayerKey = getString(R.string.background_player_key); final String popupPlayerKey = getString(R.string.popup_player_key); final SharedPreferences preferences = PreferenceManager .getDefaultSharedPreferences(this); final boolean isExtVideoEnabled = preferences.getBoolean( getString(R.string.use_external_video_player_key), false); final boolean isExtAudioEnabled = preferences.getBoolean( getString(R.string.use_external_audio_player_key), false); final PlayQueue playQueue; if (info instanceof StreamInfo) { if (choice.playerChoice.equals(backgroundPlayerKey) && isExtAudioEnabled) { NavigationHelper.playOnExternalAudioPlayer(this, (StreamInfo) info); return; } else if (choice.playerChoice.equals(videoPlayerKey) && isExtVideoEnabled) { NavigationHelper.playOnExternalVideoPlayer(this, (StreamInfo) info); return; } playQueue = new SinglePlayQueue((StreamInfo) info); } else if (info instanceof ChannelInfo) { final Optional playableTab = ((ChannelInfo) info).getTabs() .stream() .filter(ChannelTabHelper::isStreamsTab) .findFirst(); if (playableTab.isPresent()) { playQueue = new ChannelTabPlayQueue(info.getServiceId(), playableTab.get()); } else { return; // there is no playable tab } } else if (info instanceof PlaylistInfo) { playQueue = new PlaylistPlayQueue((PlaylistInfo) info); } else { return; } if (choice.playerChoice.equals(videoPlayerKey)) { NavigationHelper.playOnMainPlayer(this, playQueue, false); } else if (choice.playerChoice.equals(backgroundPlayerKey)) { NavigationHelper.playOnBackgroundPlayer(this, playQueue, true); } else if (choice.playerChoice.equals(popupPlayerKey)) { NavigationHelper.playOnPopupPlayer(this, playQueue, true); } else if (choice.playerChoice.equals(getString(R.string.enqueue_key))) { NavigationHelper.enqueueOnPlayer(this, playQueue); } }; } @Override public void onDestroy() { super.onDestroy(); ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); if (fetcher != null) { fetcher.dispose(); } } private NotificationCompat.Builder createNotification() { return new NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) .setOngoing(true) .setSmallIcon(R.drawable.ic_newpipe_triangle_white) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setContentTitle( getString(R.string.preferred_player_fetcher_notification_title)) .setContentText( getString(R.string.preferred_player_fetcher_notification_message)); } } /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ @Nullable private String getUrl(final Intent intent) { String foundUrl = null; if (intent.getData() != null) { // Called from another app foundUrl = intent.getData().toString(); } else if (intent.getStringExtra(Intent.EXTRA_TEXT) != null) { // Called from the share menu final String extraText = intent.getStringExtra(Intent.EXTRA_TEXT); foundUrl = UrlFinder.firstUrlFromInput(extraText); } return foundUrl; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt ================================================ package org.schabi.newpipe.about import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.Button import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.viewpager2.adapter.FragmentStateAdapter import com.google.android.material.tabs.TabLayoutMediator import org.schabi.newpipe.BuildConfig import org.schabi.newpipe.R import org.schabi.newpipe.databinding.ActivityAboutBinding import org.schabi.newpipe.databinding.FragmentAboutBinding import org.schabi.newpipe.util.ThemeHelper import org.schabi.newpipe.util.external_communication.ShareUtils class AboutActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ThemeHelper.setTheme(this) title = getString(R.string.title_activity_about) val aboutBinding = ActivityAboutBinding.inflate(layoutInflater) setContentView(aboutBinding.root) setSupportActionBar(aboutBinding.aboutToolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) // Create the adapter that will return a fragment for each of the three // primary sections of the activity. val mAboutStateAdapter = AboutStateAdapter(this) // Set up the ViewPager with the sections adapter. aboutBinding.aboutViewPager2.adapter = mAboutStateAdapter TabLayoutMediator( aboutBinding.aboutTabLayout, aboutBinding.aboutViewPager2 ) { tab, position -> tab.setText(mAboutStateAdapter.getPageTitle(position)) }.attach() } override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == android.R.id.home) { finish() return true } return super.onOptionsItemSelected(item) } /** * A placeholder fragment containing a simple view. */ class AboutFragment : Fragment() { private fun Button.openLink(@StringRes url: Int) { setOnClickListener { ShareUtils.openUrlInApp(context, requireContext().getString(url)) } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { FragmentAboutBinding.inflate(inflater, container, false).apply { aboutAppVersion.text = BuildConfig.VERSION_NAME aboutGithubLink.openLink(R.string.github_url) aboutDonationLink.openLink(R.string.donation_url) aboutWebsiteLink.openLink(R.string.website_url) aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url) faqLink.openLink(R.string.faq_url) return root } } } /** * A [FragmentStateAdapter] that returns a fragment corresponding to * one of the sections/tabs/pages. */ private class AboutStateAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) { private val posAbout = 0 private val posLicense = 1 private val totalCount = 2 override fun createFragment(position: Int): Fragment { return when (position) { posAbout -> AboutFragment() posLicense -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS) else -> error("Unknown position for ViewPager2") } } override fun getItemCount(): Int { // Show 2 total pages. return totalCount } fun getPageTitle(position: Int): Int { return when (position) { posAbout -> R.string.tab_about posLicense -> R.string.tab_licenses else -> error("Unknown position for ViewPager2") } } } companion object { /** * List of all software components. */ private val SOFTWARE_COMPONENTS = arrayListOf( SoftwareComponent( "ACRA", "2013", "Kevin Gaudin", "https://github.com/ACRA/acra", StandardLicenses.APACHE2 ), SoftwareComponent( "AndroidX", "2005 - 2011", "The Android Open Source Project", "https://developer.android.com/jetpack", StandardLicenses.APACHE2 ), SoftwareComponent( "ExoPlayer", "2014 - 2020", "Google, Inc.", "https://github.com/google/ExoPlayer", StandardLicenses.APACHE2 ), SoftwareComponent( "GigaGet", "2014 - 2015", "Peter Cai", "https://github.com/PaperAirplane-Dev-Team/GigaGet", StandardLicenses.GPL3 ), SoftwareComponent( "Groupie", "2016", "Lisa Wray", "https://github.com/lisawray/groupie", StandardLicenses.MIT ), SoftwareComponent( "Android-State", "2018", "Evernote", "https://github.com/Evernote/android-state", StandardLicenses.EPL1 ), SoftwareComponent( "Bridge", "2021", "Livefront", "https://github.com/livefront/bridge", StandardLicenses.APACHE2 ), SoftwareComponent( "Jsoup", "2009 - 2020", "Jonathan Hedley", "https://github.com/jhy/jsoup", StandardLicenses.MIT ), SoftwareComponent( "Markwon", "2019", "Dimitry Ivanov", "https://github.com/noties/Markwon", StandardLicenses.APACHE2 ), SoftwareComponent( "Material Components for Android", "2016 - 2020", "Google, Inc.", "https://github.com/material-components/material-components-android", StandardLicenses.APACHE2 ), SoftwareComponent( "NewPipe Extractor", "2017 - 2020", "Christian Schabesberger", "https://github.com/TeamNewPipe/NewPipeExtractor", StandardLicenses.GPL3 ), SoftwareComponent( "NoNonsense-FilePicker", "2016", "Jonas Kalderstam", "https://github.com/spacecowboy/NoNonsense-FilePicker", StandardLicenses.MPL2 ), SoftwareComponent( "OkHttp", "2019", "Square, Inc.", "https://square.github.io/okhttp/", StandardLicenses.APACHE2 ), SoftwareComponent( "Coil", "2023", "Coil Contributors", "https://coil-kt.github.io/coil/", StandardLicenses.APACHE2 ), SoftwareComponent( "PrettyTime", "2012 - 2020", "Lincoln Baxter, III", "https://github.com/ocpsoft/prettytime", StandardLicenses.APACHE2 ), SoftwareComponent( "ProcessPhoenix", "2015", "Jake Wharton", "https://github.com/JakeWharton/ProcessPhoenix", StandardLicenses.APACHE2 ), SoftwareComponent( "RxAndroid", "2015", "The RxAndroid authors", "https://github.com/ReactiveX/RxAndroid", StandardLicenses.APACHE2 ), SoftwareComponent( "RxBinding", "2015", "Jake Wharton", "https://github.com/JakeWharton/RxBinding", StandardLicenses.APACHE2 ), SoftwareComponent( "RxJava", "2016 - 2020", "RxJava Contributors", "https://github.com/ReactiveX/RxJava", StandardLicenses.APACHE2 ), SoftwareComponent( "SearchPreference", "2018", "ByteHamster", "https://github.com/ByteHamster/SearchPreference", StandardLicenses.MIT ) ) } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/about/License.kt ================================================ package org.schabi.newpipe.about import android.os.Parcelable import java.io.Serializable import kotlinx.parcelize.Parcelize /** * Class for storing information about a software license. */ @Parcelize class License(val name: String, val abbreviation: String, val filename: String) : Parcelable, Serializable ================================================ FILE: app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt ================================================ package org.schabi.newpipe.about import android.os.Bundle import android.util.Base64 import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.webkit.WebView import androidx.appcompat.app.AlertDialog import androidx.core.os.BundleCompat import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.BuildConfig import org.schabi.newpipe.R import org.schabi.newpipe.databinding.FragmentLicensesBinding import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding import org.schabi.newpipe.ktx.parcelableArrayList import org.schabi.newpipe.util.external_communication.ShareUtils /** * Fragment containing the software licenses. */ class LicenseFragment : Fragment() { private lateinit var softwareComponents: List private var activeSoftwareComponent: SoftwareComponent? = null private val compositeDisposable = CompositeDisposable() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) softwareComponents = arguments?.parcelableArrayList(ARG_COMPONENTS)!! .sortedBy { it.name } // Sort components by name activeSoftwareComponent = savedInstanceState?.let { BundleCompat.getSerializable(it, SOFTWARE_COMPONENT_KEY, SoftwareComponent::class.java) } } override fun onDestroy() { compositeDisposable.dispose() super.onDestroy() } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { val binding = FragmentLicensesBinding.inflate(inflater, container, false) binding.licensesAppReadLicense.setOnClickListener { compositeDisposable.add( showLicense(NEWPIPE_SOFTWARE_COMPONENT) ) } for (component in softwareComponents) { val componentBinding = ItemSoftwareComponentBinding .inflate(inflater, container, false) componentBinding.name.text = component.name componentBinding.copyright.text = getString( R.string.copyright, component.years, component.copyrightOwner, component.license.abbreviation ) val root: View = componentBinding.root root.tag = component root.setOnClickListener { compositeDisposable.add( showLicense(component) ) } binding.licensesSoftwareComponents.addView(root) registerForContextMenu(root) } activeSoftwareComponent?.let { compositeDisposable.add(showLicense(it)) } return binding.root } override fun onSaveInstanceState(savedInstanceState: Bundle) { super.onSaveInstanceState(savedInstanceState) activeSoftwareComponent?.let { savedInstanceState.putSerializable(SOFTWARE_COMPONENT_KEY, it) } } private fun showLicense( softwareComponent: SoftwareComponent ): Disposable { return if (context == null) { Disposable.empty() } else { val context = requireContext() activeSoftwareComponent = softwareComponent Observable.fromCallable { getFormattedLicense(context, softwareComponent.license) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe { formattedLicense -> val webViewData = Base64.encodeToString( formattedLicense.toByteArray(), Base64.NO_PADDING ) val webView = WebView(context) webView.loadData(webViewData, "text/html; charset=UTF-8", "base64") val builder = AlertDialog.Builder(requireContext()) .setTitle(softwareComponent.name) .setView(webView) .setOnCancelListener { activeSoftwareComponent = null } .setOnDismissListener { activeSoftwareComponent = null } .setPositiveButton(R.string.done) { dialog, _ -> dialog.dismiss() } if (softwareComponent != NEWPIPE_SOFTWARE_COMPONENT) { builder.setNeutralButton(R.string.open_website_license) { _, _ -> ShareUtils.openUrlInApp(requireContext(), softwareComponent.link) } } builder.show() } } } companion object { private const val ARG_COMPONENTS = "components" private const val SOFTWARE_COMPONENT_KEY = "ACTIVE_SOFTWARE_COMPONENT" private val NEWPIPE_SOFTWARE_COMPONENT = SoftwareComponent( "NewPipe", "2014-2023", "Team NewPipe", "https://newpipe.net/", StandardLicenses.GPL3, BuildConfig.VERSION_NAME ) fun newInstance(softwareComponents: ArrayList): LicenseFragment { val fragment = LicenseFragment() fragment.arguments = bundleOf(ARG_COMPONENTS to softwareComponents) return fragment } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt ================================================ package org.schabi.newpipe.about import android.content.Context import java.io.IOException import org.schabi.newpipe.R import org.schabi.newpipe.util.ThemeHelper /** * @param context the context to use * @param license the license * @return String which contains a HTML formatted license page * styled according to the context's theme */ fun getFormattedLicense(context: Context, license: License): String { try { return context.assets.open(license.filename).bufferedReader().use { it.readText() } // split the HTML file and insert the stylesheet into the HEAD of the file .replace("", "") } catch (e: IOException) { throw IllegalArgumentException("Could not get license file: ${license.filename}", e) } } /** * @param context the Android context * @return String which is a CSS stylesheet according to the context's theme */ fun getLicenseStylesheet(context: Context): String { val isLightTheme = ThemeHelper.isLightThemeSelected(context) val licenseBackgroundColor = getHexRGBColor( context, if (isLightTheme) R.color.light_license_background_color else R.color.dark_license_background_color ) val licenseTextColor = getHexRGBColor( context, if (isLightTheme) R.color.light_license_text_color else R.color.dark_license_text_color ) val youtubePrimaryColor = getHexRGBColor( context, if (isLightTheme) R.color.light_youtube_primary_color else R.color.dark_youtube_primary_color ) return "body{padding:12px 15px;margin:0;background:#$licenseBackgroundColor;color:#$licenseTextColor}" + "a[href]{color:#$youtubePrimaryColor}pre{white-space:pre-wrap}" } /** * Cast R.color to a hexadecimal color value. * * @param context the context to use * @param color the color number from R.color * @return a six characters long String with hexadecimal RGB values */ fun getHexRGBColor(context: Context, color: Int): String { return context.getString(color).substring(3) } ================================================ FILE: app/src/main/java/org/schabi/newpipe/about/SoftwareComponent.kt ================================================ package org.schabi.newpipe.about import android.os.Parcelable import java.io.Serializable import kotlinx.parcelize.Parcelize @Parcelize class SoftwareComponent @JvmOverloads constructor( val name: String, val years: String, val copyrightOwner: String, val link: String, val license: License, val version: String? = null ) : Parcelable, Serializable ================================================ FILE: app/src/main/java/org/schabi/newpipe/about/StandardLicenses.kt ================================================ package org.schabi.newpipe.about /** * Class containing information about standard software licenses. */ object StandardLicenses { @JvmField val GPL3 = License("GNU General Public License, Version 3.0", "GPLv3", "gpl_3.html") @JvmField val APACHE2 = License("Apache License, Version 2.0", "ALv2", "apache2.html") @JvmField val MPL2 = License("Mozilla Public License, Version 2.0", "MPL 2.0", "mpl2.html") @JvmField val MIT = License("MIT License", "MIT", "mit.html") @JvmField val EPL1 = License("Eclipse Public License, Version 1.0", "EPL 1.0", "epl1.html") } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/AppDatabase.kt ================================================ /* * SPDX-FileCopyrightText: 2017-2024 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters import org.schabi.newpipe.database.feed.dao.FeedDAO import org.schabi.newpipe.database.feed.dao.FeedGroupDAO import org.schabi.newpipe.database.feed.model.FeedEntity import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity import org.schabi.newpipe.database.history.dao.SearchHistoryDAO import org.schabi.newpipe.database.history.dao.StreamHistoryDAO import org.schabi.newpipe.database.history.model.SearchHistoryEntry import org.schabi.newpipe.database.history.model.StreamHistoryEntity import org.schabi.newpipe.database.playlist.dao.PlaylistDAO import org.schabi.newpipe.database.playlist.dao.PlaylistRemoteDAO 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.stream.dao.StreamDAO import org.schabi.newpipe.database.stream.dao.StreamStateDAO import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.stream.model.StreamStateEntity import org.schabi.newpipe.database.subscription.SubscriptionDAO import org.schabi.newpipe.database.subscription.SubscriptionEntity @TypeConverters(Converters::class) @Database( version = Migrations.DB_VER_9, entities = [ SubscriptionEntity::class, SearchHistoryEntry::class, StreamEntity::class, StreamHistoryEntity::class, StreamStateEntity::class, PlaylistEntity::class, PlaylistStreamEntity::class, PlaylistRemoteEntity::class, FeedEntity::class, FeedGroupEntity::class, FeedGroupSubscriptionEntity::class, FeedLastUpdatedEntity::class ] ) abstract class AppDatabase : RoomDatabase() { abstract fun feedDAO(): FeedDAO abstract fun feedGroupDAO(): FeedGroupDAO abstract fun playlistDAO(): PlaylistDAO abstract fun playlistRemoteDAO(): PlaylistRemoteDAO abstract fun playlistStreamDAO(): PlaylistStreamDAO abstract fun searchHistoryDAO(): SearchHistoryDAO abstract fun streamDAO(): StreamDAO abstract fun streamHistoryDAO(): StreamHistoryDAO abstract fun streamStateDAO(): StreamStateDAO abstract fun subscriptionDAO(): SubscriptionDAO companion object { const val DATABASE_NAME: String = "newpipe.db" } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/BasicDAO.kt ================================================ /* * SPDX-FileCopyrightText: 2017-2022 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.Update import io.reactivex.rxjava3.core.Flowable @Dao interface BasicDAO { /* Inserts */ @Insert fun insert(entity: Entity): Long @Insert fun insertAll(entities: Collection): List /* Searches */ fun getAll(): Flowable> fun listByService(serviceId: Int): Flowable> /* Deletes */ @Delete fun delete(entity: Entity) fun deleteAll(): Int /* Updates */ @Update fun update(entity: Entity): Int @Update fun update(entities: Collection) } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/Converters.kt ================================================ package org.schabi.newpipe.database import androidx.room.TypeConverter import java.time.Instant import java.time.OffsetDateTime import java.time.ZoneOffset import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.local.subscription.FeedGroupIcon class Converters { /** * Convert a long value to a [OffsetDateTime]. * * @param value the long value * @return the `OffsetDateTime` */ @TypeConverter fun offsetDateTimeFromTimestamp(value: Long?): OffsetDateTime? { return value?.let { OffsetDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) } } /** * Convert a [OffsetDateTime] to a long value. * * @param offsetDateTime the `OffsetDateTime` * @return the long value */ @TypeConverter fun offsetDateTimeToTimestamp(offsetDateTime: OffsetDateTime?): Long? { return offsetDateTime?.withOffsetSameInstant(ZoneOffset.UTC)?.toInstant()?.toEpochMilli() } @TypeConverter fun streamTypeOf(value: String): StreamType { return StreamType.valueOf(value) } @TypeConverter fun stringOf(streamType: StreamType): String { return streamType.name } @TypeConverter fun integerOf(feedGroupIcon: FeedGroupIcon): Int { return feedGroupIcon.id } @TypeConverter fun feedGroupIconOf(id: Int): FeedGroupIcon { return FeedGroupIcon.entries.first { it.id == id } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/LocalItem.kt ================================================ /* * SPDX-FileCopyrightText: 2018-2020 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database interface LocalItem { val localItemType: LocalItemType enum class LocalItemType { PLAYLIST_LOCAL_ITEM, PLAYLIST_REMOTE_ITEM, PLAYLIST_STREAM_ITEM, STATISTIC_STREAM_ITEM } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/Migrations.kt ================================================ /* * SPDX-FileCopyrightText: 2018-2024 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database import android.util.Log import androidx.room.migration.Migration import org.schabi.newpipe.MainActivity object Migrations { // /////////////////////////////////////////////////////////////////////// // // Test new migrations manually by importing a database from daily usage // // and checking if the migration works (Use the Database Inspector // // https://developer.android.com/studio/inspect/database). // // If you add a migration point it out in the pull request, so that // // others remember to test it themselves. // // /////////////////////////////////////////////////////////////////////// // const val DB_VER_1 = 1 const val DB_VER_2 = 2 const val DB_VER_3 = 3 const val DB_VER_4 = 4 const val DB_VER_5 = 5 const val DB_VER_6 = 6 const val DB_VER_7 = 7 const val DB_VER_8 = 8 const val DB_VER_9 = 9 private val TAG = Migrations::class.java.getName() private val isDebug = MainActivity.DEBUG val MIGRATION_1_2 = Migration(DB_VER_1, DB_VER_2) { db -> if (isDebug) { Log.d(TAG, "Start migrating database") } /* * Unfortunately these queries must be hardcoded due to the possibility of * schema and names changing at a later date, thus invalidating the older migration * scripts if they are not hardcoded. * */ // Not much we can do about this, since room doesn't create tables before migration. // It's either this or blasting the entire database anew. db.execSQL( "CREATE INDEX `index_search_history_search` " + "ON `search_history` (`search`)" ) db.execSQL( "CREATE TABLE IF NOT EXISTS `streams` " + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`service_id` INTEGER NOT NULL, `url` TEXT, `title` TEXT, " + "`stream_type` TEXT, `duration` INTEGER, `uploader` TEXT, " + "`thumbnail_url` TEXT)" ) db.execSQL( "CREATE UNIQUE INDEX `index_streams_service_id_url` " + "ON `streams` (`service_id`, `url`)" ) db.execSQL( "CREATE TABLE IF NOT EXISTS `stream_history` " + "(`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, " + "`repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), " + "FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) " + "ON UPDATE CASCADE ON DELETE CASCADE )" ) db.execSQL( "CREATE INDEX `index_stream_history_stream_id` " + "ON `stream_history` (`stream_id`)" ) db.execSQL( "CREATE TABLE IF NOT EXISTS `stream_state` " + "(`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, " + "PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) " + "REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )" ) db.execSQL( "CREATE TABLE IF NOT EXISTS `playlists` " + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`name` TEXT, `thumbnail_url` TEXT)" ) db.execSQL("CREATE INDEX `index_playlists_name` ON `playlists` (`name`)") db.execSQL( "CREATE TABLE IF NOT EXISTS `playlist_stream_join` " + "(`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, " + "`join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), " + "FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) " + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " + "FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) " + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)" ) db.execSQL( "CREATE UNIQUE INDEX " + "`index_playlist_stream_join_playlist_id_join_index` " + "ON `playlist_stream_join` (`playlist_id`, `join_index`)" ) db.execSQL( "CREATE INDEX `index_playlist_stream_join_stream_id` " + "ON `playlist_stream_join` (`stream_id`)" ) db.execSQL( "CREATE TABLE IF NOT EXISTS `remote_playlists` " + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, " + "`thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)" ) db.execSQL( "CREATE INDEX `index_remote_playlists_name` " + "ON `remote_playlists` (`name`)" ) db.execSQL( "CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " + "ON `remote_playlists` (`service_id`, `url`)" ) // Populate streams table with existing entries in watch history // Latest data first, thus ignoring older entries with the same indices db.execSQL( "INSERT OR IGNORE INTO streams (service_id, url, title, " + "stream_type, duration, uploader, thumbnail_url) " + "SELECT service_id, url, title, 'VIDEO_STREAM', duration, " + "uploader, thumbnail_url " + "FROM watch_history " + "ORDER BY creation_date DESC" ) // Once the streams have PKs, join them with the normalized history table // and populate it with the remaining data from watch history db.execSQL( "INSERT INTO stream_history (stream_id, access_date, repeat_count)" + "SELECT uid, creation_date, 1 " + "FROM watch_history INNER JOIN streams " + "ON watch_history.service_id == streams.service_id " + "AND watch_history.url == streams.url " + "ORDER BY creation_date DESC" ) db.execSQL("DROP TABLE IF EXISTS watch_history") if (isDebug) { Log.d(TAG, "Stop migrating database") } } val MIGRATION_2_3 = Migration(DB_VER_2, DB_VER_3) { db -> // Add NOT NULLs and new fields db.execSQL( "CREATE TABLE IF NOT EXISTS streams_new " + "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "service_id INTEGER NOT NULL, url TEXT NOT NULL, title TEXT NOT NULL, " + "stream_type TEXT NOT NULL, duration INTEGER NOT NULL, " + "uploader TEXT NOT NULL, thumbnail_url TEXT, view_count INTEGER, " + "textual_upload_date TEXT, upload_date INTEGER, " + "is_upload_date_approximation INTEGER)" ) db.execSQL( "INSERT INTO streams_new (uid, service_id, url, title, stream_type, " + "duration, uploader, thumbnail_url, view_count, textual_upload_date, " + "upload_date, is_upload_date_approximation) " + "SELECT uid, service_id, url, ifnull(title, ''), " + "ifnull(stream_type, 'VIDEO_STREAM'), ifnull(duration, 0), " + "ifnull(uploader, ''), ifnull(thumbnail_url, ''), NULL, NULL, NULL, NULL " + "FROM streams WHERE url IS NOT NULL" ) db.execSQL("DROP TABLE streams") db.execSQL("ALTER TABLE streams_new RENAME TO streams") db.execSQL( "CREATE UNIQUE INDEX index_streams_service_id_url " + "ON streams (service_id, url)" ) // Tables for feed feature db.execSQL( "CREATE TABLE IF NOT EXISTS feed " + "(stream_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, " + "PRIMARY KEY(stream_id, subscription_id), " + "FOREIGN KEY(stream_id) REFERENCES streams(uid) " + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " + "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)" ) db.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)") db.execSQL( "CREATE TABLE IF NOT EXISTS feed_group " + "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, " + "icon_id INTEGER NOT NULL, sort_order INTEGER NOT NULL)" ) db.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)") db.execSQL( "CREATE TABLE IF NOT EXISTS feed_group_subscription_join " + "(group_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, " + "PRIMARY KEY(group_id, subscription_id), " + "FOREIGN KEY(group_id) REFERENCES feed_group(uid) " + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " + "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)" ) db.execSQL( "CREATE INDEX index_feed_group_subscription_join_subscription_id " + "ON feed_group_subscription_join (subscription_id)" ) db.execSQL( "CREATE TABLE IF NOT EXISTS feed_last_updated " + "(subscription_id INTEGER NOT NULL, last_updated INTEGER, " + "PRIMARY KEY(subscription_id), " + "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)" ) } val MIGRATION_3_4 = Migration(DB_VER_3, DB_VER_4) { db -> db.execSQL("ALTER TABLE streams ADD COLUMN uploader_url TEXT") } val MIGRATION_4_5 = Migration(DB_VER_4, DB_VER_5) { db -> db.execSQL( "ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` " + "INTEGER NOT NULL DEFAULT 0" ) } val MIGRATION_5_6 = Migration(DB_VER_5, DB_VER_6) { db -> db.execSQL( "ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` " + "INTEGER NOT NULL DEFAULT 0" ) } val MIGRATION_6_7 = Migration(DB_VER_6, DB_VER_7) { db -> // Create a new column thumbnail_stream_id db.execSQL( "ALTER TABLE `playlists` ADD COLUMN `thumbnail_stream_id` " + "INTEGER NOT NULL DEFAULT -1" ) // Migrate the thumbnail_url to the thumbnail_stream_id db.execSQL( "UPDATE playlists SET thumbnail_stream_id = (" + " SELECT CASE WHEN COUNT(*) != 0 then stream_uid ELSE -1 END" + " FROM (" + " SELECT p.uid AS playlist_uid, s.uid AS stream_uid" + " FROM playlists p" + " LEFT JOIN playlist_stream_join ps ON p.uid = ps.playlist_id" + " LEFT JOIN streams s ON s.uid = ps.stream_id" + " WHERE s.thumbnail_url = p.thumbnail_url) AS temporary_table" + " WHERE playlist_uid = playlists.uid)" ) // Remove the thumbnail_url field in the playlist table db.execSQL( "CREATE TABLE IF NOT EXISTS `playlists_new`" + "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "name TEXT, " + "is_thumbnail_permanent INTEGER NOT NULL, " + "thumbnail_stream_id INTEGER NOT NULL)" ) db.execSQL( "INSERT INTO playlists_new" + " SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id " + " FROM playlists" ) db.execSQL("DROP TABLE playlists") db.execSQL("ALTER TABLE playlists_new RENAME TO playlists") db.execSQL( "CREATE INDEX IF NOT EXISTS " + "`index_playlists_name` ON `playlists` (`name`)" ) } val MIGRATION_7_8 = Migration(DB_VER_7, DB_VER_8) { db -> db.execSQL( "DELETE FROM search_history WHERE id NOT IN (SELECT id FROM (SELECT " + "MIN(id) as id FROM search_history GROUP BY trim(search), service_id ) tmp)" ) db.execSQL("UPDATE search_history SET search = trim(search)") } val MIGRATION_8_9 = Migration(DB_VER_8, DB_VER_9) { db -> try { db.beginTransaction() // Update playlists. // Create a temp table to initialize display_index. db.execSQL( "CREATE TABLE `playlists_tmp` " + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, " + "`thumbnail_stream_id` INTEGER NOT NULL, " + "`display_index` INTEGER NOT NULL)" ) db.execSQL( "INSERT INTO `playlists_tmp` " + "(`uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, " + "`display_index`) " + "SELECT `uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, " + "-1 " + "FROM `playlists`" ) // Replace the old table, note that this also removes the index on the name which // we don't need anymore. db.execSQL("DROP TABLE `playlists`") db.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`") // Update remote_playlists. // Create a temp table to initialize display_index. db.execSQL( "CREATE TABLE `remote_playlists_tmp` " + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, " + "`thumbnail_url` TEXT, `uploader` TEXT, " + "`display_index` INTEGER NOT NULL," + "`stream_count` INTEGER)" ) db.execSQL( "INSERT INTO `remote_playlists_tmp` (`uid`, `service_id`, " + "`name`, `url`, `thumbnail_url`, `uploader`, `display_index`, " + "`stream_count`)" + "SELECT `uid`, `service_id`, `name`, `url`, `thumbnail_url`, `uploader`, " + "-1, `stream_count` FROM `remote_playlists`" ) // Replace the old table, note that this also removes the index on the name which // we don't need anymore. db.execSQL("DROP TABLE `remote_playlists`") db.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`") // Create index on the new table. db.execSQL( "CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " + "ON `remote_playlists` (`service_id`, `url`)" ) db.setTransactionSuccessful() } finally { db.endTransaction() } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt ================================================ package org.schabi.newpipe.database.feed.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import androidx.room.Update import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Maybe import java.time.OffsetDateTime import org.schabi.newpipe.database.feed.model.FeedEntity import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity import org.schabi.newpipe.database.stream.StreamWithState import org.schabi.newpipe.database.stream.model.StreamStateEntity import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.database.subscription.SubscriptionEntity @Dao abstract class FeedDAO { @Query("DELETE FROM feed") abstract fun deleteAll(): Int /** * @param groupId the group id to get feed streams of; use * [FeedGroupEntity.GROUP_ALL_ID] to not filter by group * @param includePlayed if false, only return all of the live, never-played or non-finished * feed streams (see `@see` items); if true no filter is applied * @param uploadDateBefore get only streams uploaded before this date (useful to filter out * future streams); use null to not filter by upload date * @return the feed streams filtered according to the conditions provided in the parameters * @see StreamStateEntity.isFinished() * @see StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS * @see StreamStateEntity.PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS */ @Query( """ SELECT s.*, sst.progress_time FROM streams s LEFT JOIN stream_state sst ON s.uid = sst.stream_id LEFT JOIN stream_history sh ON s.uid = sh.stream_id INNER JOIN feed f ON s.uid = f.stream_id LEFT JOIN feed_group_subscription_join fgs ON ( :groupId <> ${FeedGroupEntity.GROUP_ALL_ID} AND fgs.subscription_id = f.subscription_id ) WHERE ( :groupId = ${FeedGroupEntity.GROUP_ALL_ID} OR fgs.group_id = :groupId ) AND ( :includePlayed OR sh.stream_id IS NULL OR sst.stream_id IS NULL OR sst.progress_time < s.duration * 1000 - ${StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS} OR sst.progress_time < s.duration * 1000 * 3 / 4 OR s.stream_type = 'LIVE_STREAM' OR s.stream_type = 'AUDIO_LIVE_STREAM' ) AND ( :includePartiallyPlayed OR sh.stream_id IS NULL OR sst.stream_id IS NULL OR (sst.progress_time <= ${StreamStateEntity.PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS} AND sst.progress_time <= s.duration * 1000 / 4) OR (sst.progress_time >= s.duration * 1000 - ${StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS} AND sst.progress_time >= s.duration * 1000 * 3 / 4) ) AND ( :uploadDateBefore IS NULL OR s.upload_date IS NULL OR s.upload_date < :uploadDateBefore ) ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC LIMIT 500 """ ) abstract fun getStreams( groupId: Long, includePlayed: Boolean, includePartiallyPlayed: Boolean, uploadDateBefore: OffsetDateTime? ): Maybe> /** * Remove links to streams that are older than the given date * **but keep at least one stream per uploader**. * * One stream per uploader is kept because it is needed as reference * when fetching new streams to check if they are new or not. * @param offsetDateTime the newest date to keep, older streams are removed */ @Query( """ DELETE FROM feed WHERE feed.stream_id IN (SELECT uid from ( SELECT s.uid, (SELECT MAX(upload_date) FROM streams s1 INNER JOIN feed f1 ON s1.uid = f1.stream_id WHERE f1.subscription_id = f.subscription_id) max_upload_date FROM streams s INNER JOIN feed f ON s.uid = f.stream_id WHERE s.upload_date < :offsetDateTime AND s.upload_date <> max_upload_date)) """ ) abstract fun unlinkStreamsOlderThan(offsetDateTime: OffsetDateTime) @Query( """ DELETE FROM feed WHERE feed.subscription_id = :subscriptionId AND feed.stream_id IN ( SELECT s.uid FROM streams s INNER JOIN feed f ON s.uid = f.stream_id WHERE s.stream_type = "LIVE_STREAM" OR s.stream_type = "AUDIO_LIVE_STREAM" ) """ ) abstract fun unlinkOldLivestreams(subscriptionId: Long) @Insert(onConflict = OnConflictStrategy.IGNORE) abstract fun insert(feedEntity: FeedEntity) @Insert(onConflict = OnConflictStrategy.IGNORE) abstract fun insertAll(entities: List): List @Insert(onConflict = OnConflictStrategy.IGNORE) internal abstract fun insertLastUpdated(lastUpdatedEntity: FeedLastUpdatedEntity): Long @Update(onConflict = OnConflictStrategy.IGNORE) internal abstract fun updateLastUpdated(lastUpdatedEntity: FeedLastUpdatedEntity) @Transaction open fun setLastUpdatedForSubscription(lastUpdatedEntity: FeedLastUpdatedEntity) { val id = insertLastUpdated(lastUpdatedEntity) if (id == -1L) { updateLastUpdated(lastUpdatedEntity) } } @Query( """ SELECT MIN(lu.last_updated) FROM feed_last_updated lu INNER JOIN feed_group_subscription_join fgs ON fgs.subscription_id = lu.subscription_id AND fgs.group_id = :groupId """ ) abstract fun oldestSubscriptionUpdate(groupId: Long): Flowable> @Query("SELECT MIN(last_updated) FROM feed_last_updated") abstract fun oldestSubscriptionUpdateFromAll(): Flowable> @Query("SELECT COUNT(*) FROM feed_last_updated WHERE last_updated IS NULL") abstract fun notLoadedCount(): Flowable @Query( """ SELECT COUNT(*) FROM subscriptions s INNER JOIN feed_group_subscription_join fgs ON s.uid = fgs.subscription_id AND fgs.group_id = :groupId LEFT JOIN feed_last_updated lu ON s.uid = lu.subscription_id WHERE lu.last_updated IS NULL """ ) abstract fun notLoadedCountForGroup(groupId: Long): Flowable @Query( """ SELECT s.* FROM subscriptions s LEFT JOIN feed_last_updated lu ON s.uid = lu.subscription_id WHERE lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold """ ) abstract fun getAllOutdated(outdatedThreshold: OffsetDateTime): Flowable> @Query( """ SELECT s.* FROM subscriptions s INNER JOIN feed_group_subscription_join fgs ON s.uid = fgs.subscription_id AND fgs.group_id = :groupId LEFT JOIN feed_last_updated lu ON s.uid = lu.subscription_id WHERE lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold """ ) abstract fun getAllOutdatedForGroup(groupId: Long, outdatedThreshold: OffsetDateTime): Flowable> @Query( """ SELECT s.* FROM subscriptions s LEFT JOIN feed_last_updated lu ON s.uid = lu.subscription_id WHERE (lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold) AND s.notification_mode = :notificationMode """ ) abstract fun getOutdatedWithNotificationMode( outdatedThreshold: OffsetDateTime, @NotificationMode notificationMode: Int ): Flowable> } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedGroupDAO.kt ================================================ package org.schabi.newpipe.database.feed.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import androidx.room.Update import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Maybe import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity @Dao abstract class FeedGroupDAO { @Query("SELECT * FROM feed_group ORDER BY sort_order ASC") abstract fun getAll(): Flowable> @Query("SELECT * FROM feed_group WHERE uid = :groupId") abstract fun getGroup(groupId: Long): Maybe @Transaction open fun insert(feedGroupEntity: FeedGroupEntity): Long { val nextSortOrder = nextSortOrder() feedGroupEntity.sortOrder = nextSortOrder return insertInternal(feedGroupEntity) } @Update(onConflict = OnConflictStrategy.IGNORE) abstract fun update(feedGroupEntity: FeedGroupEntity): Int @Query("DELETE FROM feed_group") abstract fun deleteAll(): Int @Query("DELETE FROM feed_group WHERE uid = :groupId") abstract fun delete(groupId: Long): Int @Query("SELECT subscription_id FROM feed_group_subscription_join WHERE group_id = :groupId") abstract fun getSubscriptionIdsFor(groupId: Long): Flowable> @Query("DELETE FROM feed_group_subscription_join WHERE group_id = :groupId") abstract fun deleteSubscriptionsFromGroup(groupId: Long): Int @Insert(onConflict = OnConflictStrategy.IGNORE) abstract fun insertSubscriptionsToGroup(entities: List): List @Transaction open fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List) { deleteSubscriptionsFromGroup(groupId) insertSubscriptionsToGroup(subscriptionIds.map { FeedGroupSubscriptionEntity(groupId, it) }) } @Transaction open fun updateOrder(orderMap: Map) { orderMap.forEach { (groupId, sortOrder) -> updateOrder(groupId, sortOrder) } } @Query("UPDATE feed_group SET sort_order = :sortOrder WHERE uid = :groupId") abstract fun updateOrder(groupId: Long, sortOrder: Long): Int @Query("SELECT IFNULL(MAX(sort_order) + 1, 0) FROM feed_group") protected abstract fun nextSortOrder(): Long @Insert(onConflict = OnConflictStrategy.ABORT) protected abstract fun insertInternal(feedGroupEntity: FeedGroupEntity): Long } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/feed/model/FeedEntity.kt ================================================ package org.schabi.newpipe.database.feed.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.FEED_TABLE import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.STREAM_ID import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.SUBSCRIPTION_ID import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.subscription.SubscriptionEntity @Entity( tableName = FEED_TABLE, primaryKeys = [STREAM_ID, SUBSCRIPTION_ID], indices = [Index(SUBSCRIPTION_ID)], foreignKeys = [ ForeignKey( entity = StreamEntity::class, parentColumns = [StreamEntity.STREAM_ID], childColumns = [STREAM_ID], onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true ), ForeignKey( entity = SubscriptionEntity::class, parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID], childColumns = [SUBSCRIPTION_ID], onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true ) ] ) data class FeedEntity( @ColumnInfo(name = STREAM_ID) var streamId: Long, @ColumnInfo(name = SUBSCRIPTION_ID) var subscriptionId: Long ) { companion object { const val FEED_TABLE = "feed" const val STREAM_ID = "stream_id" const val SUBSCRIPTION_ID = "subscription_id" } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupEntity.kt ================================================ package org.schabi.newpipe.database.feed.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Index import androidx.room.PrimaryKey import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.FEED_GROUP_TABLE import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.SORT_ORDER import org.schabi.newpipe.local.subscription.FeedGroupIcon @Entity( tableName = FEED_GROUP_TABLE, indices = [Index(SORT_ORDER)] ) data class FeedGroupEntity( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = ID) val uid: Long, @ColumnInfo(name = NAME) var name: String, @ColumnInfo(name = ICON) var icon: FeedGroupIcon, @ColumnInfo(name = SORT_ORDER) var sortOrder: Long = -1 ) { companion object { const val FEED_GROUP_TABLE = "feed_group" const val ID = "uid" const val NAME = "name" const val ICON = "icon_id" const val SORT_ORDER = "sort_order" const val GROUP_ALL_ID = -1L } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupSubscriptionEntity.kt ================================================ package org.schabi.newpipe.database.feed.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.FEED_GROUP_SUBSCRIPTION_TABLE import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.GROUP_ID import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.SUBSCRIPTION_ID import org.schabi.newpipe.database.subscription.SubscriptionEntity @Entity( tableName = FEED_GROUP_SUBSCRIPTION_TABLE, primaryKeys = [GROUP_ID, SUBSCRIPTION_ID], indices = [Index(SUBSCRIPTION_ID)], foreignKeys = [ ForeignKey( entity = FeedGroupEntity::class, parentColumns = [FeedGroupEntity.ID], childColumns = [GROUP_ID], onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true ), ForeignKey( entity = SubscriptionEntity::class, parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID], childColumns = [SUBSCRIPTION_ID], onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true ) ] ) data class FeedGroupSubscriptionEntity( @ColumnInfo(name = GROUP_ID) var feedGroupId: Long, @ColumnInfo(name = SUBSCRIPTION_ID) var subscriptionId: Long ) { companion object { const val FEED_GROUP_SUBSCRIPTION_TABLE = "feed_group_subscription_join" const val GROUP_ID = "group_id" const val SUBSCRIPTION_ID = "subscription_id" } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/feed/model/FeedLastUpdatedEntity.kt ================================================ package org.schabi.newpipe.database.feed.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.PrimaryKey import java.time.OffsetDateTime import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity.Companion.FEED_LAST_UPDATED_TABLE import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity.Companion.SUBSCRIPTION_ID import org.schabi.newpipe.database.subscription.SubscriptionEntity @Entity( tableName = FEED_LAST_UPDATED_TABLE, foreignKeys = [ ForeignKey( entity = SubscriptionEntity::class, parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID], childColumns = [SUBSCRIPTION_ID], onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true ) ] ) data class FeedLastUpdatedEntity( @PrimaryKey @ColumnInfo(name = SUBSCRIPTION_ID) var subscriptionId: Long, @ColumnInfo(name = LAST_UPDATED) var lastUpdated: OffsetDateTime? = null ) { companion object { const val FEED_LAST_UPDATED_TABLE = "feed_last_updated" const val SUBSCRIPTION_ID = "subscription_id" const val LAST_UPDATED = "last_updated" } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.kt ================================================ /* * SPDX-FileCopyrightText: 2017-2021 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database.history.dao import androidx.room.Dao import androidx.room.Query import io.reactivex.rxjava3.core.Flowable import org.schabi.newpipe.database.BasicDAO import org.schabi.newpipe.database.history.model.SearchHistoryEntry @Dao interface SearchHistoryDAO : BasicDAO { @get:Query("SELECT * FROM search_history WHERE id = (SELECT MAX(id) FROM search_history)") val latestEntry: SearchHistoryEntry? @Query("DELETE FROM search_history") override fun deleteAll(): Int @Query("DELETE FROM search_history WHERE search = :query") fun deleteAllWhereQuery(query: String): Int @Query("SELECT * FROM search_history ORDER BY creation_date DESC") override fun getAll(): Flowable> @Query("SELECT search FROM search_history GROUP BY search ORDER BY MAX(creation_date) DESC LIMIT :limit") fun getUniqueEntries(limit: Int): Flowable> @Query("SELECT * FROM search_history WHERE service_id = :serviceId ORDER BY creation_date DESC") override fun listByService(serviceId: Int): Flowable> @Query( """ SELECT search FROM search_history WHERE search LIKE :query || '%' GROUP BY search ORDER BY MAX(creation_date) DESC LIMIT :limit """ ) fun getSimilarEntries(query: String, limit: Int): Flowable> } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt ================================================ /* * SPDX-FileCopyrightText: 2018-2022 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database.history.dao import androidx.room.Dao import androidx.room.Query import androidx.room.RewriteQueriesToDropUnusedColumns import io.reactivex.rxjava3.core.Flowable import org.schabi.newpipe.database.BasicDAO import org.schabi.newpipe.database.history.model.StreamHistoryEntity import org.schabi.newpipe.database.history.model.StreamHistoryEntry import org.schabi.newpipe.database.stream.StreamStatisticsEntry @Dao abstract class StreamHistoryDAO : BasicDAO { @Query("SELECT * FROM stream_history") abstract override fun getAll(): Flowable> @Query("DELETE FROM stream_history") abstract override fun deleteAll(): Int override fun listByService(serviceId: Int): Flowable> { throw UnsupportedOperationException() } @get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY access_date DESC") abstract val history: Flowable> @get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY uid ASC") abstract val historySortedById: Flowable> @Query("SELECT * FROM stream_history WHERE stream_id = :streamId ORDER BY access_date DESC LIMIT 1") abstract fun getLatestEntry(streamId: Long): StreamHistoryEntity? @Query("DELETE FROM stream_history WHERE stream_id = :streamId") abstract fun deleteStreamHistory(streamId: Long): Int // Select the latest entry and watch count for each stream id on history table @RewriteQueriesToDropUnusedColumns @Query( """ SELECT * FROM streams INNER JOIN ( SELECT stream_id, MAX(access_date) AS latestAccess, SUM(repeat_count) AS watchCount FROM stream_history GROUP BY stream_id ) ON uid = stream_id LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state ) ON uid = stream_id_alias """ ) abstract fun getStatistics(): Flowable> } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/history/model/SearchHistoryEntry.kt ================================================ /* * SPDX-FileCopyrightText: 2022 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database.history.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Ignore import androidx.room.Index import androidx.room.PrimaryKey import java.time.OffsetDateTime @Entity( tableName = SearchHistoryEntry.TABLE_NAME, indices = [Index(value = [SearchHistoryEntry.SEARCH])] ) data class SearchHistoryEntry @JvmOverloads constructor( @ColumnInfo(name = CREATION_DATE) var creationDate: OffsetDateTime?, @ColumnInfo(name = SERVICE_ID) val serviceId: Int, @ColumnInfo(name = SEARCH) val search: String?, @ColumnInfo(name = ID) @PrimaryKey(autoGenerate = true) val id: Long = 0 ) { @Ignore fun hasEqualValues(otherEntry: SearchHistoryEntry): Boolean { return serviceId == otherEntry.serviceId && search == otherEntry.search } companion object { const val ID = "id" const val TABLE_NAME = "search_history" const val SERVICE_ID = "service_id" const val CREATION_DATE = "creation_date" const val SEARCH = "search" } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.kt ================================================ /* * SPDX-FileCopyrightText: 2018-2022 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database.history.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.ForeignKey.Companion.CASCADE import androidx.room.Index import java.time.OffsetDateTime import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.JOIN_STREAM_ID import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.STREAM_ACCESS_DATE import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID /** * @param streamUid the stream id this history item will refer to * @param accessDate the last time the stream was accessed * @param repeatCount the total number of views this stream received */ @Entity( tableName = STREAM_HISTORY_TABLE, primaryKeys = [JOIN_STREAM_ID, STREAM_ACCESS_DATE], indices = [Index(value = [JOIN_STREAM_ID])], foreignKeys = [ ForeignKey( entity = StreamEntity::class, parentColumns = arrayOf(STREAM_ID), childColumns = arrayOf(JOIN_STREAM_ID), onDelete = CASCADE, onUpdate = CASCADE ) ] ) data class StreamHistoryEntity( @ColumnInfo(name = JOIN_STREAM_ID) val streamUid: Long, @ColumnInfo(name = STREAM_ACCESS_DATE) var accessDate: OffsetDateTime, @ColumnInfo(name = STREAM_REPEAT_COUNT) var repeatCount: Long ) { companion object { const val STREAM_HISTORY_TABLE: String = "stream_history" const val STREAM_ACCESS_DATE: String = "access_date" const val JOIN_STREAM_ID: String = "stream_id" const val STREAM_REPEAT_COUNT: String = "repeat_count" } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt ================================================ package org.schabi.newpipe.database.history.model import androidx.room.ColumnInfo import androidx.room.Embedded import java.time.OffsetDateTime import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.util.image.ImageStrategy data class StreamHistoryEntry( @Embedded val streamEntity: StreamEntity, @ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID) val streamId: Long, @ColumnInfo(name = StreamHistoryEntity.STREAM_ACCESS_DATE) val accessDate: OffsetDateTime, @ColumnInfo(name = StreamHistoryEntity.STREAM_REPEAT_COUNT) val repeatCount: Long ) { fun toStreamHistoryEntity(): StreamHistoryEntity { return StreamHistoryEntity(streamId, accessDate, repeatCount) } fun hasEqualValues(other: StreamHistoryEntry): Boolean { return this.streamEntity.uid == other.streamEntity.uid && streamId == other.streamId && accessDate.isEqual(other.accessDate) } fun toStreamInfoItem(): StreamInfoItem = StreamInfoItem( streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType ).apply { duration = streamEntity.duration uploaderName = streamEntity.uploader uploaderUrl = streamEntity.uploaderUrl thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.kt ================================================ /* * SPDX-FileCopyrightText: 2023-2024 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database.playlist import androidx.room.ColumnInfo import org.schabi.newpipe.database.playlist.model.PlaylistEntity /** * This class adds a field to [PlaylistMetadataEntry] that contains an integer representing * how many times a specific stream is already contained inside a local playlist. Used to be able * to grey out playlists which already contain the current stream in the playlist append dialog. * @see org.schabi.newpipe.local.playlist.LocalPlaylistManager.getPlaylistDuplicates */ data class PlaylistDuplicatesEntry( @ColumnInfo(name = PlaylistEntity.PLAYLIST_ID) override val uid: Long, @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_URL) override val thumbnailUrl: String?, @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT) override val isThumbnailPermanent: Boolean?, @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID) override val thumbnailStreamId: Long?, @ColumnInfo(name = PlaylistEntity.PLAYLIST_DISPLAY_INDEX) override var displayIndex: Long?, @ColumnInfo(name = PLAYLIST_STREAM_COUNT) override val streamCount: Long, @ColumnInfo(name = PlaylistEntity.PLAYLIST_NAME) override val orderingName: String?, @ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED) val timesStreamIsContained: Long ) : PlaylistMetadataEntry( uid = uid, orderingName = orderingName, thumbnailUrl = thumbnailUrl, isThumbnailPermanent = isThumbnailPermanent, thumbnailStreamId = thumbnailStreamId, displayIndex = displayIndex, streamCount = streamCount ) { companion object { const val PLAYLIST_TIMES_STREAM_IS_CONTAINED: String = "timesStreamIsContained" } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt ================================================ /* * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database.playlist import org.schabi.newpipe.database.LocalItem interface PlaylistLocalItem : LocalItem { val orderingName: String? val displayIndex: Long? val uid: Long val thumbnailUrl: String? } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.kt ================================================ /* * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database.playlist import androidx.room.ColumnInfo import org.schabi.newpipe.database.LocalItem.LocalItemType import org.schabi.newpipe.database.playlist.model.PlaylistEntity open class PlaylistMetadataEntry( @ColumnInfo(name = PlaylistEntity.PLAYLIST_ID) override val uid: Long, @ColumnInfo(name = PlaylistEntity.PLAYLIST_NAME) override val orderingName: String?, @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_URL) override val thumbnailUrl: String?, @ColumnInfo(name = PlaylistEntity.PLAYLIST_DISPLAY_INDEX) override var displayIndex: Long?, @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT) open val isThumbnailPermanent: Boolean?, @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID) open val thumbnailStreamId: Long?, @ColumnInfo(name = PLAYLIST_STREAM_COUNT) open val streamCount: Long ) : PlaylistLocalItem { override val localItemType: LocalItemType get() = LocalItemType.PLAYLIST_LOCAL_ITEM companion object { const val PLAYLIST_STREAM_COUNT: String = "streamCount" } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt ================================================ /* * SPDX-FileCopyrightText: 2020-2023 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database.playlist import androidx.room.ColumnInfo import androidx.room.Embedded import org.schabi.newpipe.database.LocalItem import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.stream.model.StreamStateEntity import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.util.image.ImageStrategy data class PlaylistStreamEntry( @Embedded val streamEntity: StreamEntity, @ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_MILLIS, defaultValue = "0") val progressMillis: Long, @ColumnInfo(name = PlaylistStreamEntity.JOIN_STREAM_ID) val streamId: Long, @ColumnInfo(name = PlaylistStreamEntity.JOIN_INDEX) val joinIndex: Int ) : LocalItem { override val localItemType: LocalItem.LocalItemType get() = LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM @Throws(IllegalArgumentException::class) fun toStreamInfoItem(): StreamInfoItem { return StreamInfoItem( streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType ).apply { duration = streamEntity.duration uploaderName = streamEntity.uploader uploaderUrl = streamEntity.uploaderUrl thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt ================================================ /* * SPDX-FileCopyrightText: 2018-2022 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database.playlist.dao import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction import io.reactivex.rxjava3.core.Flowable import org.schabi.newpipe.database.BasicDAO import org.schabi.newpipe.database.playlist.model.PlaylistEntity @Dao interface PlaylistDAO : BasicDAO { @Query("SELECT * FROM playlists") override fun getAll(): Flowable> @Query("DELETE FROM playlists") override fun deleteAll(): Int override fun listByService(serviceId: Int): Flowable> { throw UnsupportedOperationException() } @Query("SELECT * FROM playlists WHERE uid = :playlistId") fun getPlaylist(playlistId: Long): Flowable> @Query("DELETE FROM playlists WHERE uid = :playlistId") fun deletePlaylist(playlistId: Long): Int @get:Query("SELECT COUNT(*) FROM playlists") val count: Flowable @Transaction fun upsertPlaylist(playlist: PlaylistEntity): Long { if (playlist.uid == -1L) { // This situation is probably impossible. return insert(playlist) } else { update(playlist) return playlist.uid } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt ================================================ /* * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database.playlist.dao import androidx.room.Dao import androidx.room.Query import androidx.room.Transaction import io.reactivex.rxjava3.core.Flowable import org.schabi.newpipe.database.BasicDAO import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity @Dao interface PlaylistRemoteDAO : BasicDAO { @Query("SELECT * FROM remote_playlists") override fun getAll(): Flowable> @Query("DELETE FROM remote_playlists") override fun deleteAll(): Int @Query("SELECT * FROM remote_playlists WHERE service_id = :serviceId") override fun listByService(serviceId: Int): Flowable> @Query("SELECT * FROM remote_playlists WHERE uid = :playlistId") fun getPlaylist(playlistId: Long): Flowable @Query("SELECT * FROM remote_playlists WHERE url = :url AND service_id = :serviceId") fun getPlaylist(serviceId: Long, url: String?): Flowable> @get:Query("SELECT * FROM remote_playlists ORDER BY display_index") val playlists: Flowable> @Query("SELECT uid FROM remote_playlists WHERE url = :url AND service_id = :serviceId") fun getPlaylistIdInternal(serviceId: Long, url: String?): Long? @Transaction fun upsert(playlist: PlaylistRemoteEntity): Long { val playlistId = getPlaylistIdInternal(playlist.serviceId.toLong(), playlist.url) if (playlistId == null) { return insert(playlist) } else { playlist.uid = playlistId update(playlist) return playlistId } } @Query("DELETE FROM remote_playlists WHERE uid = :playlistId") fun deletePlaylist(playlistId: Long): Int } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt ================================================ /* * SPDX-FileCopyrightText: 2018-2024 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database.playlist.dao import androidx.room.Dao import androidx.room.Query import androidx.room.RewriteQueriesToDropUnusedColumns import androidx.room.Transaction import io.reactivex.rxjava3.core.Flowable import org.schabi.newpipe.database.BasicDAO import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry import org.schabi.newpipe.database.playlist.PlaylistStreamEntry import org.schabi.newpipe.database.playlist.model.PlaylistEntity.Companion.DEFAULT_THUMBNAIL_ID import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity @Dao interface PlaylistStreamDAO : BasicDAO { @Query("SELECT * FROM playlist_stream_join") override fun getAll(): Flowable> @Query("DELETE FROM playlist_stream_join") override fun deleteAll(): Int override fun listByService(serviceId: Int): Flowable> { throw UnsupportedOperationException() } @Query("DELETE FROM playlist_stream_join WHERE playlist_id = :playlistId") fun deleteBatch(playlistId: Long) @Query("SELECT COALESCE(MAX(join_index), -1) FROM playlist_stream_join WHERE playlist_id = :playlistId") fun getMaximumIndexOf(playlistId: Long): Flowable @Query( """ SELECT CASE WHEN COUNT(*) != 0 then stream_id ELSE $DEFAULT_THUMBNAIL_ID END FROM streams LEFT JOIN playlist_stream_join ON uid = stream_id WHERE playlist_id = :playlistId LIMIT 1 """ ) fun getAutomaticThumbnailStreamId(playlistId: Long): Flowable // get ids of streams of the given playlist then merge with the stream metadata @RewriteQueriesToDropUnusedColumns @Transaction @Query( """ SELECT * FROM streams INNER JOIN (SELECT stream_id, join_index FROM playlist_stream_join WHERE playlist_id = :playlistId) ON uid = stream_id LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state ) ON uid = stream_id_alias ORDER BY join_index ASC """ ) fun getOrderedStreamsOf(playlistId: Long): Flowable> // If a playlist has no streams, there won’t be any rows in the **playlist_stream_join** table // that have a foreign key to that playlist. Thus, the **playlist_id** will not have a // corresponding value in any rows of the join table. So, if you group by the **playlist_id**, // only playlists that contain videos are grouped and displayed. Look at #9642 #13055 @Transaction @Query( """ SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id, display_index, (SELECT thumbnail_url FROM streams WHERE streams.uid = thumbnail_stream_id) AS thumbnail_url, COALESCE(COUNT(playlist_id), 0) AS streamCount FROM playlists LEFT JOIN playlist_stream_join ON playlists.uid = playlist_id GROUP BY uid ORDER BY display_index """ ) fun getPlaylistMetadata(): Flowable> @RewriteQueriesToDropUnusedColumns @Transaction @Query( """ SELECT *, MIN(join_index) FROM streams INNER JOIN (SELECT stream_id, join_index FROM playlist_stream_join WHERE playlist_id = :playlistId) ON uid = stream_id LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state ) ON uid = stream_id_alias GROUP BY uid ORDER BY MIN(join_index) ASC """ ) fun getStreamsWithoutDuplicates(playlistId: Long): Flowable> // If a playlist has no streams, there won’t be any rows in the **playlist_stream_join** table // that have a foreign key to that playlist. Thus, the **playlist_id** will not have a // corresponding value in any rows of the join table. So, if you group by the **playlist_id**, // only playlists that contain videos are grouped and displayed. Look at #9642 #13055 @Transaction @Query( """ SELECT playlists.uid, name, is_thumbnail_permanent, thumbnail_stream_id, display_index, (SELECT thumbnail_url FROM streams WHERE streams.uid = thumbnail_stream_id) AS thumbnail_url, COALESCE(COUNT(playlist_id), 0) AS streamCount, COALESCE(SUM(url = :streamUrl), 0) AS timesStreamIsContained FROM playlists LEFT JOIN playlist_stream_join ON playlists.uid = playlist_id LEFT JOIN streams ON streams.uid = stream_id AND :streamUrl = :streamUrl GROUP BY playlists.uid ORDER BY display_index, name """ ) fun getPlaylistDuplicatesMetadata(streamUrl: String): Flowable> } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.kt ================================================ /* * SPDX-FileCopyrightText: 2018-2024 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database.playlist.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Ignore import androidx.room.PrimaryKey import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry @Entity(tableName = PlaylistEntity.Companion.PLAYLIST_TABLE) data class PlaylistEntity @JvmOverloads constructor( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = PLAYLIST_ID) var uid: Long = 0, @ColumnInfo(name = PLAYLIST_NAME) var name: String?, @ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT) var isThumbnailPermanent: Boolean, @ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID) var thumbnailStreamId: Long, @ColumnInfo(name = PLAYLIST_DISPLAY_INDEX) var displayIndex: Long ) { @Ignore constructor(item: PlaylistMetadataEntry) : this( uid = item.uid, name = item.orderingName, isThumbnailPermanent = item.isThumbnailPermanent!!, thumbnailStreamId = item.thumbnailStreamId!!, displayIndex = item.displayIndex!! ) companion object { const val DEFAULT_THUMBNAIL_ID = -1L const val PLAYLIST_TABLE = "playlists" const val PLAYLIST_ID = "uid" const val PLAYLIST_NAME = "name" const val PLAYLIST_THUMBNAIL_URL = "thumbnail_url" const val PLAYLIST_DISPLAY_INDEX = "display_index" const val PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent" const val PLAYLIST_THUMBNAIL_STREAM_ID = "thumbnail_stream_id" } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.kt ================================================ /* * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database.playlist.model import android.text.TextUtils import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Ignore import androidx.room.Index import androidx.room.PrimaryKey import org.schabi.newpipe.database.LocalItem.LocalItemType import org.schabi.newpipe.database.playlist.PlaylistLocalItem import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_SERVICE_ID import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_URL import org.schabi.newpipe.extractor.playlist.PlaylistInfo import org.schabi.newpipe.util.NO_SERVICE_ID import org.schabi.newpipe.util.image.ImageStrategy @Entity( tableName = REMOTE_PLAYLIST_TABLE, indices = [ Index( value = [REMOTE_PLAYLIST_SERVICE_ID, REMOTE_PLAYLIST_URL], unique = true ) ] ) data class PlaylistRemoteEntity( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = REMOTE_PLAYLIST_ID) override var uid: Long = 0, @ColumnInfo(name = REMOTE_PLAYLIST_SERVICE_ID) val serviceId: Int = NO_SERVICE_ID, @ColumnInfo(name = REMOTE_PLAYLIST_NAME) override val orderingName: String?, @ColumnInfo(name = REMOTE_PLAYLIST_URL) val url: String?, @ColumnInfo(name = REMOTE_PLAYLIST_THUMBNAIL_URL) override val thumbnailUrl: String?, @ColumnInfo(name = REMOTE_PLAYLIST_UPLOADER_NAME) val uploader: String?, @ColumnInfo(name = REMOTE_PLAYLIST_DISPLAY_INDEX) override var displayIndex: Long = -1, // Make sure the new item is on the top @ColumnInfo(name = REMOTE_PLAYLIST_STREAM_COUNT) val streamCount: Long? ) : PlaylistLocalItem { constructor(playlistInfo: PlaylistInfo) : this( serviceId = playlistInfo.serviceId, orderingName = playlistInfo.name, url = playlistInfo.url, thumbnailUrl = ImageStrategy.imageListToDbUrl( playlistInfo.thumbnails.ifEmpty { playlistInfo.uploaderAvatars } ), uploader = playlistInfo.uploaderName, streamCount = playlistInfo.streamCount ) override val localItemType: LocalItemType get() = LocalItemType.PLAYLIST_REMOTE_ITEM /** * Returns boolean comparing the online playlist and the local copy. * (False if info changed such as playlist name or track count) */ @Ignore fun isIdenticalTo(info: PlaylistInfo): Boolean { return this.serviceId == info.serviceId && this.streamCount == info.streamCount && TextUtils.equals(this.orderingName, info.name) && TextUtils.equals(this.url, info.url) && // we want to update the local playlist data even when either the remote thumbnail // URL changes, or the preferred image quality setting is changed by the user TextUtils.equals(thumbnailUrl, ImageStrategy.imageListToDbUrl(info.thumbnails)) && TextUtils.equals(this.uploader, info.uploaderName) } companion object { const val REMOTE_PLAYLIST_TABLE = "remote_playlists" const val REMOTE_PLAYLIST_ID = "uid" const val REMOTE_PLAYLIST_SERVICE_ID = "service_id" const val REMOTE_PLAYLIST_NAME = "name" const val REMOTE_PLAYLIST_URL = "url" const val REMOTE_PLAYLIST_THUMBNAIL_URL = "thumbnail_url" const val REMOTE_PLAYLIST_UPLOADER_NAME = "uploader" const val REMOTE_PLAYLIST_DISPLAY_INDEX = "display_index" const val REMOTE_PLAYLIST_STREAM_COUNT = "stream_count" } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.kt ================================================ /* * SPDX-FileCopyrightText: 2018-2020 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database.playlist.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.ForeignKey.Companion.CASCADE import androidx.room.Index import org.schabi.newpipe.database.LocalItem import org.schabi.newpipe.database.playlist.model.PlaylistEntity.Companion.PLAYLIST_ID import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.JOIN_INDEX import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.JOIN_STREAM_ID import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE import org.schabi.newpipe.database.stream.model.StreamEntity @Entity( tableName = PLAYLIST_STREAM_JOIN_TABLE, primaryKeys = [JOIN_PLAYLIST_ID, JOIN_INDEX], indices = [ Index(value = [JOIN_PLAYLIST_ID, JOIN_INDEX], unique = true), Index(value = [JOIN_STREAM_ID]) ], foreignKeys = [ ForeignKey( entity = PlaylistEntity::class, parentColumns = arrayOf(PLAYLIST_ID), childColumns = arrayOf(JOIN_PLAYLIST_ID), onDelete = CASCADE, onUpdate = CASCADE, deferred = true ), ForeignKey( entity = StreamEntity::class, parentColumns = arrayOf(StreamEntity.STREAM_ID), childColumns = arrayOf(JOIN_STREAM_ID), onDelete = CASCADE, onUpdate = CASCADE, deferred = true ) ] ) data class PlaylistStreamEntity( @ColumnInfo(name = JOIN_PLAYLIST_ID) val playlistUid: Long, @ColumnInfo(name = JOIN_STREAM_ID) val streamUid: Long, @ColumnInfo(name = JOIN_INDEX) val index: Int ) : LocalItem { override val localItemType: LocalItem.LocalItemType get() = LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM companion object { const val PLAYLIST_STREAM_JOIN_TABLE = "playlist_stream_join" const val JOIN_PLAYLIST_ID = "playlist_id" const val JOIN_STREAM_ID = "stream_id" const val JOIN_INDEX = "join_index" } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt ================================================ /* * SPDX-FileCopyrightText: 2020-2023 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database.stream import androidx.room.ColumnInfo import androidx.room.Embedded import androidx.room.Ignore import java.time.OffsetDateTime import org.schabi.newpipe.database.LocalItem import org.schabi.newpipe.database.history.model.StreamHistoryEntity import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.STREAM_PROGRESS_MILLIS import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.util.image.ImageStrategy data class StreamStatisticsEntry( @Embedded val streamEntity: StreamEntity, @ColumnInfo(name = STREAM_PROGRESS_MILLIS, defaultValue = "0") val progressMillis: Long, @ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID) val streamId: Long, @ColumnInfo(name = STREAM_LATEST_DATE) val latestAccessDate: OffsetDateTime, @ColumnInfo(name = STREAM_WATCH_COUNT) val watchCount: Long ) : LocalItem { override val localItemType: LocalItem.LocalItemType get() = LocalItem.LocalItemType.STATISTIC_STREAM_ITEM @Ignore fun toStreamInfoItem(): StreamInfoItem { return StreamInfoItem( streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType ).apply { duration = streamEntity.duration uploaderName = streamEntity.uploader uploaderUrl = streamEntity.uploaderUrl thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) } } companion object { const val STREAM_LATEST_DATE = "latestAccess" const val STREAM_WATCH_COUNT = "watchCount" } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/stream/StreamWithState.kt ================================================ package org.schabi.newpipe.database.stream import androidx.room.ColumnInfo import androidx.room.Embedded import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.stream.model.StreamStateEntity data class StreamWithState( @Embedded val stream: StreamEntity, @ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_MILLIS) val stateProgressMillis: Long? ) ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt ================================================ package org.schabi.newpipe.database.stream.dao import androidx.room.ColumnInfo import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable import java.time.OffsetDateTime import org.schabi.newpipe.database.BasicDAO import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.util.StreamTypeUtil @Dao abstract class StreamDAO : BasicDAO { @Query("SELECT * FROM streams") abstract override fun getAll(): Flowable> @Query("DELETE FROM streams") abstract override fun deleteAll(): Int @Query("SELECT * FROM streams WHERE service_id = :serviceId") abstract override fun listByService(serviceId: Int): Flowable> @Query("SELECT * FROM streams WHERE url = :url AND service_id = :serviceId") abstract fun getStream(serviceId: Long, url: String): Flowable> @Query("UPDATE streams SET uploader_url = :uploaderUrl WHERE url = :url AND service_id = :serviceId") abstract fun setUploaderUrl(serviceId: Long, url: String, uploaderUrl: String): Completable @Insert(onConflict = OnConflictStrategy.IGNORE) internal abstract fun silentInsertInternal(stream: StreamEntity): Long @Insert(onConflict = OnConflictStrategy.IGNORE) internal abstract fun silentInsertAllInternal(streams: List): List @Query("SELECT COUNT(*) != 0 FROM streams WHERE url = :url AND service_id = :serviceId") internal abstract fun exists(serviceId: Int, url: String): Boolean @Query( """ SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration FROM streams WHERE url = :url AND service_id = :serviceId """ ) internal abstract fun getMinimalStreamForCompare(serviceId: Int, url: String): StreamCompareFeed? @Transaction open fun upsert(newerStream: StreamEntity): Long { val uid = silentInsertInternal(newerStream) if (uid != -1L) { newerStream.uid = uid return uid } compareAndUpdateStream(newerStream) update(newerStream) return newerStream.uid } @Transaction open fun upsertAll(streams: List): List { val insertUidList = silentInsertAllInternal(streams) val streamIds = ArrayList(streams.size) for ((index, uid) in insertUidList.withIndex()) { val newerStream = streams[index] if (uid != -1L) { streamIds.add(uid) newerStream.uid = uid continue } compareAndUpdateStream(newerStream) streamIds.add(newerStream.uid) } update(streams) return streamIds } private fun compareAndUpdateStream(newerStream: StreamEntity) { val existentMinimalStream = getMinimalStreamForCompare(newerStream.serviceId, newerStream.url) ?: error("Stream cannot be null just after insertion.") newerStream.uid = existentMinimalStream.uid if (!StreamTypeUtil.isLiveStream(newerStream.streamType)) { // Use the existent upload date if the newer stream does not have a better precision // (i.e. is an approximation). This is done to prevent unnecessary changes. val hasBetterPrecision = newerStream.uploadDate != null && newerStream.isUploadDateApproximation != true if (existentMinimalStream.uploadDate != null && !hasBetterPrecision) { newerStream.uploadDate = existentMinimalStream.uploadDate newerStream.textualUploadDate = existentMinimalStream.textualUploadDate newerStream.isUploadDateApproximation = existentMinimalStream.isUploadDateApproximation } if (existentMinimalStream.duration > 0 && newerStream.duration < 0) { newerStream.duration = existentMinimalStream.duration } } } @Query( """ DELETE FROM streams WHERE NOT EXISTS (SELECT 1 FROM stream_history sh WHERE sh.stream_id = streams.uid) AND NOT EXISTS (SELECT 1 FROM playlist_stream_join ps WHERE ps.stream_id = streams.uid) AND NOT EXISTS (SELECT 1 FROM feed f WHERE f.stream_id = streams.uid) """ ) abstract fun deleteOrphans(): Int /** * Minimal entry class used when comparing/updating an existent stream. */ internal data class StreamCompareFeed( @ColumnInfo(name = STREAM_ID) var uid: Long = 0, @ColumnInfo(name = StreamEntity.STREAM_TYPE) var streamType: StreamType, @ColumnInfo(name = StreamEntity.STREAM_TEXTUAL_UPLOAD_DATE) var textualUploadDate: String? = null, @ColumnInfo(name = StreamEntity.STREAM_UPLOAD_DATE) var uploadDate: OffsetDateTime? = null, @ColumnInfo(name = StreamEntity.STREAM_IS_UPLOAD_DATE_APPROXIMATION) var isUploadDateApproximation: Boolean? = null, @ColumnInfo(name = StreamEntity.STREAM_DURATION) var duration: Long ) } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt ================================================ /* * SPDX-FileCopyrightText: 2018-2021 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database.stream.dao import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction import io.reactivex.rxjava3.core.Flowable import org.schabi.newpipe.database.BasicDAO import org.schabi.newpipe.database.stream.model.StreamStateEntity @Dao interface StreamStateDAO : BasicDAO { @Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE) override fun getAll(): Flowable> @Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE) override fun deleteAll(): Int override fun listByService(serviceId: Int): Flowable> { throw UnsupportedOperationException() } @Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId") fun getState(streamId: Long): Flowable> @Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId") fun deleteState(streamId: Long): Int @Insert(onConflict = OnConflictStrategy.Companion.IGNORE) fun silentInsertInternal(streamState: StreamStateEntity) @Transaction fun upsert(stream: StreamStateEntity): Long { silentInsertInternal(stream) return update(stream).toLong() } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.kt ================================================ package org.schabi.newpipe.database.stream.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Ignore import androidx.room.Index import androidx.room.PrimaryKey import java.io.Serializable import java.time.OffsetDateTime import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_SERVICE_ID import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_TABLE import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_URL import org.schabi.newpipe.extractor.localization.DateWrapper import org.schabi.newpipe.extractor.stream.StreamInfo import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.player.playqueue.PlayQueueItem import org.schabi.newpipe.util.image.ImageStrategy @Entity( tableName = STREAM_TABLE, indices = [ Index(value = [STREAM_SERVICE_ID, STREAM_URL], unique = true) ] ) data class StreamEntity( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = STREAM_ID) var uid: Long = 0, @ColumnInfo(name = STREAM_SERVICE_ID) var serviceId: Int, @ColumnInfo(name = STREAM_URL) var url: String, @ColumnInfo(name = STREAM_TITLE) var title: String, @ColumnInfo(name = STREAM_TYPE) var streamType: StreamType, @ColumnInfo(name = STREAM_DURATION) var duration: Long, @ColumnInfo(name = STREAM_UPLOADER) var uploader: String, @ColumnInfo(name = STREAM_UPLOADER_URL) var uploaderUrl: String? = null, @ColumnInfo(name = STREAM_THUMBNAIL_URL) var thumbnailUrl: String? = null, @ColumnInfo(name = STREAM_VIEWS) var viewCount: Long? = null, @ColumnInfo(name = STREAM_TEXTUAL_UPLOAD_DATE) var textualUploadDate: String? = null, @ColumnInfo(name = STREAM_UPLOAD_DATE) var uploadDate: OffsetDateTime? = null, @ColumnInfo(name = STREAM_IS_UPLOAD_DATE_APPROXIMATION) var isUploadDateApproximation: Boolean? = null ) : Serializable { @Ignore constructor(item: StreamInfoItem) : this( serviceId = item.serviceId, url = item.url, title = item.name, streamType = item.streamType, duration = item.duration, uploader = item.uploaderName, uploaderUrl = item.uploaderUrl, thumbnailUrl = ImageStrategy.imageListToDbUrl(item.thumbnails), viewCount = item.viewCount, textualUploadDate = item.textualUploadDate, uploadDate = item.uploadDate?.offsetDateTime(), isUploadDateApproximation = item.uploadDate?.isApproximation ) @Ignore constructor(info: StreamInfo) : this( serviceId = info.serviceId, url = info.url, title = info.name, streamType = info.streamType, duration = info.duration, uploader = info.uploaderName, uploaderUrl = info.uploaderUrl, thumbnailUrl = ImageStrategy.imageListToDbUrl(info.thumbnails), viewCount = info.viewCount, textualUploadDate = info.textualUploadDate, uploadDate = info.uploadDate?.offsetDateTime(), isUploadDateApproximation = info.uploadDate?.isApproximation ) @Ignore constructor(item: PlayQueueItem) : this( serviceId = item.serviceId, url = item.url, title = item.title, streamType = item.streamType, duration = item.duration, uploader = item.uploader, uploaderUrl = item.uploaderUrl, thumbnailUrl = ImageStrategy.imageListToDbUrl(item.thumbnails) ) fun toStreamInfoItem(): StreamInfoItem { val item = StreamInfoItem(serviceId, url, title, streamType) item.duration = duration item.uploaderName = uploader item.uploaderUrl = uploaderUrl item.thumbnails = ImageStrategy.dbUrlToImageList(thumbnailUrl) if (viewCount != null) item.viewCount = viewCount as Long item.textualUploadDate = textualUploadDate item.uploadDate = uploadDate?.let { DateWrapper(it, isUploadDateApproximation ?: false) } return item } companion object { const val STREAM_TABLE = "streams" const val STREAM_ID = "uid" const val STREAM_SERVICE_ID = "service_id" const val STREAM_URL = "url" const val STREAM_TITLE = "title" const val STREAM_TYPE = "stream_type" const val STREAM_DURATION = "duration" const val STREAM_UPLOADER = "uploader" const val STREAM_UPLOADER_URL = "uploader_url" const val STREAM_THUMBNAIL_URL = "thumbnail_url" const val STREAM_VIEWS = "view_count" const val STREAM_TEXTUAL_UPLOAD_DATE = "textual_upload_date" const val STREAM_UPLOAD_DATE = "upload_date" const val STREAM_IS_UPLOAD_DATE_APPROXIMATION = "is_upload_date_approximation" } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.kt ================================================ /* * SPDX-FileCopyrightText: 2018-2023 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database.stream.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.ForeignKey.Companion.CASCADE import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.JOIN_STREAM_ID import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.PLAYBACK_FINISHED_END_MILLISECONDS import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.STREAM_STATE_TABLE @Entity( tableName = STREAM_STATE_TABLE, primaryKeys = [JOIN_STREAM_ID], foreignKeys = [ ForeignKey( entity = StreamEntity::class, parentColumns = arrayOf(STREAM_ID), childColumns = arrayOf(JOIN_STREAM_ID), onDelete = CASCADE, onUpdate = CASCADE ) ] ) data class StreamStateEntity( @ColumnInfo(name = JOIN_STREAM_ID) val streamUid: Long, @ColumnInfo(name = STREAM_PROGRESS_MILLIS) val progressMillis: Long ) { /** * The state will be considered valid, and thus be saved, if the progress is more than * [PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS] or at least 1/4 of the video length. * @param durationInSeconds the duration of the stream connected with this state, in seconds * @return whether this stream state entity should be saved or not */ fun isValid(durationInSeconds: Long): Boolean { return progressMillis > PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS || progressMillis > durationInSeconds * 1000 / 4 } /** * The video will be considered as finished, if the time left is less than * [PLAYBACK_FINISHED_END_MILLISECONDS] and the progress is at least 3/4 of the video length. * The state will be saved anyway, so that it can be shown under stream info items, but the * player will not resume if a state is considered as finished. Finished streams are also the * ones that can be filtered out in the feed fragment. * @param durationInSeconds the duration of the stream connected with this state, in seconds * @return whether the stream is finished or not */ fun isFinished(durationInSeconds: Long): Boolean { return progressMillis >= durationInSeconds * 1000 - PLAYBACK_FINISHED_END_MILLISECONDS && progressMillis >= durationInSeconds * 1000 * 3 / 4 } companion object { const val STREAM_STATE_TABLE = "stream_state" const val JOIN_STREAM_ID = "stream_id" // This additional field is required for the SQL query because 'stream_id' is used // for some other joins already const val JOIN_STREAM_ID_ALIAS = "stream_id_alias" const val STREAM_PROGRESS_MILLIS = "progress_time" /** * Playback state will not be saved, if playback time is less than this threshold * (5000ms = 5s). */ const val PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS = 5000L /** * Stream will be considered finished if the playback time left exceeds this threshold * (60000ms = 60s). * @see org.schabi.newpipe.database.stream.model.StreamStateEntity.isFinished */ const val PLAYBACK_FINISHED_END_MILLISECONDS = 60000L } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.kt ================================================ /* * SPDX-FileCopyrightText: 2021 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database.subscription import androidx.annotation.IntDef @IntDef(NotificationMode.Companion.DISABLED, NotificationMode.Companion.ENABLED) @Retention(AnnotationRetention.SOURCE) annotation class NotificationMode { companion object { const val DISABLED = 0 const val ENABLED = 1 // other values reserved for the future } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt ================================================ package org.schabi.newpipe.database.subscription import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.RewriteQueriesToDropUnusedColumns import androidx.room.Transaction import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Maybe import org.schabi.newpipe.database.BasicDAO @Dao abstract class SubscriptionDAO : BasicDAO { @Query("SELECT COUNT(*) FROM subscriptions") abstract fun rowCount(): Flowable @Query("SELECT * FROM subscriptions WHERE service_id = :serviceId") abstract override fun listByService(serviceId: Int): Flowable> @Query("SELECT * FROM subscriptions ORDER BY name COLLATE NOCASE ASC") abstract override fun getAll(): Flowable> @Query( """ SELECT * FROM subscriptions WHERE name LIKE '%' || :filter || '%' ORDER BY name COLLATE NOCASE ASC """ ) abstract fun getSubscriptionsFiltered(filter: String): Flowable> @RewriteQueriesToDropUnusedColumns @Query( """ SELECT * FROM subscriptions s LEFT JOIN feed_group_subscription_join fgs ON s.uid = fgs.subscription_id WHERE (fgs.subscription_id IS NULL OR fgs.group_id = :currentGroupId) ORDER BY name COLLATE NOCASE ASC """ ) abstract fun getSubscriptionsOnlyUngrouped( currentGroupId: Long ): Flowable> @RewriteQueriesToDropUnusedColumns @Query( """ SELECT * FROM subscriptions s LEFT JOIN feed_group_subscription_join fgs ON s.uid = fgs.subscription_id WHERE (fgs.subscription_id IS NULL OR fgs.group_id = :currentGroupId) AND s.name LIKE '%' || :filter || '%' ORDER BY name COLLATE NOCASE ASC """ ) abstract fun getSubscriptionsOnlyUngroupedFiltered( currentGroupId: Long, filter: String ): Flowable> @Query("SELECT * FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId") abstract fun getSubscriptionFlowable(serviceId: Int, url: String): Flowable> @Query("SELECT * FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId") abstract fun getSubscription(serviceId: Int, url: String): Maybe @Query("SELECT * FROM subscriptions WHERE uid = :subscriptionId") abstract fun getSubscription(subscriptionId: Long): SubscriptionEntity @Query("DELETE FROM subscriptions") abstract override fun deleteAll(): Int @Query("DELETE FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId") abstract fun deleteSubscription(serviceId: Int, url: String): Int @Query("SELECT uid FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId") internal abstract fun getSubscriptionIdInternal(serviceId: Int, url: String): Long? @Insert(onConflict = OnConflictStrategy.IGNORE) internal abstract fun silentInsertAllInternal(entities: List): List @Transaction open fun upsertAll(entities: List): List { val insertUidList = silentInsertAllInternal(entities) insertUidList.forEachIndexed { index: Int, uidFromInsert: Long -> val entity = entities[index] if (uidFromInsert != -1L) { entity.uid = uidFromInsert } else { val subscriptionIdFromDb = getSubscriptionIdInternal(entity.serviceId, entity.url!!) ?: error("Subscription cannot be null just after insertion.") entity.uid = subscriptionIdFromDb update(entity) } } return entities } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt ================================================ /* * SPDX-FileCopyrightText: 2017-2024 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.database.subscription import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Ignore import androidx.room.Index import androidx.room.PrimaryKey import org.schabi.newpipe.extractor.channel.ChannelInfo import org.schabi.newpipe.extractor.channel.ChannelInfoItem import org.schabi.newpipe.util.NO_SERVICE_ID import org.schabi.newpipe.util.image.ImageStrategy @Entity( tableName = SubscriptionEntity.Companion.SUBSCRIPTION_TABLE, indices = [ Index( value = [SubscriptionEntity.Companion.SUBSCRIPTION_SERVICE_ID, SubscriptionEntity.Companion.SUBSCRIPTION_URL], unique = true ) ] ) data class SubscriptionEntity( @PrimaryKey(autoGenerate = true) var uid: Long = 0, @ColumnInfo(name = SUBSCRIPTION_SERVICE_ID) var serviceId: Int = NO_SERVICE_ID, @ColumnInfo(name = SUBSCRIPTION_URL) var url: String? = null, @ColumnInfo(name = SUBSCRIPTION_NAME) var name: String? = null, @ColumnInfo(name = SUBSCRIPTION_AVATAR_URL) var avatarUrl: String? = null, @ColumnInfo(name = SUBSCRIPTION_SUBSCRIBER_COUNT) var subscriberCount: Long? = null, @ColumnInfo(name = SUBSCRIPTION_DESCRIPTION) var description: String? = null, @get:NotificationMode @ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE) var notificationMode: Int = 0 ) { @Ignore fun toChannelInfoItem(): ChannelInfoItem { return ChannelInfoItem(this.serviceId, this.url, this.name).apply { thumbnails = ImageStrategy.dbUrlToImageList(this@SubscriptionEntity.avatarUrl) subscriberCount = this@SubscriptionEntity.subscriberCount ?: -1 description = this@SubscriptionEntity.description } } companion object { const val SUBSCRIPTION_UID: String = "uid" const val SUBSCRIPTION_TABLE: String = "subscriptions" const val SUBSCRIPTION_SERVICE_ID: String = "service_id" const val SUBSCRIPTION_URL: String = "url" const val SUBSCRIPTION_NAME: String = "name" const val SUBSCRIPTION_AVATAR_URL: String = "avatar_url" const val SUBSCRIPTION_SUBSCRIBER_COUNT: String = "subscriber_count" const val SUBSCRIPTION_DESCRIPTION: String = "description" const val SUBSCRIPTION_NOTIFICATION_MODE: String = "notification_mode" @JvmStatic @Ignore fun from(info: ChannelInfo): SubscriptionEntity { return SubscriptionEntity( serviceId = info.serviceId, url = info.url, name = info.name, avatarUrl = ImageStrategy.imageListToDbUrl(info.avatars), description = info.description, subscriberCount = info.subscriberCount ) } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java ================================================ package org.schabi.newpipe.download; import android.content.Intent; import android.os.Bundle; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.ViewTreeObserver; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import androidx.fragment.app.FragmentTransaction; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.ActivityDownloaderBinding; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.views.FocusOverlayView; import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.ui.fragment.MissionsFragment; public class DownloadActivity extends AppCompatActivity { private static final String MISSIONS_FRAGMENT_TAG = "fragment_tag"; @Override protected void onCreate(final Bundle savedInstanceState) { // Service final Intent i = new Intent(); i.setClass(this, DownloadManagerService.class); startService(i); ThemeHelper.setTheme(this); super.onCreate(savedInstanceState); final ActivityDownloaderBinding downloaderBinding = ActivityDownloaderBinding.inflate(getLayoutInflater()); setContentView(downloaderBinding.getRoot()); setSupportActionBar(downloaderBinding.toolbarLayout.toolbar); final ActionBar actionBar = getSupportActionBar(); if (actionBar != null) { actionBar.setDisplayHomeAsUpEnabled(true); actionBar.setTitle(R.string.downloads_title); actionBar.setDisplayShowTitleEnabled(true); } getWindow().getDecorView().getViewTreeObserver() .addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { updateFragments(); getWindow().getDecorView().getViewTreeObserver().removeOnGlobalLayoutListener(this); } }); if (DeviceUtils.isTv(this)) { FocusOverlayView.setupFocusObserver(this); } } private void updateFragments() { final MissionsFragment fragment = new MissionsFragment(); getSupportFragmentManager().beginTransaction() .replace(R.id.frame, fragment, MISSIONS_FRAGMENT_TAG) .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) .commit(); } @Override public boolean onCreateOptionsMenu(final Menu menu) { super.onCreateOptionsMenu(menu); final MenuInflater inflater = getMenuInflater(); inflater.inflate(R.menu.download_menu, menu); return true; } @Override public boolean onOptionsItemSelected(final MenuItem item) { switch (item.getItemId()) { case android.R.id.home: onBackPressed(); return true; default: return super.onOptionsItemSelected(item); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java ================================================ package org.schabi.newpipe.download; import static org.schabi.newpipe.extractor.stream.DeliveryMethod.PROGRESSIVE_HTTP; import static org.schabi.newpipe.util.ListHelper.getStreamsOfSpecifiedDelivery; import android.app.Activity; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; import android.content.SharedPreferences; import android.net.Uri; import android.os.Bundle; import android.os.Environment; import android.os.IBinder; import android.provider.Settings; import android.util.Log; import android.view.LayoutInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.AdapterView; import android.widget.RadioGroup; import android.widget.SeekBar; import android.widget.Toast; import androidx.activity.result.ActivityResult; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.Toolbar; import androidx.collection.SparseArrayCompat; import androidx.documentfile.provider.DocumentFile; import androidx.fragment.app.DialogFragment; import androidx.preference.PreferenceManager; import com.evernote.android.state.State; import com.livefront.bridge.Bridge; import com.nononsenseapps.filepicker.Utils; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.DownloadDialogBinding; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.localization.Localization; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.settings.NewPipeSettings; import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard; import org.schabi.newpipe.streams.io.StoredDirectoryHelper; import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.AudioTrackAdapter; import org.schabi.newpipe.util.AudioTrackAdapter.AudioTracksWrapper; import org.schabi.newpipe.util.FilePickerActivityHelper; import org.schabi.newpipe.util.FilenameUtils; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.SecondaryStreamHelper; import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener; import org.schabi.newpipe.util.StreamItemAdapter; import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper; import org.schabi.newpipe.util.ThemeHelper; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.Optional; import io.reactivex.rxjava3.disposables.CompositeDisposable; import us.shandian.giga.get.MissionRecoveryInfo; import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder; import us.shandian.giga.service.MissionState; public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheckedChangeListener, AdapterView.OnItemSelectedListener { private static final String TAG = "DialogFragment"; private static final boolean DEBUG = MainActivity.DEBUG; @State StreamInfo currentInfo; @State StreamInfoWrapper wrappedVideoStreams; @State StreamInfoWrapper wrappedSubtitleStreams; @State AudioTracksWrapper wrappedAudioTracks; @State int selectedAudioTrackIndex; @State int selectedVideoIndex; // set in the constructor @State int selectedAudioIndex = 0; // default to the first item @State int selectedSubtitleIndex = 0; // default to the first item private StoredDirectoryHelper mainStorageAudio = null; private StoredDirectoryHelper mainStorageVideo = null; private DownloadManager downloadManager = null; private MenuItem okButton = null; private Context context = null; private boolean askForSavePath; private AudioTrackAdapter audioTrackAdapter; private StreamItemAdapter audioStreamsAdapter; private StreamItemAdapter videoStreamsAdapter; private StreamItemAdapter subtitleStreamsAdapter; private final CompositeDisposable disposables = new CompositeDisposable(); private DownloadDialogBinding dialogBinding; private SharedPreferences prefs; // Variables for file name and MIME type when picking new folder because it's not set yet private String filenameTmp; private String mimeTmp; private final ActivityResultLauncher requestDownloadSaveAsLauncher = registerForActivityResult( new StartActivityForResult(), this::requestDownloadSaveAsResult); private final ActivityResultLauncher requestDownloadPickAudioFolderLauncher = registerForActivityResult( new StartActivityForResult(), this::requestDownloadPickAudioFolderResult); private final ActivityResultLauncher requestDownloadPickVideoFolderLauncher = registerForActivityResult( new StartActivityForResult(), this::requestDownloadPickVideoFolderResult); /*////////////////////////////////////////////////////////////////////////// // Instance creation //////////////////////////////////////////////////////////////////////////*/ public DownloadDialog() { // Just an empty default no-arg ctor to keep Fragment.instantiate() happy // otherwise InstantiationException will be thrown when fragment is recreated // TODO: Maybe use a custom FragmentFactory instead? } /** * Create a new download dialog with the video, audio and subtitle streams from the provided * stream info. Video streams and video-only streams will be put into a single list menu, * sorted according to their resolution and the default video resolution will be selected. * * @param context the context to use just to obtain preferences and strings (will not be stored) * @param info the info from which to obtain downloadable streams and other info (e.g. title) */ public DownloadDialog(@NonNull final Context context, @NonNull final StreamInfo info) { this.currentInfo = info; final List audioStreams = getStreamsOfSpecifiedDelivery(info.getAudioStreams(), PROGRESSIVE_HTTP); final List> groupedAudioStreams = ListHelper.getGroupedAudioStreams(context, audioStreams); this.wrappedAudioTracks = new AudioTracksWrapper(groupedAudioStreams, context); this.selectedAudioTrackIndex = ListHelper.getDefaultAudioTrackGroup(context, groupedAudioStreams); // TODO: Adapt this code when the downloader support other types of stream deliveries final List videoStreams = ListHelper.getSortedStreamVideosList( context, getStreamsOfSpecifiedDelivery(info.getVideoStreams(), PROGRESSIVE_HTTP), getStreamsOfSpecifiedDelivery(info.getVideoOnlyStreams(), PROGRESSIVE_HTTP), false, // If there are multiple languages available, prefer streams without audio // to allow language selection wrappedAudioTracks.size() > 1 ); this.wrappedVideoStreams = new StreamInfoWrapper<>(videoStreams, context); this.wrappedSubtitleStreams = new StreamInfoWrapper<>( getStreamsOfSpecifiedDelivery(info.getSubtitles(), PROGRESSIVE_HTTP), context); this.selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams); } /*////////////////////////////////////////////////////////////////////////// // Android lifecycle //////////////////////////////////////////////////////////////////////////*/ @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (DEBUG) { Log.d(TAG, "onCreate() called with: " + "savedInstanceState = [" + savedInstanceState + "]"); } if (!PermissionHelper.checkStoragePermissions(getActivity(), PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { dismiss(); return; } // context will remain null if dismiss() was called above, allowing to check whether the // dialog is being dismissed in onViewCreated() context = getContext(); setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(context)); Bridge.restoreInstanceState(this, savedInstanceState); this.audioTrackAdapter = new AudioTrackAdapter(wrappedAudioTracks); this.subtitleStreamsAdapter = new StreamItemAdapter<>(wrappedSubtitleStreams); updateSecondaryStreams(); final Intent intent = new Intent(context, DownloadManagerService.class); context.startService(intent); context.bindService(intent, new ServiceConnection() { @Override public void onServiceConnected(final ComponentName cname, final IBinder service) { final DownloadManagerBinder mgr = (DownloadManagerBinder) service; mainStorageAudio = mgr.getMainStorageAudio(); mainStorageVideo = mgr.getMainStorageVideo(); downloadManager = mgr.getDownloadManager(); askForSavePath = mgr.askForSavePath(); okButton.setEnabled(true); context.unbindService(this); } @Override public void onServiceDisconnected(final ComponentName name) { // nothing to do } }, Context.BIND_AUTO_CREATE); } /** * Update the displayed video streams based on the selected audio track. */ private void updateSecondaryStreams() { final StreamInfoWrapper audioStreams = getWrappedAudioStreams(); final var secondaryStreams = new SparseArrayCompat>(4); final List videoStreams = wrappedVideoStreams.getStreamsList(); wrappedVideoStreams.resetInfo(); for (int i = 0; i < videoStreams.size(); i++) { if (!videoStreams.get(i).isVideoOnly()) { continue; } final AudioStream audioStream = SecondaryStreamHelper.getAudioStreamFor( context, audioStreams.getStreamsList(), videoStreams.get(i)); if (audioStream != null) { secondaryStreams.append(i, new SecondaryStreamHelper<>(audioStreams, audioStream)); } else if (DEBUG) { final MediaFormat mediaFormat = videoStreams.get(i).getFormat(); if (mediaFormat != null) { Log.w(TAG, "No audio stream candidates for video format " + mediaFormat.name()); } else { Log.w(TAG, "No audio stream candidates for unknown video format"); } } } this.videoStreamsAdapter = new StreamItemAdapter<>(wrappedVideoStreams, secondaryStreams); this.audioStreamsAdapter = new StreamItemAdapter<>(audioStreams); } @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { if (DEBUG) { Log.d(TAG, "onCreateView() called with: " + "inflater = [" + inflater + "], container = [" + container + "], " + "savedInstanceState = [" + savedInstanceState + "]"); } return inflater.inflate(R.layout.download_dialog, container); } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); dialogBinding = DownloadDialogBinding.bind(view); if (context == null) { return; // the dialog is being dismissed, see the call to dismiss() in onCreate() } dialogBinding.fileName.setText(FilenameUtils.createFilename(getContext(), currentInfo.getName())); selectedAudioIndex = ListHelper.getDefaultAudioFormat(getContext(), getWrappedAudioStreams().getStreamsList()); selectedSubtitleIndex = getSubtitleIndexBy(subtitleStreamsAdapter.getAll()); dialogBinding.qualitySpinner.setOnItemSelectedListener(this); dialogBinding.audioStreamSpinner.setOnItemSelectedListener(this); dialogBinding.audioTrackSpinner.setOnItemSelectedListener(this); dialogBinding.videoAudioGroup.setOnCheckedChangeListener(this); initToolbar(dialogBinding.toolbarLayout.toolbar); setupDownloadOptions(); prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); final int threads = prefs.getInt(getString(R.string.default_download_threads), 3); dialogBinding.threadsCount.setText(String.valueOf(threads)); dialogBinding.threads.setProgress(threads - 1); dialogBinding.threads.setOnSeekBarChangeListener(new SimpleOnSeekBarChangeListener() { @Override public void onProgressChanged(@NonNull final SeekBar seekbar, final int progress, final boolean fromUser) { final int newProgress = progress + 1; prefs.edit().putInt(getString(R.string.default_download_threads), newProgress) .apply(); dialogBinding.threadsCount.setText(String.valueOf(newProgress)); } }); fetchStreamsSize(); } private void initToolbar(final Toolbar toolbar) { if (DEBUG) { Log.d(TAG, "initToolbar() called with: toolbar = [" + toolbar + "]"); } toolbar.setTitle(R.string.download_dialog_title); toolbar.setNavigationIcon(R.drawable.ic_arrow_back); toolbar.inflateMenu(R.menu.dialog_url); toolbar.setNavigationOnClickListener(v -> dismiss()); toolbar.setNavigationContentDescription(R.string.cancel); okButton = toolbar.getMenu().findItem(R.id.okay); okButton.setEnabled(false); // disable until the download service connection is done toolbar.setOnMenuItemClickListener(item -> { if (item.getItemId() == R.id.okay) { prepareSelectedDownload(); return true; } return false; }); } @Override public void onDestroy() { super.onDestroy(); disposables.clear(); } @Override public void onDestroyView() { dialogBinding = null; super.onDestroyView(); } @Override public void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); Bridge.saveInstanceState(this, outState); } /*////////////////////////////////////////////////////////////////////////// // Video, audio and subtitle spinners //////////////////////////////////////////////////////////////////////////*/ private void fetchStreamsSize() { disposables.clear(); disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedVideoStreams) .subscribe(result -> { if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.video_button) { setupVideoSpinner(); } }, throwable -> ErrorUtil.showSnackbar(context, new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, "Downloading video stream size", currentInfo)))); disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(getWrappedAudioStreams()) .subscribe(result -> { if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) { setupAudioSpinner(); } }, throwable -> ErrorUtil.showSnackbar(context, new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, "Downloading audio stream size", currentInfo)))); disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedSubtitleStreams) .subscribe(result -> { if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.subtitle_button) { setupSubtitleSpinner(); } }, throwable -> ErrorUtil.showSnackbar(context, new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, "Downloading subtitle stream size", currentInfo)))); } private void setupAudioTrackSpinner() { if (getContext() == null) { return; } dialogBinding.audioTrackSpinner.setAdapter(audioTrackAdapter); dialogBinding.audioTrackSpinner.setSelection(selectedAudioTrackIndex); } private void setupAudioSpinner() { if (getContext() == null) { return; } dialogBinding.qualitySpinner.setVisibility(View.GONE); setRadioButtonsState(true); dialogBinding.audioStreamSpinner.setAdapter(audioStreamsAdapter); dialogBinding.audioStreamSpinner.setSelection(selectedAudioIndex); dialogBinding.audioStreamSpinner.setVisibility(View.VISIBLE); dialogBinding.audioTrackSpinner.setVisibility( wrappedAudioTracks.size() > 1 ? View.VISIBLE : View.GONE); dialogBinding.audioTrackPresentInVideoText.setVisibility(View.GONE); } private void setupVideoSpinner() { if (getContext() == null) { return; } dialogBinding.qualitySpinner.setAdapter(videoStreamsAdapter); dialogBinding.qualitySpinner.setSelection(selectedVideoIndex); dialogBinding.qualitySpinner.setVisibility(View.VISIBLE); setRadioButtonsState(true); dialogBinding.audioStreamSpinner.setVisibility(View.GONE); onVideoStreamSelected(); } private void onVideoStreamSelected() { final boolean isVideoOnly = videoStreamsAdapter.getItem(selectedVideoIndex).isVideoOnly(); dialogBinding.audioTrackSpinner.setVisibility( isVideoOnly && wrappedAudioTracks.size() > 1 ? View.VISIBLE : View.GONE); dialogBinding.audioTrackPresentInVideoText.setVisibility( !isVideoOnly && wrappedAudioTracks.size() > 1 ? View.VISIBLE : View.GONE); } private void setupSubtitleSpinner() { if (getContext() == null) { return; } dialogBinding.qualitySpinner.setAdapter(subtitleStreamsAdapter); dialogBinding.qualitySpinner.setSelection(selectedSubtitleIndex); dialogBinding.qualitySpinner.setVisibility(View.VISIBLE); setRadioButtonsState(true); dialogBinding.audioStreamSpinner.setVisibility(View.GONE); dialogBinding.audioTrackSpinner.setVisibility(View.GONE); dialogBinding.audioTrackPresentInVideoText.setVisibility(View.GONE); } /*////////////////////////////////////////////////////////////////////////// // Activity results //////////////////////////////////////////////////////////////////////////*/ private void requestDownloadPickAudioFolderResult(final ActivityResult result) { requestDownloadPickFolderResult( result, getString(R.string.download_path_audio_key), DownloadManager.TAG_AUDIO); } private void requestDownloadPickVideoFolderResult(final ActivityResult result) { requestDownloadPickFolderResult( result, getString(R.string.download_path_video_key), DownloadManager.TAG_VIDEO); } private void requestDownloadSaveAsResult(@NonNull final ActivityResult result) { if (result.getResultCode() != Activity.RESULT_OK) { return; } if (result.getData() == null || result.getData().getData() == null) { showFailedDialog(R.string.general_error); return; } if (FilePickerActivityHelper.isOwnFileUri(context, result.getData().getData())) { final File file = Utils.getFileForUri(result.getData().getData()); checkSelectedDownload(null, Uri.fromFile(file), file.getName(), StoredFileHelper.DEFAULT_MIME); return; } final DocumentFile docFile = DocumentFile.fromSingleUri(context, result.getData().getData()); if (docFile == null) { showFailedDialog(R.string.general_error); return; } // check if the selected file was previously used checkSelectedDownload(null, result.getData().getData(), docFile.getName(), docFile.getType()); } private void requestDownloadPickFolderResult(@NonNull final ActivityResult result, final String key, final String tag) { if (result.getResultCode() != Activity.RESULT_OK) { return; } if (result.getData() == null || result.getData().getData() == null) { showFailedDialog(R.string.general_error); return; } Uri uri = result.getData().getData(); if (FilePickerActivityHelper.isOwnFileUri(context, uri)) { uri = Uri.fromFile(Utils.getFileForUri(uri)); } else { context.grantUriPermission(context.getPackageName(), uri, StoredDirectoryHelper.PERMISSION_FLAGS); } PreferenceManager.getDefaultSharedPreferences(context).edit().putString(key, uri.toString()).apply(); try { final StoredDirectoryHelper mainStorage = new StoredDirectoryHelper(context, uri, tag); checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp, mimeTmp); } catch (final IOException e) { showFailedDialog(R.string.general_error); } } /*////////////////////////////////////////////////////////////////////////// // Listeners //////////////////////////////////////////////////////////////////////////*/ @Override public void onCheckedChanged(final RadioGroup group, @IdRes final int checkedId) { if (DEBUG) { Log.d(TAG, "onCheckedChanged() called with: " + "group = [" + group + "], checkedId = [" + checkedId + "]"); } boolean flag = true; if (checkedId == R.id.audio_button) { setupAudioSpinner(); } else if (checkedId == R.id.video_button) { setupVideoSpinner(); } else if (checkedId == R.id.subtitle_button) { setupSubtitleSpinner(); flag = false; } dialogBinding.threads.setEnabled(flag); } @Override public void onItemSelected(final AdapterView parent, final View view, final int position, final long id) { if (DEBUG) { Log.d(TAG, "onItemSelected() called with: " + "parent = [" + parent + "], view = [" + view + "], " + "position = [" + position + "], id = [" + id + "]"); } final int parentId = parent.getId(); if (parentId == R.id.quality_spinner) { final int checkedRadioButtonId = dialogBinding.videoAudioGroup .getCheckedRadioButtonId(); if (checkedRadioButtonId == R.id.video_button) { selectedVideoIndex = position; onVideoStreamSelected(); } else if (checkedRadioButtonId == R.id.subtitle_button) { selectedSubtitleIndex = position; } onItemSelectedSetFileName(); } else if (parentId == R.id.audio_track_spinner) { final boolean trackChanged = selectedAudioTrackIndex != position; selectedAudioTrackIndex = position; if (trackChanged) { updateSecondaryStreams(); fetchStreamsSize(); } } else if (parentId == R.id.audio_stream_spinner) { selectedAudioIndex = position; } } private void onItemSelectedSetFileName() { final String fileName = FilenameUtils.createFilename(getContext(), currentInfo.getName()); final String prevFileName = Optional.ofNullable(dialogBinding.fileName.getText()) .map(Object::toString) .orElse(""); if (prevFileName.isEmpty() || prevFileName.equals(fileName) || prevFileName.startsWith(getString(R.string.caption_file_name, fileName, ""))) { // only update the file name field if it was not edited by the user final int radioButtonId = dialogBinding.videoAudioGroup .getCheckedRadioButtonId(); if (radioButtonId == R.id.audio_button || radioButtonId == R.id.video_button) { if (!prevFileName.equals(fileName)) { // since the user might have switched between audio and video, the correct // text might already be in place, so avoid resetting the cursor position dialogBinding.fileName.setText(fileName); } } else if (radioButtonId == R.id.subtitle_button) { final String setSubtitleLanguageCode = subtitleStreamsAdapter .getItem(selectedSubtitleIndex).getLanguageTag(); // this will reset the cursor position, which is bad UX, but it can't be avoided dialogBinding.fileName.setText(getString( R.string.caption_file_name, fileName, setSubtitleLanguageCode)); } } } @Override public void onNothingSelected(final AdapterView parent) { } /*////////////////////////////////////////////////////////////////////////// // Download //////////////////////////////////////////////////////////////////////////*/ protected void setupDownloadOptions() { setRadioButtonsState(false); setupAudioTrackSpinner(); final boolean isVideoStreamsAvailable = videoStreamsAdapter.getCount() > 0; final boolean isAudioStreamsAvailable = audioStreamsAdapter.getCount() > 0; final boolean isSubtitleStreamsAvailable = subtitleStreamsAdapter.getCount() > 0; dialogBinding.audioButton.setVisibility(isAudioStreamsAvailable ? View.VISIBLE : View.GONE); dialogBinding.videoButton.setVisibility(isVideoStreamsAvailable ? View.VISIBLE : View.GONE); dialogBinding.subtitleButton.setVisibility(isSubtitleStreamsAvailable ? View.VISIBLE : View.GONE); prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); final String defaultMedia = prefs.getString(getString(R.string.last_used_download_type), getString(R.string.last_download_type_video_key)); if (isVideoStreamsAvailable && (defaultMedia.equals(getString(R.string.last_download_type_video_key)))) { dialogBinding.videoButton.setChecked(true); setupVideoSpinner(); } else if (isAudioStreamsAvailable && (defaultMedia.equals(getString(R.string.last_download_type_audio_key)))) { dialogBinding.audioButton.setChecked(true); setupAudioSpinner(); } else if (isSubtitleStreamsAvailable && (defaultMedia.equals(getString(R.string.last_download_type_subtitle_key)))) { dialogBinding.subtitleButton.setChecked(true); setupSubtitleSpinner(); } else if (isVideoStreamsAvailable) { dialogBinding.videoButton.setChecked(true); setupVideoSpinner(); } else if (isAudioStreamsAvailable) { dialogBinding.audioButton.setChecked(true); setupAudioSpinner(); } else if (isSubtitleStreamsAvailable) { dialogBinding.subtitleButton.setChecked(true); setupSubtitleSpinner(); } else { Toast.makeText(getContext(), R.string.no_streams_available_download, Toast.LENGTH_SHORT).show(); dismiss(); } } private void setRadioButtonsState(final boolean enabled) { dialogBinding.audioButton.setEnabled(enabled); dialogBinding.videoButton.setEnabled(enabled); dialogBinding.subtitleButton.setEnabled(enabled); } private StreamInfoWrapper getWrappedAudioStreams() { if (selectedAudioTrackIndex < 0 || selectedAudioTrackIndex > wrappedAudioTracks.size()) { return StreamInfoWrapper.empty(); } return wrappedAudioTracks.getTracksList().get(selectedAudioTrackIndex); } private int getSubtitleIndexBy(@NonNull final List streams) { final Localization preferredLocalization = NewPipe.getPreferredLocalization(); int candidate = 0; for (int i = 0; i < streams.size(); i++) { final Locale streamLocale = streams.get(i).getLocale(); final boolean languageEquals = streamLocale.getLanguage() != null && preferredLocalization.getLanguageCode() != null && streamLocale.getLanguage() .equals(new Locale(preferredLocalization.getLanguageCode()).getLanguage()); final boolean countryEquals = streamLocale.getCountry() != null && streamLocale.getCountry().equals(preferredLocalization.getCountryCode()); if (languageEquals) { if (countryEquals) { return i; } candidate = i; } } return candidate; } @NonNull private String getNameEditText() { final String str = Objects.requireNonNull(dialogBinding.fileName.getText()).toString() .trim(); return FilenameUtils.createFilename(context, str.isEmpty() ? currentInfo.getName() : str); } private void showFailedDialog(@StringRes final int msg) { new AlertDialog.Builder(context) .setTitle(R.string.general_error) .setMessage(msg) .setNegativeButton(getString(R.string.ok), null) .show(); } private void launchDirectoryPicker(final ActivityResultLauncher launcher) { NoFileManagerSafeGuard.launchSafe(launcher, StoredDirectoryHelper.getPicker(context), TAG, context); } private void prepareSelectedDownload() { final StoredDirectoryHelper mainStorage; final MediaFormat format; final String selectedMediaType; final long size; // first, build the filename and get the output folder (if possible) // later, run a very very very large file checking logic filenameTmp = getNameEditText().concat("."); final int checkedRadioButtonId = dialogBinding.videoAudioGroup.getCheckedRadioButtonId(); if (checkedRadioButtonId == R.id.audio_button) { selectedMediaType = getString(R.string.last_download_type_audio_key); mainStorage = mainStorageAudio; format = audioStreamsAdapter.getItem(selectedAudioIndex).getFormat(); size = getWrappedAudioStreams().getSizeInBytes(selectedAudioIndex); if (format == MediaFormat.WEBMA_OPUS) { mimeTmp = "audio/ogg"; filenameTmp += "opus"; } else if (format != null) { mimeTmp = format.mimeType; filenameTmp += format.getSuffix(); } } else if (checkedRadioButtonId == R.id.video_button) { selectedMediaType = getString(R.string.last_download_type_video_key); mainStorage = mainStorageVideo; format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat(); size = wrappedVideoStreams.getSizeInBytes(selectedVideoIndex); if (format != null) { mimeTmp = format.mimeType; filenameTmp += format.getSuffix(); } } else if (checkedRadioButtonId == R.id.subtitle_button) { selectedMediaType = getString(R.string.last_download_type_subtitle_key); mainStorage = mainStorageVideo; // subtitle & video files go together format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat(); size = wrappedSubtitleStreams.getSizeInBytes(selectedSubtitleIndex); if (format != null) { mimeTmp = format.mimeType; } if (format == MediaFormat.TTML) { filenameTmp += MediaFormat.SRT.getSuffix(); } else if (format != null) { filenameTmp += format.getSuffix(); } } else { throw new RuntimeException("No stream selected"); } if (!askForSavePath && (mainStorage == null || mainStorage.isDirect() == NewPipeSettings.useStorageAccessFramework(context) || mainStorage.isInvalidSafStorage())) { // Pick new download folder if one of: // - Download folder is not set // - Download folder uses SAF while SAF is disabled // - Download folder doesn't use SAF while SAF is enabled // - Download folder uses SAF but the user manually revoked access to it Toast.makeText(context, getString(R.string.no_dir_yet), Toast.LENGTH_LONG).show(); if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) { launchDirectoryPicker(requestDownloadPickAudioFolderLauncher); } else { launchDirectoryPicker(requestDownloadPickVideoFolderLauncher); } return; } if (askForSavePath) { final Uri initialPath; if (NewPipeSettings.useStorageAccessFramework(context)) { initialPath = null; } else { final File initialSavePath; if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) { initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MUSIC); } else { initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MOVIES); } initialPath = Uri.parse(initialSavePath.getAbsolutePath()); } NoFileManagerSafeGuard.launchSafe(requestDownloadSaveAsLauncher, StoredFileHelper.getNewPicker(context, filenameTmp, mimeTmp, initialPath), TAG, context); return; } // Check for free storage space final long freeSpace = mainStorage.getFreeStorageSpace(); if (freeSpace <= size) { Toast.makeText(context, getString(R. string.error_insufficient_storage), Toast.LENGTH_LONG).show(); // move the user to storage setting tab final Intent storageSettingsIntent = new Intent(Settings. ACTION_INTERNAL_STORAGE_SETTINGS); if (storageSettingsIntent.resolveActivity(context.getPackageManager()) != null) { startActivity(storageSettingsIntent); } return; } // check for existing file with the same name checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp, mimeTmp); // remember the last media type downloaded by the user prefs.edit().putString(getString(R.string.last_used_download_type), selectedMediaType) .apply(); } private void checkSelectedDownload(final StoredDirectoryHelper mainStorage, final Uri targetFile, final String filename, final String mime) { StoredFileHelper storage; try { if (mainStorage == null) { // using SAF on older android version storage = new StoredFileHelper(context, null, targetFile, ""); } else if (targetFile == null) { // the file does not exist, but it is probably used in a pending download storage = new StoredFileHelper(mainStorage.getUri(), filename, mime, mainStorage.getTag()); } else { // the target filename is already use, attempt to use it storage = new StoredFileHelper(context, mainStorage.getUri(), targetFile, mainStorage.getTag()); } } catch (final Exception e) { ErrorUtil.createNotification(requireContext(), new ErrorInfo(e, UserAction.DOWNLOAD_FAILED, "Getting storage")); return; } // get state of potential mission referring to the same file final MissionState state = downloadManager.checkForExistingMission(storage); @StringRes final int msgBtn; @StringRes final int msgBody; // this switch checks if there is already a mission referring to the same file switch (state) { case Finished: // there is already a finished mission msgBtn = R.string.overwrite; msgBody = R.string.overwrite_finished_warning; break; case Pending: msgBtn = R.string.overwrite; msgBody = R.string.download_already_pending; break; case PendingRunning: msgBtn = R.string.generate_unique_name; msgBody = R.string.download_already_running; break; case None: // there is no mission referring to the same file if (mainStorage == null) { // This part is called if: // * using SAF on older android version // * save path not defined // * if the file exists overwrite it, is not necessary ask if (!storage.existsAsFile() && !storage.create()) { showFailedDialog(R.string.error_file_creation); return; } continueSelectedDownload(storage); return; } else if (targetFile == null) { // This part is called if: // * the filename is not used in a pending/finished download // * the file does not exists, create if (!mainStorage.mkdirs()) { showFailedDialog(R.string.error_path_creation); return; } storage = mainStorage.createFile(filename, mime); if (storage == null || !storage.canWrite()) { showFailedDialog(R.string.error_file_creation); return; } continueSelectedDownload(storage); return; } msgBtn = R.string.overwrite; msgBody = R.string.overwrite_unrelated_warning; break; default: return; // unreachable } final AlertDialog.Builder askDialog = new AlertDialog.Builder(context) .setTitle(R.string.download_dialog_title) .setMessage(msgBody) .setNegativeButton(R.string.cancel, null); final StoredFileHelper finalStorage = storage; if (mainStorage == null) { // This part is called if: // * using SAF on older android version // * save path not defined switch (state) { case Pending: case Finished: askDialog.setPositiveButton(msgBtn, (dialog, which) -> { dialog.dismiss(); downloadManager.forgetMission(finalStorage); continueSelectedDownload(finalStorage); }); break; } askDialog.show(); return; } askDialog.setPositiveButton(msgBtn, (dialog, which) -> { dialog.dismiss(); StoredFileHelper storageNew; switch (state) { case Finished: case Pending: downloadManager.forgetMission(finalStorage); case None: if (targetFile == null) { storageNew = mainStorage.createFile(filename, mime); } else { try { // try take (or steal) the file storageNew = new StoredFileHelper(context, mainStorage.getUri(), targetFile, mainStorage.getTag()); } catch (final IOException e) { Log.e(TAG, "Failed to take (or steal) the file in " + targetFile.toString()); storageNew = null; } } if (storageNew != null && storageNew.canWrite()) { continueSelectedDownload(storageNew); } else { showFailedDialog(R.string.error_file_creation); } break; case PendingRunning: storageNew = mainStorage.createUniqueFile(filename, mime); if (storageNew == null) { showFailedDialog(R.string.error_file_creation); } else { continueSelectedDownload(storageNew); } break; } }); askDialog.show(); } private void continueSelectedDownload(@NonNull final StoredFileHelper storage) { if (!storage.canWrite()) { showFailedDialog(R.string.permission_denied); return; } // check if the selected file has to be overwritten, by simply checking its length try { if (storage.length() > 0) { storage.truncate(); } } catch (final IOException e) { Log.e(TAG, "Failed to truncate the file: " + storage.getUri().toString(), e); showFailedDialog(R.string.overwrite_failed); return; } final Stream selectedStream; Stream secondaryStream = null; final char kind; int threads = dialogBinding.threads.getProgress() + 1; final String[] urls; final List recoveryInfo; String psName = null; String[] psArgs = null; long nearLength = 0; // more download logic: select muxer, subtitle converter, etc. final int checkedRadioButtonId = dialogBinding.videoAudioGroup.getCheckedRadioButtonId(); if (checkedRadioButtonId == R.id.audio_button) { kind = 'a'; selectedStream = audioStreamsAdapter.getItem(selectedAudioIndex); if (selectedStream.getFormat() == MediaFormat.M4A) { psName = Postprocessing.ALGORITHM_M4A_NO_DASH; } else if (selectedStream.getFormat() == MediaFormat.WEBMA_OPUS) { psName = Postprocessing.ALGORITHM_OGG_FROM_WEBM_DEMUXER; } } else if (checkedRadioButtonId == R.id.video_button) { kind = 'v'; selectedStream = videoStreamsAdapter.getItem(selectedVideoIndex); final SecondaryStreamHelper secondary = videoStreamsAdapter .getAllSecondary() .get(wrappedVideoStreams.getStreamsList().indexOf(selectedStream)); if (secondary != null) { secondaryStream = secondary.getStream(); if (selectedStream.getFormat() == MediaFormat.MPEG_4) { psName = Postprocessing.ALGORITHM_MP4_FROM_DASH_MUXER; } else { psName = Postprocessing.ALGORITHM_WEBM_MUXER; } final long videoSize = wrappedVideoStreams.getSizeInBytes( (VideoStream) selectedStream); // set nearLength, only, if both sizes are fetched or known. This probably // does not work on slow networks but is later updated in the downloader if (secondary.getSizeInBytes() > 0 && videoSize > 0) { nearLength = secondary.getSizeInBytes() + videoSize; } } } else if (checkedRadioButtonId == R.id.subtitle_button) { threads = 1; // use unique thread for subtitles due small file size kind = 's'; selectedStream = subtitleStreamsAdapter.getItem(selectedSubtitleIndex); if (selectedStream.getFormat() == MediaFormat.TTML) { psName = Postprocessing.ALGORITHM_TTML_CONVERTER; psArgs = new String[]{ selectedStream.getFormat().getSuffix(), "false" // ignore empty frames }; } } else { return; } if (secondaryStream == null) { urls = new String[] { selectedStream.getContent() }; recoveryInfo = List.of(new MissionRecoveryInfo(selectedStream)); } else { if (secondaryStream.getDeliveryMethod() != PROGRESSIVE_HTTP) { throw new IllegalArgumentException("Unsupported stream delivery format" + secondaryStream.getDeliveryMethod()); } urls = new String[] { selectedStream.getContent(), secondaryStream.getContent() }; recoveryInfo = List.of( new MissionRecoveryInfo(selectedStream), new MissionRecoveryInfo(secondaryStream) ); } DownloadManagerService.startMission(context, urls, storage, kind, threads, currentInfo, psName, psArgs, nearLength, new ArrayList<>(recoveryInfo)); Toast.makeText(context, getString(R.string.download_has_started), Toast.LENGTH_SHORT).show(); dismiss(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/download/LoadingDialog.java ================================================ package org.schabi.newpipe.download; import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.widget.Toolbar; import androidx.fragment.app.DialogFragment; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.DownloadLoadingDialogBinding; /** * This class contains a dialog which shows a loading indicator and has a customizable title. */ public class LoadingDialog extends DialogFragment { private static final String TAG = "LoadingDialog"; private static final boolean DEBUG = MainActivity.DEBUG; private DownloadLoadingDialogBinding dialogLoadingBinding; private final @StringRes int title; /** * Create a new LoadingDialog. * *

* The dialog contains a loading indicator and has a customizable title. *
* Use {@code show()} to display the dialog to the user. *

* * @param title an informative title shown in the dialog's toolbar */ public LoadingDialog(final @StringRes int title) { this.title = title; } @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (DEBUG) { Log.d(TAG, "onCreate() called with: " + "savedInstanceState = [" + savedInstanceState + "]"); } this.setCancelable(false); } @Override public View onCreateView( @NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { if (DEBUG) { Log.d(TAG, "onCreateView() called with: " + "inflater = [" + inflater + "], container = [" + container + "], " + "savedInstanceState = [" + savedInstanceState + "]"); } return inflater.inflate(R.layout.download_loading_dialog, container); } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); dialogLoadingBinding = DownloadLoadingDialogBinding.bind(view); initToolbar(dialogLoadingBinding.toolbarLayout.toolbar); } private void initToolbar(final Toolbar toolbar) { if (DEBUG) { Log.d(TAG, "initToolbar() called with: toolbar = [" + toolbar + "]"); } toolbar.setTitle(requireContext().getString(title)); toolbar.setNavigationOnClickListener(v -> dismiss()); } @Override public void onDestroyView() { dialogLoadingBinding = null; super.onDestroyView(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java ================================================ package org.schabi.newpipe.error; import android.content.Context; import androidx.annotation.NonNull; import org.acra.ReportField; import org.acra.data.CrashReportData; import org.acra.sender.ReportSender; import org.schabi.newpipe.R; /* * Created by Christian Schabesberger on 13.09.16. * * Copyright (C) Christian Schabesberger 2015 * AcraReportSender.java is part of NewPipe. * * NewPipe is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * NewPipe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ public class AcraReportSender implements ReportSender { @Override public void send(@NonNull final Context context, @NonNull final CrashReportData report) { ErrorUtil.openActivity(context, new ErrorInfo( new String[]{report.getString(ReportField.STACK_TRACE)}, UserAction.UI_ERROR, "ACRA report", null, R.string.app_ui_crash)); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/error/AcraReportSenderFactory.java ================================================ package org.schabi.newpipe.error; import android.content.Context; import androidx.annotation.NonNull; import com.google.auto.service.AutoService; import org.acra.config.CoreConfiguration; import org.acra.sender.ReportSender; import org.acra.sender.ReportSenderFactory; import org.schabi.newpipe.App; /* * Created by Christian Schabesberger on 13.09.16. * * Copyright (C) Christian Schabesberger 2015 * AcraReportSenderFactory.java is part of NewPipe. * * NewPipe is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * NewPipe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ /** * Used by ACRA in {@link App}.initAcra() as the factory for report senders. */ @AutoService(ReportSenderFactory.class) public class AcraReportSenderFactory implements ReportSenderFactory { @NonNull public ReportSender create(@NonNull final Context context, @NonNull final CoreConfiguration config) { return new AcraReportSender(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/error/ErrorActivity.kt ================================================ /* * SPDX-FileCopyrightText: 2015-2026 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.error import android.content.Context import android.content.Intent import android.os.Build import android.os.Bundle import android.util.Log import android.view.Menu import android.view.MenuItem import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.core.content.IntentCompat import androidx.core.net.toUri import com.grack.nanojson.JsonWriter import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import org.schabi.newpipe.BuildConfig import org.schabi.newpipe.R import org.schabi.newpipe.databinding.ActivityErrorBinding import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.ThemeHelper import org.schabi.newpipe.util.external_communication.ShareUtils import org.schabi.newpipe.util.text.setTextWithLinks /** * This activity is used to show error details and allow reporting them in various ways. * Use [ErrorUtil.openActivity] to correctly open this activity. */ class ErrorActivity : AppCompatActivity() { private lateinit var errorInfo: ErrorInfo private lateinit var currentTimeStamp: String private lateinit var binding: ActivityErrorBinding private val contentCountryString: String get() = Localization.getPreferredContentCountry(this).countryCode private val contentLanguageString: String get() = Localization.getPreferredLocalization(this).localizationCode private val appLanguage: String get() = Localization.getAppLocale().toString() private val osString: String get() { val name = System.getProperty("os.name")!! val osBase = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { Build.VERSION.BASE_OS.ifEmpty { "Android" } } else { "Android" } return "$name $osBase ${Build.VERSION.RELEASE} - ${Build.VERSION.SDK_INT}" } private val errorEmailSubject: String get() = "$ERROR_EMAIL_SUBJECT ${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME}" // ///////////////////////////////////////////////////////////////////// // Activity lifecycle // ///////////////////////////////////////////////////////////////////// override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) ThemeHelper.setDayNightMode(this) ThemeHelper.setTheme(this) binding = ActivityErrorBinding.inflate(layoutInflater) setContentView(binding.getRoot()) setSupportActionBar(binding.toolbarLayout.toolbar) supportActionBar?.apply { setDisplayHomeAsUpEnabled(true) setTitle(R.string.error_report_title) setDisplayShowTitleEnabled(true) } errorInfo = IntentCompat.getParcelableExtra(intent, ERROR_INFO, ErrorInfo::class.java)!! // important add guru meditation addGuruMeditation() // print current time, as zoned ISO8601 timestamp currentTimeStamp = ZonedDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME) binding.errorReportEmailButton.setOnClickListener { _ -> openPrivacyPolicyDialog(this, "EMAIL") } binding.errorReportCopyButton.setOnClickListener { _ -> ShareUtils.copyToClipboard(this, buildMarkdown()) } binding.errorReportGitHubButton.setOnClickListener { _ -> openPrivacyPolicyDialog(this, "GITHUB") } // normal bugreport buildInfo(errorInfo) binding.errorMessageView.setTextWithLinks(errorInfo.getMessage(this)) binding.errorView.text = formErrorText(errorInfo.stackTraces) // print stack trace once again for debugging: errorInfo.stackTraces.forEach { Log.e(TAG, it) } } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.error_menu, menu) return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { android.R.id.home -> { onBackPressed() true } R.id.menu_item_share_error -> { ShareUtils.shareText( applicationContext, getString(R.string.error_report_title), buildJson() ) true } else -> false } } private fun openPrivacyPolicyDialog(context: Context, action: String) { AlertDialog.Builder(context) .setIcon(android.R.drawable.ic_dialog_alert) .setTitle(R.string.privacy_policy_title) .setMessage(R.string.start_accept_privacy_policy) .setCancelable(false) .setNeutralButton(R.string.read_privacy_policy) { _, _ -> ShareUtils.openUrlInApp(context, context.getString(R.string.privacy_policy_url)) } .setPositiveButton(R.string.accept) { _, _ -> if (action == "EMAIL") { // send on email val intent = Intent(Intent.ACTION_SENDTO) .setData("mailto:".toUri()) // only email apps should handle this .putExtra(Intent.EXTRA_EMAIL, arrayOf(ERROR_EMAIL_ADDRESS)) .putExtra(Intent.EXTRA_SUBJECT, errorEmailSubject) .putExtra(Intent.EXTRA_TEXT, buildJson()) ShareUtils.openIntentInApp(context, intent) } else if (action == "GITHUB") { // open the NewPipe issue page on GitHub ShareUtils.openUrlInApp(this, ERROR_GITHUB_ISSUE_URL) } } .setNegativeButton(R.string.decline, null) .show() } private fun formErrorText(stacktrace: Array): String { val separator = "-------------------------------------" return stacktrace.joinToString(separator + "\n", separator + "\n", separator) } private fun buildInfo(info: ErrorInfo) { binding.errorInfoLabelsView.text = getString(R.string.info_labels) val text = info.userAction.message + "\n" + info.request + "\n" + contentLanguageString + "\n" + contentCountryString + "\n" + appLanguage + "\n" + info.getServiceName() + "\n" + currentTimeStamp + "\n" + packageName + "\n" + BuildConfig.VERSION_NAME + "\n" + osString binding.errorInfosView.text = text } private fun buildJson(): String { try { return JsonWriter.string() .`object`() .value("user_action", errorInfo.userAction.message) .value("request", errorInfo.request) .value("content_language", contentLanguageString) .value("content_country", contentCountryString) .value("app_language", appLanguage) .value("service", errorInfo.getServiceName()) .value("package", packageName) .value("version", BuildConfig.VERSION_NAME) .value("os", osString) .value("time", currentTimeStamp) .array("exceptions", errorInfo.stackTraces.toList()) .value("user_comment", binding.errorCommentBox.getText().toString()) .end() .done() } catch (exception: Exception) { Log.e(TAG, "Error while erroring: Could not build json", exception) } return "" } private fun buildMarkdown(): String { try { return buildString(1024) { val userComment = binding.errorCommentBox.text.toString() if (userComment.isNotEmpty()) { appendLine(userComment) } // basic error info appendLine("## Exception") appendLine("* __User Action:__ ${errorInfo.userAction.message}") appendLine("* __Request:__ ${errorInfo.request}") appendLine("* __Content Country:__ $contentCountryString") appendLine("* __Content Language:__ $contentLanguageString") appendLine("* __App Language:__ $appLanguage") appendLine("* __Service:__ ${errorInfo.getServiceName()}") appendLine("* __Timestamp:__ $currentTimeStamp") appendLine("* __Package:__ $packageName") appendLine("* __Service:__ ${errorInfo.getServiceName()}") appendLine("* __Version:__ ${BuildConfig.VERSION_NAME}") appendLine("* __OS:__ $osString") // Collapse all logs to a single paragraph when there are more than one // to keep the GitHub issue clean. if (errorInfo.stackTraces.size > 1) { append("
Exceptions (") append(errorInfo.stackTraces.size) append(")

\n") } // add the logs errorInfo.stackTraces.forEachIndexed { index, stacktrace -> append("

Crash log ") if (errorInfo.stackTraces.size > 1) { append(index + 1) } append("") append("

\n") append("\n```\n${stacktrace}\n```\n") append("

\n") } // make sure to close everything if (errorInfo.stackTraces.size > 1) { append("

\n") } append("
\n") } } catch (exception: Exception) { Log.e(TAG, "Error while erroring: Could not build markdown", exception) return "" } } private fun addGuruMeditation() { // just an easter egg var text = binding.errorSorryView.text.toString() text += "\n" + getString(R.string.guru_meditation) binding.errorSorryView.text = text } companion object { // LOG TAGS private val TAG = ErrorActivity::class.java.toString() // BUNDLE TAGS const val ERROR_INFO = "error_info" private const val ERROR_EMAIL_ADDRESS = "crashreport@newpipe.schabi.org" private const val ERROR_EMAIL_SUBJECT = "Exception in " private const val ERROR_GITHUB_ISSUE_URL = "https://github.com/TeamNewPipe/NewPipe/issues" } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt ================================================ package org.schabi.newpipe.error import android.content.Context import android.os.Parcelable import androidx.annotation.StringRes import androidx.core.content.ContextCompat import com.google.android.exoplayer2.ExoPlaybackException import com.google.android.exoplayer2.upstream.HttpDataSource import com.google.android.exoplayer2.upstream.Loader import java.net.UnknownHostException import kotlinx.parcelize.Parcelize import org.schabi.newpipe.R import org.schabi.newpipe.extractor.Info import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.ServiceList.YouTube import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException import org.schabi.newpipe.extractor.exceptions.ExtractionException import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException import org.schabi.newpipe.extractor.exceptions.PaidContentException import org.schabi.newpipe.extractor.exceptions.PrivateContentException import org.schabi.newpipe.extractor.exceptions.ReCaptchaException import org.schabi.newpipe.extractor.exceptions.SignInConfirmNotBotException import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException import org.schabi.newpipe.extractor.exceptions.UnsupportedContentInCountryException import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException import org.schabi.newpipe.ktx.isNetworkRelated import org.schabi.newpipe.player.mediasource.FailedMediaSource import org.schabi.newpipe.player.resolver.PlaybackResolver import org.schabi.newpipe.util.text.getText /** * An error has occurred in the app. This class contains plain old parcelable data that can be used * to report the error and to show it to the user along with correct action buttons. */ @Parcelize class ErrorInfo private constructor( val stackTraces: Array, val userAction: UserAction, val request: String, val serviceId: Int?, private val message: ErrorMessage, /** * If `true`, a report button will be shown for this error. Otherwise the error is not something * that can really be reported (e.g. a network issue, or content not being available at all). */ val isReportable: Boolean, /** * If `true`, the process causing this error can be retried, otherwise not. */ val isRetryable: Boolean, /** * If present, indicates that the exception was a ReCaptchaException, and this is the URL * provided by the service that can be used to solve the ReCaptcha challenge. */ val recaptchaUrl: String?, /** * If present, this resource can alternatively be opened in browser (useful if NewPipe is * badly broken). */ val openInBrowserUrl: String? ) : Parcelable { @JvmOverloads constructor( throwable: Throwable, userAction: UserAction, request: String, serviceId: Int? = null, openInBrowserUrl: String? = null ) : this( throwableToStringList(throwable), userAction, request, serviceId, getMessage(throwable, userAction, serviceId), isReportable(throwable), isRetryable(throwable), (throwable as? ReCaptchaException)?.url, openInBrowserUrl ) @JvmOverloads constructor( throwables: List, userAction: UserAction, request: String, serviceId: Int? = null, openInBrowserUrl: String? = null ) : this( throwableListToStringList(throwables), userAction, request, serviceId, getMessage(throwables.firstOrNull(), userAction, serviceId), throwables.any(::isReportable), throwables.isEmpty() || throwables.any(::isRetryable), throwables.firstNotNullOfOrNull { it as? ReCaptchaException }?.url, openInBrowserUrl ) // constructor to manually build ErrorInfo when no throwable is available constructor( stackTraces: Array, userAction: UserAction, request: String, serviceId: Int?, @StringRes message: Int ) : this( stackTraces, userAction, request, serviceId, ErrorMessage(message), true, false, null, null ) // constructor with only one throwable to extract service id and openInBrowserUrl from an Info constructor( throwable: Throwable, userAction: UserAction, request: String, info: Info? ) : this(throwable, userAction, request, info?.serviceId, info?.url) // constructor with multiple throwables to extract service id and openInBrowserUrl from an Info constructor( throwables: List, userAction: UserAction, request: String, info: Info? ) : this(throwables, userAction, request, info?.serviceId, info?.url) fun getServiceName(): String { return getServiceName(serviceId) } fun getMessage(context: Context): CharSequence { return message.getText(context) } companion object { @Parcelize class ErrorMessage( @StringRes private val stringRes: Int, private vararg val formatArgs: String ) : Parcelable { fun getText(context: Context): CharSequence { // Ensure locale aware context via ContextCompat.getContextForLanguage() (just in case context is not AppCompatActivity) val ctx = ContextCompat.getContextForLanguage(context) return if (formatArgs.isEmpty()) { ctx.getText(stringRes) } else { // ContextCompat.getString() with formatArgs does not exist, so we just // replicate its source code but with formatArgs ctx.resources.getText(stringRes, *formatArgs) } } } const val SERVICE_NONE = "" const val YOUTUBE_IP_BAN_FAQ_URL = "https://newpipe.net/FAQ/#ip-banned-youtube" private fun getServiceName(serviceId: Int?) = // not using getNameOfServiceById since we want to accept a nullable serviceId and we // want to default to SERVICE_NONE ServiceList.all().firstOrNull { it.serviceId == serviceId }?.serviceInfo?.name ?: SERVICE_NONE fun throwableToStringList(throwable: Throwable) = arrayOf(throwable.stackTraceToString()) fun throwableListToStringList(throwableList: List) = throwableList.map { it.stackTraceToString() }.toTypedArray() fun getMessage( throwable: Throwable?, action: UserAction?, serviceId: Int? ): ErrorMessage { return when { // player exceptions // some may be IOException, so do these checks before isNetworkRelated! throwable is ExoPlaybackException -> { val cause = throwable.cause when { cause is HttpDataSource.InvalidResponseCodeException -> { if (cause.responseCode == 403) { if (serviceId == YouTube.serviceId) { ErrorMessage(R.string.youtube_player_http_403) } else { ErrorMessage(R.string.player_http_403) } } else { ErrorMessage(R.string.player_http_invalid_status, cause.responseCode.toString()) } } cause is Loader.UnexpectedLoaderException && cause.cause is ExtractionException -> getMessage(throwable, action, serviceId) throwable.type == ExoPlaybackException.TYPE_SOURCE -> ErrorMessage(R.string.player_stream_failure) throwable.type == ExoPlaybackException.TYPE_UNEXPECTED -> ErrorMessage(R.string.player_recoverable_failure) else -> ErrorMessage(R.string.player_unrecoverable_failure) } } throwable is FailedMediaSource.FailedMediaSourceException -> getMessage(throwable.cause, action, serviceId) throwable is PlaybackResolver.ResolverException -> ErrorMessage(R.string.player_stream_failure) // content not available exceptions throwable is AccountTerminatedException -> throwable.message ?.takeIf { reason -> !reason.isEmpty() } ?.let { reason -> ErrorMessage( R.string.account_terminated_service_provides_reason, getServiceName(serviceId), reason ) } ?: ErrorMessage(R.string.account_terminated) throwable is AgeRestrictedContentException -> ErrorMessage(R.string.restricted_video_no_stream) throwable is GeographicRestrictionException -> ErrorMessage(R.string.georestricted_content) throwable is PaidContentException -> ErrorMessage(R.string.paid_content) throwable is PrivateContentException -> ErrorMessage(R.string.private_content) throwable is SoundCloudGoPlusContentException -> ErrorMessage(R.string.soundcloud_go_plus_content) throwable is UnsupportedContentInCountryException -> ErrorMessage(R.string.unsupported_content_in_country) throwable is YoutubeMusicPremiumContentException -> ErrorMessage(R.string.youtube_music_premium_content) throwable is SignInConfirmNotBotException -> ErrorMessage( R.string.sign_in_confirm_not_bot_error, getServiceName(serviceId), YOUTUBE_IP_BAN_FAQ_URL ) throwable is ContentNotAvailableException -> ErrorMessage(R.string.content_not_available) // other extractor exceptions throwable is ContentNotSupportedException -> ErrorMessage(R.string.content_not_supported) // ReCaptchas will be handled in a special way anyway throwable is ReCaptchaException -> ErrorMessage(R.string.recaptcha_request_toast) // test this at the end as many exceptions could be a subclass of IOException throwable != null && throwable.isNetworkRelated -> ErrorMessage(R.string.network_error) // an extraction exception unrelated to the network // is likely an issue with parsing the website throwable is ExtractionException -> ErrorMessage(R.string.parsing_error) // user actions (in case the exception is null or unrecognizable) action == UserAction.UI_ERROR -> ErrorMessage(R.string.app_ui_crash) action == UserAction.REQUESTED_COMMENTS -> ErrorMessage(R.string.error_unable_to_load_comments) action == UserAction.SUBSCRIPTION_CHANGE -> ErrorMessage(R.string.subscription_change_failed) action == UserAction.SUBSCRIPTION_UPDATE -> ErrorMessage(R.string.subscription_update_failed) action == UserAction.LOAD_IMAGE -> ErrorMessage(R.string.could_not_load_thumbnails) action == UserAction.DOWNLOAD_OPEN_DIALOG -> ErrorMessage(R.string.could_not_setup_download_menu) else -> ErrorMessage(R.string.error_snackbar_message) } } fun isReportable(throwable: Throwable?): Boolean { return when (throwable) { // we don't have an exception, so this is a manually built error, which likely // indicates that it's important and is thus reportable null -> true // if the service explicitly said that content is not available (e.g. age // restrictions, video deleted, etc.), there is no use in letting users report it is ContentNotAvailableException -> !isContentSurelyNotAvailable(throwable) // we know the content is not supported, no need to let the user report it is ContentNotSupportedException -> false // happens often when there is no internet connection; we don't use // `throwable.isNetworkRelated` since any `IOException` would make that function // return true, but not all `IOException`s are network related is UnknownHostException -> false // by default, this is an unexpected exception, which the user could report else -> true } } fun isRetryable(throwable: Throwable?): Boolean { return when (throwable) { // if we know the content is surely not available, retrying won't help is ContentNotAvailableException -> !isContentSurelyNotAvailable(throwable) // we know the content is not supported, retrying won't help is ContentNotSupportedException -> false // by default (including if throwable is null), enable retrying (though the retry // button will be shown only if a way to perform the retry is implemented) else -> true } } /** * Unfortunately sometimes [ContentNotAvailableException] may not indicate that the content * is blocked/deleted/paid, but may just indicate that we could not extract it. This is an * inconsistency in the exceptions thrown by the extractor, but until it is fixed, this * function will distinguish between the two types. * @return `true` if the content is not available because of a limitation imposed by the * service or the owner, `false` if the extractor could not extract info about it */ fun isContentSurelyNotAvailable(e: ContentNotAvailableException): Boolean { return when (e) { is AccountTerminatedException, is AgeRestrictedContentException, is GeographicRestrictionException, is PaidContentException, is PrivateContentException, is SoundCloudGoPlusContentException, is UnsupportedContentInCountryException, is YoutubeMusicPremiumContentException -> true else -> false } } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt ================================================ package org.schabi.newpipe.error import android.content.Context import android.content.Intent import android.view.View import android.widget.Button import android.widget.TextView import androidx.annotation.StringRes import androidx.core.view.isVisible import androidx.fragment.app.Fragment import com.jakewharton.rxbinding4.view.clicks import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.disposables.Disposable import java.util.concurrent.TimeUnit import org.schabi.newpipe.MainActivity import org.schabi.newpipe.R import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.util.external_communication.ShareUtils import org.schabi.newpipe.util.text.setTextWithLinks class ErrorPanelHelper( private val fragment: Fragment, rootView: View, onRetry: Runnable? ) { private val context: Context = rootView.context!! private val errorPanelRoot: View = rootView.findViewById(R.id.error_panel) // the only element that is visible by default private val errorTextView: TextView = errorPanelRoot.findViewById(R.id.error_message_view) private val errorServiceInfoTextView: TextView = errorPanelRoot.findViewById(R.id.error_message_service_info_view) private val errorServiceExplanationTextView: TextView = errorPanelRoot.findViewById(R.id.error_message_service_explanation_view) private val errorActionButton: Button = errorPanelRoot.findViewById(R.id.error_action_button) private val errorRetryButton: Button = errorPanelRoot.findViewById(R.id.error_retry_button) private val errorOpenInBrowserButton: Button = errorPanelRoot.findViewById(R.id.error_open_in_browser) private var errorDisposable: Disposable? = null private var retryShouldBeShown: Boolean = (onRetry != null) init { if (onRetry != null) { errorDisposable = errorRetryButton.clicks() .debounce(300, TimeUnit.MILLISECONDS) .observeOn(AndroidSchedulers.mainThread()) .subscribe { onRetry.run() } } } private fun ensureDefaultVisibility() { errorTextView.isVisible = true errorServiceInfoTextView.isVisible = false errorServiceExplanationTextView.isVisible = false errorActionButton.isVisible = false errorRetryButton.isVisible = false errorOpenInBrowserButton.isVisible = false } fun showError(errorInfo: ErrorInfo) { ensureDefaultVisibility() errorTextView.setTextWithLinks(errorInfo.getMessage(context)) if (errorInfo.recaptchaUrl != null) { showAndSetErrorButtonAction(R.string.recaptcha_solve) { // Starting ReCaptcha Challenge Activity val intent = Intent(context, ReCaptchaActivity::class.java) intent.putExtra(ReCaptchaActivity.RECAPTCHA_URL_EXTRA, errorInfo.recaptchaUrl) fragment.startActivityForResult(intent, ReCaptchaActivity.RECAPTCHA_REQUEST) errorActionButton.setOnClickListener(null) } } else if (errorInfo.isReportable) { showAndSetErrorButtonAction(R.string.error_snackbar_action) { ErrorUtil.openActivity(context, errorInfo) } } if (errorInfo.isRetryable) { errorRetryButton.isVisible = retryShouldBeShown } if (errorInfo.openInBrowserUrl != null) { errorOpenInBrowserButton.isVisible = true errorOpenInBrowserButton.setOnClickListener { ShareUtils.openUrlInBrowser(context, errorInfo.openInBrowserUrl) } } setRootVisible() } /** * Shows the errorButtonAction, sets a text into it and sets the click listener. */ private fun showAndSetErrorButtonAction( @StringRes resid: Int, listener: View.OnClickListener ) { errorActionButton.isVisible = true errorActionButton.setText(resid) errorActionButton.setOnClickListener(listener) } fun showTextError(errorString: String) { ensureDefaultVisibility() errorTextView.setTextWithLinks(errorString) setRootVisible() } private fun setRootVisible() { errorPanelRoot.animate(true, 300) } fun hide() { errorActionButton.setOnClickListener(null) errorPanelRoot.animate(false, 150) } fun isVisible(): Boolean { return errorPanelRoot.isVisible } fun dispose() { errorActionButton.setOnClickListener(null) errorRetryButton.setOnClickListener(null) errorDisposable?.dispose() } companion object { val TAG: String = ErrorPanelHelper::class.simpleName!! val DEBUG: Boolean = MainActivity.DEBUG } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt ================================================ package org.schabi.newpipe.error import android.app.Activity import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.Color import android.view.View import android.widget.Toast import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager import com.google.android.material.snackbar.Snackbar import org.schabi.newpipe.MainActivity import org.schabi.newpipe.R /** * This class contains all of the methods that should be used to let the user know that an error has * occurred in the least intrusive way possible for each case. This class is for unexpected errors, * for handled errors (e.g. network errors) use e.g. [ErrorPanelHelper] instead. * - Use a snackbar if the exception is not critical and it happens in a place where a root view * is available. * - Use a notification if the exception happens inside a background service (player, subscription * import, ...) or there is no activity/fragment from which to extract a root view. * - Finally use the error activity only as a last resort in case the exception is critical and * happens in an open activity (since the workflow would be interrupted anyway in that case). */ class ErrorUtil { companion object { private const val ERROR_REPORT_NOTIFICATION_ID = 5340681 /** * Starts a new error activity allowing the user to report the provided error. Only use this * method directly as a last resort in case the exception is critical and happens in an open * activity (since the workflow would be interrupted anyway in that case). So never use this * for background services. * * If the crashed occurred while the app was in the background open a notification instead * * @param context the context to use to start the new activity * @param errorInfo the error info to be reported */ @JvmStatic fun openActivity(context: Context, errorInfo: ErrorInfo) { if (PreferenceManager.getDefaultSharedPreferences(context) .getBoolean(MainActivity.KEY_IS_IN_BACKGROUND, true) ) { createNotification(context, errorInfo) } else { context.startActivity(getErrorActivityIntent(context, errorInfo)) } } /** * Show a bottom snackbar to the user, with a report button that opens the error activity. * Use this method if the exception is not critical and it happens in a place where a root * view is available. * * @param context will be used to obtain the root view if it is an [Activity]; if no root * view can be found an error notification is shown instead * @param errorInfo the error info to be reported */ @JvmStatic fun showSnackbar(context: Context, errorInfo: ErrorInfo) { val rootView = (context as? Activity)?.findViewById(android.R.id.content) showSnackbar(context, rootView, errorInfo) } /** * Show a bottom snackbar to the user, with a report button that opens the error activity. * Use this method if the exception is not critical and it happens in a place where a root * view is available. * * @param fragment will be used to obtain the root view if it has a connected [Activity]; if * no root view can be found an error notification is shown instead * @param errorInfo the error info to be reported */ @JvmStatic fun showSnackbar(fragment: Fragment, errorInfo: ErrorInfo) { var rootView = fragment.view if (rootView == null && fragment.activity != null) { rootView = fragment.requireActivity().findViewById(android.R.id.content) } showSnackbar(fragment.requireContext(), rootView, errorInfo) } /** * Shortcut to calling [showSnackbar] with an [ErrorInfo] of type [UserAction.UI_ERROR] */ @JvmStatic fun showUiErrorSnackbar(context: Context, request: String, throwable: Throwable) { showSnackbar(context, ErrorInfo(throwable, UserAction.UI_ERROR, request)) } /** * Shortcut to calling [showSnackbar] with an [ErrorInfo] of type [UserAction.UI_ERROR] */ @JvmStatic fun showUiErrorSnackbar(fragment: Fragment, request: String, throwable: Throwable) { showSnackbar(fragment, ErrorInfo(throwable, UserAction.UI_ERROR, request)) } /** * Create an error notification. Tapping on the notification opens the error activity. Use * this method if the exception happens inside a background service (player, subscription * import, ...) or there is no activity/fragment from which to extract a root view. * * @param context the context to use to show the notification * @param errorInfo the error info to be reported; the error message * [ErrorInfo.messageStringId] will be shown in the notification * description */ @JvmStatic fun createNotification(context: Context, errorInfo: ErrorInfo) { val notificationBuilder: NotificationCompat.Builder = NotificationCompat.Builder( context, context.getString(R.string.error_report_channel_id) ) .setSmallIcon(R.drawable.ic_bug_report) .setContentTitle(context.getString(R.string.error_report_notification_title)) .setContentText(errorInfo.getMessage(context)) .setAutoCancel(true) .setContentIntent( PendingIntentCompat.getActivity( context, 0, getErrorActivityIntent(context, errorInfo), PendingIntent.FLAG_UPDATE_CURRENT, false ) ) val notificationManager = NotificationManagerCompat.from(context) if (notificationManager.areNotificationsEnabled()) { notificationManager .notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build()) } ContextCompat.getMainExecutor(context).execute { // since the notification is silent, also show a toast, otherwise the user is confused Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT) .show() } } private fun getErrorActivityIntent(context: Context, errorInfo: ErrorInfo): Intent { val intent = Intent(context, ErrorActivity::class.java) intent.putExtra(ErrorActivity.ERROR_INFO, errorInfo) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) return intent } private fun showSnackbar(context: Context, rootView: View?, errorInfo: ErrorInfo) { if (rootView == null) { // fallback to showing a notification if no root view is available createNotification(context, errorInfo) } else { Snackbar.make(rootView, errorInfo.getMessage(context), Snackbar.LENGTH_LONG) .setActionTextColor(Color.YELLOW) .setAction(context.getString(R.string.error_snackbar_action).uppercase()) { context.startActivity(getErrorActivityIntent(context, errorInfo)) }.show() } } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java ================================================ package org.schabi.newpipe.error; import android.annotation.SuppressLint; import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.webkit.CookieManager; import android.webkit.WebResourceRequest; import android.webkit.WebSettings; import android.webkit.WebView; import android.webkit.WebViewClient; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.NavUtils; import androidx.preference.PreferenceManager; import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.ActivityRecaptchaBinding; import org.schabi.newpipe.extractor.utils.Utils; import org.schabi.newpipe.util.ThemeHelper; /* * Created by beneth on 06.12.16. * * Copyright (C) Christian Schabesberger 2015 * ReCaptchaActivity.java is part of NewPipe. * * NewPipe is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * NewPipe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ public class ReCaptchaActivity extends AppCompatActivity { public static final int RECAPTCHA_REQUEST = 10; public static final String RECAPTCHA_URL_EXTRA = "recaptcha_url_extra"; public static final String TAG = ReCaptchaActivity.class.toString(); public static final String YT_URL = "https://www.youtube.com"; public static final String RECAPTCHA_COOKIES_KEY = "recaptcha_cookies"; public static String sanitizeRecaptchaUrl(@Nullable final String url) { if (url == null || url.trim().isEmpty()) { return YT_URL; // YouTube is the most likely service to have thrown a recaptcha } else { // remove "pbj=1" parameter from YouYube urls, as it makes the page JSON and not HTML return url.replace("&pbj=1", "").replace("pbj=1&", "").replace("?pbj=1", ""); } } private ActivityRecaptchaBinding recaptchaBinding; private String foundCookies = ""; @SuppressLint("SetJavaScriptEnabled") @Override protected void onCreate(final Bundle savedInstanceState) { ThemeHelper.setTheme(this); super.onCreate(savedInstanceState); recaptchaBinding = ActivityRecaptchaBinding.inflate(getLayoutInflater()); setContentView(recaptchaBinding.getRoot()); setSupportActionBar(recaptchaBinding.toolbar); final String url = sanitizeRecaptchaUrl(getIntent().getStringExtra(RECAPTCHA_URL_EXTRA)); // set return to Cancel by default setResult(RESULT_CANCELED); // enable Javascript final WebSettings webSettings = recaptchaBinding.reCaptchaWebView.getSettings(); webSettings.setJavaScriptEnabled(true); webSettings.setUserAgentString(DownloaderImpl.USER_AGENT); recaptchaBinding.reCaptchaWebView.setWebViewClient(new WebViewClient() { @Override public boolean shouldOverrideUrlLoading(final WebView view, final WebResourceRequest request) { if (MainActivity.DEBUG) { Log.d(TAG, "shouldOverrideUrlLoading: url=" + request.getUrl().toString()); } handleCookiesFromUrl(request.getUrl().toString()); return false; } @Override public void onPageFinished(final WebView view, final String url) { super.onPageFinished(view, url); handleCookiesFromUrl(url); } }); // cleaning cache, history and cookies from webView recaptchaBinding.reCaptchaWebView.clearCache(true); recaptchaBinding.reCaptchaWebView.clearHistory(); CookieManager.getInstance().removeAllCookies(null); recaptchaBinding.reCaptchaWebView.loadUrl(url); } @Override public boolean onCreateOptionsMenu(final Menu menu) { getMenuInflater().inflate(R.menu.menu_recaptcha, menu); final ActionBar actionBar = getSupportActionBar(); if (actionBar != null) { actionBar.setDisplayHomeAsUpEnabled(false); actionBar.setTitle(R.string.title_activity_recaptcha); actionBar.setSubtitle(R.string.subtitle_activity_recaptcha); } return true; } @Override @SuppressLint("MissingSuperCall") // saveCookiesAndFinish method handles back navigation public void onBackPressed() { saveCookiesAndFinish(); } @Override public boolean onOptionsItemSelected(final MenuItem item) { if (item.getItemId() == R.id.menu_item_done) { saveCookiesAndFinish(); return true; } return false; } private void saveCookiesAndFinish() { // try to get cookies of unclosed page handleCookiesFromUrl(recaptchaBinding.reCaptchaWebView.getUrl()); if (MainActivity.DEBUG) { Log.d(TAG, "saveCookiesAndFinish: foundCookies=" + foundCookies); } if (!foundCookies.isEmpty()) { // save cookies to preferences final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences( getApplicationContext()); final String key = getApplicationContext().getString(R.string.recaptcha_cookies_key); prefs.edit().putString(key, foundCookies).apply(); // give cookies to Downloader class DownloaderImpl.getInstance().setCookie(RECAPTCHA_COOKIES_KEY, foundCookies); setResult(RESULT_OK); } // Navigate to blank page (unloads youtube to prevent background playback) recaptchaBinding.reCaptchaWebView.loadUrl("about:blank"); final Intent intent = new Intent(this, org.schabi.newpipe.MainActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); NavUtils.navigateUpTo(this, intent); } private void handleCookiesFromUrl(@Nullable final String url) { if (MainActivity.DEBUG) { Log.d(TAG, "handleCookiesFromUrl: url=" + (url == null ? "null" : url)); } if (url == null) { return; } final String cookies = CookieManager.getInstance().getCookie(url); handleCookies(cookies); // sometimes cookies are inside the url final int abuseStart = url.indexOf("google_abuse="); if (abuseStart != -1) { final int abuseEnd = url.indexOf("+path"); try { handleCookies(Utils.decodeUrlUtf8(url.substring(abuseStart + 13, abuseEnd))); } catch (final StringIndexOutOfBoundsException e) { if (MainActivity.DEBUG) { Log.e(TAG, "handleCookiesFromUrl: invalid google abuse starting at " + abuseStart + " and ending at " + abuseEnd + " for url " + url, e); } } } } private void handleCookies(@Nullable final String cookies) { if (MainActivity.DEBUG) { Log.d(TAG, "handleCookies: cookies=" + (cookies == null ? "null" : cookies)); } if (cookies == null) { return; } addYoutubeCookies(cookies); // add here methods to extract cookies for other services } private void addYoutubeCookies(@NonNull final String cookies) { if (cookies.contains("s_gl=") || cookies.contains("goojf=") || cookies.contains("VISITOR_INFO1_LIVE=") || cookies.contains("GOOGLE_ABUSE_EXEMPTION=")) { // youtube seems to also need the other cookies: addCookie(cookies); } } private void addCookie(final String cookie) { if (foundCookies.contains(cookie)) { return; } if (foundCookies.isEmpty() || foundCookies.endsWith("; ")) { foundCookies += cookie; } else if (foundCookies.endsWith(";")) { foundCookies += " " + cookie; } else { foundCookies += "; " + cookie; } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/error/UserAction.kt ================================================ /* * SPDX-FileCopyrightText: 2017-2025 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.error /** * The user actions that can cause an error. */ enum class UserAction(val message: String) { USER_REPORT("user report"), UI_ERROR("ui error"), DATABASE_IMPORT_EXPORT("database import or export"), SUBSCRIPTION_CHANGE("subscription change"), SUBSCRIPTION_UPDATE("subscription update"), SUBSCRIPTION_GET("get subscription"), SUBSCRIPTION_IMPORT_EXPORT("subscription import or export"), LOAD_IMAGE("load image"), SOMETHING_ELSE("something else"), SEARCHED("searched"), GET_SUGGESTIONS("get suggestions"), REQUESTED_STREAM("requested stream"), REQUESTED_CHANNEL("requested channel"), REQUESTED_PLAYLIST("requested playlist"), REQUESTED_KIOSK("requested kiosk"), REQUESTED_COMMENTS("requested comments"), REQUESTED_COMMENT_REPLIES("requested comment replies"), REQUESTED_FEED("requested feed"), REQUESTED_BOOKMARK("bookmark"), DELETE_FROM_HISTORY("delete from history"), PLAY_STREAM("play stream"), DOWNLOAD_OPEN_DIALOG("download open dialog"), DOWNLOAD_POSTPROCESSING("download post-processing"), DOWNLOAD_FAILED("download failed"), NEW_STREAMS_NOTIFICATIONS("new streams notifications"), PREFERENCES_MIGRATION("migration of preferences"), SHARE_TO_NEWPIPE("share to newpipe"), CHECK_FOR_NEW_APP_VERSION("check for new app version"), OPEN_INFO_ITEM_DIALOG("open info item dialog"), GETTING_MAIN_SCREEN_TAB("getting main screen tab"), PLAY_ON_POPUP("play on popup"), SUBSCRIPTIONS("loading subscriptions") } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/BackPressable.java ================================================ package org.schabi.newpipe.fragments; /** * Indicates that the current fragment can handle back presses. */ public interface BackPressable { /** * A back press was delegated to this fragment. * * @return if the back press was handled */ boolean onBackPressed(); } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java ================================================ package org.schabi.newpipe.fragments; import static org.schabi.newpipe.ktx.ViewUtils.animate; import android.os.Bundle; import android.util.Log; import android.view.View; import android.widget.ProgressBar; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.fragment.app.Fragment; import com.evernote.android.state.State; import org.schabi.newpipe.BaseFragment; import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorPanelHelper; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.util.InfoCache; import java.util.concurrent.atomic.AtomicBoolean; public abstract class BaseStateFragment extends BaseFragment implements ViewContract { @State protected AtomicBoolean wasLoading = new AtomicBoolean(); protected AtomicBoolean isLoading = new AtomicBoolean(); @Nullable protected View emptyStateView; @Nullable protected TextView emptyStateMessageView; @Nullable private ProgressBar loadingProgressBar; private ErrorPanelHelper errorPanelHelper; @Nullable @State protected ErrorInfo lastPanelError = null; @Override public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { super.onViewCreated(rootView, savedInstanceState); doInitialLoadLogic(); } @Override public void onPause() { super.onPause(); wasLoading.set(isLoading.get()); } @Override public void onResume() { super.onResume(); if (lastPanelError != null) { showError(lastPanelError); } } /*////////////////////////////////////////////////////////////////////////// // Init //////////////////////////////////////////////////////////////////////////*/ @Override protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); emptyStateView = rootView.findViewById(R.id.empty_state_view); emptyStateMessageView = rootView.findViewById(R.id.empty_state_message); loadingProgressBar = rootView.findViewById(R.id.loading_progress_bar); errorPanelHelper = new ErrorPanelHelper(this, rootView, this::onRetryButtonClicked); } @Override public void onDestroyView() { super.onDestroyView(); if (errorPanelHelper != null) { errorPanelHelper.dispose(); } emptyStateView = null; emptyStateMessageView = null; } protected void onRetryButtonClicked() { reloadContent(); } public void reloadContent() { startLoading(true); } /*////////////////////////////////////////////////////////////////////////// // Load //////////////////////////////////////////////////////////////////////////*/ protected void doInitialLoadLogic() { startLoading(true); } protected void startLoading(final boolean forceLoad) { if (DEBUG) { Log.d(TAG, "startLoading() called with: forceLoad = [" + forceLoad + "]"); } showLoading(); isLoading.set(true); } /*////////////////////////////////////////////////////////////////////////// // Contract //////////////////////////////////////////////////////////////////////////*/ @Override public void showLoading() { if (emptyStateView != null) { animate(emptyStateView, false, 150); } if (loadingProgressBar != null) { animate(loadingProgressBar, true, 400); } hideErrorPanel(); } @Override public void hideLoading() { if (emptyStateView != null) { animate(emptyStateView, false, 150); } if (loadingProgressBar != null) { animate(loadingProgressBar, false, 0); } hideErrorPanel(); } @Override public void showEmptyState() { isLoading.set(false); if (emptyStateView != null) { animate(emptyStateView, true, 200); } if (loadingProgressBar != null) { animate(loadingProgressBar, false, 0); } hideErrorPanel(); } @Override public void handleResult(final I result) { if (DEBUG) { Log.d(TAG, "handleResult() called with: result = [" + result + "]"); } hideLoading(); } @Override public void handleError() { isLoading.set(false); InfoCache.getInstance().clearCache(); if (emptyStateView != null) { animate(emptyStateView, false, 150); } if (loadingProgressBar != null) { animate(loadingProgressBar, false, 0); } } /*////////////////////////////////////////////////////////////////////////// // Error handling //////////////////////////////////////////////////////////////////////////*/ public final void showError(final ErrorInfo errorInfo) { handleError(); if (isDetached() || isRemoving()) { if (DEBUG) { Log.w(TAG, "showError() is detached or removing = [" + errorInfo + "]"); } return; } errorPanelHelper.showError(errorInfo); lastPanelError = errorInfo; } public final void showTextError(@NonNull final String errorString) { handleError(); if (isDetached() || isRemoving()) { if (DEBUG) { Log.w(TAG, "showTextError() is detached or removing = [" + errorString + "]"); } return; } errorPanelHelper.showTextError(errorString); } protected void setEmptyStateMessage(@StringRes final int text) { if (emptyStateMessageView != null) { emptyStateMessageView.setText(text); } } public final void hideErrorPanel() { errorPanelHelper.hide(); lastPanelError = null; } public final boolean isErrorPanelVisible() { return errorPanelHelper.isVisible(); } /** * Directly calls {@link ErrorUtil#showSnackbar(Fragment, ErrorInfo)}, that shows a snackbar if * a valid view can be found, otherwise creates an error report notification. * * @param errorInfo The error information */ public void showSnackBarError(final ErrorInfo errorInfo) { if (DEBUG) { Log.d(TAG, "showSnackBarError() called with: errorInfo = [" + errorInfo + "]"); } ErrorUtil.showSnackbar(this, errorInfo); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/BlankFragment.java ================================================ package org.schabi.newpipe.fragments; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.Nullable; import com.evernote.android.state.State; import org.schabi.newpipe.BaseFragment; import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorPanelHelper; public class BlankFragment extends BaseFragment { @State @Nullable ErrorInfo errorInfo; @Nullable ErrorPanelHelper errorPanel = null; /** * Builds a blank fragment that just says the app name and suggests clicking on search. */ public BlankFragment() { this(null); } /** * @param errorInfo if null acts like {@link BlankFragment}, else shows an error panel. */ public BlankFragment(@Nullable final ErrorInfo errorInfo) { this.errorInfo = errorInfo; } @Nullable @Override public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container, final Bundle savedInstanceState) { setTitle("NewPipe"); final View view = inflater.inflate(R.layout.fragment_blank, container, false); if (errorInfo != null) { errorPanel = new ErrorPanelHelper(this, view, null); errorPanel.showError(errorInfo); view.findViewById(R.id.blank_page_content).setVisibility(View.GONE); } return view; } @Override public void onDestroyView() { super.onDestroyView(); if (errorPanel != null) { errorPanel.dispose(); errorPanel = null; } } @Override public void onResume() { super.onResume(); setTitle("NewPipe"); // leave this inline. Will make it harder for copy cats. // If you are a Copy cat FUCK YOU. // I WILL FIND YOU, AND I WILL ... } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java ================================================ package org.schabi.newpipe.fragments; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.Nullable; import org.schabi.newpipe.BaseFragment; import org.schabi.newpipe.R; public class EmptyFragment extends BaseFragment { private static final String SHOW_MESSAGE = "SHOW_MESSAGE"; public static final EmptyFragment newInstance(final boolean showMessage) { final EmptyFragment emptyFragment = new EmptyFragment(); final Bundle bundle = new Bundle(1); bundle.putBoolean(SHOW_MESSAGE, showMessage); emptyFragment.setArguments(bundle); return emptyFragment; } @Override public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container, final Bundle savedInstanceState) { final boolean showMessage = getArguments().getBoolean(SHOW_MESSAGE); final View view = inflater.inflate(R.layout.fragment_empty, container, false); view.findViewById(R.id.empty_state_view).setVisibility( showMessage ? View.VISIBLE : View.GONE); return view; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java ================================================ package org.schabi.newpipe.fragments; import static android.widget.RelativeLayout.ABOVE; import static android.widget.RelativeLayout.ALIGN_PARENT_BOTTOM; import static android.widget.RelativeLayout.ALIGN_PARENT_TOP; import static android.widget.RelativeLayout.BELOW; import static com.google.android.material.tabs.TabLayout.INDICATOR_GRAVITY_BOTTOM; import static com.google.android.material.tabs.TabLayout.INDICATOR_GRAVITY_TOP; import android.content.Context; import android.content.SharedPreferences; import android.content.res.ColorStateList; import android.graphics.Color; import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.RelativeLayout; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentStatePagerAdapterMenuWorkaround; import androidx.preference.PreferenceManager; import androidx.viewpager.widget.ViewPager; import com.google.android.material.tabs.TabLayout; import org.schabi.newpipe.BaseFragment; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.FragmentMainBinding; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.local.playlist.LocalPlaylistFragment; import org.schabi.newpipe.settings.tabs.Tab; import org.schabi.newpipe.settings.tabs.TabsManager; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.views.ScrollableTabLayout; import java.util.ArrayList; import java.util.List; public class MainFragment extends BaseFragment implements TabLayout.OnTabSelectedListener { private FragmentMainBinding binding; private SelectedTabsPagerAdapter pagerAdapter; private final List tabsList = new ArrayList<>(); private TabsManager tabsManager; private boolean hasTabsChanged = false; private SharedPreferences prefs; private boolean youtubeRestrictedModeEnabled; private String youtubeRestrictedModeEnabledKey; private boolean mainTabsPositionBottom; private String mainTabsPositionKey; /*////////////////////////////////////////////////////////////////////////// // Fragment's LifeCycle //////////////////////////////////////////////////////////////////////////*/ @Override public void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); setHasOptionsMenu(true); tabsManager = TabsManager.getManager(activity); tabsManager.setSavedTabsListener(() -> { if (DEBUG) { Log.d(TAG, "TabsManager.SavedTabsChangeListener: " + "onTabsChanged called, isResumed = " + isResumed()); } if (isResumed()) { setupTabs(); } else { hasTabsChanged = true; } }); prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled); youtubeRestrictedModeEnabled = prefs.getBoolean(youtubeRestrictedModeEnabledKey, false); mainTabsPositionKey = getString(R.string.main_tabs_position_key); mainTabsPositionBottom = prefs.getBoolean(mainTabsPositionKey, false); } @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_main, container, false); } @Override protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); binding = FragmentMainBinding.bind(rootView); binding.mainTabLayout.setupWithViewPager(binding.pager); binding.mainTabLayout.addOnTabSelectedListener(this); setupTabs(); updateTabLayoutPosition(); } @Override public void onResume() { super.onResume(); final boolean newYoutubeRestrictedModeEnabled = prefs.getBoolean(youtubeRestrictedModeEnabledKey, false); if (youtubeRestrictedModeEnabled != newYoutubeRestrictedModeEnabled || hasTabsChanged) { youtubeRestrictedModeEnabled = newYoutubeRestrictedModeEnabled; setupTabs(); } final boolean newMainTabsPosition = prefs.getBoolean(mainTabsPositionKey, false); if (mainTabsPositionBottom != newMainTabsPosition) { mainTabsPositionBottom = newMainTabsPosition; updateTabLayoutPosition(); } } @Override public void onDestroy() { super.onDestroy(); tabsManager.unsetSavedTabsListener(); if (binding != null) { binding.pager.setAdapter(null); binding = null; } } @Override public void onDestroyView() { super.onDestroyView(); binding = null; } /*////////////////////////////////////////////////////////////////////////// // Menu //////////////////////////////////////////////////////////////////////////*/ @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); if (DEBUG) { Log.d(TAG, "onCreateOptionsMenu() called with: " + "menu = [" + menu + "], inflater = [" + inflater + "]"); } inflater.inflate(R.menu.menu_main_fragment, menu); final ActionBar supportActionBar = activity.getSupportActionBar(); if (supportActionBar != null) { supportActionBar.setDisplayHomeAsUpEnabled(false); } } @Override public boolean onOptionsItemSelected(final MenuItem item) { if (item.getItemId() == R.id.action_search) { try { NavigationHelper.openSearchFragment(getFM(), ServiceHelper.getSelectedServiceId(activity), ""); } catch (final Exception e) { ErrorUtil.showUiErrorSnackbar(this, "Opening search fragment", e); } return true; } return super.onOptionsItemSelected(item); } /*////////////////////////////////////////////////////////////////////////// // Tabs //////////////////////////////////////////////////////////////////////////*/ private void setupTabs() { tabsList.clear(); tabsList.addAll(tabsManager.getTabs()); if (pagerAdapter == null || !pagerAdapter.sameTabs(tabsList)) { pagerAdapter = new SelectedTabsPagerAdapter(requireContext(), getChildFragmentManager(), tabsList); } binding.pager.setAdapter(null); binding.pager.setAdapter(pagerAdapter); updateTabsIconAndDescription(); updateTitleForTab(binding.pager.getCurrentItem()); hasTabsChanged = false; } private void updateTabsIconAndDescription() { for (int i = 0; i < tabsList.size(); i++) { final TabLayout.Tab tabToSet = binding.mainTabLayout.getTabAt(i); if (tabToSet != null) { final Tab tab = tabsList.get(i); tabToSet.setIcon(tab.getTabIconRes(requireContext())); tabToSet.setContentDescription(tab.getTabName(requireContext())); } } } private void updateTitleForTab(final int tabPosition) { setTitle(tabsList.get(tabPosition).getTabName(requireContext())); } public void commitPlaylistTabs() { pagerAdapter.getLocalPlaylistFragments() .stream() .forEach(LocalPlaylistFragment::saveImmediate); } private void updateTabLayoutPosition() { final ScrollableTabLayout tabLayout = binding.mainTabLayout; final ViewPager viewPager = binding.pager; final boolean bottom = mainTabsPositionBottom; // change layout params to make the tab layout appear either at the top or at the bottom final var tabParams = (RelativeLayout.LayoutParams) tabLayout.getLayoutParams(); final var pagerParams = (RelativeLayout.LayoutParams) viewPager.getLayoutParams(); tabParams.removeRule(bottom ? ALIGN_PARENT_TOP : ALIGN_PARENT_BOTTOM); tabParams.addRule(bottom ? ALIGN_PARENT_BOTTOM : ALIGN_PARENT_TOP); pagerParams.removeRule(bottom ? BELOW : ABOVE); pagerParams.addRule(bottom ? ABOVE : BELOW, R.id.main_tab_layout); tabLayout.setSelectedTabIndicatorGravity( bottom ? INDICATOR_GRAVITY_TOP : INDICATOR_GRAVITY_BOTTOM); tabLayout.setLayoutParams(tabParams); viewPager.setLayoutParams(pagerParams); // change the background and icon color of the tab layout: // service-colored at the top, app-background-colored at the bottom tabLayout.setBackgroundColor(ThemeHelper.resolveColorFromAttr(requireContext(), bottom ? android.R.attr.windowBackground : R.attr.colorPrimary)); @ColorInt final int iconColor = bottom ? ThemeHelper.resolveColorFromAttr(requireContext(), android.R.attr.colorAccent) : Color.WHITE; tabLayout.setTabRippleColor(ColorStateList.valueOf(iconColor).withAlpha(32)); tabLayout.setTabIconTint(ColorStateList.valueOf(iconColor)); tabLayout.setSelectedTabIndicatorColor(iconColor); } @Override public void onTabSelected(final TabLayout.Tab selectedTab) { if (DEBUG) { Log.d(TAG, "onTabSelected() called with: selectedTab = [" + selectedTab + "]"); } updateTitleForTab(selectedTab.getPosition()); } @Override public void onTabUnselected(final TabLayout.Tab tab) { } @Override public void onTabReselected(final TabLayout.Tab tab) { if (DEBUG) { Log.d(TAG, "onTabReselected() called with: tab = [" + tab + "]"); } updateTitleForTab(tab.getPosition()); } public static final class SelectedTabsPagerAdapter extends FragmentStatePagerAdapterMenuWorkaround { private final Context context; private final List internalTabsList; /** * Keep reference to LocalPlaylistFragments, because their data can be modified by the user * during runtime and changes are not committed immediately. However, in some cases, * the changes need to be committed immediately by calling * {@link LocalPlaylistFragment#saveImmediate()}. * The fragments are removed when {@link LocalPlaylistFragment#onDestroy()} is called. */ private final List localPlaylistFragments = new ArrayList<>(); private SelectedTabsPagerAdapter(final Context context, final FragmentManager fragmentManager, final List tabsList) { super(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT); this.context = context; this.internalTabsList = new ArrayList<>(tabsList); } @NonNull @Override public Fragment getItem(final int position) { final Tab tab = internalTabsList.get(position); final Fragment fragment; try { fragment = tab.getFragment(context); } catch (final Throwable t) { return new BlankFragment(new ErrorInfo(t, UserAction.GETTING_MAIN_SCREEN_TAB, "Tab " + tab.getClass().getSimpleName() + ":" + tab.getTabName(context))); } if (fragment instanceof BaseFragment) { ((BaseFragment) fragment).useAsFrontPage(true); } if (fragment instanceof LocalPlaylistFragment) { localPlaylistFragments.add((LocalPlaylistFragment) fragment); } return fragment; } public List getLocalPlaylistFragments() { return localPlaylistFragments; } @Override public int getItemPosition(@NonNull final Object object) { // Causes adapter to reload all Fragments when // notifyDataSetChanged is called return POSITION_NONE; } @Override public int getCount() { return internalTabsList.size(); } public boolean sameTabs(final List tabsToCompare) { return internalTabsList.equals(tabsToCompare); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.java ================================================ package org.schabi.newpipe.fragments; import androidx.annotation.NonNull; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.StaggeredGridLayoutManager; /** * Recycler view scroll listener which calls the method {@link #onScrolledDown(RecyclerView)} * if the view is scrolled below the last item. */ public abstract class OnScrollBelowItemsListener extends RecyclerView.OnScrollListener { @Override public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) { super.onScrolled(recyclerView, dx, dy); if (dy > 0) { int pastVisibleItems = 0; final RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager(); final int visibleItemCount = layoutManager.getChildCount(); final int totalItemCount = layoutManager.getItemCount(); // Already covers the GridLayoutManager case if (layoutManager instanceof LinearLayoutManager) { pastVisibleItems = ((LinearLayoutManager) layoutManager) .findFirstVisibleItemPosition(); } else if (layoutManager instanceof StaggeredGridLayoutManager) { final int[] positions = ((StaggeredGridLayoutManager) layoutManager) .findFirstVisibleItemPositions(null); if (positions != null && positions.length > 0) { pastVisibleItems = positions[0]; } } if ((visibleItemCount + pastVisibleItems) >= totalItemCount) { onScrolledDown(recyclerView); } } } /** * Called when the recycler view is scrolled below the last item. * * @param recyclerView the recycler view */ public abstract void onScrolledDown(RecyclerView recyclerView); } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/ViewContract.java ================================================ package org.schabi.newpipe.fragments; public interface ViewContract { void showLoading(); void hideLoading(); void showEmptyState(); void handleResult(I result); void handleError(); } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java ================================================ package org.schabi.newpipe.fragments.detail; import static android.text.TextUtils.isEmpty; import static org.schabi.newpipe.extractor.utils.Utils.isBlank; import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD; import android.graphics.Typeface; import android.os.Bundle; import android.text.SpannableStringBuilder; import android.text.Spanned; import android.text.method.LinkMovementMethod; import android.text.style.ClickableSpan; import android.text.style.StyleSpan; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.LinearLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.widget.TooltipCompat; import androidx.core.text.HtmlCompat; import com.google.android.material.chip.Chip; import org.schabi.newpipe.BaseFragment; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.FragmentDescriptionBinding; import org.schabi.newpipe.databinding.ItemMetadataBinding; import org.schabi.newpipe.databinding.ItemMetadataTagsBinding; import org.schabi.newpipe.extractor.Image; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.stream.Description; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.image.ImageStrategy; import org.schabi.newpipe.util.text.TextLinkifier; import java.util.List; import io.reactivex.rxjava3.disposables.CompositeDisposable; public abstract class BaseDescriptionFragment extends BaseFragment { private final CompositeDisposable descriptionDisposables = new CompositeDisposable(); protected FragmentDescriptionBinding binding; @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { binding = FragmentDescriptionBinding.inflate(inflater, container, false); setupDescription(); setupMetadata(inflater, binding.detailMetadataLayout); addTagsMetadataItem(inflater, binding.detailMetadataLayout); return binding.getRoot(); } @Override public void onDestroy() { descriptionDisposables.clear(); super.onDestroy(); } /** * Get the description to display. * @return description object, if available */ @Nullable protected abstract Description getDescription(); /** * Get the streaming service. Used for generating description links. * @return streaming service */ @NonNull protected abstract StreamingService getService(); /** * Get the streaming service ID. Used for tag links. * @return service ID */ protected abstract int getServiceId(); /** * Get the URL of the described video or audio, used to generate description links. * @return stream URL */ @Nullable protected abstract String getStreamUrl(); /** * Get the list of tags to display below the description. * @return tag list */ @NonNull public abstract List getTags(); /** * Add additional metadata to display. * @param inflater LayoutInflater * @param layout detailMetadataLayout */ protected abstract void setupMetadata(LayoutInflater inflater, LinearLayout layout); private void setupDescription() { final Description description = getDescription(); if (description == null || isEmpty(description.getContent()) || description == Description.EMPTY_DESCRIPTION) { binding.detailDescriptionView.setVisibility(View.GONE); binding.detailSelectDescriptionButton.setVisibility(View.GONE); return; } // start with disabled state. This also loads description content (!) disableDescriptionSelection(); binding.detailSelectDescriptionButton.setOnClickListener(v -> { if (binding.detailDescriptionNoteView.getVisibility() == View.VISIBLE) { disableDescriptionSelection(); } else { // enable selection only when button is clicked to prevent flickering enableDescriptionSelection(); } }); } private void enableDescriptionSelection() { binding.detailDescriptionNoteView.setVisibility(View.VISIBLE); binding.detailDescriptionView.setTextIsSelectable(true); final String buttonLabel = getString(R.string.description_select_disable); binding.detailSelectDescriptionButton.setContentDescription(buttonLabel); TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel); binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_close); } private void disableDescriptionSelection() { // show description content again, otherwise some links are not clickable final Description description = getDescription(); if (description != null) { TextLinkifier.fromDescription(binding.detailDescriptionView, description, HtmlCompat.FROM_HTML_MODE_LEGACY, getService(), getStreamUrl(), descriptionDisposables, SET_LINK_MOVEMENT_METHOD); } binding.detailDescriptionNoteView.setVisibility(View.GONE); binding.detailDescriptionView.setTextIsSelectable(false); final String buttonLabel = getString(R.string.description_select_enable); binding.detailSelectDescriptionButton.setContentDescription(buttonLabel); TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel); binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all); } protected void addMetadataItem(final LayoutInflater inflater, final LinearLayout layout, final boolean linkifyContent, @StringRes final int type, @NonNull final String content) { if (isBlank(content)) { return; } final ItemMetadataBinding itemBinding = ItemMetadataBinding.inflate(inflater, layout, false); itemBinding.metadataTypeView.setText(type); itemBinding.metadataTypeView.setOnLongClickListener(v -> { ShareUtils.copyToClipboard(requireContext(), content); return true; }); if (linkifyContent) { TextLinkifier.fromPlainText(itemBinding.metadataContentView, content, null, null, descriptionDisposables, SET_LINK_MOVEMENT_METHOD); } else { itemBinding.metadataContentView.setText(content); } itemBinding.metadataContentView.setClickable(true); layout.addView(itemBinding.getRoot()); } private String imageSizeToText(final int heightOrWidth) { if (heightOrWidth < 0) { return getString(R.string.question_mark); } else { return String.valueOf(heightOrWidth); } } protected void addImagesMetadataItem(final LayoutInflater inflater, final LinearLayout layout, @StringRes final int type, final List images) { final String preferredImageUrl = ImageStrategy.choosePreferredImage(images); if (preferredImageUrl == null) { return; // null will be returned in case there is no image } final ItemMetadataBinding itemBinding = ItemMetadataBinding.inflate(inflater, layout, false); itemBinding.metadataTypeView.setText(type); final SpannableStringBuilder urls = new SpannableStringBuilder(); for (final Image image : images) { if (urls.length() != 0) { urls.append(", "); } final int entryBegin = urls.length(); if (image.getHeight() != Image.HEIGHT_UNKNOWN || image.getWidth() != Image.WIDTH_UNKNOWN // if even the resolution level is unknown, ?x? will be shown || image.getEstimatedResolutionLevel() == Image.ResolutionLevel.UNKNOWN) { urls.append(imageSizeToText(image.getWidth())); urls.append('x'); urls.append(imageSizeToText(image.getHeight())); } else { switch (image.getEstimatedResolutionLevel()) { case LOW -> urls.append(getString(R.string.image_quality_low)); case MEDIUM -> urls.append(getString(R.string.image_quality_medium)); case HIGH -> urls.append(getString(R.string.image_quality_high)); default -> { // unreachable, Image.ResolutionLevel.UNKNOWN is already filtered out } } } urls.setSpan(new ClickableSpan() { @Override public void onClick(@NonNull final View widget) { ShareUtils.openUrlInBrowser(requireContext(), image.getUrl()); } }, entryBegin, urls.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); if (preferredImageUrl.equals(image.getUrl())) { urls.setSpan(new StyleSpan(Typeface.BOLD), entryBegin, urls.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } } itemBinding.metadataContentView.setText(urls); itemBinding.metadataContentView.setMovementMethod(LinkMovementMethod.getInstance()); layout.addView(itemBinding.getRoot()); } private void addTagsMetadataItem(final LayoutInflater inflater, final LinearLayout layout) { final List tags = getTags(); if (!tags.isEmpty()) { final var itemBinding = ItemMetadataTagsBinding.inflate(inflater, layout, false); tags.stream().sorted(String.CASE_INSENSITIVE_ORDER).forEach(tag -> { final Chip chip = (Chip) inflater.inflate(R.layout.chip, itemBinding.metadataTagsChips, false); chip.setText(tag); chip.setOnClickListener(this::onTagClick); chip.setOnLongClickListener(this::onTagLongClick); itemBinding.metadataTagsChips.addView(chip); }); layout.addView(itemBinding.getRoot()); } } private void onTagClick(final View chip) { if (getParentFragment() != null) { NavigationHelper.openSearchFragment(getParentFragment().getParentFragmentManager(), getServiceId(), ((Chip) chip).getText().toString()); } } private boolean onTagLongClick(final View chip) { ShareUtils.copyToClipboard(requireContext(), ((Chip) chip).getText().toString()); return true; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java ================================================ package org.schabi.newpipe.fragments.detail; import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; import static org.schabi.newpipe.util.Localization.getAppLocale; import android.view.LayoutInflater; import android.view.View; import android.widget.LinearLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import com.evernote.android.state.State; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.stream.Description; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.util.Localization; import java.util.List; public class DescriptionFragment extends BaseDescriptionFragment { @State StreamInfo streamInfo; public DescriptionFragment(final StreamInfo streamInfo) { this.streamInfo = streamInfo; } public DescriptionFragment() { // keep empty constructor for State when resuming fragment from memory } @Nullable @Override protected Description getDescription() { return streamInfo.getDescription(); } @NonNull @Override protected StreamingService getService() { return streamInfo.getService(); } @Override protected int getServiceId() { return streamInfo.getServiceId(); } @NonNull @Override protected String getStreamUrl() { return streamInfo.getUrl(); } @NonNull @Override public List getTags() { return streamInfo.getTags(); } @Override protected void setupMetadata(final LayoutInflater inflater, final LinearLayout layout) { if (streamInfo != null && streamInfo.getUploadDate() != null) { binding.detailUploadDateView.setText(Localization .localizeUploadDate(activity, streamInfo.getUploadDate().offsetDateTime())); } else { binding.detailUploadDateView.setVisibility(View.GONE); } if (streamInfo == null) { return; } addMetadataItem(inflater, layout, false, R.string.metadata_category, streamInfo.getCategory()); addMetadataItem(inflater, layout, false, R.string.metadata_licence, streamInfo.getLicence()); addPrivacyMetadataItem(inflater, layout); if (streamInfo.getAgeLimit() != NO_AGE_LIMIT) { addMetadataItem(inflater, layout, false, R.string.metadata_age_limit, String.valueOf(streamInfo.getAgeLimit())); } if (streamInfo.getLanguageInfo() != null) { addMetadataItem(inflater, layout, false, R.string.metadata_language, streamInfo.getLanguageInfo().getDisplayLanguage(getAppLocale())); } addMetadataItem(inflater, layout, true, R.string.metadata_support, streamInfo.getSupportInfo()); addMetadataItem(inflater, layout, true, R.string.metadata_host, streamInfo.getHost()); addImagesMetadataItem(inflater, layout, R.string.metadata_thumbnails, streamInfo.getThumbnails()); addImagesMetadataItem(inflater, layout, R.string.metadata_uploader_avatars, streamInfo.getUploaderAvatars()); addImagesMetadataItem(inflater, layout, R.string.metadata_subchannel_avatars, streamInfo.getSubChannelAvatars()); } private void addPrivacyMetadataItem(final LayoutInflater inflater, final LinearLayout layout) { if (streamInfo.getPrivacy() != null) { @StringRes final int contentRes; switch (streamInfo.getPrivacy()) { case PUBLIC: contentRes = R.string.metadata_privacy_public; break; case UNLISTED: contentRes = R.string.metadata_privacy_unlisted; break; case PRIVATE: contentRes = R.string.metadata_privacy_private; break; case INTERNAL: contentRes = R.string.metadata_privacy_internal; break; case OTHER: default: contentRes = 0; break; } if (contentRes != 0) { addMetadataItem(inflater, layout, false, R.string.metadata_privacy, getString(contentRes)); } } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.java ================================================ package org.schabi.newpipe.fragments.detail; import androidx.annotation.NonNull; import org.schabi.newpipe.player.playqueue.PlayQueue; import java.io.Serializable; class StackItem implements Serializable { private final int serviceId; private String url; private String title; private PlayQueue playQueue; StackItem(final int serviceId, final String url, final String title, final PlayQueue playQueue) { this.serviceId = serviceId; this.url = url; this.title = title; this.playQueue = playQueue; } public void setUrl(final String url) { this.url = url; } public void setPlayQueue(final PlayQueue queue) { this.playQueue = queue; } public int getServiceId() { return serviceId; } public String getTitle() { return title; } public void setTitle(final String title) { this.title = title; } public String getUrl() { return url; } public PlayQueue getPlayQueue() { return playQueue; } @NonNull @Override public String toString() { return getServiceId() + ":" + getUrl() + " > " + getTitle(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java ================================================ package org.schabi.newpipe.fragments.detail; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentPagerAdapter; import java.util.ArrayList; import java.util.List; public class TabAdapter extends FragmentPagerAdapter { private final List mFragmentList = new ArrayList<>(); private final List mFragmentTitleList = new ArrayList<>(); private final FragmentManager fragmentManager; public TabAdapter(final FragmentManager fm) { // if changed to BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT => crash if enqueueing stream in // the background and then clicking on it to open VideoDetailFragment: // "Cannot setMaxLifecycle for Fragment not attached to FragmentManager" super(fm, BEHAVIOR_SET_USER_VISIBLE_HINT); this.fragmentManager = fm; } @NonNull @Override public Fragment getItem(final int position) { return mFragmentList.get(position); } @Override public int getCount() { return mFragmentList.size(); } public void addFragment(final Fragment fragment, final String title) { mFragmentList.add(fragment); mFragmentTitleList.add(title); } public void clearAllItems() { mFragmentList.clear(); mFragmentTitleList.clear(); } public void removeItem(final int position) { mFragmentList.remove(position == 0 ? 0 : position - 1); mFragmentTitleList.remove(position == 0 ? 0 : position - 1); } public void updateItem(final int position, final Fragment fragment) { mFragmentList.set(position, fragment); } public void updateItem(final String title, final Fragment fragment) { final int index = mFragmentTitleList.indexOf(title); if (index != -1) { updateItem(index, fragment); } } @Override public int getItemPosition(@NonNull final Object object) { if (mFragmentList.contains(object)) { return mFragmentList.indexOf(object); } else { return POSITION_NONE; } } public int getItemPositionByTitle(final String title) { return mFragmentTitleList.indexOf(title); } @Nullable public String getItemTitle(final int position) { if (position < 0 || position >= mFragmentTitleList.size()) { return null; } return mFragmentTitleList.get(position); } public void notifyDataSetUpdate() { notifyDataSetChanged(); } @Override public void destroyItem(@NonNull final ViewGroup container, final int position, @NonNull final Object object) { fragmentManager.beginTransaction().remove((Fragment) object).commitNowAllowingStateLoss(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java ================================================ package org.schabi.newpipe.fragments.detail; import static android.text.TextUtils.isEmpty; import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.COMMENTS; import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateRotation; import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientationLocked; import static org.schabi.newpipe.player.helper.PlayerHelper.isClearingQueueConfirmationRequired; import static org.schabi.newpipe.util.DependentPreferenceHelper.getResumePlaybackEnabled; import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView; import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams; import static org.schabi.newpipe.util.NavigationHelper.openPlayQueue; import android.animation.ValueAnimator; import android.annotation.SuppressLint; import android.app.Activity; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.content.pm.ActivityInfo; import android.database.ContentObserver; import android.graphics.Color; import android.graphics.Rect; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.provider.Settings; import android.util.DisplayMetrics; import android.util.Log; import android.util.TypedValue; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.view.WindowManager; import android.view.animation.DecelerateInterpolator; import android.widget.FrameLayout; import android.widget.RelativeLayout; import android.widget.Toast; import androidx.annotation.AttrRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.widget.Toolbar; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; import androidx.preference.PreferenceManager; import com.evernote.android.state.State; import com.google.android.exoplayer2.PlaybackException; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.material.appbar.AppBarLayout; import com.google.android.material.bottomsheet.BottomSheetBehavior; import com.google.android.material.tabs.TabLayout; import org.schabi.newpipe.App; import org.schabi.newpipe.R; import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.databinding.FragmentVideoDetailBinding; import org.schabi.newpipe.download.DownloadDialog; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.ReCaptchaActivity; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.Image; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.fragments.BackPressable; 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.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.player.Player; import org.schabi.newpipe.player.PlayerIntentType; import org.schabi.newpipe.player.PlayerService; import org.schabi.newpipe.player.PlayerType; import org.schabi.newpipe.player.event.OnKeyDownListener; import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener; import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.player.ui.MainPlayerUi; import org.schabi.newpipe.player.ui.VideoPlayerUi; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.InfoCache; 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.PlayButtonHelper; 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.image.CoilHelper; import java.util.ArrayList; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import coil3.util.CoilUtils; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; public final class VideoDetailFragment extends BaseStateFragment implements BackPressable, PlayerServiceExtendedEventListener, OnKeyDownListener { public static final String KEY_SWITCHING_PLAYERS = "switching_players"; private static final float MAX_OVERLAY_ALPHA = 0.9f; private static final float MAX_PLAYER_HEIGHT = 0.7f; public static final String ACTION_SHOW_MAIN_PLAYER = App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER"; public static final String ACTION_HIDE_MAIN_PLAYER = App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER"; public static final String ACTION_PLAYER_STARTED = App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_PLAYER_STARTED"; public static final String ACTION_VIDEO_FRAGMENT_RESUMED = App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED"; public static final String ACTION_VIDEO_FRAGMENT_STOPPED = App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED"; 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 EMPTY_TAB_TAG = "EMPTY TAB"; // tabs private boolean showComments; private boolean showRelatedItems; private boolean showDescription; private String selectedTabTag; @AttrRes @NonNull final List tabIcons = new ArrayList<>(); @StringRes @NonNull final List tabContentDescriptions = new ArrayList<>(); private boolean tabSettingsChanged = false; 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; } }; @State protected int serviceId = Constants.NO_SERVICE_ID; @State @NonNull protected String title = ""; @State @Nullable protected String url = null; @Nullable protected PlayQueue playQueue = null; @State int bottomSheetState = BottomSheetBehavior.STATE_EXPANDED; @State int lastStableBottomSheetState = BottomSheetBehavior.STATE_EXPANDED; @State protected boolean autoPlayEnabled = true; @Nullable private StreamInfo currentInfo = null; private Disposable currentWorker; @NonNull private final CompositeDisposable disposables = new CompositeDisposable(); @Nullable private Disposable positionSubscriber = null; private BottomSheetBehavior bottomSheetBehavior; private BottomSheetBehavior.BottomSheetCallback bottomSheetCallback; private BroadcastReceiver broadcastReceiver; /*////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////*/ private FragmentVideoDetailBinding binding; private TabAdapter pageAdapter; private ContentObserver settingsContentObserver; @Nullable private PlayerService playerService; private Player player; private final PlayerHolder playerHolder = PlayerHolder.getInstance(); /*////////////////////////////////////////////////////////////////////////// // Service management //////////////////////////////////////////////////////////////////////////*/ @Override public void onServiceConnected(@NonNull final PlayerService connectedPlayerService) { playerService = connectedPlayerService; } @Override public void onPlayerConnected(@NonNull final Player connectedPlayer, final boolean playAfterConnect) { player = connectedPlayer; // It will do nothing if the player is not in fullscreen mode hideSystemUiIfNeeded(); final Optional playerUi = player.UIs().get(MainPlayerUi.class); if (!player.videoPlayerSelected() && !playAfterConnect) { return; } if (DeviceUtils.isLandscape(requireContext())) { // If the video is playing but orientation changed // let's make the video in fullscreen again checkLandscape(); } else if (playerUi.map(ui -> ui.isFullscreen() && !ui.isVerticalVideo()).orElse(false) // Tablet UI has orientation-independent fullscreen && !DeviceUtils.isTablet(activity)) { // Device is in portrait orientation after rotation but UI is in fullscreen. // Return back to non-fullscreen state playerUi.ifPresent(MainPlayerUi::toggleFullscreen); } if (playAfterConnect || (currentInfo != null && isAutoplayEnabled() && playerUi.isEmpty())) { autoPlayEnabled = true; // forcefully start playing openVideoPlayerAutoFullscreen(); } updateOverlayPlayQueueButtonVisibility(); } @Override public void onPlayerDisconnected() { player = null; // the binding could be null at this point, if the app is finishing if (binding != null) { restoreDefaultBrightness(); } } @Override public void onServiceDisconnected() { playerService = null; } /*////////////////////////////////////////////////////////////////////////*/ public static VideoDetailFragment getInstance(final int serviceId, @Nullable final String url, @NonNull final String name, @Nullable final PlayQueue queue) { final VideoDetailFragment instance = new VideoDetailFragment(); instance.setInitialData(serviceId, url, name, queue); return instance; } public static VideoDetailFragment getInstanceInCollapsedState() { final VideoDetailFragment instance = new VideoDetailFragment(); instance.updateBottomSheetState(BottomSheetBehavior.STATE_COLLAPSED); return instance; } /*////////////////////////////////////////////////////////////////////////// // Fragment's Lifecycle //////////////////////////////////////////////////////////////////////////*/ @Override public void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); 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); selectedTabTag = prefs.getString( getString(R.string.stream_info_selected_tab_key), COMMENTS_TAB_TAG); prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener); setupBroadcastReceiver(); settingsContentObserver = new ContentObserver(new Handler()) { @Override public void onChange(final boolean selfChange) { if (activity != null && !globalScreenOrientationLocked(activity)) { activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); } } }; activity.getContentResolver().registerContentObserver( Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false, settingsContentObserver); } @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { binding = FragmentVideoDetailBinding.inflate(inflater, container, false); return binding.getRoot(); } @Override public void onPause() { super.onPause(); if (currentWorker != null) { currentWorker.dispose(); } restoreDefaultBrightness(); PreferenceManager.getDefaultSharedPreferences(requireContext()) .edit() .putString(getString(R.string.stream_info_selected_tab_key), pageAdapter.getItemTitle(binding.viewPager.getCurrentItem())) .apply(); } @Override public void onResume() { super.onResume(); if (DEBUG) { Log.d(TAG, "onResume() called"); } activity.sendBroadcast(new Intent(ACTION_VIDEO_FRAGMENT_RESUMED)); updateOverlayPlayQueueButtonVisibility(); setupBrightness(); if (tabSettingsChanged) { tabSettingsChanged = false; initTabs(); if (currentInfo != null) { updateTabs(currentInfo); } } // Check if it was loading when the fragment was stopped/paused if (wasLoading.getAndSet(false) && !wasCleared()) { startLoading(false); } } @Override public void onStop() { super.onStop(); if (!activity.isChangingConfigurations()) { activity.sendBroadcast(new Intent(ACTION_VIDEO_FRAGMENT_STOPPED)); } } @Override public void onDestroy() { super.onDestroy(); // Stop the service when user leaves the app with double back press // if video player is selected. Otherwise unbind if (activity.isFinishing() && isPlayerAvailable() && player.videoPlayerSelected()) { playerHolder.stopService(); } else { playerHolder.setListener(null); } PreferenceManager.getDefaultSharedPreferences(activity) .unregisterOnSharedPreferenceChangeListener(preferenceChangeListener); activity.unregisterReceiver(broadcastReceiver); activity.getContentResolver().unregisterContentObserver(settingsContentObserver); if (positionSubscriber != null) { positionSubscriber.dispose(); } if (currentWorker != null) { currentWorker.dispose(); } disposables.clear(); positionSubscriber = null; currentWorker = null; bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback); if (activity.isFinishing()) { playQueue = null; currentInfo = null; stack = new LinkedList<>(); } } @Override public void onDestroyView() { super.onDestroyView(); binding = null; } @Override public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { super.onActivityResult(requestCode, resultCode, data); switch (requestCode) { case ReCaptchaActivity.RECAPTCHA_REQUEST: if (resultCode == Activity.RESULT_OK) { NavigationHelper.openVideoDetailFragment(requireContext(), getFM(), serviceId, url, title, null, false); } else { Log.e(TAG, "ReCaptcha failed"); } break; default: Log.e(TAG, "Request code from activity not supported [" + requestCode + "]"); break; } } /*////////////////////////////////////////////////////////////////////////// // OnClick //////////////////////////////////////////////////////////////////////////*/ private void setOnClickListeners() { binding.detailTitleRootLayout.setOnClickListener(v -> toggleTitleAndSecondaryControls()); binding.detailUploaderRootLayout.setOnClickListener(makeOnClickListener(info -> { if (isEmpty(info.getSubChannelUrl())) { if (!isEmpty(info.getUploaderUrl())) { openChannel(info.getUploaderUrl(), info.getUploaderName()); } if (DEBUG) { Log.i(TAG, "Can't open sub-channel because we got no channel URL"); } } else { openChannel(info.getSubChannelUrl(), info.getSubChannelName()); } })); binding.detailThumbnailRootLayout.setOnClickListener(v -> { autoPlayEnabled = true; // forcefully start playing // FIXME Workaround #7427 if (isPlayerAvailable()) { player.setRecovery(); } openVideoPlayerAutoFullscreen(); }); binding.detailControlsBackground.setOnClickListener(v -> openBackgroundPlayer(false)); binding.detailControlsPopup.setOnClickListener(v -> openPopupPlayer(false)); binding.detailControlsPlaylistAppend.setOnClickListener(makeOnClickListener(info -> { if (getFM() != null && currentInfo != null) { final Fragment fragment = getParentFragmentManager(). findFragmentById(R.id.fragment_holder); // commit previous pending changes to database if (fragment instanceof LocalPlaylistFragment) { ((LocalPlaylistFragment) fragment).saveImmediate(); } else if (fragment instanceof MainFragment) { ((MainFragment) fragment).commitPlaylistTabs(); } disposables.add(PlaylistDialog.createCorrespondingDialog(requireContext(), List.of(new StreamEntity(info)), dialog -> dialog.show(getParentFragmentManager(), TAG))); } })); binding.detailControlsDownload.setOnClickListener(v -> { if (PermissionHelper.checkStoragePermissions(activity, PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { openDownloadDialog(); } }); binding.detailControlsShare.setOnClickListener(makeOnClickListener(info -> ShareUtils.shareText(requireContext(), info.getName(), info.getUrl(), info.getThumbnails()))); binding.detailControlsOpenInBrowser.setOnClickListener(makeOnClickListener(info -> ShareUtils.openUrlInBrowser(requireContext(), info.getUrl()))); binding.detailControlsPlayWithKodi.setOnClickListener(makeOnClickListener(info -> KoreUtils.playWithKore(requireContext(), Uri.parse(info.getUrl())))); if (DEBUG) { binding.detailControlsCrashThePlayer.setOnClickListener(v -> VideoDetailPlayerCrasher.onCrashThePlayer(requireContext(), player)); } final View.OnClickListener overlayListener = v -> bottomSheetBehavior .setState(BottomSheetBehavior.STATE_EXPANDED); binding.overlayThumbnail.setOnClickListener(overlayListener); binding.overlayMetadataLayout.setOnClickListener(overlayListener); binding.overlayButtonsLayout.setOnClickListener(overlayListener); binding.overlayCloseButton.setOnClickListener(v -> bottomSheetBehavior .setState(BottomSheetBehavior.STATE_HIDDEN)); binding.overlayPlayQueueButton.setOnClickListener(v -> openPlayQueue(requireContext())); binding.overlayPlayPauseButton.setOnClickListener(v -> { if (playerIsNotStopped()) { player.playPause(); player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); showSystemUi(); } else { autoPlayEnabled = true; // forcefully start playing openVideoPlayer(false); } setOverlayPlayPauseImage(isPlayerAvailable() && player.isPlaying()); }); } private View.OnClickListener makeOnClickListener(final Consumer consumer) { return v -> { if (!isLoading.get() && currentInfo != null) { consumer.accept(currentInfo); } }; } private void setOnLongClickListeners() { binding.detailTitleRootLayout.setOnLongClickListener(makeOnLongClickListener(info -> ShareUtils.copyToClipboard(requireContext(), binding.detailVideoTitleView.getText().toString()))); binding.detailUploaderRootLayout.setOnLongClickListener(makeOnLongClickListener(info -> { if (isEmpty(info.getSubChannelUrl())) { Log.w(TAG, "Can't open parent channel because we got no parent channel URL"); } else { openChannel(info.getUploaderUrl(), info.getUploaderName()); } })); binding.detailControlsBackground.setOnLongClickListener(makeOnLongClickListener(info -> openBackgroundPlayer(true) )); binding.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(info -> openPopupPlayer(true) )); binding.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(info -> NavigationHelper.openDownloads(activity))); final View.OnLongClickListener overlayListener = makeOnLongClickListener(info -> openChannel(info.getUploaderUrl(), info.getUploaderName())); binding.overlayThumbnail.setOnLongClickListener(overlayListener); binding.overlayMetadataLayout.setOnLongClickListener(overlayListener); } private View.OnLongClickListener makeOnLongClickListener(final Consumer consumer) { return v -> { if (isLoading.get() || currentInfo == null) { return false; } consumer.accept(currentInfo); return true; }; } private void openChannel(final String subChannelUrl, final String subChannelName) { try { NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(), subChannelUrl, subChannelName); } catch (final Exception e) { ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e); } } private void toggleTitleAndSecondaryControls() { if (binding.detailSecondaryControlPanel.getVisibility() == View.GONE) { binding.detailVideoTitleView.setMaxLines(10); animateRotation(binding.detailToggleSecondaryControlsView, VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 180); binding.detailSecondaryControlPanel.setVisibility(View.VISIBLE); } else { binding.detailVideoTitleView.setMaxLines(1); animateRotation(binding.detailToggleSecondaryControlsView, VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 0); binding.detailSecondaryControlPanel.setVisibility(View.GONE); } // view pager height has changed, update the tab layout updateTabLayoutVisibility(); } /*////////////////////////////////////////////////////////////////////////// // Init //////////////////////////////////////////////////////////////////////////*/ @Override // called from onViewCreated in {@link BaseFragment#onViewCreated} protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); pageAdapter = new TabAdapter(getChildFragmentManager()); binding.viewPager.setAdapter(pageAdapter); binding.tabLayout.setupWithViewPager(binding.viewPager); binding.detailThumbnailRootLayout.requestFocus(); binding.detailControlsPlayWithKodi.setVisibility( KoreUtils.shouldShowPlayWithKodi(requireContext(), serviceId) ? View.VISIBLE : View.GONE ); binding.detailControlsCrashThePlayer.setVisibility( DEBUG && PreferenceManager.getDefaultSharedPreferences(getContext()) .getBoolean(getString(R.string.show_crash_the_player_key), false) ? View.VISIBLE : View.GONE ); accommodateForTvAndDesktopMode(); } @Override @SuppressLint("ClickableViewAccessibility") protected void initListeners() { super.initListeners(); // Workaround for #5600 // Forcefully catch click events uncaught by children because otherwise // they will be caught by underlying view and "click through" will happen binding.getRoot().setOnClickListener(v -> { }); binding.getRoot().setOnLongClickListener(v -> true); setOnClickListeners(); setOnLongClickListeners(); final View.OnTouchListener controlsTouchListener = (view, motionEvent) -> { if (motionEvent.getAction() == MotionEvent.ACTION_DOWN && PlayButtonHelper.shouldShowHoldToAppendTip(activity)) { animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA, 0, () -> animate(binding.touchAppendDetail, false, 1500, AnimationType.ALPHA, 1000)); } return false; }; binding.detailControlsBackground.setOnTouchListener(controlsTouchListener); binding.detailControlsPopup.setOnTouchListener(controlsTouchListener); binding.appBarLayout.addOnOffsetChangedListener((layout, verticalOffset) -> { // prevent useless updates to tab layout visibility if nothing changed if (verticalOffset != lastAppBarVerticalOffset) { lastAppBarVerticalOffset = verticalOffset; // the view was scrolled updateTabLayoutVisibility(); } }); setupBottomPlayer(); if (!playerHolder.isBound()) { setHeightThumbnail(); } else { playerHolder.startService(false, this); } } /*////////////////////////////////////////////////////////////////////////// // OwnStack //////////////////////////////////////////////////////////////////////////*/ /** * Stack that contains the "navigation history".
* The peek is the current video. */ private static LinkedList stack = new LinkedList<>(); @Override public boolean onKeyDown(final int keyCode) { return isPlayerAvailable() && player.UIs().get(VideoPlayerUi.class) .map(playerUi -> playerUi.onKeyDown(keyCode)).orElse(false); } @Override public boolean onBackPressed() { if (DEBUG) { Log.d(TAG, "onBackPressed() called"); } // If we are in fullscreen mode just exit from it via first back press if (isFullscreen()) { if (!DeviceUtils.isTablet(activity)) { player.pause(); } restoreDefaultOrientation(); setAutoPlay(false); return true; } // If we have something in history of played items we replay it here if (isPlayerAvailable() && player.getPlayQueue() != null && player.videoPlayerSelected() && player.getPlayQueue().previous()) { return true; // no code here, as previous() was used in the if } // That means that we are on the start of the stack, if (stack.size() <= 1) { restoreDefaultOrientation(); return false; // let MainActivity handle the onBack (e.g. to minimize the mini player) } // Remove top stack.pop(); // Get stack item from the new top setupFromHistoryItem(Objects.requireNonNull(stack.peek())); return true; } private void setupFromHistoryItem(final StackItem item) { setAutoPlay(false); hideMainPlayerOnLoadingNewStream(); setInitialData(item.getServiceId(), item.getUrl(), item.getTitle() == null ? "" : item.getTitle(), item.getPlayQueue()); startLoading(false); // Maybe an item was deleted in background activity if (item.getPlayQueue().getItem() == null) { return; } final PlayQueueItem playQueueItem = item.getPlayQueue().getItem(); // Update title, url, uploader from the last item in the stack (it's current now) final boolean isPlayerStopped = !isPlayerAvailable() || player.isStopped(); if (playQueueItem != null && isPlayerStopped) { updateOverlayData(playQueueItem.getTitle(), playQueueItem.getUploader(), playQueueItem.getThumbnails()); } } /*////////////////////////////////////////////////////////////////////////// // Info loading and handling //////////////////////////////////////////////////////////////////////////*/ @Override protected void doInitialLoadLogic() { if (wasCleared()) { return; } if (currentInfo == null) { prepareAndLoadInfo(); } else { prepareAndHandleInfoIfNeededAfterDelay(currentInfo, false, 50); } } public void selectAndLoadVideo(final int newServiceId, @Nullable final String newUrl, @NonNull final String newTitle, @Nullable final PlayQueue newQueue) { if (isPlayerAvailable() && newQueue != null && playQueue != null && playQueue.getItem() != null && !playQueue.getItem().getUrl().equals(newUrl)) { // Preloading can be disabled since playback is surely being replaced. player.disablePreloadingOfCurrentTrack(); } setInitialData(newServiceId, newUrl, newTitle, newQueue); startLoading(false, true); } private void prepareAndHandleInfoIfNeededAfterDelay(final StreamInfo info, final boolean scrollToTop, final long delay) { new Handler(Looper.getMainLooper()).postDelayed(() -> { if (activity == null) { return; } // Data can already be drawn, don't spend time twice if (info.getName().equals(binding.detailVideoTitleView.getText().toString())) { return; } prepareAndHandleInfo(info, scrollToTop); }, delay); } private void prepareAndHandleInfo(final StreamInfo info, final boolean scrollToTop) { if (DEBUG) { Log.d(TAG, "prepareAndHandleInfo() called with: " + "info = [" + info + "], scrollToTop = [" + scrollToTop + "]"); } showLoading(); initTabs(); if (scrollToTop) { scrollToTop(); } handleResult(info); showContent(); } protected void prepareAndLoadInfo() { scrollToTop(); startLoading(false); } @Override public void startLoading(final boolean forceLoad) { super.startLoading(forceLoad); initTabs(); currentInfo = null; if (currentWorker != null) { currentWorker.dispose(); } runWorker(forceLoad, stack.isEmpty()); } private void startLoading(final boolean forceLoad, final boolean addToBackStack) { super.startLoading(forceLoad); initTabs(); currentInfo = null; if (currentWorker != null) { currentWorker.dispose(); } runWorker(forceLoad, addToBackStack); } private void runWorker(final boolean forceLoad, final boolean addToBackStack) { final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); currentWorker = ExtractorHelper.getStreamInfo(serviceId, url, forceLoad) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(result -> { isLoading.set(false); hideMainPlayerOnLoadingNewStream(); if (result.getAgeLimit() != NO_AGE_LIMIT && !prefs.getBoolean( getString(R.string.show_age_restricted_content), false)) { hideAgeRestrictedContent(); } else { handleResult(result); showContent(); if (addToBackStack) { if (playQueue == null) { playQueue = new SinglePlayQueue(result); } if (stack.isEmpty() || !stack.peek().getPlayQueue() .equalStreams(playQueue)) { stack.push(new StackItem(serviceId, url, title, playQueue)); } } if (isAutoplayEnabled()) { openVideoPlayerAutoFullscreen(); } } }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_STREAM, url == null ? "no url" : url, serviceId, url))); } /*////////////////////////////////////////////////////////////////////////// // Tabs //////////////////////////////////////////////////////////////////////////*/ private void initTabs() { if (pageAdapter.getCount() != 0) { selectedTabTag = pageAdapter.getItemTitle(binding.viewPager.getCurrentItem()); } pageAdapter.clearAllItems(); tabIcons.clear(); tabContentDescriptions.clear(); if (shouldShowComments()) { pageAdapter.addFragment( CommentsFragment.getInstance(serviceId, url, title), COMMENTS_TAB_TAG); tabIcons.add(R.drawable.ic_comment); tabContentDescriptions.add(R.string.comments_tab_description); } if (showRelatedItems && binding.relatedItemsLayout == null) { // temp empty fragment. will be updated in handleResult pageAdapter.addFragment(EmptyFragment.newInstance(false), RELATED_TAB_TAG); tabIcons.add(R.drawable.ic_art_track); tabContentDescriptions.add(R.string.related_items_tab_description); } if (showDescription) { // temp empty fragment. will be updated in handleResult pageAdapter.addFragment(EmptyFragment.newInstance(false), DESCRIPTION_TAB_TAG); tabIcons.add(R.drawable.ic_description); tabContentDescriptions.add(R.string.description_tab_description); } if (pageAdapter.getCount() == 0) { pageAdapter.addFragment(EmptyFragment.newInstance(true), EMPTY_TAB_TAG); } pageAdapter.notifyDataSetUpdate(); if (pageAdapter.getCount() >= 2) { final int position = pageAdapter.getItemPositionByTitle(selectedTabTag); if (position != -1) { binding.viewPager.setCurrentItem(position); } updateTabIconsAndContentDescriptions(); } // the page adapter now contains tabs: show the tab layout updateTabLayoutVisibility(); } /** * To be called whenever {@link #pageAdapter} is modified, since that triggers a refresh in * {@link FragmentVideoDetailBinding#tabLayout} resetting all tab's icons and content * descriptions. This reads icons from {@link #tabIcons} and content descriptions from * {@link #tabContentDescriptions}, which are all set in {@link #initTabs()}. */ private void updateTabIconsAndContentDescriptions() { for (int i = 0; i < tabIcons.size(); ++i) { final TabLayout.Tab tab = binding.tabLayout.getTabAt(i); if (tab != null) { tab.setIcon(tabIcons.get(i)); tab.setContentDescription(tabContentDescriptions.get(i)); } } } private void updateTabs(@NonNull final StreamInfo info) { if (showRelatedItems) { if (binding.relatedItemsLayout == null) { // phone pageAdapter.updateItem(RELATED_TAB_TAG, RelatedItemsFragment.getInstance(info)); } else { // tablet + TV getChildFragmentManager().beginTransaction() .replace(R.id.relatedItemsLayout, RelatedItemsFragment.getInstance(info)) .commitAllowingStateLoss(); binding.relatedItemsLayout.setVisibility(isFullscreen() ? View.GONE : View.VISIBLE); } } if (showDescription) { pageAdapter.updateItem(DESCRIPTION_TAB_TAG, new DescriptionFragment(info)); } binding.viewPager.setVisibility(View.VISIBLE); // make sure the tab layout is visible updateTabLayoutVisibility(); pageAdapter.notifyDataSetUpdate(); updateTabIconsAndContentDescriptions(); } private boolean shouldShowComments() { try { return showComments && NewPipe.getService(serviceId) .getServiceInfo() .getMediaCapabilities() .contains(COMMENTS); } catch (final ExtractionException e) { return false; } } public void updateTabLayoutVisibility() { if (binding == null) { //If binding is null we do not need to and should not do anything with its object(s) return; } if (pageAdapter.getCount() < 2 || binding.viewPager.getVisibility() != View.VISIBLE) { // hide tab layout if there is only one tab or if the view pager is also hidden binding.tabLayout.setVisibility(View.GONE); } else { // call `post()` to be sure `viewPager.getHitRect()` // is up to date and not being currently recomputed binding.tabLayout.post(() -> { final var activity = getActivity(); if (activity != null) { final Rect pagerHitRect = new Rect(); binding.viewPager.getHitRect(pagerHitRect); final int height = DeviceUtils.getWindowHeight(activity.getWindowManager()); final int viewPagerVisibleHeight = height - pagerHitRect.top; // see TabLayout.DEFAULT_HEIGHT, which is equal to 48dp final float tabLayoutHeight = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, 48, getResources().getDisplayMetrics()); if (viewPagerVisibleHeight > tabLayoutHeight * 2) { // no translation at all when viewPagerVisibleHeight > tabLayout.height * 3 binding.tabLayout.setTranslationY( Math.max(0, tabLayoutHeight * 3 - viewPagerVisibleHeight)); binding.tabLayout.setVisibility(View.VISIBLE); } else { // view pager is not visible enough binding.tabLayout.setVisibility(View.GONE); } } }); } } public void scrollToTop() { binding.appBarLayout.setExpanded(true, true); // notify tab layout of scrolling updateTabLayoutVisibility(); } public void scrollToComment(final CommentsInfoItem comment) { final int commentsTabPos = pageAdapter.getItemPositionByTitle(COMMENTS_TAB_TAG); final Fragment fragment = pageAdapter.getItem(commentsTabPos); if (!(fragment instanceof CommentsFragment)) { return; } // unexpand the app bar only if scrolling to the comment succeeded if (((CommentsFragment) fragment).scrollToComment(comment)) { binding.appBarLayout.setExpanded(false, false); binding.viewPager.setCurrentItem(commentsTabPos, false); } } /*////////////////////////////////////////////////////////////////////////// // Play Utils //////////////////////////////////////////////////////////////////////////*/ private void toggleFullscreenIfInFullscreenMode() { // If a user watched video inside fullscreen mode and than chose another player // return to non-fullscreen mode if (isPlayerAvailable()) { player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> { if (playerUi.isFullscreen()) { playerUi.toggleFullscreen(); } }); } } private void openBackgroundPlayer(final boolean append) { final boolean useExternalAudioPlayer = PreferenceManager .getDefaultSharedPreferences(activity) .getBoolean(activity.getString(R.string.use_external_audio_player_key), false); toggleFullscreenIfInFullscreenMode(); if (isPlayerAvailable()) { // FIXME Workaround #7427 player.setRecovery(); } if (useExternalAudioPlayer) { showExternalAudioPlaybackDialog(); } else { openNormalBackgroundPlayer(append); } } private void openPopupPlayer(final boolean append) { if (!PermissionHelper.isPopupEnabledElseAsk(activity)) { return; } // See UI changes while remote playQueue changes if (!isPlayerAvailable()) { playerHolder.startService(false, this); } else { // FIXME Workaround #7427 player.setRecovery(); } toggleFullscreenIfInFullscreenMode(); final PlayQueue queue = setupPlayQueueForIntent(append); if (append) { //resumePlayback: false NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.POPUP); } else { replaceQueueIfUserConfirms(() -> NavigationHelper .playOnPopupPlayer(activity, queue, true)); } } /** * Opens the video player, in fullscreen if needed. In order to open fullscreen, the activity * is toggled to landscape orientation (which will then cause fullscreen mode). * * @param directlyFullscreenIfApplicable whether to open fullscreen if we are not already * in landscape and screen orientation is locked */ public void openVideoPlayer(final boolean directlyFullscreenIfApplicable) { if (directlyFullscreenIfApplicable && !DeviceUtils.isLandscape(requireContext()) && PlayerHelper.globalScreenOrientationLocked(requireContext())) { // Make sure the bottom sheet turns out expanded. When this code kicks in the bottom // sheet could not have fully expanded yet, and thus be in the STATE_SETTLING state. // When the activity is rotated, and its state is saved and then restored, the bottom // sheet would forget what it was doing, since even if STATE_SETTLING is restored, it // doesn't tell which state it was settling to, and thus the bottom sheet settles to // STATE_COLLAPSED. This can be solved by manually setting the state that will be // restored (i.e. bottomSheetState) to STATE_EXPANDED. updateBottomSheetState(BottomSheetBehavior.STATE_EXPANDED); // toggle landscape in order to open directly in fullscreen onScreenRotationButtonClicked(); } if (PreferenceManager.getDefaultSharedPreferences(activity) .getBoolean(this.getString(R.string.use_external_video_player_key), false)) { showExternalVideoPlaybackDialog(); } else { replaceQueueIfUserConfirms(this::openMainPlayer); } } /** * If the option to start directly fullscreen is enabled, calls * {@link #openVideoPlayer(boolean)} with {@code directlyFullscreenIfApplicable = true}, so that * if the user is not already in landscape and he has screen orientation locked the activity * rotates and fullscreen starts. Otherwise, if the option to start directly fullscreen is * disabled, calls {@link #openVideoPlayer(boolean)} with {@code directlyFullscreenIfApplicable * = false}, hence preventing it from going directly fullscreen. */ public void openVideoPlayerAutoFullscreen() { openVideoPlayer(PlayerHelper.isStartMainPlayerFullscreenEnabled(requireContext())); } private void openNormalBackgroundPlayer(final boolean append) { // See UI changes while remote playQueue changes if (!isPlayerAvailable()) { playerHolder.startService(false, this); } final PlayQueue queue = setupPlayQueueForIntent(append); if (append) { NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.AUDIO); } else { replaceQueueIfUserConfirms(() -> NavigationHelper .playOnBackgroundPlayer(activity, queue, true)); } } private void openMainPlayer() { if (!isPlayerServiceAvailable()) { playerHolder.startService(autoPlayEnabled, this); return; } if (currentInfo == null) { return; } final PlayQueue queue = setupPlayQueueForIntent(false); tryAddVideoPlayerView(); final Context context = requireContext(); final Intent playerIntent = NavigationHelper.getPlayerIntent(context, PlayerService.class, queue, PlayerIntentType.AllOthers) .putExtra(Player.PLAY_WHEN_READY, autoPlayEnabled) .putExtra(Player.RESUME_PLAYBACK, true); ContextCompat.startForegroundService(activity, playerIntent); } /** * When the video detail fragment is already showing details for a video and the user opens a * new one, the video detail fragment changes all of its old data to the new stream, so if there * is a video player currently open it should be hidden. This method does exactly that. If * autoplay is enabled, the underlying player is not stopped completely, since it is going to * be reused in a few milliseconds and the flickering would be annoying. */ private void hideMainPlayerOnLoadingNewStream() { final var root = getRoot(); if (!isPlayerServiceAvailable() || root.isEmpty() || !player.videoPlayerSelected()) { return; } removeVideoPlayerView(); if (isAutoplayEnabled()) { playerService.stopForImmediateReusing(); root.ifPresent(view -> view.setVisibility(View.GONE)); } else { playerHolder.stopService(); } } private PlayQueue setupPlayQueueForIntent(final boolean append) { if (append) { return new SinglePlayQueue(currentInfo); } PlayQueue queue = playQueue; // Size can be 0 because queue removes bad stream automatically when error occurs if (queue == null || queue.isEmpty()) { queue = new SinglePlayQueue(currentInfo); } return queue; } /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ public void setAutoPlay(final boolean autoPlay) { this.autoPlayEnabled = autoPlay; } private void startOnExternalPlayer(@NonNull final Context context, @NonNull final StreamInfo info, @NonNull final Stream selectedStream) { NavigationHelper.playOnExternalPlayer(context, currentInfo.getName(), currentInfo.getSubChannelName(), selectedStream); final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext()); disposables.add(recordManager.onViewed(info).onErrorComplete() .subscribe( ignored -> { /* successful */ }, error -> showSnackBarError( new ErrorInfo( error, UserAction.PLAY_STREAM, "Got an error when modifying history on viewed" ) ) )); } private boolean isExternalPlayerEnabled() { return PreferenceManager.getDefaultSharedPreferences(requireContext()) .getBoolean(getString(R.string.use_external_video_player_key), false); } // This method overrides default behaviour when setAutoPlay() is called. // Don't auto play if the user selected an external player or disabled it in settings private boolean isAutoplayEnabled() { return autoPlayEnabled && !isExternalPlayerEnabled() && (!isPlayerAvailable() || player.videoPlayerSelected()) && bottomSheetState != BottomSheetBehavior.STATE_HIDDEN && PlayerHelper.isAutoplayAllowedByUser(requireContext()); } private void tryAddVideoPlayerView() { if (isPlayerAvailable() && getView() != null) { // Setup the surface view height, so that it fits the video correctly; this is done also // here, and not only in the Handler, to avoid a choppy fullscreen rotation animation. setHeightThumbnail(); } // do all the null checks in the posted lambda, too, since the player, the binding and the // view could be set or unset before the lambda gets executed on the next main thread cycle new Handler(Looper.getMainLooper()).post(() -> { if (!isPlayerAvailable() || getView() == null) { return; } // setup the surface view height, so that it fits the video correctly setHeightThumbnail(); player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> { // sometimes binding would be null here, even though getView() != null above u.u if (binding != null) { // prevent from re-adding a view multiple times playerUi.removeViewFromParent(); binding.playerPlaceholder.addView(playerUi.getBinding().getRoot()); playerUi.setupVideoSurfaceIfNeeded(); } }); }); } private void removeVideoPlayerView() { makeDefaultHeightForVideoPlaceholder(); if (player != null) { player.UIs().get(VideoPlayerUi.class).ifPresent(VideoPlayerUi::removeViewFromParent); } } private void makeDefaultHeightForVideoPlaceholder() { if (getView() == null) { return; } binding.playerPlaceholder.getLayoutParams().height = FrameLayout.LayoutParams.MATCH_PARENT; binding.playerPlaceholder.requestLayout(); } private final ViewTreeObserver.OnPreDrawListener preDrawListener = new ViewTreeObserver.OnPreDrawListener() { @Override public boolean onPreDraw() { final DisplayMetrics metrics = getResources().getDisplayMetrics(); if (getView() != null) { final int height = (DeviceUtils.isInMultiWindow(activity) ? requireView() : activity.getWindow().getDecorView()).getHeight(); setHeightThumbnail(height, metrics); getView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener); } return false; } }; /** * Method which controls the size of thumbnail and the size of main player inside * a layout with thumbnail. It decides what height the player should have in both * screen orientations. It knows about multiWindow feature * and about videos with aspectRatio ZOOM (the height for them will be a bit higher, * {@link #MAX_PLAYER_HEIGHT}) */ private void setHeightThumbnail() { final DisplayMetrics metrics = getResources().getDisplayMetrics(); final boolean isPortrait = metrics.heightPixels > metrics.widthPixels; requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener); if (isFullscreen()) { final int height = (DeviceUtils.isInMultiWindow(activity) ? requireView() : activity.getWindow().getDecorView()).getHeight(); // Height is zero when the view is not yet displayed like after orientation change if (height != 0) { setHeightThumbnail(height, metrics); } else { requireView().getViewTreeObserver().addOnPreDrawListener(preDrawListener); } } else { final int height = (int) (isPortrait ? metrics.widthPixels / (16.0f / 9.0f) : metrics.heightPixels / 2.0f); setHeightThumbnail(height, metrics); } } private void setHeightThumbnail(final int newHeight, final DisplayMetrics metrics) { binding.detailThumbnailImageView.setLayoutParams( new FrameLayout.LayoutParams( RelativeLayout.LayoutParams.MATCH_PARENT, newHeight)); binding.detailThumbnailImageView.setMinimumHeight(newHeight); if (isPlayerAvailable()) { final int maxHeight = (int) (metrics.heightPixels * MAX_PLAYER_HEIGHT); player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.getBinding().surfaceView.setHeights(newHeight, ui.isFullscreen() ? newHeight : maxHeight)); } } private void showContent() { binding.detailContentRootHiding.setVisibility(View.VISIBLE); } protected void setInitialData(final int newServiceId, @Nullable final String newUrl, @NonNull final String newTitle, @Nullable final PlayQueue newPlayQueue) { this.serviceId = newServiceId; this.url = newUrl; this.title = newTitle; this.playQueue = newPlayQueue; } private void setErrorImage(final int imageResource) { if (binding == null || activity == null) { return; } binding.detailThumbnailImageView.setImageDrawable( AppCompatResources.getDrawable(requireContext(), imageResource)); animate(binding.detailThumbnailImageView, false, 0, AnimationType.ALPHA, 0, () -> animate(binding.detailThumbnailImageView, true, 500)); } @Override public void handleError() { super.handleError(); setErrorImage(R.drawable.not_available_monkey); if (binding.relatedItemsLayout != null) { // hide related streams for tablets binding.relatedItemsLayout.setVisibility(View.INVISIBLE); } // hide comments / related streams / description tabs binding.viewPager.setVisibility(View.GONE); binding.tabLayout.setVisibility(View.GONE); } private void hideAgeRestrictedContent() { showTextError(getString(R.string.restricted_video, getString(R.string.show_age_restricted_content_title))); } private void setupBroadcastReceiver() { broadcastReceiver = new BroadcastReceiver() { @Override public void onReceive(final Context context, final Intent intent) { switch (intent.getAction()) { case ACTION_SHOW_MAIN_PLAYER: bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); break; case ACTION_HIDE_MAIN_PLAYER: bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); break; case ACTION_PLAYER_STARTED: // If the state is not hidden we don't need to show the mini player if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN) { bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); } // Rebound to the service if it was closed via notification or mini player if (!playerHolder.isBound()) { playerHolder.startService( false, VideoDetailFragment.this); } break; } } }; final IntentFilter intentFilter = new IntentFilter(); intentFilter.addAction(ACTION_SHOW_MAIN_PLAYER); intentFilter.addAction(ACTION_HIDE_MAIN_PLAYER); intentFilter.addAction(ACTION_PLAYER_STARTED); ContextCompat.registerReceiver(activity, broadcastReceiver, intentFilter, ContextCompat.RECEIVER_EXPORTED); } /*////////////////////////////////////////////////////////////////////////// // Orientation listener //////////////////////////////////////////////////////////////////////////*/ private void restoreDefaultOrientation() { if (isPlayerAvailable() && player.videoPlayerSelected()) { toggleFullscreenIfInFullscreenMode(); } // This will show systemUI and pause the player. // User can tap on Play button and video will be in fullscreen mode again // Note for tablet: trying to avoid orientation changes since it's not easy // to physically rotate the tablet every time if (activity != null && !DeviceUtils.isTablet(activity)) { activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED); } } /*////////////////////////////////////////////////////////////////////////// // Contract //////////////////////////////////////////////////////////////////////////*/ @Override public void showLoading() { super.showLoading(); //if data is already cached, transition from VISIBLE -> INVISIBLE -> VISIBLE is not required if (!ExtractorHelper.isCached(serviceId, url, InfoCache.Type.STREAM)) { binding.detailContentRootHiding.setVisibility(View.INVISIBLE); } animate(binding.detailThumbnailPlayButton, false, 50); animate(binding.detailDurationView, false, 100); binding.detailPositionView.setVisibility(View.GONE); binding.positionView.setVisibility(View.GONE); binding.detailVideoTitleView.setText(title); binding.detailVideoTitleView.setMaxLines(1); animate(binding.detailVideoTitleView, true, 0); binding.detailToggleSecondaryControlsView.setVisibility(View.GONE); binding.detailTitleRootLayout.setClickable(false); binding.detailSecondaryControlPanel.setVisibility(View.GONE); if (binding.relatedItemsLayout != null) { if (showRelatedItems) { binding.relatedItemsLayout.setVisibility( isFullscreen() ? View.GONE : View.INVISIBLE); } else { binding.relatedItemsLayout.setVisibility(View.GONE); } } CoilUtils.dispose(binding.detailThumbnailImageView); CoilUtils.dispose(binding.detailSubChannelThumbnailView); CoilUtils.dispose(binding.overlayThumbnail); CoilUtils.dispose(binding.detailUploaderThumbnailView); binding.detailThumbnailImageView.setImageBitmap(null); binding.detailSubChannelThumbnailView.setImageBitmap(null); } @Override public void handleResult(@NonNull final StreamInfo info) { super.handleResult(info); currentInfo = info; setInitialData(info.getServiceId(), info.getOriginalUrl(), info.getName(), playQueue); updateTabs(info); animate(binding.detailThumbnailPlayButton, true, 200); binding.detailVideoTitleView.setText(title); binding.detailSubChannelThumbnailView.setVisibility(View.GONE); if (!isEmpty(info.getSubChannelName())) { displayBothUploaderAndSubChannel(info); } else { displayUploaderAsSubChannel(info); } if (info.getViewCount() >= 0) { if (info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { binding.detailViewCountView.setText(Localization.listeningCount(activity, info.getViewCount())); } else if (info.getStreamType().equals(StreamType.LIVE_STREAM)) { binding.detailViewCountView.setText(Localization .localizeWatchingCount(activity, info.getViewCount())); } else { binding.detailViewCountView.setText(Localization .localizeViewCount(activity, info.getViewCount())); } binding.detailViewCountView.setVisibility(View.VISIBLE); } else { binding.detailViewCountView.setVisibility(View.GONE); } if (info.getDislikeCount() == -1 && info.getLikeCount() == -1) { binding.detailThumbsDownImgView.setVisibility(View.VISIBLE); binding.detailThumbsUpImgView.setVisibility(View.VISIBLE); binding.detailThumbsUpCountView.setVisibility(View.GONE); binding.detailThumbsDownCountView.setVisibility(View.GONE); binding.detailThumbsDisabledView.setVisibility(View.VISIBLE); } else { if (info.getDislikeCount() >= 0) { binding.detailThumbsDownCountView.setText(Localization .shortCount(activity, info.getDislikeCount())); binding.detailThumbsDownCountView.setVisibility(View.VISIBLE); binding.detailThumbsDownImgView.setVisibility(View.VISIBLE); } else { binding.detailThumbsDownCountView.setVisibility(View.GONE); binding.detailThumbsDownImgView.setVisibility(View.GONE); } if (info.getLikeCount() >= 0) { binding.detailThumbsUpCountView.setText(Localization.shortCount(activity, info.getLikeCount())); binding.detailThumbsUpCountView.setVisibility(View.VISIBLE); binding.detailThumbsUpImgView.setVisibility(View.VISIBLE); } else { binding.detailThumbsUpCountView.setVisibility(View.GONE); binding.detailThumbsUpImgView.setVisibility(View.GONE); } binding.detailThumbsDisabledView.setVisibility(View.GONE); } if (info.getDuration() > 0) { binding.detailDurationView.setText(Localization.getDurationString(info.getDuration())); binding.detailDurationView.setBackgroundColor( ContextCompat.getColor(activity, R.color.duration_background_color)); animate(binding.detailDurationView, true, 100); } else if (info.getStreamType() == StreamType.LIVE_STREAM) { binding.detailDurationView.setText(R.string.duration_live); binding.detailDurationView.setBackgroundColor( ContextCompat.getColor(activity, R.color.live_duration_background_color)); animate(binding.detailDurationView, true, 100); } else { binding.detailDurationView.setVisibility(View.GONE); } binding.detailTitleRootLayout.setClickable(true); binding.detailToggleSecondaryControlsView.setRotation(0); binding.detailToggleSecondaryControlsView.setVisibility(View.VISIBLE); binding.detailSecondaryControlPanel.setVisibility(View.GONE); checkUpdateProgressInfo(info); CoilHelper.INSTANCE.loadDetailsThumbnail(binding.detailThumbnailImageView, info.getThumbnails()); showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, binding.detailMetaInfoSeparator, disposables); if (!isPlayerAvailable() || player.isStopped()) { updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails()); } if (!info.getErrors().isEmpty()) { // Bandcamp fan pages are not yet supported and thus a ContentNotAvailableException is // thrown. This is not an error and thus should not be shown to the user. for (final Throwable throwable : info.getErrors()) { if (throwable instanceof ContentNotSupportedException && "Fan pages are not supported".equals(throwable.getMessage())) { info.getErrors().remove(throwable); } } if (!info.getErrors().isEmpty()) { showSnackBarError(new ErrorInfo(info.getErrors(), UserAction.REQUESTED_STREAM, "Some info not extracted: " + info.getUrl(), info)); } } binding.detailControlsDownload.setVisibility( StreamTypeUtil.isLiveStream(info.getStreamType()) ? View.GONE : View.VISIBLE); binding.detailControlsBackground.setVisibility( info.getAudioStreams().isEmpty() && info.getVideoStreams().isEmpty() ? View.GONE : View.VISIBLE); final boolean noVideoStreams = info.getVideoStreams().isEmpty() && info.getVideoOnlyStreams().isEmpty(); binding.detailControlsPopup.setVisibility(noVideoStreams ? View.GONE : View.VISIBLE); binding.detailThumbnailPlayButton.setImageResource( noVideoStreams ? R.drawable.ic_headset_shadow : R.drawable.ic_play_arrow_shadow); } private void displayUploaderAsSubChannel(final StreamInfo info) { binding.detailSubChannelTextView.setText(info.getUploaderName()); binding.detailSubChannelTextView.setVisibility(View.VISIBLE); binding.detailSubChannelTextView.setSelected(true); if (info.getUploaderSubscriberCount() > -1) { binding.detailUploaderTextView.setText( Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount())); binding.detailUploaderTextView.setVisibility(View.VISIBLE); } else { binding.detailUploaderTextView.setVisibility(View.GONE); } CoilHelper.INSTANCE.loadAvatar(binding.detailSubChannelThumbnailView, info.getUploaderAvatars()); binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); binding.detailUploaderThumbnailView.setVisibility(View.GONE); } private void displayBothUploaderAndSubChannel(final StreamInfo info) { binding.detailSubChannelTextView.setText(info.getSubChannelName()); binding.detailSubChannelTextView.setVisibility(View.VISIBLE); binding.detailSubChannelTextView.setSelected(true); final StringBuilder subText = new StringBuilder(); if (!isEmpty(info.getUploaderName())) { subText.append( String.format(getString(R.string.video_detail_by), info.getUploaderName())); } if (info.getUploaderSubscriberCount() > -1) { if (subText.length() > 0) { subText.append(Localization.DOT_SEPARATOR); } subText.append( Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount())); } if (subText.length() > 0) { binding.detailUploaderTextView.setText(subText); binding.detailUploaderTextView.setVisibility(View.VISIBLE); binding.detailUploaderTextView.setSelected(true); } else { binding.detailUploaderTextView.setVisibility(View.GONE); } CoilHelper.INSTANCE.loadAvatar(binding.detailSubChannelThumbnailView, info.getSubChannelAvatars()); binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); CoilHelper.INSTANCE.loadAvatar(binding.detailUploaderThumbnailView, info.getUploaderAvatars()); binding.detailUploaderThumbnailView.setVisibility(View.VISIBLE); } public void openDownloadDialog() { if (currentInfo == null) { return; } try { final DownloadDialog downloadDialog = new DownloadDialog(activity, currentInfo); downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog"); } catch (final Exception e) { ErrorUtil.showSnackbar(activity, new ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG, "Showing download dialog", currentInfo)); } } /*////////////////////////////////////////////////////////////////////////// // Stream Results //////////////////////////////////////////////////////////////////////////*/ private void checkUpdateProgressInfo(@NonNull final StreamInfo info) { if (positionSubscriber != null) { positionSubscriber.dispose(); } if (!getResumePlaybackEnabled(activity)) { binding.positionView.setVisibility(View.GONE); binding.detailPositionView.setVisibility(View.GONE); return; } final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext()); positionSubscriber = recordManager.loadStreamState(info) .subscribeOn(Schedulers.io()) .onErrorComplete() .observeOn(AndroidSchedulers.mainThread()) .subscribe(state -> { updatePlaybackProgress( state.getProgressMillis(), info.getDuration() * 1000); }, e -> { // impossible since the onErrorComplete() }, () -> { binding.positionView.setVisibility(View.GONE); binding.detailPositionView.setVisibility(View.GONE); }); } private void updatePlaybackProgress(final long progress, final long duration) { if (!getResumePlaybackEnabled(activity)) { return; } final int progressSeconds = (int) TimeUnit.MILLISECONDS.toSeconds(progress); final int durationSeconds = (int) TimeUnit.MILLISECONDS.toSeconds(duration); // If the old and the new progress values have a big difference then use animation. // Otherwise don't because it affects CPU final int progressDifference = Math.abs(binding.positionView.getProgress() - progressSeconds); binding.positionView.setMax(durationSeconds); if (progressDifference > 2) { binding.positionView.setProgressAnimated(progressSeconds); } else { binding.positionView.setProgress(progressSeconds); } final String position = Localization.getDurationString(progressSeconds); if (position != binding.detailPositionView.getText()) { binding.detailPositionView.setText(position); } if (binding.positionView.getVisibility() != View.VISIBLE) { animate(binding.positionView, true, 100); animate(binding.detailPositionView, true, 100); } } /*////////////////////////////////////////////////////////////////////////// // Player event listener //////////////////////////////////////////////////////////////////////////*/ @Override public void onViewCreated() { tryAddVideoPlayerView(); } @Override public void onQueueUpdate(final PlayQueue queue) { playQueue = queue; if (DEBUG) { Log.d(TAG, "onQueueUpdate() called with: serviceId = [" + serviceId + "], url = [" + url + "], name = [" + title + "], playQueue = [" + playQueue + "]"); } // Register broadcast receiver to listen to playQueue changes // and hide the overlayPlayQueueButton when the playQueue is empty / destroyed. if (playQueue != null && playQueue.getBroadcastReceiver() != null) { playQueue.getBroadcastReceiver().subscribe( event -> updateOverlayPlayQueueButtonVisibility() ); } // This should be the only place where we push data to stack. // It will allow to have live instance of PlayQueue with actual information about // deleted/added items inside Channel/Playlist queue and makes possible to have // a history of played items @Nullable final StackItem stackPeek = stack.peek(); if (stackPeek != null && !stackPeek.getPlayQueue().equalStreams(queue)) { @Nullable final PlayQueueItem playQueueItem = queue.getItem(); if (playQueueItem != null) { stack.push(new StackItem(playQueueItem.getServiceId(), playQueueItem.getUrl(), playQueueItem.getTitle(), queue)); return; } // else continue below } @Nullable final StackItem stackWithQueue = findQueueInStack(queue); if (stackWithQueue != null) { // On every MainPlayer service's destroy() playQueue gets disposed and // no longer able to track progress. That's why we update our cached disposed // queue with the new one that is active and have the same history. // Without that the cached playQueue will have an old recovery position stackWithQueue.setPlayQueue(queue); } } @Override public void onPlaybackUpdate(final int state, final int repeatMode, final boolean shuffled, final PlaybackParameters parameters) { setOverlayPlayPauseImage(player != null && player.isPlaying()); switch (state) { case Player.STATE_PLAYING: if (binding.positionView.getAlpha() != 1.0f && player.getPlayQueue() != null && player.getPlayQueue().getItem() != null && player.getPlayQueue().getItem().getUrl().equals(url)) { animate(binding.positionView, true, 100); animate(binding.detailPositionView, true, 100); } break; } } @Override public void onProgressUpdate(final int currentProgress, final int duration, final int bufferPercent) { // Progress updates every second even if media is paused. It's useless until playing if (!player.isPlaying() || playQueue == null) { return; } if (player.getPlayQueue().getItem().getUrl().equals(url)) { updatePlaybackProgress(currentProgress, duration); } } @Override public void onMetadataUpdate(final StreamInfo info, final PlayQueue queue) { final StackItem item = findQueueInStack(queue); if (item != null) { // When PlayQueue can have multiple streams (PlaylistPlayQueue or ChannelPlayQueue) // every new played stream gives new title and url. // StackItem contains information about first played stream. Let's update it here item.setTitle(info.getName()); item.setUrl(info.getUrl()); } // They are not equal when user watches something in popup while browsing in fragment and // then changes screen orientation. In that case the fragment will set itself as // a service listener and will receive initial call to onMetadataUpdate() if (!queue.equalStreams(playQueue)) { return; } updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails()); if (currentInfo != null && info.getUrl().equals(currentInfo.getUrl())) { return; } currentInfo = info; setInitialData(info.getServiceId(), info.getUrl(), info.getName(), queue); setAutoPlay(false); // Delay execution just because it freezes the main thread, and while playing // next/previous video you see visual glitches // (when non-vertical video goes after vertical video) prepareAndHandleInfoIfNeededAfterDelay(info, true, 200); } @Override public void onPlayerError(final PlaybackException error, final boolean isCatchableException) { if (!isCatchableException) { // Properly exit from fullscreen toggleFullscreenIfInFullscreenMode(); hideMainPlayerOnLoadingNewStream(); } } @Override public void onServiceStopped() { // the binding could be null at this point, if the app is finishing if (binding != null) { setOverlayPlayPauseImage(false); if (currentInfo != null) { updateOverlayData(currentInfo.getName(), currentInfo.getUploaderName(), currentInfo.getThumbnails()); } updateOverlayPlayQueueButtonVisibility(); } } @Override public void onFullscreenStateChanged(final boolean fullscreen) { setupBrightness(); if (!isPlayerAndPlayerServiceAvailable() || player.UIs().get(MainPlayerUi.class).isEmpty() || getRoot().map(View::getParent).isEmpty()) { return; } if (fullscreen) { hideSystemUiIfNeeded(); binding.overlayPlayPauseButton.requestFocus(); } else { showSystemUi(); } if (binding.relatedItemsLayout != null) { if (showRelatedItems) { binding.relatedItemsLayout.setVisibility(fullscreen ? View.GONE : View.VISIBLE); } else { binding.relatedItemsLayout.setVisibility(View.GONE); } } scrollToTop(); tryAddVideoPlayerView(); } @Override public void onScreenRotationButtonClicked() { // On Android TV screen rotation is not supported // In tablet user experience will be better if screen will not be rotated // from landscape to portrait every time. // Just turn on fullscreen mode in landscape orientation // or portrait & unlocked global orientation final boolean isLandscape = DeviceUtils.isLandscape(requireContext()); if (DeviceUtils.isTv(activity) || DeviceUtils.isTablet(activity) && (!globalScreenOrientationLocked(activity) || isLandscape)) { player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::toggleFullscreen); return; } final int newOrientation = isLandscape ? ActivityInfo.SCREEN_ORIENTATION_PORTRAIT : ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE; activity.setRequestedOrientation(newOrientation); } /* * Will scroll down to description view after long click on moreOptionsButton * */ @Override public void onMoreOptionsLongClicked() { final CoordinatorLayout.LayoutParams params = (CoordinatorLayout.LayoutParams) binding.appBarLayout.getLayoutParams(); final AppBarLayout.Behavior behavior = (AppBarLayout.Behavior) params.getBehavior(); final ValueAnimator valueAnimator = ValueAnimator .ofInt(0, -binding.playerPlaceholder.getHeight()); valueAnimator.setInterpolator(new DecelerateInterpolator()); valueAnimator.addUpdateListener(animation -> { behavior.setTopAndBottomOffset((int) animation.getAnimatedValue()); binding.appBarLayout.requestLayout(); }); valueAnimator.setInterpolator(new DecelerateInterpolator()); valueAnimator.setDuration(500); valueAnimator.start(); } /*////////////////////////////////////////////////////////////////////////// // Player related utils //////////////////////////////////////////////////////////////////////////*/ private void showSystemUi() { if (DEBUG) { Log.d(TAG, "showSystemUi() called"); } if (activity == null) { return; } // Prevent jumping of the player on devices with cutout if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { activity.getWindow().getAttributes().layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT; } activity.getWindow().getDecorView().setSystemUiVisibility(0); activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); activity.getWindow().setStatusBarColor(ThemeHelper.resolveColorFromAttr( requireContext(), android.R.attr.colorPrimary)); } private void hideSystemUi() { if (DEBUG) { Log.d(TAG, "hideSystemUi() called"); } if (activity == null) { return; } // Prevent jumping of the player on devices with cutout if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { activity.getWindow().getAttributes().layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES; } int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY; // In multiWindow mode status bar is not transparent for devices with cutout // if I include this flag. So without it is better in this case final boolean isInMultiWindow = DeviceUtils.isInMultiWindow(activity); if (!isInMultiWindow) { visibility |= View.SYSTEM_UI_FLAG_FULLSCREEN; } activity.getWindow().getDecorView().setSystemUiVisibility(visibility); if (isInMultiWindow || isFullscreen()) { activity.getWindow().setStatusBarColor(Color.TRANSPARENT); activity.getWindow().setNavigationBarColor(Color.TRANSPARENT); } activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); } // Listener implementation @Override public void hideSystemUiIfNeeded() { if (isFullscreen() && bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) { hideSystemUi(); } } private boolean isFullscreen() { return isPlayerAvailable() && player.UIs().get(VideoPlayerUi.class) .map(VideoPlayerUi::isFullscreen).orElse(false); } private boolean playerIsNotStopped() { return isPlayerAvailable() && !player.isStopped(); } private void restoreDefaultBrightness() { final WindowManager.LayoutParams lp = activity.getWindow().getAttributes(); if (lp.screenBrightness == -1) { return; } // Restore the old brightness when fragment.onPause() called or // when a player is in portrait lp.screenBrightness = -1; activity.getWindow().setAttributes(lp); } private void setupBrightness() { if (activity == null) { return; } final WindowManager.LayoutParams lp = activity.getWindow().getAttributes(); if (!isFullscreen() || bottomSheetState != BottomSheetBehavior.STATE_EXPANDED) { // Apply system brightness when the player is not in fullscreen restoreDefaultBrightness(); } else { // Do not restore if user has disabled brightness gesture if (!PlayerHelper.getActionForRightGestureSide(activity) .equals(getString(R.string.brightness_control_key)) && !PlayerHelper.getActionForLeftGestureSide(activity) .equals(getString(R.string.brightness_control_key))) { return; } // Restore already saved brightness level final float brightnessLevel = PlayerHelper.getScreenBrightness(activity); if (brightnessLevel == lp.screenBrightness) { return; } lp.screenBrightness = brightnessLevel; activity.getWindow().setAttributes(lp); } } /** * Make changes to the UI to accommodate for better usability on bigger screens such as TVs * or in Android's desktop mode (DeX etc). */ private void accommodateForTvAndDesktopMode() { if (DeviceUtils.isTv(getContext())) { // remove ripple effects from detail controls final int transparent = ContextCompat.getColor(requireContext(), R.color.transparent_background_color); binding.detailControlsPlaylistAppend.setBackgroundColor(transparent); binding.detailControlsBackground.setBackgroundColor(transparent); binding.detailControlsPopup.setBackgroundColor(transparent); binding.detailControlsDownload.setBackgroundColor(transparent); binding.detailControlsShare.setBackgroundColor(transparent); binding.detailControlsOpenInBrowser.setBackgroundColor(transparent); binding.detailControlsPlayWithKodi.setBackgroundColor(transparent); } if (DeviceUtils.isDesktopMode(getContext())) { // Remove the "hover" overlay (since it is visible on all mouse events and interferes // with the video content being played) binding.detailThumbnailRootLayout.setForeground(null); } } private void checkLandscape() { if ((!player.isPlaying() && player.getPlayQueue() != playQueue) || player.getPlayQueue() == null) { setAutoPlay(true); } player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::checkLandscape); // Let's give a user time to look at video information page if video is not playing if (globalScreenOrientationLocked(activity) && !player.isPlaying()) { player.play(); } } /* * Means that the player fragment was swiped away via BottomSheetLayout * and is empty but ready for any new actions. See cleanUp() * */ private boolean wasCleared() { return url == null; } @Nullable private StackItem findQueueInStack(final PlayQueue queue) { StackItem item = null; final Iterator iterator = stack.descendingIterator(); while (iterator.hasNext()) { final StackItem next = iterator.next(); if (next.getPlayQueue().equalStreams(queue)) { item = next; break; } } return item; } private void replaceQueueIfUserConfirms(final Runnable onAllow) { @Nullable final PlayQueue activeQueue = isPlayerAvailable() ? player.getPlayQueue() : null; // Player will have STATE_IDLE when a user pressed back button if (isClearingQueueConfirmationRequired(activity) && playerIsNotStopped() && activeQueue != null && !activeQueue.equalStreams(playQueue)) { showClearingQueueConfirmation(onAllow); } else { onAllow.run(); } } private void showClearingQueueConfirmation(final Runnable onAllow) { new AlertDialog.Builder(activity) .setTitle(R.string.clear_queue_confirmation_description) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok, (dialog, which) -> { onAllow.run(); dialog.dismiss(); }) .show(); } private void showExternalVideoPlaybackDialog() { if (currentInfo == null) { return; } final AlertDialog.Builder builder = new AlertDialog.Builder(activity); builder.setTitle(R.string.select_quality_external_players); builder.setNeutralButton(R.string.open_in_browser, (dialog, i) -> ShareUtils.openUrlInBrowser(requireActivity(), url)); final List videoStreamsForExternalPlayers = ListHelper.getSortedStreamVideosList( activity, getUrlAndNonTorrentStreams(currentInfo.getVideoStreams()), getUrlAndNonTorrentStreams(currentInfo.getVideoOnlyStreams()), false, false ); if (videoStreamsForExternalPlayers.isEmpty()) { builder.setMessage(R.string.no_video_streams_available_for_external_players); builder.setPositiveButton(R.string.ok, null); } else { final int selectedVideoStreamIndexForExternalPlayers = ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers); final CharSequence[] resolutions = videoStreamsForExternalPlayers.stream() .map(VideoStream::getResolution).toArray(CharSequence[]::new); builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndexForExternalPlayers, null); builder.setNegativeButton(R.string.cancel, null); builder.setPositiveButton(R.string.ok, (dialog, i) -> { final int index = ((AlertDialog) dialog).getListView().getCheckedItemPosition(); // We don't have to manage the index validity because if there is no stream // available for external players, this code will be not executed and if there is // no stream which matches the default resolution, 0 is returned by // ListHelper.getDefaultResolutionIndex. // The index cannot be outside the bounds of the list as its always between 0 and // the list size - 1, . startOnExternalPlayer(activity, currentInfo, videoStreamsForExternalPlayers.get(index)); }); } builder.show(); } private void showExternalAudioPlaybackDialog() { if (currentInfo == null) { return; } final List audioStreams = getUrlAndNonTorrentStreams( currentInfo.getAudioStreams()); final List audioTracks = ListHelper.getFilteredAudioStreams(activity, audioStreams); if (audioTracks.isEmpty()) { Toast.makeText(activity, R.string.no_audio_streams_available_for_external_players, Toast.LENGTH_SHORT).show(); } else if (audioTracks.size() == 1) { startOnExternalPlayer(activity, currentInfo, audioTracks.get(0)); } else { final int selectedAudioStream = ListHelper.getDefaultAudioFormat(activity, audioTracks); final CharSequence[] trackNames = audioTracks.stream() .map(audioStream -> Localization.audioTrackName(activity, audioStream)) .toArray(CharSequence[]::new); new AlertDialog.Builder(activity) .setTitle(R.string.select_audio_track_external_players) .setNeutralButton(R.string.open_in_browser, (dialog, i) -> ShareUtils.openUrlInBrowser(requireActivity(), url)) .setSingleChoiceItems(trackNames, selectedAudioStream, null) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok, (dialog, i) -> { final int index = ((AlertDialog) dialog).getListView() .getCheckedItemPosition(); startOnExternalPlayer(activity, currentInfo, audioTracks.get(index)); }) .show(); } } /* * Remove unneeded information while waiting for a next task * */ private void cleanUp() { // New beginning stack.clear(); if (currentWorker != null) { currentWorker.dispose(); } playerHolder.stopService(); setInitialData(0, null, "", null); currentInfo = null; updateOverlayData(null, null, List.of()); } /*////////////////////////////////////////////////////////////////////////// // Bottom mini player //////////////////////////////////////////////////////////////////////////*/ /** * That's for Android TV support. Move focus from main fragment to the player or back * based on what is currently selected * * @param toMain if true than the main fragment will be focused or the player otherwise */ private void moveFocusToMainFragment(final boolean toMain) { setupBrightness(); final ViewGroup mainFragment = requireActivity().findViewById(R.id.fragment_holder); // Hamburger button steels a focus even under bottomSheet final Toolbar toolbar = requireActivity().findViewById(R.id.toolbar); final int afterDescendants = ViewGroup.FOCUS_AFTER_DESCENDANTS; final int blockDescendants = ViewGroup.FOCUS_BLOCK_DESCENDANTS; if (toMain) { mainFragment.setDescendantFocusability(afterDescendants); toolbar.setDescendantFocusability(afterDescendants); ((ViewGroup) requireView()).setDescendantFocusability(blockDescendants); // Only focus the mainFragment if the mainFragment (e.g. search-results) // or the toolbar (e.g. Textfield for search) don't have focus. // This was done to fix problems with the keyboard input, see also #7490 if (!mainFragment.hasFocus() && !toolbar.hasFocus()) { mainFragment.requestFocus(); } } else { mainFragment.setDescendantFocusability(blockDescendants); toolbar.setDescendantFocusability(blockDescendants); ((ViewGroup) requireView()).setDescendantFocusability(afterDescendants); // Only focus the player if it not already has focus if (!binding.getRoot().hasFocus()) { binding.detailThumbnailRootLayout.requestFocus(); } } } /** * When the mini player exists the view underneath it is not touchable. * Bottom padding should be equal to the mini player's height in this case * * @param showMore whether main fragment should be expanded or not */ private void manageSpaceAtTheBottom(final boolean showMore) { final int peekHeight = getResources().getDimensionPixelSize(R.dimen.mini_player_height); final ViewGroup holder = requireActivity().findViewById(R.id.fragment_holder); final int newBottomPadding; if (showMore) { newBottomPadding = 0; } else { newBottomPadding = peekHeight; } if (holder.getPaddingBottom() == newBottomPadding) { return; } holder.setPadding(holder.getPaddingLeft(), holder.getPaddingTop(), holder.getPaddingRight(), newBottomPadding); } private void setupBottomPlayer() { final CoordinatorLayout.LayoutParams params = (CoordinatorLayout.LayoutParams) binding.appBarLayout.getLayoutParams(); final AppBarLayout.Behavior behavior = (AppBarLayout.Behavior) params.getBehavior(); final FrameLayout bottomSheetLayout = activity.findViewById(R.id.fragment_player_holder); bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout); bottomSheetBehavior.setState(lastStableBottomSheetState); updateBottomSheetState(lastStableBottomSheetState); final int peekHeight = getResources().getDimensionPixelSize(R.dimen.mini_player_height); if (bottomSheetState != BottomSheetBehavior.STATE_HIDDEN) { manageSpaceAtTheBottom(false); bottomSheetBehavior.setPeekHeight(peekHeight); if (bottomSheetState == BottomSheetBehavior.STATE_COLLAPSED) { binding.overlayLayout.setAlpha(MAX_OVERLAY_ALPHA); } else if (bottomSheetState == BottomSheetBehavior.STATE_EXPANDED) { binding.overlayLayout.setAlpha(0); setOverlayElementsClickable(false); } } bottomSheetCallback = new BottomSheetBehavior.BottomSheetCallback() { @Override public void onStateChanged(@NonNull final View bottomSheet, final int newState) { updateBottomSheetState(newState); switch (newState) { case BottomSheetBehavior.STATE_HIDDEN: moveFocusToMainFragment(true); manageSpaceAtTheBottom(true); bottomSheetBehavior.setPeekHeight(0); cleanUp(); break; case BottomSheetBehavior.STATE_EXPANDED: moveFocusToMainFragment(false); manageSpaceAtTheBottom(false); bottomSheetBehavior.setPeekHeight(peekHeight); // Disable click because overlay buttons located on top of buttons // from the player setOverlayElementsClickable(false); hideSystemUiIfNeeded(); // Conditions when the player should be expanded to fullscreen if (DeviceUtils.isLandscape(requireContext()) && isPlayerAvailable() && player.isPlaying() && !isFullscreen() && !DeviceUtils.isTablet(activity)) { player.UIs().get(MainPlayerUi.class) .ifPresent(MainPlayerUi::toggleFullscreen); } setOverlayLook(binding.appBarLayout, behavior, 1); break; case BottomSheetBehavior.STATE_COLLAPSED: moveFocusToMainFragment(true); manageSpaceAtTheBottom(false); bottomSheetBehavior.setPeekHeight(peekHeight); // Re-enable clicks setOverlayElementsClickable(true); if (isPlayerAvailable()) { player.UIs().get(MainPlayerUi.class) .ifPresent(MainPlayerUi::closeItemsList); } setOverlayLook(binding.appBarLayout, behavior, 0); break; case BottomSheetBehavior.STATE_DRAGGING: case BottomSheetBehavior.STATE_SETTLING: if (isFullscreen()) { showSystemUi(); } if (isPlayerAvailable()) { player.UIs().get(MainPlayerUi.class).ifPresent(ui -> { if (ui.isControlsVisible()) { ui.hideControls(0, 0); } }); } break; case BottomSheetBehavior.STATE_HALF_EXPANDED: break; } } @Override public void onSlide(@NonNull final View bottomSheet, final float slideOffset) { setOverlayLook(binding.appBarLayout, behavior, slideOffset); } }; bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback); // User opened a new page and the player will hide itself activity.getSupportFragmentManager().addOnBackStackChangedListener(() -> { if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) { bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); } }); } private void updateOverlayPlayQueueButtonVisibility() { final boolean isPlayQueueEmpty = player == null // no player => no play queue :) || player.getPlayQueue() == null || player.getPlayQueue().isEmpty(); if (binding != null) { // binding is null when rotating the device... binding.overlayPlayQueueButton.setVisibility( isPlayQueueEmpty ? View.GONE : View.VISIBLE); } } private void updateOverlayData(@Nullable final String overlayTitle, @Nullable final String uploader, @NonNull final List thumbnails) { binding.overlayTitleTextView.setText(isEmpty(overlayTitle) ? "" : overlayTitle); binding.overlayChannelTextView.setText(isEmpty(uploader) ? "" : uploader); binding.overlayThumbnail.setImageDrawable(null); CoilHelper.INSTANCE.loadDetailsThumbnail(binding.overlayThumbnail, thumbnails); } private void setOverlayPlayPauseImage(final boolean playerIsPlaying) { final int drawable = playerIsPlaying ? R.drawable.ic_pause : R.drawable.ic_play_arrow; binding.overlayPlayPauseButton.setImageResource(drawable); } private void setOverlayLook(final AppBarLayout appBar, final AppBarLayout.Behavior behavior, final float slideOffset) { // SlideOffset < 0 when mini player is about to close via swipe. // Stop animation in this case if (behavior == null || slideOffset < 0) { return; } binding.overlayLayout.setAlpha(Math.min(MAX_OVERLAY_ALPHA, 1 - slideOffset)); // These numbers are not special. They just do a cool transition behavior.setTopAndBottomOffset( (int) (-binding.detailThumbnailImageView.getHeight() * 2 * (1 - slideOffset) / 3)); appBar.requestLayout(); } private void setOverlayElementsClickable(final boolean enable) { binding.overlayThumbnail.setClickable(enable); binding.overlayThumbnail.setLongClickable(enable); binding.overlayMetadataLayout.setClickable(enable); binding.overlayMetadataLayout.setLongClickable(enable); binding.overlayButtonsLayout.setClickable(enable); binding.overlayPlayQueueButton.setClickable(enable); binding.overlayPlayPauseButton.setClickable(enable); binding.overlayCloseButton.setClickable(enable); } // helpers to check the state of player and playerService boolean isPlayerAvailable() { return player != null; } boolean isPlayerServiceAvailable() { return playerService != null; } boolean isPlayerAndPlayerServiceAvailable() { return player != null && playerService != null; } public Optional getRoot() { return Optional.ofNullable(player) .flatMap(player1 -> player1.UIs().get(VideoPlayerUi.class)) .map(playerUi -> playerUi.getBinding().getRoot()); } private void updateBottomSheetState(final int newState) { bottomSheetState = newState; if (newState != BottomSheetBehavior.STATE_DRAGGING && newState != BottomSheetBehavior.STATE_SETTLING) { lastStableBottomSheetState = newState; } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java ================================================ package org.schabi.newpipe.fragments.detail; import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW; import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_DECODING_FAILED; import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_UNSPECIFIED; import android.content.Context; import android.util.Log; import android.util.Pair; import android.view.ContextThemeWrapper; import android.view.LayoutInflater; import android.view.ViewGroup; import android.widget.RadioButton; import android.widget.RadioGroup; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.PlaybackException; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.ListRadioIconItemBinding; import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding; import org.schabi.newpipe.player.Player; import org.schabi.newpipe.util.ThemeHelper; import java.io.IOException; import java.util.List; import java.util.function.Supplier; /** * Outsourced logic for crashing the player in the {@link VideoDetailFragment}. */ public final class VideoDetailPlayerCrasher { // This has to be <= 23 chars on devices running Android 7 or lower (API <= 25) // or it fails with an IllegalArgumentException // https://stackoverflow.com/a/54744028 private static final String TAG = "VideoDetPlayerCrasher"; private static final String DEFAULT_MSG = "Dummy"; private static final List>> AVAILABLE_EXCEPTION_TYPES = List.of( new Pair<>("Source", () -> ExoPlaybackException.createForSource( new IOException(DEFAULT_MSG), ERROR_CODE_BEHIND_LIVE_WINDOW )), new Pair<>("Renderer", () -> ExoPlaybackException.createForRenderer( new Exception(DEFAULT_MSG), "Dummy renderer", 0, null, C.FORMAT_HANDLED, /*isRecoverable=*/false, ERROR_CODE_DECODING_FAILED )), new Pair<>("Unexpected", () -> ExoPlaybackException.createForUnexpected( new RuntimeException(DEFAULT_MSG), ERROR_CODE_UNSPECIFIED )), new Pair<>("Remote", () -> ExoPlaybackException.createForRemote(DEFAULT_MSG)) ); private VideoDetailPlayerCrasher() { // No impls } private static Context getThemeWrapperContext(final Context context) { return new ContextThemeWrapper( context, ThemeHelper.isLightThemeSelected(context) ? R.style.LightTheme : R.style.DarkTheme); } public static void onCrashThePlayer( @NonNull final Context context, @Nullable final Player player ) { if (player == null) { Log.d(TAG, "Player is not available"); Toast.makeText(context, "Player is not available", Toast.LENGTH_SHORT) .show(); return; } // -- Build the dialog/UI -- final Context themeWrapperContext = getThemeWrapperContext(context); final LayoutInflater inflater = LayoutInflater.from(themeWrapperContext); final SingleChoiceDialogViewBinding binding = SingleChoiceDialogViewBinding.inflate(inflater); final AlertDialog alertDialog = new AlertDialog.Builder(themeWrapperContext) .setTitle("Choose an exception") .setView(binding.getRoot()) .setCancelable(true) .setNegativeButton(R.string.cancel, null) .create(); for (final Pair> entry : AVAILABLE_EXCEPTION_TYPES) { final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater).getRoot(); radioButton.setText(entry.first); radioButton.setChecked(false); radioButton.setLayoutParams( new RadioGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ) ); radioButton.setOnClickListener(v -> { tryCrashPlayerWith(player, entry.second.get()); alertDialog.cancel(); }); binding.list.addView(radioButton); } alertDialog.show(); } /** * Note that this method does not crash the underlying exoplayer directly (it's not possible). * It simply supplies a Exception to {@link Player#onPlayerError(PlaybackException)}. * @param player * @param exception */ private static void tryCrashPlayerWith( @NonNull final Player player, @NonNull final ExoPlaybackException exception ) { Log.d(TAG, "Crashing the player using player.onPlayerError(ex)"); try { player.onPlayerError(exception); } catch (final Exception exPlayer) { Log.e(TAG, "Run into an exception while crashing the player:", exPlayer); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java ================================================ package org.schabi.newpipe.fragments.list; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; import android.content.Context; import android.content.SharedPreferences; import android.content.res.Resources; import android.os.Bundle; import android.util.Log; import android.view.Menu; import android.view.MenuInflater; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; import org.schabi.newpipe.info_list.InfoListAdapter; import org.schabi.newpipe.info_list.ItemViewMode; import org.schabi.newpipe.info_list.dialog.InfoItemDialog; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.StateSaver; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.views.SuperScrollLayoutManager; import java.util.List; import java.util.Queue; import java.util.function.Supplier; public abstract class BaseListFragment extends BaseStateFragment implements ListViewContract, StateSaver.WriteRead, SharedPreferences.OnSharedPreferenceChangeListener { private static final int LIST_MODE_UPDATE_FLAG = 0x32; protected org.schabi.newpipe.util.SavedState savedState; private boolean useDefaultStateSaving = true; private int updateFlags = 0; /*////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////*/ protected InfoListAdapter infoListAdapter; protected RecyclerView itemsList; private int focusedPosition = -1; /*////////////////////////////////////////////////////////////////////////// // LifeCycle //////////////////////////////////////////////////////////////////////////*/ @Override public void onAttach(@NonNull final Context context) { super.onAttach(context); if (infoListAdapter == null) { infoListAdapter = new InfoListAdapter(activity); } } @Override public void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); setHasOptionsMenu(true); PreferenceManager.getDefaultSharedPreferences(activity) .registerOnSharedPreferenceChangeListener(this); } @Override public void onDestroy() { super.onDestroy(); if (useDefaultStateSaving) { StateSaver.onDestroy(savedState); } PreferenceManager.getDefaultSharedPreferences(activity) .unregisterOnSharedPreferenceChangeListener(this); } @Override public void onResume() { super.onResume(); if (updateFlags != 0) { if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) { refreshItemViewMode(); } updateFlags = 0; } } /*////////////////////////////////////////////////////////////////////////// // State Saving //////////////////////////////////////////////////////////////////////////*/ /** * If the default implementation of {@link StateSaver.WriteRead} should be used. * * @param useDefaultStateSaving Whether the default implementation should be used * @see StateSaver */ public void setUseDefaultStateSaving(final boolean useDefaultStateSaving) { this.useDefaultStateSaving = useDefaultStateSaving; } @Override public String generateSuffix() { // Naive solution, but it's good for now (the items don't change) return "." + infoListAdapter.getItemsList().size() + ".list"; } private int getFocusedPosition() { try { final View focusedItem = itemsList.getFocusedChild(); final RecyclerView.ViewHolder itemHolder = itemsList.findContainingViewHolder(focusedItem); return itemHolder.getBindingAdapterPosition(); } catch (final NullPointerException e) { return -1; } } @Override public void writeTo(final Queue objectsToSave) { if (!useDefaultStateSaving) { return; } objectsToSave.add(infoListAdapter.getItemsList()); objectsToSave.add(getFocusedPosition()); } @Override @SuppressWarnings("unchecked") public void readFrom(@NonNull final Queue savedObjects) throws Exception { if (!useDefaultStateSaving) { return; } infoListAdapter.getItemsList().clear(); infoListAdapter.getItemsList().addAll((List) savedObjects.poll()); restoreFocus((Integer) savedObjects.poll()); } private void restoreFocus(final Integer position) { if (position == null || position < 0) { return; } itemsList.post(() -> { final RecyclerView.ViewHolder focusedHolder = itemsList.findViewHolderForAdapterPosition(position); if (focusedHolder != null) { focusedHolder.itemView.requestFocus(); } }); } @Override public void onSaveInstanceState(@NonNull final Bundle bundle) { super.onSaveInstanceState(bundle); if (useDefaultStateSaving) { savedState = StateSaver .tryToSave(activity.isChangingConfigurations(), savedState, bundle, this); } } @Override protected void onRestoreInstanceState(@NonNull final Bundle bundle) { super.onRestoreInstanceState(bundle); if (useDefaultStateSaving) { savedState = StateSaver.tryToRestore(bundle, this); } } @Override public void onStop() { focusedPosition = getFocusedPosition(); super.onStop(); } @Override public void onStart() { super.onStart(); restoreFocus(focusedPosition); } /*////////////////////////////////////////////////////////////////////////// // Init //////////////////////////////////////////////////////////////////////////*/ @Nullable protected Supplier getListHeaderSupplier() { return null; } protected RecyclerView.LayoutManager getListLayoutManager() { return new SuperScrollLayoutManager(activity); } protected RecyclerView.LayoutManager getGridLayoutManager() { final Resources resources = activity.getResources(); int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width); width += (24 * resources.getDisplayMetrics().density); final int spanCount = Math.floorDiv(resources.getDisplayMetrics().widthPixels, width); final GridLayoutManager lm = new GridLayoutManager(activity, spanCount); lm.setSpanSizeLookup(infoListAdapter.getSpanSizeLookup(spanCount)); return lm; } /** * Updates the item view mode based on user preference. */ private void refreshItemViewMode() { final ItemViewMode itemViewMode = getItemViewMode(); itemsList.setLayoutManager((itemViewMode == ItemViewMode.GRID) ? getGridLayoutManager() : getListLayoutManager()); infoListAdapter.setItemViewMode(itemViewMode); infoListAdapter.notifyDataSetChanged(); } @Override protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); itemsList = rootView.findViewById(R.id.items_list); refreshItemViewMode(); final Supplier listHeaderSupplier = getListHeaderSupplier(); if (listHeaderSupplier != null) { infoListAdapter.setHeaderSupplier(listHeaderSupplier); } itemsList.setAdapter(infoListAdapter); } protected void onItemSelected(final InfoItem selectedItem) { if (DEBUG) { Log.d(TAG, "onItemSelected() called with: selectedItem = [" + selectedItem + "]"); } } @Override protected void initListeners() { super.initListeners(); infoListAdapter.setOnStreamSelectedListener(new OnClickGesture<>() { @Override public void selected(final StreamInfoItem selectedItem) { onStreamSelected(selectedItem); } @Override public void held(final StreamInfoItem selectedItem) { showInfoItemDialog(selectedItem); } }); infoListAdapter.setOnChannelSelectedListener(selectedItem -> { try { onItemSelected(selectedItem); NavigationHelper.openChannelFragment(getFM(), selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName()); } catch (final Exception e) { ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e); } }); infoListAdapter.setOnPlaylistSelectedListener(selectedItem -> { try { onItemSelected(selectedItem); NavigationHelper.openPlaylistFragment(getFM(), selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName()); } catch (final Exception e) { ErrorUtil.showUiErrorSnackbar(this, "Opening playlist fragment", e); } }); infoListAdapter.setOnCommentsSelectedListener(this::onItemSelected); // Ensure that there is always a scroll listener (e.g. when rotating the device) useNormalItemListScrollListener(); } /** * Removes all listeners and adds the normal scroll listener to the {@link #itemsList}. */ protected void useNormalItemListScrollListener() { if (DEBUG) { Log.d(TAG, "useNormalItemListScrollListener called"); } itemsList.clearOnScrollListeners(); itemsList.addOnScrollListener(new DefaultItemListOnScrolledDownListener()); } /** * Removes all listeners and adds the initial scroll listener to the {@link #itemsList}. *
* Which tries to load more items when not enough are in the view (not scrollable) * and more are available. *
* Note: This method only works because "This callback will also be called if visible * item range changes after a layout calculation. In that case, dx and dy will be 0." * - which might be unexpected because no actual scrolling occurs... *
* This listener will be replaced by DefaultItemListOnScrolledDownListener when *
    *
  • the view was actually scrolled
  • *
  • the view is scrollable
  • *
  • no more items can be loaded
  • *
*/ protected void useInitialItemListLoadScrollListener() { if (DEBUG) { Log.d(TAG, "useInitialItemListLoadScrollListener called"); } itemsList.clearOnScrollListeners(); itemsList.addOnScrollListener(new DefaultItemListOnScrolledDownListener() { @Override public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) { super.onScrolled(recyclerView, dx, dy); if (dy != 0) { log("Vertical scroll occurred"); useNormalItemListScrollListener(); return; } if (isLoading.get()) { log("Still loading data -> Skipping"); return; } if (!hasMoreItems()) { log("No more items to load"); useNormalItemListScrollListener(); return; } if (itemsList.canScrollVertically(1) || itemsList.canScrollVertically(-1)) { log("View is scrollable"); useNormalItemListScrollListener(); return; } log("Loading more data"); loadMoreItems(); } private void log(final String msg) { if (DEBUG) { Log.d(TAG, "initItemListLoadScrollListener - " + msg); } } }); } class DefaultItemListOnScrolledDownListener extends OnScrollBelowItemsListener { @Override public void onScrolledDown(final RecyclerView recyclerView) { onScrollToBottom(); } } private void onStreamSelected(final StreamInfoItem selectedItem) { onItemSelected(selectedItem); NavigationHelper.openVideoDetailFragment(requireContext(), getFM(), selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName(), null, false); } protected void onScrollToBottom() { if (hasMoreItems() && !isLoading.get()) { loadMoreItems(); } } protected void showInfoItemDialog(final StreamInfoItem item) { try { new InfoItemDialog.Builder(getActivity(), getContext(), this, item).create().show(); } catch (final IllegalArgumentException e) { InfoItemDialog.Builder.reportErrorDuringInitialization(e, item); } } /*////////////////////////////////////////////////////////////////////////// // Menu //////////////////////////////////////////////////////////////////////////*/ @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { if (DEBUG) { Log.d(TAG, "onCreateOptionsMenu() called with: " + "menu = [" + menu + "], inflater = [" + inflater + "]"); } super.onCreateOptionsMenu(menu, inflater); final ActionBar supportActionBar = activity.getSupportActionBar(); if (supportActionBar != null) { supportActionBar.setDisplayShowTitleEnabled(true); supportActionBar.setDisplayHomeAsUpEnabled(!useAsFrontPage); } } /*////////////////////////////////////////////////////////////////////////// // Load and handle //////////////////////////////////////////////////////////////////////////*/ @Override protected void startLoading(final boolean forceLoad) { useInitialItemListLoadScrollListener(); super.startLoading(forceLoad); } protected abstract void loadMoreItems(); protected abstract boolean hasMoreItems(); /*////////////////////////////////////////////////////////////////////////// // Contract //////////////////////////////////////////////////////////////////////////*/ @Override public void showLoading() { super.showLoading(); animateHideRecyclerViewAllowingScrolling(itemsList); } @Override public void hideLoading() { super.hideLoading(); animate(itemsList, true, 300); } @Override public void showEmptyState() { super.showEmptyState(); showListFooter(false); animateHideRecyclerViewAllowingScrolling(itemsList); } @Override public void showListFooter(final boolean show) { itemsList.post(() -> { if (infoListAdapter != null && itemsList != null) { infoListAdapter.showFooter(show); } }); } @Override public void handleNextItems(final N result) { isLoading.set(false); } @Override public void handleError() { super.handleError(); showListFooter(false); animateHideRecyclerViewAllowingScrolling(itemsList); } @Override public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, final String key) { if (getString(R.string.list_view_mode_key).equals(key)) { updateFlags |= LIST_MODE_UPDATE_FLAG; } } /** * Returns preferred item view mode. * @return ItemViewMode */ protected ItemViewMode getItemViewMode() { return ThemeHelper.getItemViewMode(requireContext()); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java ================================================ package org.schabi.newpipe.fragments.list; import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; import android.os.Bundle; import android.text.TextUtils; import android.util.Log; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.evernote.android.state.State; import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.views.NewPipeRecyclerView; import java.util.ArrayList; import java.util.List; import java.util.Queue; 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 abstract class BaseListInfoFragment> extends BaseListFragment> { @State protected int serviceId = Constants.NO_SERVICE_ID; @State protected String name; @State protected String url; private final UserAction errorUserAction; protected L currentInfo; @Nullable protected Page currentNextPage; protected Disposable currentWorker; protected BaseListInfoFragment(final UserAction errorUserAction) { this.errorUserAction = errorUserAction; } @Override protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); setTitle(name); showListFooter(hasMoreItems()); } @Override public void onPause() { super.onPause(); if (currentWorker != null) { currentWorker.dispose(); } } @Override public void onResume() { super.onResume(); // Check if it was loading when the fragment was stopped/paused, if (wasLoading.getAndSet(false)) { if (hasMoreItems() && !infoListAdapter.getItemsList().isEmpty()) { loadMoreItems(); } else { doInitialLoadLogic(); } } } @Override public void onDestroy() { super.onDestroy(); if (currentWorker != null) { currentWorker.dispose(); currentWorker = null; } } /*////////////////////////////////////////////////////////////////////////// // State Saving //////////////////////////////////////////////////////////////////////////*/ @Override public void writeTo(final Queue objectsToSave) { super.writeTo(objectsToSave); objectsToSave.add(currentInfo); objectsToSave.add(currentNextPage); } @Override @SuppressWarnings("unchecked") public void readFrom(@NonNull final Queue savedObjects) throws Exception { super.readFrom(savedObjects); currentInfo = (L) savedObjects.poll(); currentNextPage = (Page) savedObjects.poll(); } /*////////////////////////////////////////////////////////////////////////// // Load and handle //////////////////////////////////////////////////////////////////////////*/ @Override protected void doInitialLoadLogic() { if (DEBUG) { Log.d(TAG, "doInitialLoadLogic() called"); } if (currentInfo == null) { startLoading(false); } else { handleResult(currentInfo); } } /** * Implement the logic to load the info from the network.
* You can use the default implementations from {@link org.schabi.newpipe.util.ExtractorHelper}. * * @param forceLoad allow or disallow the result to come from the cache * @return Rx {@link Single} containing the {@link ListInfo} */ protected abstract Single loadResult(boolean forceLoad); @Override public void startLoading(final boolean forceLoad) { super.startLoading(forceLoad); showListFooter(false); infoListAdapter.clearStreamItemList(); currentInfo = null; if (currentWorker != null) { currentWorker.dispose(); } currentWorker = loadResult(forceLoad) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe((@NonNull final L result) -> { isLoading.set(false); currentInfo = result; currentNextPage = result.getNextPage(); handleResult(result); }, throwable -> showError(new ErrorInfo(throwable, errorUserAction, "Start loading: " + url, serviceId, url))); } /** * Implement the logic to load more items. *

You can use the default implementations * from {@link org.schabi.newpipe.util.ExtractorHelper}.

* * @return Rx {@link Single} containing the {@link ListExtractor.InfoItemsPage} */ protected abstract Single> loadMoreItemsLogic(); @Override protected void loadMoreItems() { isLoading.set(true); if (currentWorker != null) { currentWorker.dispose(); } forbidDownwardFocusScroll(); currentWorker = loadMoreItemsLogic() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .doFinally(this::allowDownwardFocusScroll) .subscribe(infoItemsPage -> { isLoading.set(false); handleNextItems(infoItemsPage); }, (@NonNull Throwable throwable) -> dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(throwable, errorUserAction, "Loading more items: " + url, serviceId, url))); } private void forbidDownwardFocusScroll() { if (itemsList instanceof NewPipeRecyclerView) { ((NewPipeRecyclerView) itemsList).setFocusScrollAllowed(false); } } private void allowDownwardFocusScroll() { if (itemsList instanceof NewPipeRecyclerView) { ((NewPipeRecyclerView) itemsList).setFocusScrollAllowed(true); } } @Override public void handleNextItems(final ListExtractor.InfoItemsPage result) { super.handleNextItems(result); currentNextPage = result.getNextPage(); infoListAdapter.addInfoItemList(result.getItems()); showListFooter(hasMoreItems()); if (!result.getErrors().isEmpty()) { dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(result.getErrors(), errorUserAction, "Get next items of: " + url, serviceId, url)); } } @Override protected boolean hasMoreItems() { return Page.isValid(currentNextPage); } /*////////////////////////////////////////////////////////////////////////// // Contract //////////////////////////////////////////////////////////////////////////*/ @Override public void handleResult(@NonNull final L result) { super.handleResult(result); name = result.getName(); setTitle(name); if (infoListAdapter.getItemsList().isEmpty()) { if (!result.getRelatedItems().isEmpty()) { infoListAdapter.addInfoItemList(result.getRelatedItems()); showListFooter(hasMoreItems()); } else if (hasMoreItems()) { loadMoreItems(); } else { infoListAdapter.clearStreamItemList(); showEmptyState(); } } if (!result.getErrors().isEmpty()) { final List errors = new ArrayList<>(result.getErrors()); // handling ContentNotSupportedException not to show the error but an appropriate string // so that crashes won't be sent uselessly and the user will understand what happened errors.removeIf(ContentNotSupportedException.class::isInstance); if (!errors.isEmpty()) { dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(result.getErrors(), errorUserAction, "Start loading: " + url, serviceId, url)); } } } @Override public void showEmptyState() { // show "no streams" for SoundCloud; otherwise "no videos" // showing "no live streams" is handled in KioskFragment if (emptyStateView != null) { if (currentInfo.getService() == SoundCloud) { setEmptyStateMessage(R.string.no_streams); } else { setEmptyStateMessage(R.string.no_videos); } } super.showEmptyState(); } /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ protected void setInitialData(final int sid, final String u, final String title) { this.serviceId = sid; this.url = u; this.name = !TextUtils.isEmpty(title) ? title : ""; } private void dynamicallyShowErrorPanelOrSnackbar(final ErrorInfo errorInfo) { if (infoListAdapter.getItemCount() == 0) { // show error panel only if no items already visible showError(errorInfo); } else { isLoading.set(false); showSnackBarError(errorInfo); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/list/ListViewContract.java ================================================ package org.schabi.newpipe.fragments.list; import org.schabi.newpipe.fragments.ViewContract; public interface ListViewContract extends ViewContract { void showListFooter(boolean show); void handleNextItems(N result); } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java ================================================ package org.schabi.newpipe.fragments.list.channel; import static org.schabi.newpipe.extractor.stream.StreamExtractor.UNKNOWN_SUBSCRIBER_COUNT; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.widget.LinearLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.evernote.android.state.State; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.stream.Description; import org.schabi.newpipe.fragments.detail.BaseDescriptionFragment; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.Localization; import java.util.List; public class ChannelAboutFragment extends BaseDescriptionFragment { @State protected ChannelInfo channelInfo; ChannelAboutFragment(@NonNull final ChannelInfo channelInfo) { this.channelInfo = channelInfo; } public ChannelAboutFragment() { // keep empty constructor for State when resuming fragment from memory } @Override protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); binding.constraintLayout.setPadding(0, DeviceUtils.dpToPx(8, requireContext()), 0, 0); } @Nullable @Override protected Description getDescription() { return new Description(channelInfo.getDescription(), Description.PLAIN_TEXT); } @NonNull @Override protected StreamingService getService() { return channelInfo.getService(); } @Override protected int getServiceId() { return channelInfo.getServiceId(); } @Nullable @Override protected String getStreamUrl() { return null; } @NonNull @Override public List getTags() { return channelInfo.getTags(); } @Override protected void setupMetadata(final LayoutInflater inflater, final LinearLayout layout) { // There is no upload date available for channels, so hide the relevant UI element binding.detailUploadDateView.setVisibility(View.GONE); if (channelInfo == null) { return; } if (channelInfo.getSubscriberCount() != UNKNOWN_SUBSCRIBER_COUNT) { addMetadataItem(inflater, layout, false, R.string.metadata_subscribers, Localization.localizeNumber(channelInfo.getSubscriberCount())); } addImagesMetadataItem(inflater, layout, R.string.metadata_avatars, channelInfo.getAvatars()); addImagesMetadataItem(inflater, layout, R.string.metadata_banners, channelInfo.getBanners()); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java ================================================ package org.schabi.newpipe.fragments.list.channel; import static org.schabi.newpipe.ktx.TextViewUtils.animateTextColor; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor; import android.content.Context; import android.content.SharedPreferences; import android.graphics.Color; import android.os.Bundle; import android.text.TextUtils; import android.util.Log; import android.util.TypedValue; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; import androidx.core.graphics.ColorUtils; import androidx.core.view.MenuProvider; import androidx.preference.PreferenceManager; import com.evernote.android.state.State; import com.google.android.material.snackbar.Snackbar; import com.google.android.material.tabs.TabLayout; import com.jakewharton.rxbinding4.view.RxView; import org.schabi.newpipe.R; import org.schabi.newpipe.database.subscription.NotificationMode; import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.databinding.FragmentChannelBinding; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.detail.TabAdapter; import org.schabi.newpipe.ktx.AnimationType; import org.schabi.newpipe.local.feed.notifications.NotificationHelper; import org.schabi.newpipe.local.subscription.SubscriptionManager; import org.schabi.newpipe.util.ChannelTabHelper; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.StateSaver; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.image.CoilHelper; import org.schabi.newpipe.util.image.ImageStrategy; import java.util.List; import java.util.Queue; import java.util.concurrent.TimeUnit; import coil3.util.CoilUtils; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.functions.Action; import io.reactivex.rxjava3.functions.Consumer; import io.reactivex.rxjava3.functions.Function; import io.reactivex.rxjava3.schedulers.Schedulers; public class ChannelFragment extends BaseStateFragment implements StateSaver.WriteRead { private static final int BUTTON_DEBOUNCE_INTERVAL = 100; @State protected int serviceId = Constants.NO_SERVICE_ID; @State protected String name; @State protected String url; private ChannelInfo currentInfo; private Disposable currentWorker; private final CompositeDisposable disposables = new CompositeDisposable(); private Disposable subscribeButtonMonitor; private SubscriptionManager subscriptionManager; private int lastTab; private boolean channelContentNotSupported = false; /*////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////*/ private FragmentChannelBinding binding; private TabAdapter tabAdapter; private MenuItem menuRssButton; private MenuItem menuNotifyButton; private SubscriptionEntity channelSubscription; private MenuProvider menuProvider; public static ChannelFragment getInstance(final int serviceId, final String url, final String name) { final ChannelFragment instance = new ChannelFragment(); instance.setInitialData(serviceId, url, name); return instance; } private void setInitialData(final int sid, final String u, final String title) { this.serviceId = sid; this.url = u; this.name = !TextUtils.isEmpty(title) ? title : ""; } /*////////////////////////////////////////////////////////////////////////// // LifeCycle //////////////////////////////////////////////////////////////////////////*/ @Override public void onAttach(@NonNull final Context context) { super.onAttach(context); subscriptionManager = new SubscriptionManager(activity); } @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { binding = FragmentChannelBinding.inflate(inflater, container, false); return binding.getRoot(); } @Override public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { super.onViewCreated(rootView, savedInstanceState); menuProvider = new MenuProvider() { @Override public void onCreateMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { inflater.inflate(R.menu.menu_channel, menu); if (DEBUG) { Log.d(TAG, "onCreateOptionsMenu() called with: " + "menu = [" + menu + "], inflater = [" + inflater + "]"); } } @Override public void onPrepareMenu(@NonNull final Menu menu) { menuRssButton = menu.findItem(R.id.menu_item_rss); menuNotifyButton = menu.findItem(R.id.menu_item_notify); updateRssButton(); updateNotifyButton(channelSubscription); } @Override public boolean onMenuItemSelected(@NonNull final MenuItem item) { final int itemId = item.getItemId(); if (itemId == R.id.menu_item_notify) { final boolean value = !item.isChecked(); item.setEnabled(false); setNotify(value); } else if (itemId == R.id.action_settings) { NavigationHelper.openSettings(requireContext()); } else if (itemId == R.id.menu_item_rss) { if (currentInfo != null) { ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl()); } } else if (itemId == R.id.menu_item_openInBrowser) { if (currentInfo != null) { ShareUtils.openUrlInBrowser(requireContext(), currentInfo.getOriginalUrl()); } } else if (itemId == R.id.menu_item_share) { if (currentInfo != null) { ShareUtils.shareText(requireContext(), name, currentInfo.getOriginalUrl(), currentInfo.getAvatars()); } } else { return false; } return true; } }; activity.addMenuProvider(menuProvider); } @Override // called from onViewCreated in BaseFragment.onViewCreated protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); tabAdapter = new TabAdapter(getChildFragmentManager()); binding.viewPager.setAdapter(tabAdapter); binding.tabLayout.setupWithViewPager(binding.viewPager); setTitle(name); binding.channelTitleView.setText(name); if (!ImageStrategy.shouldLoadImages()) { // do not waste space for the banner if it is not going to be loaded binding.channelBannerImage.setImageDrawable(null); } } @Override protected void initListeners() { super.initListeners(); final View.OnClickListener openSubChannel = v -> { if (!TextUtils.isEmpty(currentInfo.getParentChannelUrl())) { try { NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(), currentInfo.getParentChannelUrl(), currentInfo.getParentChannelName()); } catch (final Exception e) { ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e); } } else if (DEBUG) { Log.i(TAG, "Can't open parent channel because we got no channel URL"); } }; binding.subChannelAvatarView.setOnClickListener(openSubChannel); binding.subChannelTitleView.setOnClickListener(openSubChannel); } @Override public void onDestroyView() { super.onDestroyView(); if (menuProvider != null) { activity.removeMenuProvider(menuProvider); } } @Override public void onDestroy() { super.onDestroy(); if (currentWorker != null) { currentWorker.dispose(); } disposables.clear(); binding = null; menuProvider = null; } /*////////////////////////////////////////////////////////////////////////// // Channel Subscription //////////////////////////////////////////////////////////////////////////*/ private void monitorSubscription(final ChannelInfo info) { final Consumer onError = (final Throwable throwable) -> { animate(binding.channelSubscribeButton, false, 100); showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_GET, "Get subscription status", currentInfo)); }; final Observable> observable = subscriptionManager .subscriptionTable() .getSubscriptionFlowable(info.getServiceId(), info.getUrl()) .toObservable(); disposables.add(observable .observeOn(AndroidSchedulers.mainThread()) .subscribe(getSubscribeUpdateMonitor(info), onError)); disposables.add(observable .map(List::isEmpty) .distinctUntilChanged() .observeOn(AndroidSchedulers.mainThread()) .subscribe(isEmpty -> updateSubscribeButton(!isEmpty), onError)); disposables.add(observable .map(List::isEmpty) .distinctUntilChanged() .skip(1) // channel has just been opened .filter(x -> NotificationHelper.areNewStreamsNotificationsEnabled(requireContext())) .observeOn(AndroidSchedulers.mainThread()) .subscribe(isEmpty -> { if (!isEmpty) { showNotifySnackbar(); } }, onError)); } private Function mapOnSubscribe(final SubscriptionEntity subscription) { return (@NonNull final Object o) -> { subscriptionManager.insertSubscription(subscription); return o; }; } private Function mapOnUnsubscribe(final SubscriptionEntity subscription) { return (@NonNull final Object o) -> { subscriptionManager.deleteSubscription(subscription); return o; }; } private void updateSubscription(final ChannelInfo info) { if (DEBUG) { Log.d(TAG, "updateSubscription() called with: info = [" + info + "]"); } final Action onComplete = () -> { if (DEBUG) { Log.d(TAG, "Updated subscription: " + info.getUrl()); } }; final Consumer onError = (@NonNull Throwable throwable) -> showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_UPDATE, "Updating subscription for " + info.getUrl(), info)); disposables.add(subscriptionManager.updateChannelInfo(info) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(onComplete, onError)); } private Disposable monitorSubscribeButton(final Function action) { final Consumer onNext = (@NonNull final Object o) -> { if (DEBUG) { Log.d(TAG, "Changed subscription status to this channel!"); } }; final Consumer onError = (@NonNull Throwable throwable) -> showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_CHANGE, "Changing subscription for " + currentInfo.getUrl(), currentInfo)); /* Emit clicks from main thread unto io thread */ return RxView.clicks(binding.channelSubscribeButton) .subscribeOn(AndroidSchedulers.mainThread()) .observeOn(Schedulers.io()) .debounce(BUTTON_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS) // Ignore rapid clicks .map(action) .subscribe(onNext, onError); } private Consumer> getSubscribeUpdateMonitor(final ChannelInfo info) { return (final List subscriptionEntities) -> { if (DEBUG) { Log.d(TAG, "subscriptionManager.subscriptionTable.doOnNext() called with: " + "subscriptionEntities = [" + subscriptionEntities + "]"); } if (subscribeButtonMonitor != null) { subscribeButtonMonitor.dispose(); } if (subscriptionEntities.isEmpty()) { if (DEBUG) { Log.d(TAG, "No subscription to this channel!"); } final SubscriptionEntity channel = new SubscriptionEntity(); channel.setServiceId(info.getServiceId()); channel.setUrl(info.getUrl()); channel.setName(info.getName()); channel.setAvatarUrl(ImageStrategy.imageListToDbUrl(info.getAvatars())); channel.setDescription(info.getDescription()); channel.setSubscriberCount(info.getSubscriberCount()); channelSubscription = null; updateNotifyButton(null); subscribeButtonMonitor = monitorSubscribeButton(mapOnSubscribe(channel)); } else { if (DEBUG) { Log.d(TAG, "Found subscription to this channel!"); } channelSubscription = subscriptionEntities.get(0); updateNotifyButton(channelSubscription); subscribeButtonMonitor = monitorSubscribeButton(mapOnUnsubscribe(channelSubscription)); } }; } private void updateSubscribeButton(final boolean isSubscribed) { if (DEBUG) { Log.d(TAG, "updateSubscribeButton() called with: " + "isSubscribed = [" + isSubscribed + "]"); } final boolean isButtonVisible = binding.channelSubscribeButton.getVisibility() == View.VISIBLE; final int backgroundDuration = isButtonVisible ? 300 : 0; final int textDuration = isButtonVisible ? 200 : 0; final int subscribedBackground = ContextCompat .getColor(activity, R.color.subscribed_background_color); final int subscribedText = ContextCompat.getColor(activity, R.color.subscribed_text_color); final int subscribeBackground = ColorUtils.blendARGB(ThemeHelper .resolveColorFromAttr(activity, R.attr.colorPrimary), subscribedBackground, 0.35f); final int subscribeText = ContextCompat.getColor(activity, R.color.subscribe_text_color); if (isSubscribed) { binding.channelSubscribeButton.setText(R.string.subscribed_button_title); animateBackgroundColor(binding.channelSubscribeButton, backgroundDuration, subscribeBackground, subscribedBackground); animateTextColor(binding.channelSubscribeButton, textDuration, subscribeText, subscribedText); } else { binding.channelSubscribeButton.setText(R.string.subscribe_button_title); animateBackgroundColor(binding.channelSubscribeButton, backgroundDuration, subscribedBackground, subscribeBackground); animateTextColor(binding.channelSubscribeButton, textDuration, subscribedText, subscribeText); } animate(binding.channelSubscribeButton, true, 100, AnimationType.LIGHT_SCALE_AND_ALPHA); } private void updateRssButton() { if (menuRssButton == null || currentInfo == null) { return; } menuRssButton.setVisible(!TextUtils.isEmpty(currentInfo.getFeedUrl())); } private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) { if (menuNotifyButton == null) { return; } if (subscription != null) { menuNotifyButton.setEnabled( NotificationHelper.areNewStreamsNotificationsEnabled(requireContext()) ); menuNotifyButton.setChecked( subscription.getNotificationMode() == NotificationMode.ENABLED ); } menuNotifyButton.setVisible(subscription != null); } private void setNotify(final boolean isEnabled) { disposables.add( subscriptionManager .updateNotificationMode( currentInfo.getServiceId(), currentInfo.getUrl(), isEnabled ? NotificationMode.ENABLED : NotificationMode.DISABLED) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe() ); } /** * Show a snackbar with the option to enable notifications on new streams for this channel. */ private void showNotifySnackbar() { Snackbar.make(binding.getRoot(), R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG) .setAction(R.string.get_notified, v -> setNotify(true)) .setActionTextColor(Color.YELLOW) .show(); } /*////////////////////////////////////////////////////////////////////////// // Init //////////////////////////////////////////////////////////////////////////*/ private void updateTabs() { tabAdapter.clearAllItems(); if (currentInfo != null && !channelContentNotSupported) { final Context context = requireContext(); final SharedPreferences preferences = PreferenceManager .getDefaultSharedPreferences(context); for (final ListLinkHandler linkHandler : currentInfo.getTabs()) { final String tab = linkHandler.getContentFilters().get(0); if (ChannelTabHelper.showChannelTab(context, preferences, tab)) { final ChannelTabFragment channelTabFragment = ChannelTabFragment.getInstance(serviceId, linkHandler, name); channelTabFragment.useAsFrontPage(useAsFrontPage); tabAdapter.addFragment(channelTabFragment, context.getString(ChannelTabHelper.getTranslationKey(tab))); } } if (ChannelTabHelper.showChannelTab( context, preferences, R.string.show_channel_tabs_about)) { tabAdapter.addFragment( new ChannelAboutFragment(currentInfo), context.getString(R.string.channel_tab_about)); } } tabAdapter.notifyDataSetUpdate(); for (int i = 0; i < tabAdapter.getCount(); i++) { binding.tabLayout.getTabAt(i).setText(tabAdapter.getItemTitle(i)); } // Restore previously selected tab final TabLayout.Tab ltab = binding.tabLayout.getTabAt(lastTab); if (ltab != null) { binding.tabLayout.selectTab(ltab); } } /*////////////////////////////////////////////////////////////////////////// // State Saving //////////////////////////////////////////////////////////////////////////*/ @Override public String generateSuffix() { return null; } @Override public void writeTo(final Queue objectsToSave) { objectsToSave.add(currentInfo); objectsToSave.add(binding == null ? 0 : binding.tabLayout.getSelectedTabPosition()); } @Override public void readFrom(@NonNull final Queue savedObjects) { currentInfo = (ChannelInfo) savedObjects.poll(); lastTab = (Integer) savedObjects.poll(); } @Override public void onSaveInstanceState(final @NonNull Bundle outState) { super.onSaveInstanceState(outState); if (binding != null) { outState.putInt("LastTab", binding.tabLayout.getSelectedTabPosition()); } } @Override protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) { super.onRestoreInstanceState(savedInstanceState); lastTab = savedInstanceState.getInt("LastTab", 0); } /*////////////////////////////////////////////////////////////////////////// // Contract //////////////////////////////////////////////////////////////////////////*/ @Override protected void doInitialLoadLogic() { if (currentInfo == null) { startLoading(false); } else { handleResult(currentInfo); } } @Override public void startLoading(final boolean forceLoad) { super.startLoading(forceLoad); currentInfo = null; updateTabs(); if (currentWorker != null) { currentWorker.dispose(); } runWorker(forceLoad); } private void runWorker(final boolean forceLoad) { currentWorker = ExtractorHelper.getChannelInfo(serviceId, url, forceLoad) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(result -> { isLoading.set(false); handleResult(result); }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_CHANNEL, url == null ? "No URL" : url, serviceId, url))); } @Override public void showLoading() { super.showLoading(); CoilUtils.dispose(binding.channelAvatarView); CoilUtils.dispose(binding.channelBannerImage); CoilUtils.dispose(binding.subChannelAvatarView); animate(binding.channelSubscribeButton, false, 100); } @Override public void handleResult(@NonNull final ChannelInfo result) { super.handleResult(result); currentInfo = result; setInitialData(result.getServiceId(), result.getOriginalUrl(), result.getName()); if (ImageStrategy.shouldLoadImages() && !result.getBanners().isEmpty()) { CoilHelper.INSTANCE.loadBanner(binding.channelBannerImage, result.getBanners()); } else { // do not waste space for the banner, if the user disabled images or there is not one binding.channelBannerImage.setImageDrawable(null); } CoilHelper.INSTANCE.loadAvatar(binding.channelAvatarView, result.getAvatars()); CoilHelper.INSTANCE.loadAvatar(binding.subChannelAvatarView, result.getParentChannelAvatars()); binding.channelTitleView.setText(result.getName()); binding.channelSubscriberView.setVisibility(View.VISIBLE); if (result.getSubscriberCount() >= 0) { binding.channelSubscriberView.setText(Localization .shortSubscriberCount(activity, result.getSubscriberCount())); } else { binding.channelSubscriberView.setText(R.string.subscribers_count_not_available); } if (!TextUtils.isEmpty(currentInfo.getParentChannelName())) { binding.subChannelTitleView.setText(String.format( getString(R.string.channel_created_by), currentInfo.getParentChannelName()) ); binding.subChannelTitleView.setVisibility(View.VISIBLE); binding.subChannelAvatarView.setVisibility(View.VISIBLE); } updateRssButton(); channelContentNotSupported = false; for (final Throwable throwable : result.getErrors()) { if (throwable instanceof ContentNotSupportedException) { channelContentNotSupported = true; showContentNotSupportedIfNeeded(); break; } } disposables.clear(); if (subscribeButtonMonitor != null) { subscribeButtonMonitor.dispose(); } updateTabs(); updateSubscription(result); monitorSubscription(result); } private void showContentNotSupportedIfNeeded() { // channelBinding might not be initialized when handleResult() is called // (e.g. after rotating the screen, #6696) if (!channelContentNotSupported || binding == null) { return; } binding.errorContentNotSupported.setVisibility(View.VISIBLE); binding.channelKaomoji.setText("(︶︹︺)"); binding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java ================================================ package org.schabi.newpipe.fragments.list.channel; import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.evernote.android.state.State; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.PlaylistControlBinding; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; import org.schabi.newpipe.extractor.exceptions.ParsingException; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory; import org.schabi.newpipe.extractor.linkhandler.ReadyChannelTabListLinkHandler; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.util.ChannelTabHelper; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.PlayButtonHelper; import java.util.List; import java.util.function.Supplier; import java.util.stream.Collectors; import io.reactivex.rxjava3.core.Single; public class ChannelTabFragment extends BaseListInfoFragment implements PlaylistControlViewHolder { // states must be protected and not private for State being able to access them @State protected ListLinkHandler tabHandler; @State protected String channelName; private PlaylistControlBinding playlistControlBinding; @NonNull public static ChannelTabFragment getInstance(final int serviceId, final ListLinkHandler tabHandler, final String channelName) { final ChannelTabFragment instance = new ChannelTabFragment(); instance.serviceId = serviceId; instance.tabHandler = tabHandler; instance.channelName = channelName; return instance; } public ChannelTabFragment() { super(UserAction.REQUESTED_CHANNEL); } /*////////////////////////////////////////////////////////////////////////// // LifeCycle //////////////////////////////////////////////////////////////////////////*/ @Override public void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); setHasOptionsMenu(false); } @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_channel_tab, container, false); } @Override public void onDestroyView() { super.onDestroyView(); playlistControlBinding = null; } @Override protected Supplier getListHeaderSupplier() { if (ChannelTabHelper.isStreamsTab(tabHandler)) { playlistControlBinding = PlaylistControlBinding .inflate(activity.getLayoutInflater(), itemsList, false); return playlistControlBinding::getRoot; } return null; } @Override protected Single loadResult(final boolean forceLoad) { return ExtractorHelper.getChannelTab(serviceId, tabHandler, forceLoad); } @Override protected Single> loadMoreItemsLogic() { return ExtractorHelper.getMoreChannelTabItems(serviceId, tabHandler, currentNextPage); } @Override public void setTitle(final String title) { // The channel name is displayed as title in the toolbar. // The title is always a description of the content of the tab fragment. // It should be unique for each channel because multiple channel tabs // can be added to the main page. Therefore, the channel name is used. // Using the title variable would cause the title to be the same for all channel tabs. super.setTitle(channelName); } @Override public void handleResult(@NonNull final ChannelTabInfo result) { super.handleResult(result); // FIXME this is a really hacky workaround, to avoid storing useless data in the fragment // state. The problem is, `ReadyChannelTabListLinkHandler` might contain raw JSON data that // uses a lot of memory (e.g. ~800KB for YouTube). While 800KB doesn't seem much, if // you combine just a couple of channel tab fragments you easily go over the 1MB // save&restore transaction limit, and get `TransactionTooLargeException`s. A proper // solution would require rethinking about `ReadyChannelTabListLinkHandler`s. if (tabHandler instanceof ReadyChannelTabListLinkHandler) { try { // once `handleResult` is called, the parsed data was already saved to cache, so // we can discard any raw data in ReadyChannelTabListLinkHandler and create a // link handler with identical properties, but without any raw data final ListLinkHandlerFactory channelTabLHFactory = result.getService() .getChannelTabLHFactory(); if (channelTabLHFactory != null) { // some services do not not have a ChannelTabLHFactory tabHandler = channelTabLHFactory.fromQuery(tabHandler.getId(), tabHandler.getContentFilters(), tabHandler.getSortFilter()); } } catch (final ParsingException e) { // silently ignore the error, as the app can continue to function normally Log.w(TAG, "Could not recreate channel tab handler", e); } } if (playlistControlBinding != null) { // PlaylistControls should be visible only if there is some item in // infoListAdapter other than header if (infoListAdapter.getItemCount() > 1) { playlistControlBinding.getRoot().setVisibility(View.VISIBLE); } else { playlistControlBinding.getRoot().setVisibility(View.GONE); } PlayButtonHelper.initPlaylistControlClickListener( activity, playlistControlBinding, this); } } @Override public PlayQueue getPlayQueue() { final List streamItems = infoListAdapter.getItemsList().stream() .filter(StreamInfoItem.class::isInstance) .map(StreamInfoItem.class::cast) .collect(Collectors.toList()); return new ChannelTabPlayQueue(currentInfo.getServiceId(), tabHandler, currentInfo.getNextPage(), streamItems, 0); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.java ================================================ package org.schabi.newpipe.fragments.list.comments; import static org.schabi.newpipe.util.ServiceHelper.getServiceById; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.constraintlayout.widget.ConstraintLayout; import androidx.core.text.HtmlCompat; import com.evernote.android.state.State; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.CommentRepliesHeaderBinding; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.info_list.ItemViewMode; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.image.CoilHelper; import org.schabi.newpipe.util.image.ImageStrategy; import org.schabi.newpipe.util.text.TextLinkifier; import org.schabi.newpipe.util.text.LongPressLinkMovementMethod; import java.util.Queue; import java.util.function.Supplier; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; public final class CommentRepliesFragment extends BaseListInfoFragment { public static final String TAG = CommentRepliesFragment.class.getSimpleName(); @State CommentsInfoItem commentsInfoItem; // the comment to show replies of private final CompositeDisposable disposables = new CompositeDisposable(); /*////////////////////////////////////////////////////////////////////////// // Constructors and lifecycle //////////////////////////////////////////////////////////////////////////*/ // only called by the Android framework, after which readFrom is called and restores all data public CommentRepliesFragment() { super(UserAction.REQUESTED_COMMENT_REPLIES); } public CommentRepliesFragment(@NonNull final CommentsInfoItem commentsInfoItem) { this(); this.commentsInfoItem = commentsInfoItem; // setting "" as title since the title will be properly set right after setInitialData(commentsInfoItem.getServiceId(), commentsInfoItem.getUrl(), ""); } @Nullable @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_comments, container, false); } @Override public void onDestroyView() { disposables.clear(); super.onDestroyView(); } @Override protected Supplier getListHeaderSupplier() { return () -> { final CommentRepliesHeaderBinding binding = CommentRepliesHeaderBinding .inflate(activity.getLayoutInflater(), itemsList, false); final CommentsInfoItem item = commentsInfoItem; // load the author avatar CoilHelper.INSTANCE.loadAvatar(binding.authorAvatar, item.getUploaderAvatars()); binding.authorAvatar.setVisibility(ImageStrategy.shouldLoadImages() ? View.VISIBLE : View.GONE); // setup author name and comment date binding.authorName.setText(item.getUploaderName()); binding.uploadDate.setText(Localization.relativeTimeOrTextual( getContext(), item.getUploadDate(), item.getTextualUploadDate())); binding.authorTouchArea.setOnClickListener( v -> NavigationHelper.openCommentAuthorIfPresent(requireActivity(), item)); // setup like count, hearted and pinned binding.thumbsUpCount.setText( Localization.likeCount(requireContext(), item.getLikeCount())); // for heartImage goneMarginEnd was used, but there is no way to tell ConstraintLayout // not to use a different margin only when both the next two views are gone ((ConstraintLayout.LayoutParams) binding.thumbsUpCount.getLayoutParams()) .setMarginEnd(DeviceUtils.dpToPx( (item.isHeartedByUploader() || item.isPinned() ? 8 : 16), requireContext())); binding.heartImage.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE); binding.pinnedImage.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE); // setup comment content TextLinkifier.fromDescription(binding.commentContent, item.getCommentText(), HtmlCompat.FROM_HTML_MODE_LEGACY, getServiceById(item.getServiceId()), item.getUrl(), disposables, null); binding.commentContent.setMovementMethod(LongPressLinkMovementMethod.getInstance()); return binding.getRoot(); }; } /*////////////////////////////////////////////////////////////////////////// // State saving //////////////////////////////////////////////////////////////////////////*/ @Override public void writeTo(final Queue objectsToSave) { super.writeTo(objectsToSave); objectsToSave.add(commentsInfoItem); } @Override public void readFrom(@NonNull final Queue savedObjects) throws Exception { super.readFrom(savedObjects); commentsInfoItem = (CommentsInfoItem) savedObjects.poll(); } /*////////////////////////////////////////////////////////////////////////// // Data loading //////////////////////////////////////////////////////////////////////////*/ @Override protected Single loadResult(final boolean forceLoad) { return Single.fromCallable(() -> new CommentRepliesInfo(commentsInfoItem, // the reply count string will be shown as the activity title Localization.replyCount(requireContext(), commentsInfoItem.getReplyCount()))); } @Override protected Single> loadMoreItemsLogic() { // commentsInfoItem.getUrl() should contain the url of the original // ListInfo, which should be the stream url return ExtractorHelper.getMoreCommentItems( serviceId, commentsInfoItem.getUrl(), currentNextPage); } /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ @Override protected ItemViewMode getItemViewMode() { return ItemViewMode.LIST; } /** * @return the comment to which the replies are shown */ public CommentsInfoItem getCommentsInfoItem() { return commentsInfoItem; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.java ================================================ package org.schabi.newpipe.fragments.list.comments; import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; import java.util.Collections; public final class CommentRepliesInfo extends ListInfo { /** * This class is used to wrap the comment replies page into a ListInfo object. * * @param comment the comment from which to get replies * @param name will be shown as the fragment title */ public CommentRepliesInfo(final CommentsInfoItem comment, final String name) { super(comment.getServiceId(), new ListLinkHandler("", "", "", Collections.emptyList(), null), name); setNextPage(comment.getReplies()); setRelatedItems(Collections.emptyList()); // since it must be non-null } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java ================================================ package org.schabi.newpipe.fragments.list.comments; import android.os.Bundle; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.schabi.newpipe.R; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.comments.CommentsInfo; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.info_list.ItemViewMode; import org.schabi.newpipe.ktx.ViewUtils; import org.schabi.newpipe.util.ExtractorHelper; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; public class CommentsFragment extends BaseListInfoFragment { private final CompositeDisposable disposables = new CompositeDisposable(); private TextView emptyStateDesc; public static CommentsFragment getInstance(final int serviceId, final String url, final String name) { final CommentsFragment instance = new CommentsFragment(); instance.setInitialData(serviceId, url, name); return instance; } public CommentsFragment() { super(UserAction.REQUESTED_COMMENTS); } @Override protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); emptyStateDesc = rootView.findViewById(R.id.empty_state_desc); } /*////////////////////////////////////////////////////////////////////////// // LifeCycle //////////////////////////////////////////////////////////////////////////*/ @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_comments, container, false); } @Override public void onDestroy() { super.onDestroy(); disposables.clear(); } /*////////////////////////////////////////////////////////////////////////// // Load and handle //////////////////////////////////////////////////////////////////////////*/ @Override protected Single> loadMoreItemsLogic() { return ExtractorHelper.getMoreCommentItems(serviceId, currentInfo, currentNextPage); } @Override protected Single loadResult(final boolean forceLoad) { return ExtractorHelper.getCommentsInfo(serviceId, url, forceLoad); } /*////////////////////////////////////////////////////////////////////////// // Contract //////////////////////////////////////////////////////////////////////////*/ @Override public void handleResult(@NonNull final CommentsInfo result) { super.handleResult(result); emptyStateDesc.setText( result.isCommentsDisabled() ? R.string.comments_are_disabled : R.string.no_comments); ViewUtils.slideUp(requireView(), 120, 150, 0.06f); disposables.clear(); } /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ @Override public void setTitle(final String title) { } @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { } @Override protected ItemViewMode getItemViewMode() { return ItemViewMode.LIST; } public boolean scrollToComment(final CommentsInfoItem comment) { final int position = infoListAdapter.getItemsList().indexOf(comment); if (position < 0) { return false; } itemsList.scrollToPosition(position); return true; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/DefaultKioskFragment.java ================================================ package org.schabi.newpipe.fragments.list.kiosk; import android.os.Bundle; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.kiosk.KioskList; import org.schabi.newpipe.util.KioskTranslator; import org.schabi.newpipe.util.ServiceHelper; public class DefaultKioskFragment extends KioskFragment { @Override public void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (serviceId < 0) { updateSelectedDefaultKiosk(); } } @Override public void onResume() { super.onResume(); if (serviceId != ServiceHelper.getSelectedServiceId(requireContext())) { if (currentWorker != null) { currentWorker.dispose(); } updateSelectedDefaultKiosk(); reloadContent(); } } private void updateSelectedDefaultKiosk() { try { serviceId = ServiceHelper.getSelectedServiceId(requireContext()); final KioskList kioskList = NewPipe.getService(serviceId).getKioskList(); kioskId = kioskList.getDefaultKioskId(); url = kioskList.getListLinkHandlerFactoryByType(kioskId).fromId(kioskId).getUrl(); kioskTranslatedName = KioskTranslator.getTranslatedKioskName(kioskId, requireContext()); name = kioskTranslatedName; currentInfo = null; currentNextPage = null; } catch (final ExtractionException e) { showError(new ErrorInfo(e, UserAction.REQUESTED_KIOSK, "Loading default kiosk for selected service")); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java ================================================ package org.schabi.newpipe.fragments.list.kiosk; import android.os.Bundle; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import com.evernote.android.state.State; import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.kiosk.KioskInfo; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory; import org.schabi.newpipe.extractor.localization.ContentCountry; import org.schabi.newpipe.extractor.services.media_ccc.extractors.MediaCCCLiveStreamKiosk; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.KioskTranslator; import org.schabi.newpipe.util.Localization; import io.reactivex.rxjava3.core.Single; /** * Created by Christian Schabesberger on 23.09.17. *

* Copyright (C) Christian Schabesberger 2017 * KioskFragment.java is part of NewPipe. *

*

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

*

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

*

* You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . *

*/ public class KioskFragment extends BaseListInfoFragment { @State String kioskId = ""; String kioskTranslatedName; @State ContentCountry contentCountry; /*////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////*/ public static KioskFragment getInstance(final int serviceId) throws ExtractionException { return getInstance(serviceId, NewPipe.getService(serviceId) .getKioskList().getDefaultKioskId()); } public static KioskFragment getInstance(final int serviceId, final String kioskId) throws ExtractionException { final KioskFragment instance = new KioskFragment(); final StreamingService service = NewPipe.getService(serviceId); final ListLinkHandlerFactory kioskLinkHandlerFactory = service.getKioskList() .getListLinkHandlerFactoryByType(kioskId); instance.setInitialData(serviceId, kioskLinkHandlerFactory.fromId(kioskId).getUrl(), kioskId); instance.kioskId = kioskId; return instance; } public KioskFragment() { super(UserAction.REQUESTED_KIOSK); } /*////////////////////////////////////////////////////////////////////////// // LifeCycle //////////////////////////////////////////////////////////////////////////*/ @Override public void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); kioskTranslatedName = KioskTranslator.getTranslatedKioskName(kioskId, activity); name = kioskTranslatedName; contentCountry = Localization.getPreferredContentCountry(requireContext()); } @Override public void onResume() { super.onResume(); if (!Localization.getPreferredContentCountry(requireContext()).equals(contentCountry)) { reloadContent(); } if (useAsFrontPage && activity != null) { try { setTitle(kioskTranslatedName); } catch (final Exception e) { showSnackBarError(new ErrorInfo(e, UserAction.UI_ERROR, "Setting kiosk title")); } } } @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_kiosk, container, false); } /*////////////////////////////////////////////////////////////////////////// // Menu //////////////////////////////////////////////////////////////////////////*/ @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); final ActionBar supportActionBar = activity.getSupportActionBar(); if (supportActionBar != null && useAsFrontPage) { supportActionBar.setDisplayHomeAsUpEnabled(false); } } /*////////////////////////////////////////////////////////////////////////// // Load and handle //////////////////////////////////////////////////////////////////////////*/ @Override public Single loadResult(final boolean forceReload) { contentCountry = Localization.getPreferredContentCountry(requireContext()); return ExtractorHelper.getKioskInfo(serviceId, url, forceReload); } @Override public Single> loadMoreItemsLogic() { return ExtractorHelper.getMoreKioskItems(serviceId, url, currentNextPage); } /*////////////////////////////////////////////////////////////////////////// // Contract //////////////////////////////////////////////////////////////////////////*/ @Override public void handleResult(@NonNull final KioskInfo result) { super.handleResult(result); name = kioskTranslatedName; setTitle(kioskTranslatedName); } @Override public void showEmptyState() { // show "no live streams" for live stream kiosk super.showEmptyState(); if (MediaCCCLiveStreamKiosk.KIOSK_ID.equals(currentInfo.getId()) && ServiceList.MediaCCC.getServiceId() == currentInfo.getServiceId()) { setEmptyStateMessage(R.string.no_live_streams); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistControlViewHolder.java ================================================ package org.schabi.newpipe.fragments.list.playlist; import org.schabi.newpipe.player.playqueue.PlayQueue; /** * Interface for {@code R.layout.playlist_control} view holders * to give access to the play queue. */ public interface PlaylistControlViewHolder { PlayQueue getPlayQueue(); } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java ================================================ package org.schabi.newpipe.fragments.list.playlist; import static org.schabi.newpipe.extractor.utils.Utils.isBlank; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; import static org.schabi.newpipe.util.ServiceHelper.getServiceById; import android.content.Context; import android.os.Bundle; import android.text.TextUtils; import android.util.Log; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; import com.google.android.material.shape.CornerFamily; import com.google.android.material.shape.ShapeAppearanceModel; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.databinding.PlaylistControlBinding; import org.schabi.newpipe.databinding.PlaylistHeaderBinding; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper; import org.schabi.newpipe.extractor.stream.Description; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.info_list.dialog.InfoItemDialog; import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.local.playlist.RemotePlaylistManager; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PlayButtonHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.image.CoilHelper; import org.schabi.newpipe.util.text.TextEllipsizer; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Supplier; import java.util.stream.Collectors; import coil3.util.CoilUtils; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Flowable; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; public class PlaylistFragment extends BaseListInfoFragment implements PlaylistControlViewHolder { private CompositeDisposable disposables; private Subscription bookmarkReactor; private AtomicBoolean isBookmarkButtonReady; private RemotePlaylistManager remotePlaylistManager; private PlaylistRemoteEntity playlistEntity; /*////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////*/ private PlaylistHeaderBinding headerBinding; private PlaylistControlBinding playlistControlBinding; private MenuItem playlistBookmarkButton; private long streamCount; private long playlistOverallDurationSeconds; public static PlaylistFragment getInstance(final int serviceId, final String url, final String name) { final PlaylistFragment instance = new PlaylistFragment(); instance.setInitialData(serviceId, url, name); return instance; } public PlaylistFragment() { super(UserAction.REQUESTED_PLAYLIST); } /*////////////////////////////////////////////////////////////////////////// // LifeCycle //////////////////////////////////////////////////////////////////////////*/ @Override public void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); disposables = new CompositeDisposable(); isBookmarkButtonReady = new AtomicBoolean(false); remotePlaylistManager = new RemotePlaylistManager(NewPipeDatabase .getInstance(requireContext())); } @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_playlist, container, false); } /*////////////////////////////////////////////////////////////////////////// // Init //////////////////////////////////////////////////////////////////////////*/ @Override protected Supplier getListHeaderSupplier() { headerBinding = PlaylistHeaderBinding .inflate(activity.getLayoutInflater(), itemsList, false); playlistControlBinding = headerBinding.playlistControl; return headerBinding::getRoot; } @Override protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); // Is mini variant still relevant? // Only the remote playlist screen uses it now infoListAdapter.setUseMiniVariant(true); } private PlayQueue getPlayQueueStartingAt(final StreamInfoItem infoItem) { return getPlayQueue(Math.max(infoListAdapter.getItemsList().indexOf(infoItem), 0)); } @Override protected void showInfoItemDialog(final StreamInfoItem item) { final Context context = getContext(); try { final InfoItemDialog.Builder dialogBuilder = new InfoItemDialog.Builder(getActivity(), context, this, item); dialogBuilder .setAction( StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND, (f, infoItem) -> NavigationHelper.playOnBackgroundPlayer( context, getPlayQueueStartingAt(infoItem), true)) .create() .show(); } catch (final IllegalArgumentException e) { InfoItemDialog.Builder.reportErrorDuringInitialization(e, item); } } @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { if (DEBUG) { Log.d(TAG, "onCreateOptionsMenu() called with: " + "menu = [" + menu + "], inflater = [" + inflater + "]"); } super.onCreateOptionsMenu(menu, inflater); inflater.inflate(R.menu.menu_playlist, menu); playlistBookmarkButton = menu.findItem(R.id.menu_item_bookmark); updateBookmarkButtons(); } @Override public void onDestroyView() { headerBinding = null; playlistControlBinding = null; super.onDestroyView(); if (isBookmarkButtonReady != null) { isBookmarkButtonReady.set(false); } if (disposables != null) { disposables.clear(); } if (bookmarkReactor != null) { bookmarkReactor.cancel(); } bookmarkReactor = null; } @Override public void onDestroy() { super.onDestroy(); if (disposables != null) { disposables.dispose(); } disposables = null; remotePlaylistManager = null; playlistEntity = null; isBookmarkButtonReady = null; } /*////////////////////////////////////////////////////////////////////////// // Load and handle //////////////////////////////////////////////////////////////////////////*/ @Override protected Single> loadMoreItemsLogic() { return ExtractorHelper.getMorePlaylistItems(serviceId, url, currentNextPage); } @Override protected Single loadResult(final boolean forceLoad) { return ExtractorHelper.getPlaylistInfo(serviceId, url, forceLoad); } @Override public boolean onOptionsItemSelected(final MenuItem item) { final int itemId = item.getItemId(); if (itemId == R.id.action_settings) { NavigationHelper.openSettings(requireContext()); } else if (itemId == R.id.menu_item_openInBrowser) { ShareUtils.openUrlInBrowser(requireContext(), url); } else if (itemId == R.id.menu_item_share) { ShareUtils.shareText(requireContext(), name, url, currentInfo == null ? List.of() : currentInfo.getThumbnails()); } else if (itemId == R.id.menu_item_bookmark) { onBookmarkClicked(); } else if (itemId == R.id.menu_item_append_playlist) { if (currentInfo != null) { disposables.add(PlaylistDialog.createCorrespondingDialog( getContext(), getPlayQueue() .getStreams() .stream() .map(StreamEntity::new) .collect(Collectors.toList()), dialog -> dialog.show(getFM(), TAG) )); } } else { return super.onOptionsItemSelected(item); } return true; } /*////////////////////////////////////////////////////////////////////////// // Contract //////////////////////////////////////////////////////////////////////////*/ @Override public void showLoading() { super.showLoading(); animate(headerBinding.getRoot(), false, 200); animateHideRecyclerViewAllowingScrolling(itemsList); CoilUtils.dispose(headerBinding.uploaderAvatarView); animate(headerBinding.uploaderLayout, false, 200); } @Override public void handleNextItems(final ListExtractor.InfoItemsPage result) { super.handleNextItems(result); setStreamCountAndOverallDuration(result.getItems(), !result.hasNextPage()); } @Override public void handleResult(@NonNull final PlaylistInfo result) { super.handleResult(result); animate(headerBinding.getRoot(), true, 100); animate(headerBinding.uploaderLayout, true, 300); headerBinding.uploaderLayout.setOnClickListener(null); // If we have an uploader put them into the UI if (!TextUtils.isEmpty(result.getUploaderName())) { headerBinding.uploaderName.setText(result.getUploaderName()); if (!TextUtils.isEmpty(result.getUploaderUrl())) { headerBinding.uploaderLayout.setOnClickListener(v -> { try { NavigationHelper.openChannelFragment(getFM(), result.getServiceId(), result.getUploaderUrl(), result.getUploaderName()); } catch (final Exception e) { ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e); } }); } } else { // Otherwise say we have no uploader headerBinding.uploaderName.setText(R.string.playlist_no_uploader); } playlistControlBinding.getRoot().setVisibility(View.VISIBLE); if (result.getServiceId() == ServiceList.YouTube.getServiceId() && (YoutubeParsingHelper.isYoutubeMixId(result.getId()) || YoutubeParsingHelper.isYoutubeMusicMixId(result.getId()))) { // this is an auto-generated playlist (e.g. Youtube mix), so a radio is shown final ShapeAppearanceModel model = ShapeAppearanceModel.builder() .setAllCorners(CornerFamily.ROUNDED, 0f) .build(); // this turns the image back into a square headerBinding.uploaderAvatarView.setShapeAppearanceModel(model); headerBinding.uploaderAvatarView.setStrokeColor(AppCompatResources .getColorStateList(requireContext(), R.color.transparent_background_color)); headerBinding.uploaderAvatarView.setImageDrawable( AppCompatResources.getDrawable(requireContext(), R.drawable.ic_radio) ); } else { CoilHelper.INSTANCE.loadAvatar(headerBinding.uploaderAvatarView, result.getUploaderAvatars()); } streamCount = result.getStreamCount(); setStreamCountAndOverallDuration(result.getRelatedItems(), !result.hasNextPage()); final Description description = result.getDescription(); if (description != null && description != Description.EMPTY_DESCRIPTION && !isBlank(description.getContent())) { final TextEllipsizer ellipsizer = new TextEllipsizer( headerBinding.playlistDescription, 5, getServiceById(result.getServiceId())); ellipsizer.setStateChangeListener(isEllipsized -> headerBinding.playlistDescriptionReadMore.setText( Boolean.TRUE.equals(isEllipsized) ? R.string.show_more : R.string.show_less )); ellipsizer.setOnContentChanged(canBeEllipsized -> { headerBinding.playlistDescriptionReadMore.setVisibility( Boolean.TRUE.equals(canBeEllipsized) ? View.VISIBLE : View.GONE); if (Boolean.TRUE.equals(canBeEllipsized)) { ellipsizer.ellipsize(); } }); ellipsizer.setContent(description); headerBinding.playlistDescriptionReadMore.setOnClickListener(v -> ellipsizer.toggle()); headerBinding.playlistDescription.setOnClickListener(v -> ellipsizer.toggle()); } else { headerBinding.playlistDescription.setVisibility(View.GONE); headerBinding.playlistDescriptionReadMore.setVisibility(View.GONE); } if (!result.getErrors().isEmpty()) { showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.REQUESTED_PLAYLIST, result.getUrl(), result)); } remotePlaylistManager.getPlaylist(result) .flatMap(lists -> getUpdateProcessor(lists, result), (lists, id) -> lists) .onBackpressureLatest() .observeOn(AndroidSchedulers.mainThread()) .subscribe(getPlaylistBookmarkSubscriber()); PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this); } public PlayQueue getPlayQueue() { return getPlayQueue(0); } private PlayQueue getPlayQueue(final int index) { final List infoItems = new ArrayList<>(); for (final InfoItem i : infoListAdapter.getItemsList()) { if (i instanceof StreamInfoItem) { infoItems.add((StreamInfoItem) i); } } return new PlaylistPlayQueue( currentInfo.getServiceId(), currentInfo.getUrl(), currentInfo.getNextPage(), infoItems, index ); } /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ private Flowable getUpdateProcessor( @NonNull final List playlists, @NonNull final PlaylistInfo result) { final Flowable noItemToUpdate = Flowable.just(/*noItemToUpdate=*/-1); if (playlists.isEmpty()) { return noItemToUpdate; } final PlaylistRemoteEntity playlistRemoteEntity = playlists.get(0); if (playlistRemoteEntity.isIdenticalTo(result)) { return noItemToUpdate; } return remotePlaylistManager.onUpdate(playlists.get(0).getUid(), result).toFlowable(); } private Subscriber> getPlaylistBookmarkSubscriber() { return new Subscriber<>() { @Override public void onSubscribe(final Subscription s) { if (bookmarkReactor != null) { bookmarkReactor.cancel(); } bookmarkReactor = s; bookmarkReactor.request(1); } @Override public void onNext(final List playlist) { playlistEntity = playlist.isEmpty() ? null : playlist.get(0); updateBookmarkButtons(); isBookmarkButtonReady.set(true); if (bookmarkReactor != null) { bookmarkReactor.request(1); } } @Override public void onError(final Throwable throwable) { showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, "Get playlist bookmarks")); } @Override public void onComplete() { } }; } @Override public void setTitle(final String title) { super.setTitle(title); if (headerBinding != null) { headerBinding.playlistTitleView.setText(title); } } private void onBookmarkClicked() { if (isBookmarkButtonReady == null || !isBookmarkButtonReady.get() || remotePlaylistManager == null) { return; } final Disposable action; if (currentInfo != null && playlistEntity == null) { action = remotePlaylistManager.onBookmark(currentInfo) .observeOn(AndroidSchedulers.mainThread()) .subscribe(ignored -> { /* Do nothing */ }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, "Adding playlist bookmark"))); } else if (playlistEntity != null) { action = remotePlaylistManager.deletePlaylist(playlistEntity.getUid()) .observeOn(AndroidSchedulers.mainThread()) .doFinally(() -> playlistEntity = null) .subscribe(ignored -> { /* Do nothing */ }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, "Deleting playlist bookmark"))); } else { action = Disposable.empty(); } disposables.add(action); } private void updateBookmarkButtons() { if (playlistBookmarkButton == null || activity == null) { return; } final int drawable = playlistEntity == null ? R.drawable.ic_playlist_add : R.drawable.ic_playlist_add_check; final int titleRes = playlistEntity == null ? R.string.bookmark_playlist : R.string.unbookmark_playlist; playlistBookmarkButton.setIcon(drawable); playlistBookmarkButton.setTitle(titleRes); } private void setStreamCountAndOverallDuration(final List list, final boolean isDurationComplete) { if (activity != null && headerBinding != null) { playlistOverallDurationSeconds += list.stream() .mapToLong(x -> x.getDuration()) .sum(); headerBinding.playlistStreamCount.setText( Localization.concatenateStrings( Localization.localizeStreamCount(activity, streamCount), Localization.getDurationString(playlistOverallDurationSeconds, isDurationComplete, true)) ); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java ================================================ package org.schabi.newpipe.fragments.list.search; import static androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags; import static org.schabi.newpipe.extractor.utils.Utils.isBlank; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView; import static java.util.Arrays.asList; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.os.Bundle; import android.text.Editable; import android.text.Html; import android.text.TextUtils; import android.text.TextWatcher; import android.text.style.CharacterStyle; import android.util.Log; import android.view.KeyEvent; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.view.animation.DecelerateInterpolator; import android.view.inputmethod.EditorInfo; import android.widget.EditText; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.TooltipCompat; import androidx.collection.SparseArrayCompat; import androidx.core.text.HtmlCompat; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; import com.evernote.android.state.State; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.FragmentSearchBinding; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.ReCaptchaActivity; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.MetaInfo; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ParsingException; import org.schabi.newpipe.extractor.search.SearchExtractor; import org.schabi.newpipe.extractor.search.SearchInfo; import org.schabi.newpipe.extractor.services.peertube.linkHandler.PeertubeSearchQueryHandlerFactory; import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory; import org.schabi.newpipe.fragments.BackPressable; import org.schabi.newpipe.fragments.list.BaseListFragment; import org.schabi.newpipe.ktx.AnimationType; import org.schabi.newpipe.ktx.ExceptionUtils; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.settings.NewPipeSettings; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.KeyboardUtil; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.ServiceHelper; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Queue; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.subjects.PublishSubject; public class SearchFragment extends BaseListFragment> implements BackPressable { /*////////////////////////////////////////////////////////////////////////// // Search //////////////////////////////////////////////////////////////////////////*/ /** * The suggestions will only be fetched from network if the query meet this threshold (>=). * (local ones will be fetched regardless of the length) */ private static final int THRESHOLD_NETWORK_SUGGESTION = 1; /** * How much time have to pass without emitting a item (i.e. the user stop typing) * to fetch/show the suggestions, in milliseconds. */ private static final int SUGGESTIONS_DEBOUNCE = 120; //ms private final PublishSubject suggestionPublisher = PublishSubject.create(); @State int filterItemCheckedId = -1; @State protected int serviceId = Constants.NO_SERVICE_ID; // these three represents the current search query @State String searchString; /** * No content filter should add like contentFilter = all * be aware of this when implementing an extractor. */ @State String[] contentFilter = new String[0]; @State String sortFilter; // these represents the last search @State String lastSearchedString; @State String searchSuggestion; @State boolean isCorrectedSearch; @State MetaInfo[] metaInfo; @State boolean wasSearchFocused = false; private final SparseArrayCompat menuItemToFilterName = new SparseArrayCompat<>(); private StreamingService service; @Nullable private Page nextPage; private boolean showLocalSuggestions = true; private boolean showRemoteSuggestions = true; private Disposable searchDisposable; private Disposable suggestionDisposable; private final CompositeDisposable disposables = new CompositeDisposable(); private SuggestionListAdapter suggestionListAdapter; private HistoryRecordManager historyRecordManager; /*////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////*/ private FragmentSearchBinding searchBinding; private View searchToolbarContainer; private EditText searchEditText; private View searchClear; private boolean suggestionsPanelVisible = false; /*////////////////////////////////////////////////////////////////////////*/ /** * TextWatcher to remove rich-text formatting on the search EditText when pasting content * from the clipboard. */ private TextWatcher textWatcher; public static SearchFragment getInstance(final int serviceId, final String searchString) { final SearchFragment searchFragment = new SearchFragment(); searchFragment.setQuery(serviceId, searchString, new String[0], ""); if (!TextUtils.isEmpty(searchString)) { searchFragment.setSearchOnResume(); } return searchFragment; } /** * Set wasLoading to true so when the fragment onResume is called, the initial search is done. */ private void setSearchOnResume() { wasLoading.set(true); } /*////////////////////////////////////////////////////////////////////////// // Fragment's LifeCycle //////////////////////////////////////////////////////////////////////////*/ @Override public void onAttach(@NonNull final Context context) { super.onAttach(context); final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); showLocalSuggestions = NewPipeSettings.showLocalSearchSuggestions(activity, prefs); showRemoteSuggestions = NewPipeSettings.showRemoteSearchSuggestions(activity, prefs); suggestionListAdapter = new SuggestionListAdapter(); historyRecordManager = new HistoryRecordManager(context); } @Override public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_search, container, false); } @Override public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { searchBinding = FragmentSearchBinding.bind(rootView); super.onViewCreated(rootView, savedInstanceState); updateService(); // Add the service name to search string hint // to make it more obvious which platform is being searched. if (service != null) { searchEditText.setHint( getString(R.string.search_with_service_name, service.getServiceInfo().getName())); } showSearchOnStart(); initSearchListeners(); } private void updateService() { try { service = NewPipe.getService(serviceId); } catch (final Exception e) { ErrorUtil.showUiErrorSnackbar(this, "Getting service for id " + serviceId, e); } } @Override public void onStart() { if (DEBUG) { Log.d(TAG, "onStart() called"); } super.onStart(); updateService(); } @Override public void onPause() { super.onPause(); wasSearchFocused = searchEditText.hasFocus(); if (searchDisposable != null) { searchDisposable.dispose(); } if (suggestionDisposable != null) { suggestionDisposable.dispose(); } disposables.clear(); hideKeyboardSearch(); } @Override public void onResume() { if (DEBUG) { Log.d(TAG, "onResume() called"); } super.onResume(); if (suggestionDisposable == null || suggestionDisposable.isDisposed()) { initSuggestionObserver(); } if (!TextUtils.isEmpty(searchString)) { if (wasLoading.getAndSet(false)) { search(searchString, contentFilter, sortFilter); return; } else if (infoListAdapter.getItemsList().isEmpty()) { if (savedState == null) { search(searchString, contentFilter, sortFilter); return; } else if (!isLoading.get() && !wasSearchFocused && lastPanelError == null) { infoListAdapter.clearStreamItemList(); showEmptyState(); } } } handleSearchSuggestion(); showMetaInfoInTextView(metaInfo == null ? null : Arrays.asList(metaInfo), searchBinding.searchMetaInfoTextView, searchBinding.searchMetaInfoSeparator, disposables); if (TextUtils.isEmpty(searchString) || wasSearchFocused) { showKeyboardSearch(); showSuggestionsPanel(); } else { hideKeyboardSearch(); hideSuggestionsPanel(); } wasSearchFocused = false; } @Override public void onDestroyView() { if (DEBUG) { Log.d(TAG, "onDestroyView() called"); } unsetSearchListeners(); searchBinding = null; super.onDestroyView(); } @Override public void onDestroy() { super.onDestroy(); if (searchDisposable != null) { searchDisposable.dispose(); } if (suggestionDisposable != null) { suggestionDisposable.dispose(); } disposables.clear(); } @Override public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { if (requestCode == ReCaptchaActivity.RECAPTCHA_REQUEST) { if (resultCode == Activity.RESULT_OK && !TextUtils.isEmpty(searchString)) { search(searchString, contentFilter, sortFilter); } else { Log.e(TAG, "ReCaptcha failed"); } } else { Log.e(TAG, "Request code from activity not supported [" + requestCode + "]"); } } /*////////////////////////////////////////////////////////////////////////// // Init //////////////////////////////////////////////////////////////////////////*/ @Override protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); searchBinding.suggestionsList.setAdapter(suggestionListAdapter); // animations are just strange and useless, since the suggestions keep changing too much searchBinding.suggestionsList.setItemAnimator(null); new ItemTouchHelper(new ItemTouchHelper.Callback() { @Override public int getMovementFlags(@NonNull final RecyclerView recyclerView, @NonNull final RecyclerView.ViewHolder viewHolder) { return getSuggestionMovementFlags(viewHolder); } @Override public boolean onMove(@NonNull final RecyclerView recyclerView, @NonNull final RecyclerView.ViewHolder viewHolder, @NonNull final RecyclerView.ViewHolder viewHolder1) { return false; } @Override public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, final int i) { onSuggestionItemSwiped(viewHolder); } }).attachToRecyclerView(searchBinding.suggestionsList); searchToolbarContainer = activity.findViewById(R.id.toolbar_search_container); searchEditText = searchToolbarContainer.findViewById(R.id.toolbar_search_edit_text); searchClear = searchToolbarContainer.findViewById(R.id.toolbar_search_clear); } /*////////////////////////////////////////////////////////////////////////// // State Saving //////////////////////////////////////////////////////////////////////////*/ @Override public void writeTo(final Queue objectsToSave) { super.writeTo(objectsToSave); objectsToSave.add(nextPage); } @Override public void readFrom(@NonNull final Queue savedObjects) throws Exception { super.readFrom(savedObjects); nextPage = (Page) savedObjects.poll(); } @Override public void onSaveInstanceState(@NonNull final Bundle bundle) { searchString = searchEditText != null ? getSearchEditString().trim() : searchString; super.onSaveInstanceState(bundle); } /*////////////////////////////////////////////////////////////////////////// // Init's //////////////////////////////////////////////////////////////////////////*/ @Override public void reloadContent() { if (!TextUtils.isEmpty(searchString) || (searchEditText != null && !isSearchEditBlank())) { search(!TextUtils.isEmpty(searchString) ? searchString : getSearchEditString(), this.contentFilter, ""); } else { if (searchEditText != null) { searchEditText.setText(""); showKeyboardSearch(); } hideErrorPanel(); } } /*////////////////////////////////////////////////////////////////////////// // Menu //////////////////////////////////////////////////////////////////////////*/ @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); final ActionBar supportActionBar = activity.getSupportActionBar(); if (supportActionBar != null) { supportActionBar.setDisplayShowTitleEnabled(false); supportActionBar.setDisplayHomeAsUpEnabled(true); } int itemId = 0; boolean isFirstItem = true; final Context c = getContext(); if (service == null) { Log.w(TAG, "onCreateOptionsMenu() called with null service"); updateService(); } for (final String filter : service.getSearchQHFactory().getAvailableContentFilter()) { if (filter.equals(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS)) { final MenuItem musicItem = menu.add(2, itemId++, 0, "YouTube Music"); musicItem.setEnabled(false); } else if (filter.equals(PeertubeSearchQueryHandlerFactory.SEPIA_VIDEOS)) { final MenuItem sepiaItem = menu.add(2, itemId++, 0, "Sepia Search"); sepiaItem.setEnabled(false); } menuItemToFilterName.put(itemId, filter); final MenuItem item = menu.add(1, itemId++, 0, ServiceHelper.getTranslatedFilterString(filter, c)); if (isFirstItem) { item.setChecked(true); isFirstItem = false; } } menu.setGroupCheckable(1, true, true); restoreFilterChecked(menu, filterItemCheckedId); } @Override public boolean onOptionsItemSelected(@NonNull final MenuItem item) { final var filter = Collections.singletonList(menuItemToFilterName.get(item.getItemId())); changeContentFilter(item, filter); return true; } private void restoreFilterChecked(final Menu menu, final int itemId) { if (itemId != -1) { final MenuItem item = menu.findItem(itemId); if (item == null) { return; } item.setChecked(true); } } /*////////////////////////////////////////////////////////////////////////// // Search //////////////////////////////////////////////////////////////////////////*/ private void showSearchOnStart() { if (DEBUG) { Log.d(TAG, "showSearchOnStart() called, searchQuery → " + searchString + ", lastSearchedQuery → " + lastSearchedString); } searchEditText.setText(searchString); if (TextUtils.isEmpty(searchString) || isSearchEditBlank()) { searchToolbarContainer.setTranslationX(100); searchToolbarContainer.setAlpha(0.0f); searchToolbarContainer.setVisibility(View.VISIBLE); searchToolbarContainer.animate() .translationX(0) .alpha(1.0f) .setDuration(200) .setInterpolator(new DecelerateInterpolator()).start(); } else { searchToolbarContainer.setTranslationX(0); searchToolbarContainer.setAlpha(1.0f); searchToolbarContainer.setVisibility(View.VISIBLE); } } private void initSearchListeners() { if (DEBUG) { Log.d(TAG, "initSearchListeners() called"); } searchClear.setOnClickListener(v -> { if (DEBUG) { Log.d(TAG, "onClick() called with: v = [" + v + "]"); } if (isSearchEditBlank()) { NavigationHelper.gotoMainFragment(getFM()); return; } searchBinding.correctSuggestion.setVisibility(View.GONE); searchEditText.setText(""); suggestionListAdapter.submitList(null); showKeyboardSearch(); }); TooltipCompat.setTooltipText(searchClear, getString(R.string.clear)); searchEditText.setOnClickListener(v -> { if (DEBUG) { Log.d(TAG, "onClick() called with: v = [" + v + "]"); } if ((showLocalSuggestions || showRemoteSuggestions) && !isErrorPanelVisible()) { showSuggestionsPanel(); } if (DeviceUtils.isTv(getContext())) { showKeyboardSearch(); } }); searchEditText.setOnFocusChangeListener((final View v, final boolean hasFocus) -> { if (DEBUG) { Log.d(TAG, "onFocusChange() called with: " + "v = [" + v + "], hasFocus = [" + hasFocus + "]"); } if ((showLocalSuggestions || showRemoteSuggestions) && hasFocus && !isErrorPanelVisible()) { showSuggestionsPanel(); } }); suggestionListAdapter.setListener(new SuggestionListAdapter.OnSuggestionItemSelected() { @Override public void onSuggestionItemSelected(final SuggestionItem item) { search(item.query, new String[0], ""); searchEditText.setText(item.query); } @Override public void onSuggestionItemInserted(final SuggestionItem item) { searchEditText.setText(item.query); searchEditText.setSelection(searchEditText.getText().length()); } @Override public void onSuggestionItemLongClick(final SuggestionItem item) { if (item.fromHistory) { showDeleteSuggestionDialog(item); } } }); if (textWatcher != null) { searchEditText.removeTextChangedListener(textWatcher); } textWatcher = new TextWatcher() { @Override public void beforeTextChanged(final CharSequence s, final int start, final int count, final int after) { // Do nothing, old text is already clean } @Override public void onTextChanged(final CharSequence s, final int start, final int before, final int count) { // Changes are handled in afterTextChanged; CharSequence cannot be changed here. } @Override public void afterTextChanged(final Editable s) { // Remove rich text formatting for (final CharacterStyle span : s.getSpans(0, s.length(), CharacterStyle.class)) { s.removeSpan(span); } final String newText = getSearchEditString().trim(); suggestionPublisher.onNext(newText); } }; searchEditText.addTextChangedListener(textWatcher); searchEditText.setOnEditorActionListener( (final TextView v, final int actionId, final KeyEvent event) -> { if (DEBUG) { Log.d(TAG, "onEditorAction() called with: v = [" + v + "], " + "actionId = [" + actionId + "], event = [" + event + "]"); } if (actionId == EditorInfo.IME_ACTION_PREVIOUS) { hideKeyboardSearch(); } else if (event != null && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER || event.getAction() == EditorInfo.IME_ACTION_SEARCH)) { searchEditText.setText(getSearchEditString().trim()); search(getSearchEditString(), new String[0], ""); return true; } return false; }); if (suggestionDisposable == null || suggestionDisposable.isDisposed()) { initSuggestionObserver(); } } private void unsetSearchListeners() { if (DEBUG) { Log.d(TAG, "unsetSearchListeners() called"); } searchClear.setOnClickListener(null); searchClear.setOnLongClickListener(null); searchEditText.setOnClickListener(null); searchEditText.setOnFocusChangeListener(null); searchEditText.setOnEditorActionListener(null); if (textWatcher != null) { searchEditText.removeTextChangedListener(textWatcher); } textWatcher = null; } private void showSuggestionsPanel() { if (DEBUG) { Log.d(TAG, "showSuggestionsPanel() called"); } suggestionsPanelVisible = true; animate(searchBinding.suggestionsPanel, true, 200, AnimationType.LIGHT_SLIDE_AND_ALPHA); } private void hideSuggestionsPanel() { if (DEBUG) { Log.d(TAG, "hideSuggestionsPanel() called"); } suggestionsPanelVisible = false; animate(searchBinding.suggestionsPanel, false, 200, AnimationType.LIGHT_SLIDE_AND_ALPHA); } private void showKeyboardSearch() { if (DEBUG) { Log.d(TAG, "showKeyboardSearch() called"); } KeyboardUtil.showKeyboard(activity, searchEditText); } private void hideKeyboardSearch() { if (DEBUG) { Log.d(TAG, "hideKeyboardSearch() called"); } KeyboardUtil.hideKeyboard(activity, searchEditText); } private void showDeleteSuggestionDialog(final SuggestionItem item) { if (activity == null || historyRecordManager == null || searchEditText == null) { return; } final String query = item.query; new AlertDialog.Builder(activity) .setTitle(query) .setMessage(R.string.delete_item_search_history) .setCancelable(true) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.delete, (dialog, which) -> { final Disposable onDelete = historyRecordManager.deleteSearchHistory(query) .observeOn(AndroidSchedulers.mainThread()) .subscribe( howManyDeleted -> suggestionPublisher .onNext(getSearchEditString()), throwable -> showSnackBarError(new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, "Deleting item failed"))); disposables.add(onDelete); }) .show(); } @Override public boolean onBackPressed() { if (suggestionsPanelVisible && !infoListAdapter.getItemsList().isEmpty() && !isLoading.get()) { hideSuggestionsPanel(); hideKeyboardSearch(); searchEditText.setText(lastSearchedString); return true; } return false; } private Observable> getLocalSuggestionsObservable( final String query, final int similarQueryLimit) { return historyRecordManager .getRelatedSearches(query, similarQueryLimit, 25) .toObservable() .map(searchHistoryEntries -> searchHistoryEntries.stream() .map(entry -> new SuggestionItem(true, entry)) .collect(Collectors.toList())); } private Observable> getRemoteSuggestionsObservable(final String query) { return ExtractorHelper .suggestionsFor(serviceId, query) .toObservable() .map(strings -> { final List result = new ArrayList<>(); for (final String entry : strings) { result.add(new SuggestionItem(false, entry)); } return result; }); } private void initSuggestionObserver() { if (DEBUG) { Log.d(TAG, "initSuggestionObserver() called"); } if (suggestionDisposable != null) { suggestionDisposable.dispose(); } suggestionDisposable = suggestionPublisher .debounce(SUGGESTIONS_DEBOUNCE, TimeUnit.MILLISECONDS) .startWithItem(searchString == null ? "" : searchString) .switchMap(query -> { // Only show remote suggestions if they are enabled in settings and // the query length is at least THRESHOLD_NETWORK_SUGGESTION final boolean shallShowRemoteSuggestionsNow = showRemoteSuggestions && query.length() >= THRESHOLD_NETWORK_SUGGESTION; if (showLocalSuggestions && shallShowRemoteSuggestionsNow) { return Observable.zip( getLocalSuggestionsObservable(query, 3), getRemoteSuggestionsObservable(query), (local, remote) -> { remote.removeIf(remoteItem -> local.stream().anyMatch( localItem -> localItem.equals(remoteItem))); local.addAll(remote); return local; }) .materialize(); } else if (showLocalSuggestions) { return getLocalSuggestionsObservable(query, 25) .materialize(); } else if (shallShowRemoteSuggestionsNow) { return getRemoteSuggestionsObservable(query) .materialize(); } else { return Single.fromCallable(Collections::emptyList) .toObservable() .materialize(); } }) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( listNotification -> { if (listNotification.isOnNext()) { if (listNotification.getValue() != null) { handleSuggestions(listNotification.getValue()); } } else if (listNotification.isOnError() && listNotification.getError() != null && !ExceptionUtils.isInterruptedCaused( listNotification.getError())) { showSnackBarError(new ErrorInfo(listNotification.getError(), UserAction.GET_SUGGESTIONS, searchString, serviceId)); } }, throwable -> showSnackBarError(new ErrorInfo( throwable, UserAction.GET_SUGGESTIONS, searchString, serviceId))); } @Override protected void doInitialLoadLogic() { // no-op } /** * Perform a search. * @param theSearchString the trimmed search string * @param theContentFilter the content filter to use. FIXME: unused param * @param theSortFilter FIXME: unused param */ private void search(@NonNull final String theSearchString, final String[] theContentFilter, final String theSortFilter) { if (DEBUG) { Log.d(TAG, "search() called with: query = [" + theSearchString + "]"); } if (theSearchString.isEmpty()) { return; } // Check if theSearchString is a URL which can be opened by NewPipe directly // and open it if possible. try { final StreamingService streamingService = NewPipe.getServiceByUrl(theSearchString); showLoading(); disposables.add(Observable .fromCallable(() -> NavigationHelper.getIntentByLink(activity, streamingService, theSearchString)) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(intent -> { getFM().popBackStackImmediate(); activity.startActivity(intent); }, throwable -> showTextError(getString(R.string.unsupported_url)))); return; } catch (final Exception ignored) { // Exception occurred, it's not a url } // prepare search lastSearchedString = this.searchString; this.searchString = theSearchString; infoListAdapter.clearStreamItemList(); hideSuggestionsPanel(); showMetaInfoInTextView(null, searchBinding.searchMetaInfoTextView, searchBinding.searchMetaInfoSeparator, disposables); hideKeyboardSearch(); // store search query if search history is enabled disposables.add(historyRecordManager.onSearched(serviceId, theSearchString) .observeOn(AndroidSchedulers.mainThread()) .subscribe( ignored -> { }, throwable -> showSnackBarError(new ErrorInfo(throwable, UserAction.SEARCHED, theSearchString, serviceId)) )); // load search results suggestionPublisher.onNext(theSearchString); startLoading(false); } @Override public void startLoading(final boolean forceLoad) { super.startLoading(forceLoad); disposables.clear(); if (searchDisposable != null) { searchDisposable.dispose(); } searchDisposable = ExtractorHelper.searchFor(serviceId, searchString, Arrays.asList(contentFilter), sortFilter) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .doOnEvent((searchResult, throwable) -> isLoading.set(false)) .subscribe(this::handleResult, this::onItemError); } @Override protected void loadMoreItems() { if (!Page.isValid(nextPage)) { return; } isLoading.set(true); showListFooter(true); if (searchDisposable != null) { searchDisposable.dispose(); } searchDisposable = ExtractorHelper.getMoreSearchItems( serviceId, searchString, asList(contentFilter), sortFilter, nextPage) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .doOnEvent((nextItemsResult, throwable) -> isLoading.set(false)) .subscribe(this::handleNextItems, this::onItemError); } @Override protected boolean hasMoreItems() { return Page.isValid(nextPage); } @Override protected void onItemSelected(final InfoItem selectedItem) { super.onItemSelected(selectedItem); hideKeyboardSearch(); } private void onItemError(final Throwable exception) { if (exception instanceof SearchExtractor.NothingFoundException) { infoListAdapter.clearStreamItemList(); showEmptyState(); } else { showError(new ErrorInfo(exception, UserAction.SEARCHED, searchString, serviceId, getOpenInBrowserUrlForErrors())); } } @Nullable private String getOpenInBrowserUrlForErrors() { if (TextUtils.isEmpty(searchString)) { return null; } try { return service.getSearchQHFactory().getUrl(searchString, Arrays.asList(contentFilter), sortFilter); } catch (final NullPointerException | ParsingException ignored) { return null; } } /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ private void changeContentFilter(final MenuItem item, final List theContentFilter) { filterItemCheckedId = item.getItemId(); item.setChecked(true); if (service != null) { final boolean isNotFiltered = theContentFilter.isEmpty() || "all".equals(theContentFilter.get(0)); if (isNotFiltered) { searchEditText.setHint( getString(R.string.search_with_service_name, service.getServiceInfo().getName())); } else { searchEditText.setHint(getString(R.string.search_with_service_name_and_filter, service.getServiceInfo().getName(), item.getTitle())); } } contentFilter = theContentFilter.toArray(new String[0]); if (!TextUtils.isEmpty(searchString)) { search(searchString, contentFilter, sortFilter); } } private void setQuery(final int theServiceId, final String theSearchString, final String[] theContentFilter, final String theSortFilter) { serviceId = theServiceId; searchString = theSearchString; contentFilter = theContentFilter; sortFilter = theSortFilter; } private String getSearchEditString() { return searchEditText.getText().toString(); } private boolean isSearchEditBlank() { return isBlank(getSearchEditString()); } /*////////////////////////////////////////////////////////////////////////// // Suggestion Results //////////////////////////////////////////////////////////////////////////*/ public void handleSuggestions(@NonNull final List suggestions) { if (DEBUG) { Log.d(TAG, "handleSuggestions() called with: suggestions = [" + suggestions + "]"); } suggestionListAdapter.submitList(suggestions, () -> { if (searchBinding != null) { searchBinding.suggestionsList.scrollToPosition(0); } }); if (suggestionsPanelVisible && isErrorPanelVisible()) { hideLoading(); } } /*////////////////////////////////////////////////////////////////////////// // Contract //////////////////////////////////////////////////////////////////////////*/ @Override public void hideLoading() { super.hideLoading(); showListFooter(false); } /*////////////////////////////////////////////////////////////////////////// // Search Results //////////////////////////////////////////////////////////////////////////*/ @Override public void handleResult(@NonNull final SearchInfo result) { final List exceptions = result.getErrors(); if (!exceptions.isEmpty() && !(exceptions.size() == 1 && exceptions.get(0) instanceof SearchExtractor.NothingFoundException)) { showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED, searchString, serviceId, getOpenInBrowserUrlForErrors())); } searchSuggestion = result.getSearchSuggestion(); if (searchSuggestion != null) { searchSuggestion = searchSuggestion.trim(); } isCorrectedSearch = result.isCorrectedSearch(); // List cannot be bundled without creating some containers metaInfo = result.getMetaInfo().toArray(new MetaInfo[0]); showMetaInfoInTextView(result.getMetaInfo(), searchBinding.searchMetaInfoTextView, searchBinding.searchMetaInfoSeparator, disposables); handleSearchSuggestion(); lastSearchedString = searchString; nextPage = result.getNextPage(); if (infoListAdapter.getItemsList().isEmpty()) { if (!result.getRelatedItems().isEmpty()) { infoListAdapter.addInfoItemList(result.getRelatedItems()); } else { infoListAdapter.clearStreamItemList(); showEmptyState(); return; } } super.handleResult(result); } private void handleSearchSuggestion() { if (TextUtils.isEmpty(searchSuggestion)) { searchBinding.correctSuggestion.setVisibility(View.GONE); } else { final String helperText = getString(isCorrectedSearch ? R.string.search_showing_result_for : R.string.did_you_mean); final String highlightedSearchSuggestion = "" + Html.escapeHtml(searchSuggestion) + ""; final String text = String.format(helperText, highlightedSearchSuggestion); searchBinding.correctSuggestion.setText(HtmlCompat.fromHtml(text, HtmlCompat.FROM_HTML_MODE_LEGACY)); searchBinding.correctSuggestion.setOnClickListener(v -> { searchBinding.correctSuggestion.setVisibility(View.GONE); search(searchSuggestion, contentFilter, sortFilter); searchEditText.setText(searchSuggestion); }); searchBinding.correctSuggestion.setOnLongClickListener(v -> { searchEditText.setText(searchSuggestion); searchEditText.setSelection(searchSuggestion.length()); showKeyboardSearch(); return true; }); searchBinding.correctSuggestion.setVisibility(View.VISIBLE); } } @Override public void handleNextItems(final ListExtractor.InfoItemsPage result) { showListFooter(false); infoListAdapter.addInfoItemList(result.getItems()); if (!result.getErrors().isEmpty()) { // nextPage should be non-null at this point, because it refers to the page // whose results are handled here, but let's check it anyway if (nextPage == null) { showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED, "\"" + searchString + "\" → nextPage == null", serviceId, getOpenInBrowserUrlForErrors())); } else { showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED, "\"" + searchString + "\" → pageUrl: " + nextPage.getUrl() + ", " + "pageIds: " + nextPage.getIds() + ", " + "pageCookies: " + nextPage.getCookies(), serviceId, getOpenInBrowserUrlForErrors())); } } // keep the reassignment of nextPage after the error handling to ensure that nextPage // still holds the correct value during the error handling nextPage = result.getNextPage(); super.handleNextItems(result); } @Override public void handleError() { super.handleError(); hideSuggestionsPanel(); hideKeyboardSearch(); } /*////////////////////////////////////////////////////////////////////////// // Suggestion item touch helper //////////////////////////////////////////////////////////////////////////*/ public int getSuggestionMovementFlags(@NonNull final RecyclerView.ViewHolder viewHolder) { final int position = viewHolder.getBindingAdapterPosition(); if (position == RecyclerView.NO_POSITION) { return 0; } final SuggestionItem item = suggestionListAdapter.getCurrentList().get(position); return item.fromHistory ? makeMovementFlags(0, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT) : 0; } public void onSuggestionItemSwiped(@NonNull final RecyclerView.ViewHolder viewHolder) { final int position = viewHolder.getBindingAdapterPosition(); final String query = suggestionListAdapter.getCurrentList().get(position).query; final Disposable onDelete = historyRecordManager.deleteSearchHistory(query) .observeOn(AndroidSchedulers.mainThread()) .subscribe( howManyDeleted -> suggestionPublisher .onNext(getSearchEditString()), throwable -> showSnackBarError(new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, "Deleting item failed"))); disposables.add(onDelete); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionItem.kt ================================================ /* * SPDX-FileCopyrightText: 2017-2025 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.fragments.list.search class SuggestionItem(@JvmField val fromHistory: Boolean, @JvmField val query: String) { override fun equals(other: Any?): Boolean { if (other is SuggestionItem) { return query == other.query } return false } override fun hashCode() = query.hashCode() override fun toString() = "[$fromHistory→$query]" } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.kt ================================================ /* * SPDX-FileCopyrightText: 2017-2025 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.fragments.list.search import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import org.schabi.newpipe.R import org.schabi.newpipe.databinding.ItemSearchSuggestionBinding import org.schabi.newpipe.fragments.list.search.SuggestionListAdapter.SuggestionItemHolder class SuggestionListAdapter : ListAdapter(SuggestionItemCallback()) { var listener: OnSuggestionItemSelected? = null override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SuggestionItemHolder { return SuggestionItemHolder( ItemSearchSuggestionBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) } override fun onBindViewHolder(holder: SuggestionItemHolder, position: Int) { val currentItem = getItem(position) holder.updateFrom(currentItem) holder.binding.suggestionSearch.setOnClickListener { listener?.onSuggestionItemSelected(currentItem) } holder.binding.suggestionSearch.setOnLongClickListener { listener?.onSuggestionItemLongClick(currentItem) true } holder.binding.suggestionInsert.setOnClickListener { listener?.onSuggestionItemInserted(currentItem) } } interface OnSuggestionItemSelected { fun onSuggestionItemSelected(item: SuggestionItem) fun onSuggestionItemInserted(item: SuggestionItem) fun onSuggestionItemLongClick(item: SuggestionItem) } class SuggestionItemHolder(val binding: ItemSearchSuggestionBinding) : RecyclerView.ViewHolder(binding.getRoot()) { fun updateFrom(item: SuggestionItem) { binding.itemSuggestionIcon.setImageResource( if (item.fromHistory) { R.drawable.ic_history } else { R.drawable.ic_search } ) binding.itemSuggestionQuery.text = item.query } } private class SuggestionItemCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: SuggestionItem, newItem: SuggestionItem): Boolean { return oldItem.fromHistory == newItem.fromHistory && oldItem.query == newItem.query } override fun areContentsTheSame(oldItem: SuggestionItem, newItem: SuggestionItem): Boolean { return true // items' contents never change; the list of items themselves does } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java ================================================ package org.schabi.newpipe.fragments.list.videos; import android.content.SharedPreferences; import android.os.Bundle; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.RelatedItemsHeaderBinding; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.info_list.ItemViewMode; import org.schabi.newpipe.info_list.dialog.InfoItemDialog; import org.schabi.newpipe.ktx.ViewUtils; import java.io.Serializable; import java.util.function.Supplier; import io.reactivex.rxjava3.core.Single; public class RelatedItemsFragment extends BaseListInfoFragment implements SharedPreferences.OnSharedPreferenceChangeListener { private static final String INFO_KEY = "related_info_key"; private RelatedItemsInfo relatedItemsInfo; /*////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////*/ private RelatedItemsHeaderBinding headerBinding; public static RelatedItemsFragment getInstance(final StreamInfo info) { final RelatedItemsFragment instance = new RelatedItemsFragment(); instance.setInitialData(info); return instance; } public RelatedItemsFragment() { super(UserAction.REQUESTED_STREAM); } /*////////////////////////////////////////////////////////////////////////// // LifeCycle //////////////////////////////////////////////////////////////////////////*/ @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_related_items, container, false); } @Override public void onDestroyView() { headerBinding = null; super.onDestroyView(); } @Override protected Supplier getListHeaderSupplier() { if (relatedItemsInfo == null || relatedItemsInfo.getRelatedItems() == null) { return null; } headerBinding = RelatedItemsHeaderBinding .inflate(activity.getLayoutInflater(), itemsList, false); final SharedPreferences pref = PreferenceManager .getDefaultSharedPreferences(requireContext()); final boolean autoplay = pref.getBoolean(getString(R.string.auto_queue_key), false); headerBinding.autoplaySwitch.setChecked(autoplay); headerBinding.autoplaySwitch.setOnCheckedChangeListener((compoundButton, b) -> PreferenceManager.getDefaultSharedPreferences(requireContext()).edit() .putBoolean(getString(R.string.auto_queue_key), b).apply()); return headerBinding::getRoot; } @Override protected Single> loadMoreItemsLogic() { return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage); } /*////////////////////////////////////////////////////////////////////////// // Contract //////////////////////////////////////////////////////////////////////////*/ @Override protected Single loadResult(final boolean forceLoad) { return Single.fromCallable(() -> relatedItemsInfo); } @Override public void showLoading() { super.showLoading(); if (headerBinding != null) { headerBinding.getRoot().setVisibility(View.INVISIBLE); } } @Override public void handleResult(@NonNull final RelatedItemsInfo result) { super.handleResult(result); if (headerBinding != null) { headerBinding.getRoot().setVisibility(View.VISIBLE); } ViewUtils.slideUp(requireView(), 120, 96, 0.06f); } /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ @Override public void setTitle(final String title) { // Nothing to do - override parent } @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { // Nothing to do - override parent } private void setInitialData(final StreamInfo info) { super.setInitialData(info.getServiceId(), info.getUrl(), info.getName()); if (this.relatedItemsInfo == null) { this.relatedItemsInfo = new RelatedItemsInfo(info); } } @Override public void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); outState.putSerializable(INFO_KEY, relatedItemsInfo); } @Override protected void onRestoreInstanceState(@NonNull final Bundle savedState) { super.onRestoreInstanceState(savedState); final Serializable serializable = savedState.getSerializable(INFO_KEY); if (serializable instanceof RelatedItemsInfo) { this.relatedItemsInfo = (RelatedItemsInfo) serializable; } } @Override public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, final String key) { if (headerBinding != null && getString(R.string.auto_queue_key).equals(key)) { headerBinding.autoplaySwitch.setChecked(sharedPreferences.getBoolean(key, false)); } } @Override protected ItemViewMode getItemViewMode() { ItemViewMode mode = super.getItemViewMode(); // Only list mode is supported. Either List or card will be used. if (mode != ItemViewMode.LIST && mode != ItemViewMode.CARD) { mode = ItemViewMode.LIST; } return mode; } @Override protected void showInfoItemDialog(final StreamInfoItem item) { // Try and attach the InfoItemDialog to the parent fragment of the RelatedItemsFragment // so that its context is not lost when the RelatedItemsFragment is reinitialized, // e.g. when a new stream is loaded in a parent VideoDetailFragment. final Fragment parentFragment = getParentFragment(); if (parentFragment != null) { try { new InfoItemDialog.Builder( parentFragment.getActivity(), parentFragment.getContext(), parentFragment, item ).create().show(); } catch (final IllegalArgumentException e) { InfoItemDialog.Builder.reportErrorDuringInitialization(e, item); } } else { super.showInfoItemDialog(item); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsInfo.java ================================================ package org.schabi.newpipe.fragments.list.videos; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; import org.schabi.newpipe.extractor.stream.StreamInfo; import java.util.ArrayList; import java.util.Collections; public final class RelatedItemsInfo extends ListInfo { /** * This class is used to wrap the related items of a StreamInfo into a ListInfo object. * * @param info the stream info from which to get related items */ public RelatedItemsInfo(final StreamInfo info) { super(info.getServiceId(), new ListLinkHandler(info.getOriginalUrl(), info.getUrl(), info.getId(), Collections.emptyList(), null), info.getName()); setRelatedItems(new ArrayList<>(info.getRelatedItems())); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.kt ================================================ /* * SPDX-FileCopyrightText: 2016-2026 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.info_list import android.content.Context import org.schabi.newpipe.extractor.channel.ChannelInfoItem import org.schabi.newpipe.extractor.comments.CommentsInfoItem import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.util.OnClickGesture class InfoItemBuilder(val context: Context) { var onStreamSelectedListener: OnClickGesture? = null var onChannelSelectedListener: OnClickGesture? = null var onPlaylistSelectedListener: OnClickGesture? = null var onCommentsSelectedListener: OnClickGesture? = null } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java ================================================ package org.schabi.newpipe.info_list; import android.content.Context; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import org.schabi.newpipe.databinding.PignateFooterBinding; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.info_list.holder.ChannelCardInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelGridInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder; import org.schabi.newpipe.info_list.holder.CommentInfoItemHolder; import org.schabi.newpipe.info_list.holder.InfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistCardInfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder; import org.schabi.newpipe.info_list.holder.StreamCardInfoItemHolder; import org.schabi.newpipe.info_list.holder.StreamGridInfoItemHolder; import org.schabi.newpipe.info_list.holder.StreamInfoItemHolder; import org.schabi.newpipe.info_list.holder.StreamMiniInfoItemHolder; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.FallbackViewHolder; import org.schabi.newpipe.util.OnClickGesture; import java.util.ArrayList; import java.util.List; import java.util.function.Supplier; /* * Created by Christian Schabesberger on 01.08.16. * * Copyright (C) Christian Schabesberger 2016 * InfoListAdapter.java is part of NewPipe. * * NewPipe is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * NewPipe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ public class InfoListAdapter extends RecyclerView.Adapter { private static final String TAG = InfoListAdapter.class.getSimpleName(); private static final boolean DEBUG = false; private static final int HEADER_TYPE = 0; private static final int FOOTER_TYPE = 1; private static final int MINI_STREAM_HOLDER_TYPE = 0x100; private static final int STREAM_HOLDER_TYPE = 0x101; private static final int GRID_STREAM_HOLDER_TYPE = 0x102; private static final int CARD_STREAM_HOLDER_TYPE = 0x103; private static final int MINI_CHANNEL_HOLDER_TYPE = 0x200; private static final int CHANNEL_HOLDER_TYPE = 0x201; private static final int GRID_CHANNEL_HOLDER_TYPE = 0x202; private static final int CARD_CHANNEL_HOLDER_TYPE = 0x203; private static final int MINI_PLAYLIST_HOLDER_TYPE = 0x300; private static final int PLAYLIST_HOLDER_TYPE = 0x301; private static final int GRID_PLAYLIST_HOLDER_TYPE = 0x302; private static final int CARD_PLAYLIST_HOLDER_TYPE = 0x303; private static final int COMMENT_HOLDER_TYPE = 0x400; private final LayoutInflater layoutInflater; private final InfoItemBuilder infoItemBuilder; private final List infoItemList; private final HistoryRecordManager recordManager; private boolean useMiniVariant = false; private boolean showFooter = false; private ItemViewMode itemMode = ItemViewMode.LIST; private Supplier headerSupplier = null; public InfoListAdapter(final Context context) { layoutInflater = LayoutInflater.from(context); recordManager = new HistoryRecordManager(context); infoItemBuilder = new InfoItemBuilder(context); infoItemList = new ArrayList<>(); } public void setOnStreamSelectedListener(final OnClickGesture listener) { infoItemBuilder.setOnStreamSelectedListener(listener); } public void setOnChannelSelectedListener(final OnClickGesture listener) { infoItemBuilder.setOnChannelSelectedListener(listener); } public void setOnPlaylistSelectedListener(final OnClickGesture listener) { infoItemBuilder.setOnPlaylistSelectedListener(listener); } public void setOnCommentsSelectedListener(final OnClickGesture listener) { infoItemBuilder.setOnCommentsSelectedListener(listener); } public void setUseMiniVariant(final boolean useMiniVariant) { this.useMiniVariant = useMiniVariant; } public void setItemViewMode(final ItemViewMode itemViewMode) { this.itemMode = itemViewMode; } public void addInfoItemList(@Nullable final List data) { if (data == null) { return; } if (DEBUG) { Log.d(TAG, "addInfoItemList() before > infoItemList.size() = " + infoItemList.size() + ", data.size() = " + data.size()); } final int offsetStart = sizeConsideringHeaderOffset(); infoItemList.addAll(data); if (DEBUG) { Log.d(TAG, "addInfoItemList() after > offsetStart = " + offsetStart + ", " + "infoItemList.size() = " + infoItemList.size() + ", " + "hasHeader = " + hasHeader() + ", " + "showFooter = " + showFooter); } notifyItemRangeInserted(offsetStart, data.size()); if (showFooter) { final int footerNow = sizeConsideringHeaderOffset(); notifyItemMoved(offsetStart, footerNow); if (DEBUG) { Log.d(TAG, "addInfoItemList() footer from " + offsetStart + " to " + footerNow); } } } public void clearStreamItemList() { if (infoItemList.isEmpty()) { return; } infoItemList.clear(); notifyDataSetChanged(); } public void setHeaderSupplier(@Nullable final Supplier headerSupplier) { final boolean changed = headerSupplier != this.headerSupplier; this.headerSupplier = headerSupplier; if (changed) { notifyDataSetChanged(); } } protected boolean hasHeader() { return this.headerSupplier != null; } public void showFooter(final boolean show) { if (DEBUG) { Log.d(TAG, "showFooter() called with: show = [" + show + "]"); } if (show == showFooter) { return; } showFooter = show; if (show) { notifyItemInserted(sizeConsideringHeaderOffset()); } else { notifyItemRemoved(sizeConsideringHeaderOffset()); } } private int sizeConsideringHeaderOffset() { final int i = infoItemList.size() + (hasHeader() ? 1 : 0); if (DEBUG) { Log.d(TAG, "sizeConsideringHeaderOffset() called → " + i); } return i; } public List getItemsList() { return infoItemList; } @Override public int getItemCount() { int count = infoItemList.size(); if (hasHeader()) { count++; } if (showFooter) { count++; } if (DEBUG) { Log.d(TAG, "getItemCount() called with: " + "count = " + count + ", infoItemList.size() = " + infoItemList.size() + ", " + "hasHeader = " + hasHeader() + ", " + "showFooter = " + showFooter); } return count; } @SuppressWarnings("FinalParameters") @Override public int getItemViewType(int position) { if (DEBUG) { Log.d(TAG, "getItemViewType() called with: position = [" + position + "]"); } if (hasHeader() && position == 0) { return HEADER_TYPE; } else if (hasHeader()) { position--; } if (position == infoItemList.size() && showFooter) { return FOOTER_TYPE; } final InfoItem item = infoItemList.get(position); switch (item.getInfoType()) { case STREAM: if (itemMode == ItemViewMode.CARD) { return CARD_STREAM_HOLDER_TYPE; } else if (itemMode == ItemViewMode.GRID) { return GRID_STREAM_HOLDER_TYPE; } else if (useMiniVariant) { return MINI_STREAM_HOLDER_TYPE; } else { return STREAM_HOLDER_TYPE; } case CHANNEL: if (itemMode == ItemViewMode.CARD) { return CARD_CHANNEL_HOLDER_TYPE; } else if (itemMode == ItemViewMode.GRID) { return GRID_CHANNEL_HOLDER_TYPE; } else if (useMiniVariant) { return MINI_CHANNEL_HOLDER_TYPE; } else { return CHANNEL_HOLDER_TYPE; } case PLAYLIST: if (itemMode == ItemViewMode.CARD) { return CARD_PLAYLIST_HOLDER_TYPE; } else if (itemMode == ItemViewMode.GRID) { return GRID_PLAYLIST_HOLDER_TYPE; } else if (useMiniVariant) { return MINI_PLAYLIST_HOLDER_TYPE; } else { return PLAYLIST_HOLDER_TYPE; } case COMMENT: return COMMENT_HOLDER_TYPE; default: return -1; } } @NonNull @Override public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int type) { if (DEBUG) { Log.d(TAG, "onCreateViewHolder() called with: " + "parent = [" + parent + "], type = [" + type + "]"); } switch (type) { // #4475 and #3368 // Always create a new instance otherwise the same instance // is sometimes reused which causes a crash case HEADER_TYPE: return new HFHolder(headerSupplier.get()); case FOOTER_TYPE: return new HFHolder(PignateFooterBinding .inflate(layoutInflater, parent, false) .getRoot() ); case MINI_STREAM_HOLDER_TYPE: return new StreamMiniInfoItemHolder(infoItemBuilder, parent); case STREAM_HOLDER_TYPE: return new StreamInfoItemHolder(infoItemBuilder, parent); case GRID_STREAM_HOLDER_TYPE: return new StreamGridInfoItemHolder(infoItemBuilder, parent); case CARD_STREAM_HOLDER_TYPE: return new StreamCardInfoItemHolder(infoItemBuilder, parent); case MINI_CHANNEL_HOLDER_TYPE: return new ChannelMiniInfoItemHolder(infoItemBuilder, parent); case CHANNEL_HOLDER_TYPE: return new ChannelInfoItemHolder(infoItemBuilder, parent); case CARD_CHANNEL_HOLDER_TYPE: return new ChannelCardInfoItemHolder(infoItemBuilder, parent); case GRID_CHANNEL_HOLDER_TYPE: return new ChannelGridInfoItemHolder(infoItemBuilder, parent); case MINI_PLAYLIST_HOLDER_TYPE: return new PlaylistMiniInfoItemHolder(infoItemBuilder, parent); case PLAYLIST_HOLDER_TYPE: return new PlaylistInfoItemHolder(infoItemBuilder, parent); case GRID_PLAYLIST_HOLDER_TYPE: return new PlaylistGridInfoItemHolder(infoItemBuilder, parent); case CARD_PLAYLIST_HOLDER_TYPE: return new PlaylistCardInfoItemHolder(infoItemBuilder, parent); case COMMENT_HOLDER_TYPE: return new CommentInfoItemHolder(infoItemBuilder, parent); default: return new FallbackViewHolder(new View(parent.getContext())); } } @Override public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position) { if (DEBUG) { Log.d(TAG, "onBindViewHolder() called with: " + "holder = [" + holder.getClass().getSimpleName() + "], " + "position = [" + position + "]"); } if (holder instanceof InfoItemHolder) { ((InfoItemHolder) holder).updateFromItem( // If header is present, offset the items by -1 infoItemList.get(hasHeader() ? position - 1 : position), recordManager); } } public GridLayoutManager.SpanSizeLookup getSpanSizeLookup(final int spanCount) { return new GridLayoutManager.SpanSizeLookup() { @Override public int getSpanSize(final int position) { final int type = getItemViewType(position); return type == HEADER_TYPE || type == FOOTER_TYPE ? spanCount : 1; } }; } static class HFHolder extends RecyclerView.ViewHolder { HFHolder(final View v) { super(v); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.kt ================================================ /* * SPDX-FileCopyrightText: 2023-2026 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.info_list /** * Item view mode for streams & playlist listing screens. */ enum class ItemViewMode { /** * Default mode. */ AUTO, /** * Full width list item with thumb on the left and two line title & uploader in right. */ LIST, /** * Grid mode places two cards per row. */ GRID, /** * A full width card in phone - portrait. */ CARD } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/StreamSegmentAdapter.kt ================================================ package org.schabi.newpipe.info_list import android.util.Log import com.xwray.groupie.GroupieAdapter import kotlin.math.max import org.schabi.newpipe.extractor.stream.StreamInfo /** * Custom RecyclerView.Adapter/GroupieAdapter for [StreamSegmentItem] for handling selection state. */ class StreamSegmentAdapter( private val listener: StreamSegmentListener ) : GroupieAdapter() { var currentIndex: Int = 0 private set /** * Returns `true` if the provided [StreamInfo] contains segments, `false` otherwise. */ fun setItems(info: StreamInfo): Boolean { if (info.streamSegments.isNotEmpty()) { clear() addAll(info.streamSegments.map { StreamSegmentItem(it, listener) }) return true } return false } fun selectSegment(segment: StreamSegmentItem) { unSelectCurrentSegment() currentIndex = max(0, getAdapterPosition(segment)) segment.isSelected = true segment.notifyChanged(StreamSegmentItem.PAYLOAD_SELECT) } fun selectSegmentAt(position: Int) { try { selectSegment(getGroupAtAdapterPosition(position) as StreamSegmentItem) } catch (e: IndexOutOfBoundsException) { // Just to make sure that getGroupAtAdapterPosition doesn't close the app // Shouldn't happen since setItems is always called before select-methods but just in case currentIndex = 0 Log.e("StreamSegmentAdapter", "selectSegmentAt: ${e.message}") } } private fun unSelectCurrentSegment() { try { val segmentItem = getGroupAtAdapterPosition(currentIndex) as StreamSegmentItem currentIndex = 0 segmentItem.isSelected = false segmentItem.notifyChanged(StreamSegmentItem.PAYLOAD_SELECT) } catch (e: IndexOutOfBoundsException) { // Just to make sure that getGroupAtAdapterPosition doesn't close the app // Shouldn't happen since setItems is always called before select-methods but just in case currentIndex = 0 Log.e("StreamSegmentAdapter", "unSelectCurrentSegment: ${e.message}") } } interface StreamSegmentListener { fun onItemClick(item: StreamSegmentItem, seconds: Int) fun onItemLongClick(item: StreamSegmentItem, seconds: Int) } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/StreamSegmentItem.kt ================================================ package org.schabi.newpipe.info_list import android.view.View import com.xwray.groupie.viewbinding.BindableItem import com.xwray.groupie.viewbinding.GroupieViewHolder import org.schabi.newpipe.R import org.schabi.newpipe.databinding.ItemStreamSegmentBinding import org.schabi.newpipe.extractor.stream.StreamSegment import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.image.CoilHelper class StreamSegmentItem( private val item: StreamSegment, private val onClick: StreamSegmentAdapter.StreamSegmentListener ) : BindableItem() { companion object { const val PAYLOAD_SELECT = 1 } var isSelected = false override fun bind(viewBinding: ItemStreamSegmentBinding, position: Int) { CoilHelper.loadThumbnail(viewBinding.previewImage, item.previewUrl) viewBinding.textViewTitle.text = item.title if (item.channelName == null) { viewBinding.textViewChannel.visibility = View.GONE // When the channel name is displayed there is less space // and thus the segment title needs to be only one line height. // But when there is no channel name displayed, the title can be two lines long. // The default maxLines value is set to 1 to display all elements in the AS preview, viewBinding.textViewTitle.maxLines = 2 } else { viewBinding.textViewChannel.text = item.channelName viewBinding.textViewChannel.visibility = View.VISIBLE } viewBinding.textViewStartSeconds.text = Localization.getDurationString(item.startTimeSeconds.toLong()) viewBinding.root.setOnClickListener { onClick.onItemClick(this, item.startTimeSeconds) } viewBinding.root.setOnLongClickListener { onClick.onItemLongClick(this, item.startTimeSeconds) true } viewBinding.root.isSelected = isSelected } override fun bind( viewHolder: GroupieViewHolder, position: Int, payloads: MutableList ) { if (payloads.contains(PAYLOAD_SELECT)) { viewHolder.root.isSelected = isSelected return } super.bind(viewHolder, position, payloads) } override fun getLayout() = R.layout.item_stream_segment override fun initializeViewBinding(view: View) = ItemStreamSegmentBinding.bind(view) } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java ================================================ package org.schabi.newpipe.info_list.dialog; import static org.schabi.newpipe.MainActivity.DEBUG; import android.app.Activity; import android.content.Context; import android.content.DialogInterface; import android.os.Build; import android.util.Log; import android.view.View; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import androidx.preference.PreferenceManager; import org.schabi.newpipe.App; 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.InfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.util.StreamTypeUtil; import org.schabi.newpipe.util.external_communication.KoreUtils; import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; /** * Dialog for a {@link StreamInfoItem}. * The dialog's content are actions that can be performed on the {@link StreamInfoItem}. * This dialog is mostly used for longpress context menus. */ public final class InfoItemDialog { private static final String TAG = Build.class.getSimpleName(); /** * Ideally, {@link InfoItemDialog} would extend {@link AlertDialog}. * However, extending {@link AlertDialog} requires many additional lines * and brings more complexity to this class, especially the constructor. * To circumvent this, an {@link AlertDialog.Builder} is used in the constructor. * Its result is stored in this class variable to allow access via the {@link #show()} method. */ private final AlertDialog dialog; private InfoItemDialog(@NonNull final Activity activity, @NonNull final Fragment fragment, @NonNull final StreamInfoItem info, @NonNull final List entries) { // Create the dialog's title final View bannerView = View.inflate(activity, R.layout.dialog_title, null); bannerView.setSelected(true); final TextView titleView = bannerView.findViewById(R.id.itemTitleView); titleView.setText(info.getName()); final TextView detailsView = bannerView.findViewById(R.id.itemAdditionalDetails); if (info.getUploaderName() != null) { detailsView.setText(info.getUploaderName()); detailsView.setVisibility(View.VISIBLE); } else { detailsView.setVisibility(View.GONE); } // Get the entry's descriptions which are displayed in the dialog final String[] items = entries.stream() .map(entry -> entry.getString(activity)).toArray(String[]::new); // Call an entry's action / onClick method when the entry is selected. final DialogInterface.OnClickListener action = (d, index) -> entries.get(index).action.onClick(fragment, info); dialog = new AlertDialog.Builder(activity) .setCustomTitle(bannerView) .setItems(items, action) .create(); } public void show() { dialog.show(); } /** *

Builder to generate a {@link InfoItemDialog} for a {@link StreamInfoItem}.

* Use {@link #addEntry(StreamDialogDefaultEntry)} * and {@link #addAllEntries(StreamDialogDefaultEntry...)} to add options to the dialog. *
* Custom actions for entries can be set using * {@link #setAction(StreamDialogDefaultEntry, StreamDialogEntry.StreamDialogEntryAction)}. */ public static class Builder { @NonNull private final Activity activity; @NonNull private final Context context; @NonNull private final StreamInfoItem infoItem; @NonNull private final Fragment fragment; @NonNull private final List entries = new ArrayList<>(); private final boolean addDefaultEntriesAutomatically; /** *

Create a {@link Builder builder} instance for a {@link StreamInfoItem} * that automatically adds the some default entries * at the top and bottom of the dialog.

* The dialog has the following structure: *
         *     + - - - - - - - - - - - - - - - - - - - - - -+
         *     | ENQUEUE                                    |
         *     | ENQUEUE_NEXT                               |
         *     | START_ON_BACKGROUND                        |
         *     | START_ON_POPUP                             |
         *     + - - - - - - - - - - - - - - - - - - - - - -+
         *     | entries added manually with                |
         *     | addEntry() and addAllEntries()             |
         *     + - - - - - - - - - - - - - - - - - - - - - -+
         *     | APPEND_PLAYLIST                            |
         *     | SHARE                                      |
         *     | OPEN_IN_BROWSER                            |
         *     | PLAY_WITH_KODI                             |
         *     | MARK_AS_WATCHED                            |
         *     | SHOW_CHANNEL_DETAILS                       |
         *     + - - - - - - - - - - - - - - - - - - - - - -+
         * 
* Please note that some entries are not added depending on the user's preferences, * the item's {@link StreamType} and the current player state. * * @param activity * @param context * @param fragment * @param infoItem the item for this dialog; all entries and their actions work with * this {@link StreamInfoItem} * @throws IllegalArgumentException if activity, context * or resources is null */ public Builder(final Activity activity, final Context context, @NonNull final Fragment fragment, @NonNull final StreamInfoItem infoItem) { this(activity, context, fragment, infoItem, true); } /** *

Create an instance of this {@link Builder} for a {@link StreamInfoItem}.

*

If {@code addDefaultEntriesAutomatically} is set to {@code true}, * some default entries are added to the top and bottom of the dialog.

* The dialog has the following structure: *
         *     + - - - - - - - - - - - - - - - - - - - - - -+
         *     | ENQUEUE                                    |
         *     | ENQUEUE_NEXT                               |
         *     | START_ON_BACKGROUND                        |
         *     | START_ON_POPUP                             |
         *     + - - - - - - - - - - - - - - - - - - - - - -+
         *     | entries added manually with                |
         *     | addEntry() and addAllEntries()             |
         *     + - - - - - - - - - - - - - - - - - - - - - -+
         *     | APPEND_PLAYLIST                            |
         *     | SHARE                                      |
         *     | OPEN_IN_BROWSER                            |
         *     | PLAY_WITH_KODI                             |
         *     | MARK_AS_WATCHED                            |
         *     | SHOW_CHANNEL_DETAILS                       |
         *     + - - - - - - - - - - - - - - - - - - - - - -+
         * 
* Please note that some entries are not added depending on the user's preferences, * the item's {@link StreamType} and the current player state. * * @param activity * @param context * @param fragment * @param infoItem * @param addDefaultEntriesAutomatically * whether default entries added with {@link #addDefaultBeginningEntries()} * and {@link #addDefaultEndEntries()} are added automatically when generating * the {@link InfoItemDialog}. *
* Entries added with {@link #addEntry(StreamDialogDefaultEntry)} and * {@link #addAllEntries(StreamDialogDefaultEntry...)} are added in between. * @throws IllegalArgumentException if activity, context * or resources is null */ public Builder(final Activity activity, final Context context, @NonNull final Fragment fragment, @NonNull final StreamInfoItem infoItem, final boolean addDefaultEntriesAutomatically) { if (activity == null || context == null || context.getResources() == null) { if (DEBUG) { Log.d(TAG, "activity, context or resources is null: activity = " + activity + ", context = " + context); } throw new IllegalArgumentException("activity, context or resources is null"); } this.activity = activity; this.context = context; this.fragment = fragment; this.infoItem = infoItem; this.addDefaultEntriesAutomatically = addDefaultEntriesAutomatically; if (addDefaultEntriesAutomatically) { addDefaultBeginningEntries(); } } /** * Adds a new entry and appends it to the current entry list. * @param entry the entry to add * @return the current {@link Builder} instance */ public Builder addEntry(@NonNull final StreamDialogDefaultEntry entry) { entries.add(entry.toStreamDialogEntry()); return this; } /** * Adds new entries. These are appended to the current entry list. * @param newEntries the entries to add * @return the current {@link Builder} instance */ public Builder addAllEntries(@NonNull final StreamDialogDefaultEntry... newEntries) { Stream.of(newEntries).forEach(this::addEntry); return this; } /** *

Change an entries' action that is called when the entry is selected.

*

Warning: Only use this method when the entry has been already added. * Changing the action of an entry which has not been added to the Builder yet * does not have an effect.

* @param entry the entry to change * @param action the action to perform when the entry is selected * @return the current {@link Builder} instance */ public Builder setAction(@NonNull final StreamDialogDefaultEntry entry, @NonNull final StreamDialogEntry.StreamDialogEntryAction action) { for (int i = 0; i < entries.size(); i++) { if (entries.get(i).resource == entry.resource) { entries.set(i, new StreamDialogEntry(entry.resource, action)); return this; } } return this; } /** * Adds {@link StreamDialogDefaultEntry#ENQUEUE} if the player is open and * {@link StreamDialogDefaultEntry#ENQUEUE_NEXT} if there are multiple streams * in the play queue. * @return the current {@link Builder} instance */ public Builder addEnqueueEntriesIfNeeded() { final PlayerHolder holder = PlayerHolder.getInstance(); if (holder.isPlayQueueReady()) { addEntry(StreamDialogDefaultEntry.ENQUEUE); if (holder.getQueuePosition() < holder.getQueueSize() - 1) { addEntry(StreamDialogDefaultEntry.ENQUEUE_NEXT); } } return this; } /** * Adds the {@link StreamDialogDefaultEntry#START_HERE_ON_BACKGROUND}. * If the {@link #infoItem} is not a pure audio (live) stream, * {@link StreamDialogDefaultEntry#START_HERE_ON_POPUP} is added, too. * @return the current {@link Builder} instance */ public Builder addStartHereEntries() { addEntry(StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND); if (!StreamTypeUtil.isAudio(infoItem.getStreamType())) { addEntry(StreamDialogDefaultEntry.START_HERE_ON_POPUP); } return this; } /** * Adds {@link StreamDialogDefaultEntry#MARK_AS_WATCHED} if the watch history is enabled * and the stream is not a livestream. * @return the current {@link Builder} instance */ public Builder addMarkAsWatchedEntryIfNeeded() { final boolean isWatchHistoryEnabled = PreferenceManager .getDefaultSharedPreferences(context) .getBoolean(context.getString(R.string.enable_watch_history_key), false); if (isWatchHistoryEnabled && !StreamTypeUtil.isLiveStream(infoItem.getStreamType())) { addEntry(StreamDialogDefaultEntry.MARK_AS_WATCHED); } return this; } /** * Adds the {@link StreamDialogDefaultEntry#PLAY_WITH_KODI} entry if it is needed. * @return the current {@link Builder} instance */ public Builder addPlayWithKodiEntryIfNeeded() { if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) { addEntry(StreamDialogDefaultEntry.PLAY_WITH_KODI); } return this; } /** * Add the entries which are usually at the top of the action list. *
* This method adds the "enqueue" (see {@link #addEnqueueEntriesIfNeeded()}) * and "start here" (see {@link #addStartHereEntries()} entries. * @return the current {@link Builder} instance */ public Builder addDefaultBeginningEntries() { addEnqueueEntriesIfNeeded(); addStartHereEntries(); return this; } /** * Add the entries which are usually at the bottom of the action list. * @return the current {@link Builder} instance */ public Builder addDefaultEndEntries() { addAllEntries( StreamDialogDefaultEntry.DOWNLOAD, StreamDialogDefaultEntry.APPEND_PLAYLIST, StreamDialogDefaultEntry.SHARE, StreamDialogDefaultEntry.OPEN_IN_BROWSER ); addPlayWithKodiEntryIfNeeded(); addMarkAsWatchedEntryIfNeeded(); addEntry(StreamDialogDefaultEntry.SHOW_CHANNEL_DETAILS); return this; } /** * Creates the {@link InfoItemDialog}. * @return a new instance of {@link InfoItemDialog} */ public InfoItemDialog create() { if (addDefaultEntriesAutomatically) { addDefaultEndEntries(); } return new InfoItemDialog(this.activity, this.fragment, this.infoItem, this.entries); } public static void reportErrorDuringInitialization(final Throwable throwable, final InfoItem item) { ErrorUtil.showSnackbar(App.getInstance().getBaseContext(), new ErrorInfo( throwable, UserAction.OPEN_INFO_ITEM_DIALOG, "none", item.getServiceId())); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java ================================================ package org.schabi.newpipe.info_list.dialog; import static org.schabi.newpipe.util.NavigationHelper.openChannelFragment; import static org.schabi.newpipe.util.SparseItemUtil.fetchItemInfoIfSparse; import static org.schabi.newpipe.util.SparseItemUtil.fetchStreamInfoAndSaveToDatabase; import static org.schabi.newpipe.util.SparseItemUtil.fetchUploaderUrlIfSparse; import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.StringRes; import org.schabi.newpipe.R; import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.download.DownloadDialog; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.local.dialog.PlaylistAppendDialog; import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.List; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; /** *

* This enum provides entries that are accepted * by the {@link InfoItemDialog.Builder}. *

*

* These entries contain a String {@link #resource} which is displayed in the dialog and * a default {@link #action} that is executed * when the entry is selected (via onClick()). *
* They action can be overridden by using the Builder's * {@link InfoItemDialog.Builder#setAction( * StreamDialogDefaultEntry, StreamDialogEntry.StreamDialogEntryAction)} * method. *

*/ public enum StreamDialogDefaultEntry { SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) -> fetchUploaderUrlIfSparse(fragment.requireContext(), item.getServiceId(), item.getUrl(), item.getUploaderUrl(), url -> openChannelFragment(fragment, item, url)) ), /** * Enqueues the stream automatically to the current PlayerType. */ ENQUEUE(R.string.enqueue_stream, (fragment, item) -> fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue -> NavigationHelper.enqueueOnPlayer(fragment.getContext(), singlePlayQueue)) ), /** * Enqueues the stream automatically to the current PlayerType * after the currently playing stream. */ ENQUEUE_NEXT(R.string.enqueue_next_stream, (fragment, item) -> fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue -> NavigationHelper.enqueueNextOnPlayer(fragment.getContext(), singlePlayQueue)) ), START_HERE_ON_BACKGROUND(R.string.start_here_on_background, (fragment, item) -> fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue -> NavigationHelper.playOnBackgroundPlayer( fragment.getContext(), singlePlayQueue, true))), START_HERE_ON_POPUP(R.string.start_here_on_popup, (fragment, item) -> fetchItemInfoIfSparse(fragment.requireContext(), item, singlePlayQueue -> NavigationHelper.playOnPopupPlayer(fragment.getContext(), singlePlayQueue, true))), SET_AS_PLAYLIST_THUMBNAIL(R.string.set_as_playlist_thumbnail, (fragment, item) -> { throw new UnsupportedOperationException("This needs to be implemented manually " + "by using InfoItemDialog.Builder.setAction()"); }), DELETE(R.string.delete, (fragment, item) -> { throw new UnsupportedOperationException("This needs to be implemented manually " + "by using InfoItemDialog.Builder.setAction()"); }), /** * Opens a {@link PlaylistDialog} to either append the stream to a playlist * or create a new playlist if there are no local playlists. */ APPEND_PLAYLIST(R.string.add_to_playlist, (fragment, item) -> PlaylistDialog.createCorrespondingDialog( fragment.getContext(), List.of(new StreamEntity(item)), dialog -> dialog.show( fragment.getParentFragmentManager(), "StreamDialogEntry@" + (dialog instanceof PlaylistAppendDialog ? "append" : "create") + "_playlist" ) ) ), PLAY_WITH_KODI(R.string.play_with_kodi_title, (fragment, item) -> KoreUtils.playWithKore(fragment.requireContext(), Uri.parse(item.getUrl()))), SHARE(R.string.share, (fragment, item) -> ShareUtils.shareText(fragment.requireContext(), item.getName(), item.getUrl(), item.getThumbnails())), /** * Opens a {@link DownloadDialog} after fetching some stream info. * If the user quits the current fragment, it will not open a DownloadDialog. */ DOWNLOAD(R.string.download, (fragment, item) -> fetchStreamInfoAndSaveToDatabase(fragment.requireContext(), item.getServiceId(), item.getUrl(), info -> { // Ensure the fragment is attached and its state hasn't been saved to avoid // showing dialog during lifecycle changes or when the activity is paused, // e.g. by selecting the download option and opening a different fragment. if (fragment.isAdded() && !fragment.isStateSaved()) { final DownloadDialog downloadDialog = new DownloadDialog(fragment.requireContext(), info); downloadDialog.show(fragment.getChildFragmentManager(), "downloadDialog"); } }) ), OPEN_IN_BROWSER(R.string.open_in_browser, (fragment, item) -> ShareUtils.openUrlInBrowser(fragment.requireContext(), item.getUrl())), MARK_AS_WATCHED(R.string.mark_as_watched, (fragment, item) -> new HistoryRecordManager(fragment.getContext()) .markAsWatched(item) .doOnError(error -> { ErrorUtil.showSnackbar( fragment.requireContext(), new ErrorInfo( error, UserAction.OPEN_INFO_ITEM_DIALOG, "Got an error when trying to mark as watched" ) ); }) .onErrorComplete() .observeOn(AndroidSchedulers.mainThread()) .subscribe() ); @StringRes public final int resource; @NonNull public final StreamDialogEntry.StreamDialogEntryAction action; StreamDialogDefaultEntry(@StringRes final int resource, @NonNull final StreamDialogEntry.StreamDialogEntryAction action) { this.resource = resource; this.action = action; } @NonNull public StreamDialogEntry toStreamDialogEntry() { return new StreamDialogEntry(resource, action); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java ================================================ package org.schabi.newpipe.info_list.dialog; import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.StringRes; import androidx.fragment.app.Fragment; import org.schabi.newpipe.extractor.stream.StreamInfoItem; public class StreamDialogEntry { @StringRes public final int resource; @NonNull public final StreamDialogEntryAction action; public StreamDialogEntry(@StringRes final int resource, @NonNull final StreamDialogEntryAction action) { this.resource = resource; this.action = action; } public String getString(@NonNull final Context context) { return context.getString(resource); } public interface StreamDialogEntryAction { void onClick(Fragment fragment, StreamInfoItem infoItem); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelCardInfoItemHolder.java ================================================ package org.schabi.newpipe.info_list.holder; import android.view.ViewGroup; import androidx.annotation.Nullable; import org.schabi.newpipe.R; import org.schabi.newpipe.info_list.InfoItemBuilder; public class ChannelCardInfoItemHolder extends ChannelMiniInfoItemHolder { public ChannelCardInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, R.layout.list_channel_card_item, parent); } @Override protected int getDescriptionMaxLineCount(@Nullable final String content) { // Based on `list_channel_card_item` left side content (thumbnail 100dp // + additional details), Right side description can grow up to 8 lines. return 8; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelGridInfoItemHolder.java ================================================ package org.schabi.newpipe.info_list.holder; import android.view.ViewGroup; import org.schabi.newpipe.R; import org.schabi.newpipe.info_list.InfoItemBuilder; public class ChannelGridInfoItemHolder extends ChannelMiniInfoItemHolder { public ChannelGridInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, R.layout.list_channel_grid_item, parent); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelInfoItemHolder.java ================================================ package org.schabi.newpipe.info_list.holder; import android.view.ViewGroup; import org.schabi.newpipe.R; import org.schabi.newpipe.info_list.InfoItemBuilder; /* * Created by Christian Schabesberger on 12.02.17. * * Copyright (C) Christian Schabesberger 2016 * ChannelInfoItemHolder .java is part of NewPipe. * * NewPipe is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * NewPipe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ public class ChannelInfoItemHolder extends ChannelMiniInfoItemHolder { public ChannelInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, R.layout.list_channel_item, parent); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java ================================================ package org.schabi.newpipe.info_list.holder; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.Nullable; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.extractor.utils.Utils; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.image.CoilHelper; public class ChannelMiniInfoItemHolder extends InfoItemHolder { private final ImageView itemThumbnailView; private final TextView itemTitleView; private final TextView itemAdditionalDetailView; private final TextView itemChannelDescriptionView; ChannelMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, final ViewGroup parent) { super(infoItemBuilder, layoutId, parent); itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); itemTitleView = itemView.findViewById(R.id.itemTitleView); itemAdditionalDetailView = itemView.findViewById(R.id.itemAdditionalDetails); itemChannelDescriptionView = itemView.findViewById(R.id.itemChannelDescriptionView); } public ChannelMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { this(infoItemBuilder, R.layout.list_channel_mini_item, parent); } @Override public void updateFromItem(final InfoItem infoItem, final HistoryRecordManager historyRecordManager) { if (!(infoItem instanceof ChannelInfoItem)) { return; } final ChannelInfoItem item = (ChannelInfoItem) infoItem; itemTitleView.setText(item.getName()); itemTitleView.setSelected(true); final String detailLine = getDetailLine(item); if (detailLine == null) { itemAdditionalDetailView.setVisibility(View.GONE); } else { itemAdditionalDetailView.setVisibility(View.VISIBLE); itemAdditionalDetailView.setText(getDetailLine(item)); } CoilHelper.INSTANCE.loadAvatar(itemThumbnailView, item.getThumbnails()); itemView.setOnClickListener(view -> { if (itemBuilder.getOnChannelSelectedListener() != null) { itemBuilder.getOnChannelSelectedListener().selected(item); } }); itemView.setOnLongClickListener(view -> { if (itemBuilder.getOnChannelSelectedListener() != null) { itemBuilder.getOnChannelSelectedListener().held(item); } return true; }); if (itemChannelDescriptionView != null) { // itemChannelDescriptionView will be null in the mini variant if (Utils.isBlank(item.getDescription())) { itemChannelDescriptionView.setVisibility(View.GONE); } else { itemChannelDescriptionView.setVisibility(View.VISIBLE); itemChannelDescriptionView.setText(item.getDescription()); // setMaxLines utilize the line space for description if the additional details // (sub / video count) are not present. // Case1: 2 lines of description + 1 line additional details // Case2: 3 lines of description (additionalDetails is GONE) itemChannelDescriptionView.setMaxLines(getDescriptionMaxLineCount(detailLine)); } } } /** * Returns max number of allowed lines for the description field. * @param content additional detail content (video / sub count) * @return max line count */ protected int getDescriptionMaxLineCount(@Nullable final String content) { return content == null ? 3 : 2; } @Nullable private String getDetailLine(final ChannelInfoItem item) { if (item.getStreamCount() >= 0 && item.getSubscriberCount() >= 0) { return Localization.concatenateStrings( Localization.shortSubscriberCount(itemBuilder.getContext(), item.getSubscriberCount()), Localization.localizeStreamCount(itemBuilder.getContext(), item.getStreamCount())); } else if (item.getStreamCount() >= 0) { return Localization.localizeStreamCount(itemBuilder.getContext(), item.getStreamCount()); } else if (item.getSubscriberCount() >= 0) { return Localization.shortSubscriberCount(itemBuilder.getContext(), item.getSubscriberCount()); } else { return null; } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java ================================================ package org.schabi.newpipe.info_list.holder; import static org.schabi.newpipe.util.ServiceHelper.getServiceById; import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine; import android.text.Spanned; import android.text.method.LinkMovementMethod; import android.text.style.ClickableSpan; import android.text.style.URLSpan; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.Button; import android.widget.ImageView; import android.widget.RelativeLayout; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.fragment.app.FragmentActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.image.CoilHelper; import org.schabi.newpipe.util.image.ImageStrategy; import org.schabi.newpipe.util.text.TextEllipsizer; public class CommentInfoItemHolder extends InfoItemHolder { private static final int COMMENT_DEFAULT_LINES = 2; private final int commentHorizontalPadding; private final int commentVerticalPadding; private final RelativeLayout itemRoot; private final ImageView itemThumbnailView; private final TextView itemContentView; private final ImageView itemThumbsUpView; private final TextView itemLikesCountView; private final TextView itemTitleView; private final ImageView itemHeartView; private final ImageView itemPinnedView; private final Button repliesButton; @NonNull private final TextEllipsizer textEllipsizer; public CommentInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, R.layout.list_comment_item, parent); itemRoot = itemView.findViewById(R.id.itemRoot); itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); itemContentView = itemView.findViewById(R.id.itemCommentContentView); itemThumbsUpView = itemView.findViewById(R.id.detail_thumbs_up_img_view); itemLikesCountView = itemView.findViewById(R.id.detail_thumbs_up_count_view); itemTitleView = itemView.findViewById(R.id.itemTitleView); itemHeartView = itemView.findViewById(R.id.detail_heart_image_view); itemPinnedView = itemView.findViewById(R.id.detail_pinned_view); repliesButton = itemView.findViewById(R.id.replies_button); commentHorizontalPadding = (int) infoItemBuilder.getContext() .getResources().getDimension(R.dimen.comments_horizontal_padding); commentVerticalPadding = (int) infoItemBuilder.getContext() .getResources().getDimension(R.dimen.comments_vertical_padding); textEllipsizer = new TextEllipsizer(itemContentView, COMMENT_DEFAULT_LINES, null); textEllipsizer.setStateChangeListener(isEllipsized -> { if (Boolean.TRUE.equals(isEllipsized)) { denyLinkFocus(); } else { determineMovementMethod(); } }); } @Override public void updateFromItem(final InfoItem infoItem, final HistoryRecordManager historyRecordManager) { if (!(infoItem instanceof CommentsInfoItem item)) { return; } // load the author avatar CoilHelper.INSTANCE.loadAvatar(itemThumbnailView, item.getUploaderAvatars()); if (ImageStrategy.shouldLoadImages()) { itemThumbnailView.setVisibility(View.VISIBLE); itemRoot.setPadding(commentVerticalPadding, commentVerticalPadding, commentVerticalPadding, commentVerticalPadding); } else { itemThumbnailView.setVisibility(View.GONE); itemRoot.setPadding(commentHorizontalPadding, commentVerticalPadding, commentHorizontalPadding, commentVerticalPadding); } itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item)); // setup the top row, with pinned icon, author name and comment date itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE); final String uploaderName = Localization.localizeUserName(item.getUploaderName()); itemTitleView.setText(Localization.concatenateStrings( uploaderName, Localization.relativeTimeOrTextual( itemBuilder.getContext(), item.getUploadDate(), item.getTextualUploadDate()))); // setup bottom row, with likes, heart and replies button itemLikesCountView.setText( Localization.likeCount(itemBuilder.getContext(), item.getLikeCount())); itemHeartView.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE); final boolean hasReplies = item.getReplies() != null; repliesButton.setOnClickListener(hasReplies ? v -> openCommentReplies(item) : null); repliesButton.setVisibility(hasReplies ? View.VISIBLE : View.GONE); repliesButton.setText(hasReplies ? Localization.replyCount(itemBuilder.getContext(), item.getReplyCount()) : ""); ((RelativeLayout.LayoutParams) itemThumbsUpView.getLayoutParams()).topMargin = hasReplies ? 0 : DeviceUtils.dpToPx(6, itemBuilder.getContext()); // setup comment content and click listeners to expand/ellipsize it textEllipsizer.setStreamingService(getServiceById(item.getServiceId())); textEllipsizer.setStreamUrl(item.getUrl()); textEllipsizer.setContent(item.getCommentText()); textEllipsizer.ellipsize(); //noinspection ClickableViewAccessibility itemContentView.setOnTouchListener((v, event) -> { final CharSequence text = itemContentView.getText(); if (text instanceof Spanned buffer) { final int action = event.getAction(); if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { final int offset = getOffsetForHorizontalLine(itemContentView, event); final var links = buffer.getSpans(offset, offset, ClickableSpan.class); if (links.length != 0) { if (action == MotionEvent.ACTION_UP) { links[0].onClick(itemContentView); } // we handle events that intersect links, so return true return true; } } } return false; }); itemView.setOnClickListener(view -> { textEllipsizer.toggle(); if (itemBuilder.getOnCommentsSelectedListener() != null) { itemBuilder.getOnCommentsSelectedListener().selected(item); } }); itemView.setOnLongClickListener(view -> { if (DeviceUtils.isTv(itemBuilder.getContext())) { openCommentAuthor(item); } else { final CharSequence text = itemContentView.getText(); if (text != null) { ShareUtils.copyToClipboard(itemBuilder.getContext(), text.toString()); } } return true; }); } private void openCommentAuthor(@NonNull final CommentsInfoItem item) { NavigationHelper.openCommentAuthorIfPresent((FragmentActivity) itemBuilder.getContext(), item); } private void openCommentReplies(@NonNull final CommentsInfoItem item) { NavigationHelper.openCommentRepliesFragment((FragmentActivity) itemBuilder.getContext(), item); } private void allowLinkFocus() { itemContentView.setMovementMethod(LinkMovementMethod.getInstance()); } private void denyLinkFocus() { itemContentView.setMovementMethod(null); } private boolean shouldFocusLinks() { if (itemView.isInTouchMode()) { return false; } final URLSpan[] urls = itemContentView.getUrls(); return urls != null && urls.length != 0; } private void determineMovementMethod() { if (shouldFocusLinks()) { allowLinkFocus(); } else { denyLinkFocus(); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/holder/InfoItemHolder.java ================================================ package org.schabi.newpipe.info_list.holder; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.recyclerview.widget.RecyclerView; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; /* * Created by Christian Schabesberger on 12.02.17. * * Copyright (C) Christian Schabesberger 2016 * InfoItemHolder.java is part of NewPipe. * * NewPipe is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * NewPipe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ public abstract class InfoItemHolder extends RecyclerView.ViewHolder { protected final InfoItemBuilder itemBuilder; public InfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, final ViewGroup parent) { super(LayoutInflater.from(infoItemBuilder.getContext()).inflate(layoutId, parent, false)); this.itemBuilder = infoItemBuilder; } public abstract void updateFromItem(InfoItem infoItem, HistoryRecordManager historyRecordManager); public void updateState(final InfoItem infoItem, final HistoryRecordManager historyRecordManager) { } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistCardInfoItemHolder.java ================================================ package org.schabi.newpipe.info_list.holder; import android.view.ViewGroup; import org.schabi.newpipe.R; import org.schabi.newpipe.info_list.InfoItemBuilder; /** * Playlist card layout. */ public class PlaylistCardInfoItemHolder extends PlaylistMiniInfoItemHolder { public PlaylistCardInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, R.layout.list_playlist_card_item, parent); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistGridInfoItemHolder.java ================================================ package org.schabi.newpipe.info_list.holder; import android.view.ViewGroup; import org.schabi.newpipe.R; import org.schabi.newpipe.info_list.InfoItemBuilder; public class PlaylistGridInfoItemHolder extends PlaylistMiniInfoItemHolder { public PlaylistGridInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, R.layout.list_playlist_grid_item, parent); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistInfoItemHolder.java ================================================ package org.schabi.newpipe.info_list.holder; import android.view.ViewGroup; import org.schabi.newpipe.R; import org.schabi.newpipe.info_list.InfoItemBuilder; public class PlaylistInfoItemHolder extends PlaylistMiniInfoItemHolder { public PlaylistInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, R.layout.list_playlist_item, parent); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java ================================================ package org.schabi.newpipe.info_list.holder; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.image.CoilHelper; public class PlaylistMiniInfoItemHolder extends InfoItemHolder { public final ImageView itemThumbnailView; private final TextView itemStreamCountView; public final TextView itemTitleView; public final TextView itemUploaderView; public PlaylistMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, final ViewGroup parent) { super(infoItemBuilder, layoutId, parent); itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); itemTitleView = itemView.findViewById(R.id.itemTitleView); itemStreamCountView = itemView.findViewById(R.id.itemStreamCountView); itemUploaderView = itemView.findViewById(R.id.itemUploaderView); } public PlaylistMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { this(infoItemBuilder, R.layout.list_playlist_mini_item, parent); } @Override public void updateFromItem(final InfoItem infoItem, final HistoryRecordManager historyRecordManager) { if (!(infoItem instanceof PlaylistInfoItem)) { return; } final PlaylistInfoItem item = (PlaylistInfoItem) infoItem; itemTitleView.setText(item.getName()); itemStreamCountView.setText(Localization .localizeStreamCountMini(itemStreamCountView.getContext(), item.getStreamCount())); itemUploaderView.setText(item.getUploaderName()); CoilHelper.INSTANCE.loadPlaylistThumbnail(itemThumbnailView, item.getThumbnails()); itemView.setOnClickListener(view -> { if (itemBuilder.getOnPlaylistSelectedListener() != null) { itemBuilder.getOnPlaylistSelectedListener().selected(item); } }); itemView.setLongClickable(true); itemView.setOnLongClickListener(view -> { if (itemBuilder.getOnPlaylistSelectedListener() != null) { itemBuilder.getOnPlaylistSelectedListener().held(item); } return true; }); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/holder/StreamCardInfoItemHolder.java ================================================ package org.schabi.newpipe.info_list.holder; import android.view.ViewGroup; import org.schabi.newpipe.R; import org.schabi.newpipe.info_list.InfoItemBuilder; /** * Card layout for stream. */ public class StreamCardInfoItemHolder extends StreamInfoItemHolder { public StreamCardInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, R.layout.list_stream_card_item, parent); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/holder/StreamGridInfoItemHolder.java ================================================ package org.schabi.newpipe.info_list.holder; import android.view.ViewGroup; import org.schabi.newpipe.R; import org.schabi.newpipe.info_list.InfoItemBuilder; public class StreamGridInfoItemHolder extends StreamInfoItemHolder { public StreamGridInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, R.layout.list_stream_grid_item, parent); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java ================================================ package org.schabi.newpipe.info_list.holder; import android.text.TextUtils; import android.view.ViewGroup; import android.widget.TextView; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.Localization; /* * Created by Christian Schabesberger on 01.08.16. *

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

*

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

*

* NewPipe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . *

*/ public class StreamInfoItemHolder extends StreamMiniInfoItemHolder { public final TextView itemAdditionalDetails; public StreamInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { this(infoItemBuilder, R.layout.list_stream_item, parent); } public StreamInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, final ViewGroup parent) { super(infoItemBuilder, layoutId, parent); itemAdditionalDetails = itemView.findViewById(R.id.itemAdditionalDetails); } @Override public void updateFromItem(final InfoItem infoItem, final HistoryRecordManager historyRecordManager) { super.updateFromItem(infoItem, historyRecordManager); if (!(infoItem instanceof StreamInfoItem)) { return; } final StreamInfoItem item = (StreamInfoItem) infoItem; itemAdditionalDetails.setText(getStreamInfoDetailLine(item)); } private String getStreamInfoDetailLine(final StreamInfoItem infoItem) { String viewsAndDate = ""; if (infoItem.getViewCount() >= 0) { if (infoItem.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { viewsAndDate = Localization .listeningCount(itemBuilder.getContext(), infoItem.getViewCount()); } else if (infoItem.getStreamType().equals(StreamType.LIVE_STREAM)) { viewsAndDate = Localization .shortWatchingCount(itemBuilder.getContext(), infoItem.getViewCount()); } else { viewsAndDate = Localization .shortViewCount(itemBuilder.getContext(), infoItem.getViewCount()); } } final String uploadDate = Localization.relativeTimeOrTextual(itemBuilder.getContext(), infoItem.getUploadDate(), infoItem.getTextualUploadDate()); if (!TextUtils.isEmpty(uploadDate)) { if (viewsAndDate.isEmpty()) { return uploadDate; } return Localization.concatenateStrings(viewsAndDate, uploadDate); } return viewsAndDate; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java ================================================ package org.schabi.newpipe.info_list.holder; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import androidx.core.content.ContextCompat; import org.schabi.newpipe.R; import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.ktx.ViewUtils; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.StreamTypeUtil; import org.schabi.newpipe.util.image.CoilHelper; import org.schabi.newpipe.views.AnimatedProgressBar; import java.util.concurrent.TimeUnit; public class StreamMiniInfoItemHolder extends InfoItemHolder { public final ImageView itemThumbnailView; public final TextView itemVideoTitleView; public final TextView itemUploaderView; public final TextView itemDurationView; private final AnimatedProgressBar itemProgressView; StreamMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, final ViewGroup parent) { super(infoItemBuilder, layoutId, parent); itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); itemVideoTitleView = itemView.findViewById(R.id.itemVideoTitleView); itemUploaderView = itemView.findViewById(R.id.itemUploaderView); itemDurationView = itemView.findViewById(R.id.itemDurationView); itemProgressView = itemView.findViewById(R.id.itemProgressView); } public StreamMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { this(infoItemBuilder, R.layout.list_stream_mini_item, parent); } @Override public void updateFromItem(final InfoItem infoItem, final HistoryRecordManager historyRecordManager) { if (!(infoItem instanceof StreamInfoItem)) { return; } final StreamInfoItem item = (StreamInfoItem) infoItem; itemVideoTitleView.setText(item.getName()); itemUploaderView.setText(item.getUploaderName()); if (item.getDuration() > 0) { itemDurationView.setText(Localization.getDurationString(item.getDuration())); itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), R.color.duration_background_color)); itemDurationView.setVisibility(View.VISIBLE); StreamStateEntity state2 = null; if (DependentPreferenceHelper .getPositionsInListsEnabled(itemProgressView.getContext())) { state2 = historyRecordManager.loadStreamState(infoItem) .blockingGet()[0]; } if (state2 != null) { itemProgressView.setVisibility(View.VISIBLE); itemProgressView.setMax((int) item.getDuration()); itemProgressView.setProgress((int) TimeUnit.MILLISECONDS .toSeconds(state2.getProgressMillis())); } else { itemProgressView.setVisibility(View.GONE); } } else if (StreamTypeUtil.isLiveStream(item.getStreamType())) { itemDurationView.setText(R.string.duration_live); itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), R.color.live_duration_background_color)); itemDurationView.setVisibility(View.VISIBLE); itemProgressView.setVisibility(View.GONE); } else { itemDurationView.setVisibility(View.GONE); itemProgressView.setVisibility(View.GONE); } // Default thumbnail is shown on error, while loading and if the url is empty CoilHelper.INSTANCE.loadThumbnail(itemThumbnailView, item.getThumbnails()); itemView.setOnClickListener(view -> { if (itemBuilder.getOnStreamSelectedListener() != null) { itemBuilder.getOnStreamSelectedListener().selected(item); } }); switch (item.getStreamType()) { case AUDIO_STREAM: case VIDEO_STREAM: case LIVE_STREAM: case AUDIO_LIVE_STREAM: case POST_LIVE_STREAM: case POST_LIVE_AUDIO_STREAM: enableLongClick(item); break; case NONE: default: disableLongClick(); break; } } @Override public void updateState(final InfoItem infoItem, final HistoryRecordManager historyRecordManager) { final StreamInfoItem item = (StreamInfoItem) infoItem; StreamStateEntity state = null; if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())) { state = historyRecordManager .loadStreamState(infoItem) .blockingGet()[0]; } if (state != null && item.getDuration() > 0 && !StreamTypeUtil.isLiveStream(item.getStreamType())) { itemProgressView.setMax((int) item.getDuration()); if (itemProgressView.getVisibility() == View.VISIBLE) { itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS .toSeconds(state.getProgressMillis())); } else { itemProgressView.setProgress((int) TimeUnit.MILLISECONDS .toSeconds(state.getProgressMillis())); ViewUtils.animate(itemProgressView, true, 500); } } else if (itemProgressView.getVisibility() == View.VISIBLE) { ViewUtils.animate(itemProgressView, false, 500); } } private void enableLongClick(final StreamInfoItem item) { itemView.setLongClickable(true); itemView.setOnLongClickListener(view -> { if (itemBuilder.getOnStreamSelectedListener() != null) { itemBuilder.getOnStreamSelectedListener().held(item); } return true; }); } private void disableLongClick() { itemView.setLongClickable(false); itemView.setOnLongClickListener(null); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/ktx/Bitmap.kt ================================================ package org.schabi.newpipe.ktx import android.graphics.Bitmap import android.graphics.Rect import androidx.core.graphics.BitmapCompat @Suppress("NOTHING_TO_INLINE") inline fun Bitmap.scale( width: Int, height: Int, srcRect: Rect? = null, scaleInLinearSpace: Boolean = true ) = BitmapCompat.createScaledBitmap(this, width, height, srcRect, scaleInLinearSpace) ================================================ FILE: app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt ================================================ package org.schabi.newpipe.ktx import android.os.Bundle import android.os.Parcelable import androidx.core.os.BundleCompat inline fun Bundle.parcelableArrayList(key: String?): ArrayList? { return BundleCompat.getParcelableArrayList(this, key, T::class.java) } fun Bundle?.toDebugString(): String { if (this == null) { return "null" } val string = StringBuilder("Bundle{") for (key in this.keySet()) { @Suppress("DEPRECATION") // we want this[key] to return items of any type string.append(" ").append(key).append(" => ").append(this[key]).append(";") } string.append(" }") return string.toString() } ================================================ FILE: app/src/main/java/org/schabi/newpipe/ktx/SharedPreferences.kt ================================================ package org.schabi.newpipe.ktx import android.content.SharedPreferences fun SharedPreferences.getStringSafe(key: String, defValue: String): String { return getString(key, null) ?: defValue } ================================================ FILE: app/src/main/java/org/schabi/newpipe/ktx/TextView.kt ================================================ @file:JvmName("TextViewUtils") package org.schabi.newpipe.ktx import android.animation.ArgbEvaluator import android.animation.ValueAnimator import android.util.Log import android.widget.TextView import androidx.annotation.ColorInt import androidx.core.animation.addListener import androidx.interpolator.view.animation.FastOutSlowInInterpolator import org.schabi.newpipe.MainActivity private const val TAG = "TextViewUtils" /** * Animate the text color of any view that extends [TextView] (Buttons, EditText...). * * @param duration the duration of the animation * @param colorStart the text color to start with * @param colorEnd the text color to end with */ fun TextView.animateTextColor(duration: Long, @ColorInt colorStart: Int, @ColorInt colorEnd: Int) { if (MainActivity.DEBUG) { Log.d( TAG, "animateTextColor() called with: " + "view = [" + this + "], duration = [" + duration + "], " + "colorStart = [" + colorStart + "], colorEnd = [" + colorEnd + "]" ) } val viewPropertyAnimator = ValueAnimator.ofObject(ArgbEvaluator(), colorStart, colorEnd) viewPropertyAnimator.interpolator = FastOutSlowInInterpolator() viewPropertyAnimator.duration = duration viewPropertyAnimator.addUpdateListener { setTextColor(it.animatedValue as Int) } viewPropertyAnimator.addListener(onCancel = { setTextColor(colorEnd) }, onEnd = { setTextColor(colorEnd) }) viewPropertyAnimator.start() } ================================================ FILE: app/src/main/java/org/schabi/newpipe/ktx/Throwable.kt ================================================ @file:JvmName("ExceptionUtils") package org.schabi.newpipe.ktx import java.io.IOException import java.io.InterruptedIOException /** * @return if throwable is related to Interrupted exceptions, or one of its causes is. */ val Throwable.isInterruptedCaused: Boolean get() = hasExactCause(InterruptedIOException::class.java, InterruptedException::class.java) /** * @return if throwable is related to network issues, or one of its causes is. */ val Throwable.isNetworkRelated: Boolean get() = hasAssignableCause() /** * Calls [hasCause] with the `checkSubtypes` parameter set to false. */ fun Throwable.hasExactCause(vararg causesToCheck: Class<*>) = hasCause(false, *causesToCheck) /** * Calls [hasCause] with a reified [Throwable] type. */ inline fun Throwable.hasExactCause() = hasExactCause(T::class.java) /** * Calls [hasCause] with the `checkSubtypes` parameter set to true. */ fun Throwable?.hasAssignableCause(vararg causesToCheck: Class<*>) = hasCause(true, *causesToCheck) /** * Calls [hasCause] with a reified [Throwable] type. */ inline fun Throwable?.hasAssignableCause() = hasAssignableCause(T::class.java) /** * Check if the throwable has some cause from the causes to check, or is itself in it. * * If `checkIfAssignable` is true, not only the exact type will be considered equals, but also its subtypes. * * @param checkSubtypes if subtypes are also checked. * @param causesToCheck an array of causes to check. * * @see Class.isAssignableFrom */ tailrec fun Throwable?.hasCause(checkSubtypes: Boolean, vararg causesToCheck: Class<*>): Boolean { if (this == null) { return false } // Check if throwable is a subtype of any of the causes to check causesToCheck.forEach { causeClass -> if (checkSubtypes) { if (causeClass.isAssignableFrom(this.javaClass)) { return true } } else if (causeClass == this.javaClass) { return true } } val currentCause: Throwable? = cause // Check if cause is not pointing to the same instance, to avoid infinite loops. if (this !== currentCause) { return currentCause.hasCause(checkSubtypes, *causesToCheck) } return false } ================================================ FILE: app/src/main/java/org/schabi/newpipe/ktx/View.kt ================================================ @file:JvmName("ViewUtils") package org.schabi.newpipe.ktx import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.ArgbEvaluator import android.animation.ValueAnimator import android.content.res.ColorStateList import android.util.Log import android.view.View import androidx.annotation.ColorInt import androidx.annotation.FloatRange import androidx.core.animation.addListener import androidx.core.view.ViewCompat import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.interpolator.view.animation.FastOutSlowInInterpolator // logs in this class are disabled by default since it's usually not useful, // you can enable them by setting this flag to MainActivity.DEBUG private const val DEBUG = false private const val TAG = "ViewUtils" /** * Animate the view. * * @param enterOrExit true to enter, false to exit * @param duration how long the animation will take, in milliseconds * @param animationType Type of the animation * @param delay how long the animation will wait to start, in milliseconds * @param execOnEnd runnable that will be executed when the animation ends */ @JvmOverloads fun View.animate( enterOrExit: Boolean, duration: Long, animationType: AnimationType = AnimationType.ALPHA, delay: Long = 0, execOnEnd: Runnable? = null ) { if (DEBUG) { val id = runCatching { resources.getResourceEntryName(id) }.getOrDefault(id.toString()) val msg = String.format( "%8s → [%s:%s] [%s %s:%s] execOnEnd=%s", enterOrExit, javaClass.simpleName, id, animationType, duration, delay, execOnEnd ) Log.d(TAG, "animate(): $msg") } if (isVisible && enterOrExit) { if (DEBUG) { Log.d(TAG, "animate(): view was already visible > view = [$this]") } animate().setListener(null).cancel() isVisible = true alpha = 1f execOnEnd?.run() return } else if ((isGone || isInvisible) && !enterOrExit) { if (DEBUG) { Log.d(TAG, "animate(): view was already gone > view = [$this]") } animate().setListener(null).cancel() isGone = true alpha = 0f execOnEnd?.run() return } animate().setListener(null).cancel() isVisible = true when (animationType) { AnimationType.ALPHA -> animateAlpha(enterOrExit, duration, delay, execOnEnd) AnimationType.SCALE_AND_ALPHA -> animateScaleAndAlpha(enterOrExit, duration, delay, execOnEnd) AnimationType.LIGHT_SCALE_AND_ALPHA -> animateLightScaleAndAlpha(enterOrExit, duration, delay, execOnEnd) AnimationType.SLIDE_AND_ALPHA -> animateSlideAndAlpha(enterOrExit, duration, delay, execOnEnd) AnimationType.LIGHT_SLIDE_AND_ALPHA -> animateLightSlideAndAlpha(enterOrExit, duration, delay, execOnEnd) } } /** * Animate the background color of a view. * * @param duration the duration of the animation * @param colorStart the background color to start with * @param colorEnd the background color to end with */ fun View.animateBackgroundColor(duration: Long, @ColorInt colorStart: Int, @ColorInt colorEnd: Int) { if (DEBUG) { Log.d( TAG, "animateBackgroundColor() called with: view = [$this], duration = [$duration], " + "colorStart = [$colorStart], colorEnd = [$colorEnd]" ) } val viewPropertyAnimator = ValueAnimator.ofObject(ArgbEvaluator(), colorStart, colorEnd) viewPropertyAnimator.interpolator = FastOutSlowInInterpolator() viewPropertyAnimator.duration = duration fun listenerAction(color: Int) { ViewCompat.setBackgroundTintList(this, ColorStateList.valueOf(color)) } viewPropertyAnimator.addUpdateListener { listenerAction(it.animatedValue as Int) } viewPropertyAnimator.addListener(onCancel = { listenerAction(colorEnd) }, onEnd = { listenerAction(colorEnd) }) viewPropertyAnimator.start() } fun View.animateHeight(duration: Long, targetHeight: Int): ValueAnimator { if (DEBUG) { Log.d(TAG, "animateHeight: duration = [$duration], from $height to → $targetHeight in: $this") } val animator = ValueAnimator.ofFloat(height.toFloat(), targetHeight.toFloat()) animator.interpolator = FastOutSlowInInterpolator() animator.duration = duration fun listenerAction(value: Int) { layoutParams.height = value requestLayout() } animator.addUpdateListener { listenerAction((it.animatedValue as Float).toInt()) } animator.addListener(onCancel = { listenerAction(targetHeight) }, onEnd = { listenerAction(targetHeight) }) animator.start() return animator } fun View.animateRotation(duration: Long, targetRotation: Int) { if (DEBUG) { Log.d(TAG, "animateRotation: duration = [$duration], from $rotation to → $targetRotation in: $this") } animate().setListener(null).cancel() animate() .rotation(targetRotation.toFloat()).setDuration(duration) .setInterpolator(FastOutSlowInInterpolator()) .setListener(object : AnimatorListenerAdapter() { override fun onAnimationCancel(animation: Animator) { rotation = targetRotation.toFloat() } override fun onAnimationEnd(animation: Animator) { rotation = targetRotation.toFloat() } }).start() } private fun View.animateAlpha(enterOrExit: Boolean, duration: Long, delay: Long, execOnEnd: Runnable?) { if (enterOrExit) { animate().setInterpolator(FastOutSlowInInterpolator()).alpha(1f) .setDuration(duration).setStartDelay(delay) .setListener(ExecOnEndListener(execOnEnd)) .start() } else { animate().setInterpolator(FastOutSlowInInterpolator()).alpha(0f) .setDuration(duration).setStartDelay(delay) .setListener(HideAndExecOnEndListener(this, execOnEnd)) .start() } } private fun View.animateScaleAndAlpha(enterOrExit: Boolean, duration: Long, delay: Long, execOnEnd: Runnable?) { if (enterOrExit) { scaleX = .8f scaleY = .8f animate() .setInterpolator(FastOutSlowInInterpolator()) .alpha(1f).scaleX(1f).scaleY(1f) .setDuration(duration).setStartDelay(delay) .setListener(ExecOnEndListener(execOnEnd)) .start() } else { scaleX = 1f scaleY = 1f animate() .setInterpolator(FastOutSlowInInterpolator()) .alpha(0f).scaleX(.8f).scaleY(.8f) .setDuration(duration).setStartDelay(delay) .setListener(HideAndExecOnEndListener(this, execOnEnd)) .start() } } private fun View.animateLightScaleAndAlpha(enterOrExit: Boolean, duration: Long, delay: Long, execOnEnd: Runnable?) { if (enterOrExit) { alpha = .5f scaleX = .95f scaleY = .95f animate() .setInterpolator(FastOutSlowInInterpolator()) .alpha(1f).scaleX(1f).scaleY(1f) .setDuration(duration).setStartDelay(delay) .setListener(ExecOnEndListener(execOnEnd)) .start() } else { alpha = 1f scaleX = 1f scaleY = 1f animate() .setInterpolator(FastOutSlowInInterpolator()) .alpha(0f).scaleX(.95f).scaleY(.95f) .setDuration(duration).setStartDelay(delay) .setListener(HideAndExecOnEndListener(this, execOnEnd)) .start() } } private fun View.animateSlideAndAlpha(enterOrExit: Boolean, duration: Long, delay: Long, execOnEnd: Runnable?) { if (enterOrExit) { translationY = -height.toFloat() alpha = 0f animate() .setInterpolator(FastOutSlowInInterpolator()).alpha(1f).translationY(0f) .setDuration(duration).setStartDelay(delay) .setListener(ExecOnEndListener(execOnEnd)) .start() } else { animate() .setInterpolator(FastOutSlowInInterpolator()) .alpha(0f).translationY(-height.toFloat()) .setDuration(duration).setStartDelay(delay) .setListener(HideAndExecOnEndListener(this, execOnEnd)) .start() } } private fun View.animateLightSlideAndAlpha(enterOrExit: Boolean, duration: Long, delay: Long, execOnEnd: Runnable?) { if (enterOrExit) { translationY = -height / 2.0f alpha = 0f animate() .setInterpolator(FastOutSlowInInterpolator()).alpha(1f).translationY(0f) .setDuration(duration).setStartDelay(delay) .setListener(ExecOnEndListener(execOnEnd)) .start() } else { animate().setInterpolator(FastOutSlowInInterpolator()) .alpha(0f).translationY(-height / 2.0f) .setDuration(duration).setStartDelay(delay) .setListener(HideAndExecOnEndListener(this, execOnEnd)) .start() } } @JvmOverloads fun View.slideUp( duration: Long, delay: Long = 0L, @FloatRange(from = 0.0, to = 1.0) translationPercent: Float = 1.0F, execOnEnd: Runnable? = null ) { val newTranslationY = (resources.displayMetrics.heightPixels * translationPercent).toInt() animate().setListener(null).cancel() alpha = 0f translationY = newTranslationY.toFloat() isVisible = true animate() .alpha(1f) .translationY(0f) .setStartDelay(delay) .setDuration(duration) .setInterpolator(FastOutSlowInInterpolator()) .setListener(ExecOnEndListener(execOnEnd)) .start() } /** * Instead of hiding normally using [animate], which would make * the recycler view unable to capture touches after being hidden, this just animates the alpha * value setting it to `0.0` after `200` milliseconds. */ fun View.animateHideRecyclerViewAllowingScrolling() { // not hiding normally because the view needs to still capture touches and allow scroll animate().alpha(0.0f).setDuration(200).start() } private open class ExecOnEndListener(private val execOnEnd: Runnable?) : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { execOnEnd?.run() } } private class HideAndExecOnEndListener(private val view: View, execOnEnd: Runnable?) : ExecOnEndListener(execOnEnd) { override fun onAnimationEnd(animation: Animator) { view.isGone = true super.onAnimationEnd(animation) } } enum class AnimationType { ALPHA, SCALE_AND_ALPHA, LIGHT_SCALE_AND_ALPHA, SLIDE_AND_ALPHA, LIGHT_SLIDE_AND_ALPHA } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java ================================================ package org.schabi.newpipe.local; import android.content.SharedPreferences; import android.content.res.Resources; import android.os.Bundle; import android.util.Log; import android.view.Menu; import android.view.MenuInflater; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.fragment.app.Fragment; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import androidx.viewbinding.ViewBinding; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.PignateFooterBinding; import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.list.ListViewContract; import org.schabi.newpipe.info_list.ItemViewMode; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; import static org.schabi.newpipe.util.ThemeHelper.getItemViewMode; import java.util.function.Supplier; /** * This fragment is design to be used with persistent data such as * {@link org.schabi.newpipe.database.LocalItem}, and does not cache the data contained * in the list adapter to avoid extra writes when the it exits or re-enters its lifecycle. *

* This fragment destroys its adapter and views when {@link Fragment#onDestroyView()} is * called and is memory efficient when in backstack. *

* * @param List of {@link org.schabi.newpipe.database.LocalItem}s * @param {@link Void} */ public abstract class BaseLocalListFragment extends BaseStateFragment implements ListViewContract, SharedPreferences.OnSharedPreferenceChangeListener { /*////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////*/ private static final int LIST_MODE_UPDATE_FLAG = 0x32; private ViewBinding headerRootBinding; private ViewBinding footerRootBinding; protected LocalItemListAdapter itemListAdapter; protected RecyclerView itemsList; private int updateFlags = 0; /*////////////////////////////////////////////////////////////////////////// // Lifecycle - Creation //////////////////////////////////////////////////////////////////////////*/ @Override public void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); setHasOptionsMenu(true); PreferenceManager.getDefaultSharedPreferences(activity) .registerOnSharedPreferenceChangeListener(this); } @Override public void onDestroy() { super.onDestroy(); PreferenceManager.getDefaultSharedPreferences(activity) .unregisterOnSharedPreferenceChangeListener(this); } @Override public void onResume() { super.onResume(); if (updateFlags != 0) { if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) { refreshItemViewMode(); } updateFlags = 0; } } /** * Updates the item view mode based on user preference. */ private void refreshItemViewMode() { final ItemViewMode itemViewMode = getItemViewMode(requireContext()); itemsList.setLayoutManager((itemViewMode == ItemViewMode.GRID) ? getGridLayoutManager() : getListLayoutManager()); itemListAdapter.setItemViewMode(itemViewMode); itemListAdapter.notifyDataSetChanged(); } /*////////////////////////////////////////////////////////////////////////// // Lifecycle - View //////////////////////////////////////////////////////////////////////////*/ @Nullable protected Supplier getListHeaderSupplier() { return null; } protected ViewBinding getListFooter() { return PignateFooterBinding.inflate(activity.getLayoutInflater(), itemsList, false); } protected RecyclerView.LayoutManager getGridLayoutManager() { final Resources resources = activity.getResources(); int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width); width += (24 * resources.getDisplayMetrics().density); final int spanCount = Math.floorDiv(resources.getDisplayMetrics().widthPixels, width); final GridLayoutManager lm = new GridLayoutManager(activity, spanCount); lm.setSpanSizeLookup(itemListAdapter.getSpanSizeLookup(spanCount)); return lm; } protected RecyclerView.LayoutManager getListLayoutManager() { return new LinearLayoutManager(activity); } @Override protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); itemListAdapter = new LocalItemListAdapter(activity); itemsList = rootView.findViewById(R.id.items_list); refreshItemViewMode(); final Supplier listHeaderSupplier = getListHeaderSupplier(); if (listHeaderSupplier != null) { itemListAdapter.setHeaderSupplier(listHeaderSupplier); } footerRootBinding = getListFooter(); itemListAdapter.setFooter(footerRootBinding.getRoot()); itemsList.setAdapter(itemListAdapter); } @Override protected void initListeners() { super.initListeners(); } /*////////////////////////////////////////////////////////////////////////// // Lifecycle - Menu //////////////////////////////////////////////////////////////////////////*/ @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); if (DEBUG) { Log.d(TAG, "onCreateOptionsMenu() called with: " + "menu = [" + menu + "], inflater = [" + inflater + "]"); } final ActionBar supportActionBar = activity.getSupportActionBar(); if (supportActionBar == null) { return; } supportActionBar.setDisplayShowTitleEnabled(true); } /*////////////////////////////////////////////////////////////////////////// // Lifecycle - Destruction //////////////////////////////////////////////////////////////////////////*/ @Override public void onDestroyView() { super.onDestroyView(); itemsList = null; itemListAdapter = null; } /*////////////////////////////////////////////////////////////////////////// // Contract //////////////////////////////////////////////////////////////////////////*/ @Override public void startLoading(final boolean forceLoad) { super.startLoading(forceLoad); resetFragment(); } @Override public void showLoading() { super.showLoading(); if (itemsList != null) { animateHideRecyclerViewAllowingScrolling(itemsList); } } @Override public void hideLoading() { super.hideLoading(); if (itemsList != null) { animate(itemsList, true, 200); } } @Override public void showEmptyState() { super.showEmptyState(); showListFooter(false); } @Deprecated(since = "Calling this method with `true` may cause crashes, see " + "https://github.com/TeamNewPipe/NewPipe/pull/12996#pullrequestreview-3713317115") @Override public void showListFooter(final boolean show) { if (itemsList == null) { return; } itemsList.post(() -> { if (itemListAdapter != null) { itemListAdapter.showFooter(show); } }); } @Override public void handleNextItems(final N result) { isLoading.set(false); } /*////////////////////////////////////////////////////////////////////////// // Error handling //////////////////////////////////////////////////////////////////////////*/ protected void resetFragment() { if (itemListAdapter != null) { itemListAdapter.clearStreamItemList(); } } @Override public void handleError() { super.handleError(); resetFragment(); showListFooter(false); if (itemsList != null) { animateHideRecyclerViewAllowingScrolling(itemsList); } } @Override public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, final String key) { if (getString(R.string.list_view_mode_key).equals(key)) { updateFlags |= LIST_MODE_UPDATE_FLAG; } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/HeaderFooterHolder.java ================================================ package org.schabi.newpipe.local; import android.view.View; import androidx.recyclerview.widget.RecyclerView; public class HeaderFooterHolder extends RecyclerView.ViewHolder { public View view; public HeaderFooterHolder(final View v) { super(v); view = v; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/LocalItemBuilder.java ================================================ package org.schabi.newpipe.local; import android.content.Context; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.util.OnClickGesture; /* * Created by Christian Schabesberger on 26.09.16. *

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

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

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

* You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ public class LocalItemBuilder { private final Context context; private OnClickGesture onSelectedListener; public LocalItemBuilder(final Context context) { this.context = context; } public Context getContext() { return context; } public OnClickGesture getOnItemSelectedListener() { return onSelectedListener; } public void setOnItemSelectedListener(final OnClickGesture listener) { this.onSelectedListener = listener; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java ================================================ package org.schabi.newpipe.local; import android.content.Context; import android.util.Log; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.info_list.ItemViewMode; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.holder.LocalBookmarkPlaylistItemHolder; import org.schabi.newpipe.local.holder.LocalItemHolder; import org.schabi.newpipe.local.holder.LocalPlaylistCardItemHolder; import org.schabi.newpipe.local.holder.LocalPlaylistGridItemHolder; import org.schabi.newpipe.local.holder.LocalPlaylistItemHolder; import org.schabi.newpipe.local.holder.LocalPlaylistStreamCardItemHolder; import org.schabi.newpipe.local.holder.LocalPlaylistStreamGridItemHolder; import org.schabi.newpipe.local.holder.LocalPlaylistStreamItemHolder; import org.schabi.newpipe.local.holder.LocalStatisticStreamCardItemHolder; import org.schabi.newpipe.local.holder.LocalStatisticStreamGridItemHolder; import org.schabi.newpipe.local.holder.LocalStatisticStreamItemHolder; import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder; import org.schabi.newpipe.local.holder.RemotePlaylistCardItemHolder; import org.schabi.newpipe.local.holder.RemotePlaylistGridItemHolder; import org.schabi.newpipe.local.holder.RemotePlaylistItemHolder; import org.schabi.newpipe.util.FallbackViewHolder; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.OnClickGesture; import java.time.format.DateTimeFormatter; import java.time.format.FormatStyle; import java.util.ArrayList; import java.util.List; import java.util.function.Supplier; /* * Created by Christian Schabesberger on 01.08.16. * * Copyright (C) Christian Schabesberger 2016 * InfoListAdapter.java is part of NewPipe. * * NewPipe is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * NewPipe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ public class LocalItemListAdapter extends RecyclerView.Adapter { private static final String TAG = LocalItemListAdapter.class.getSimpleName(); private static final boolean DEBUG = false; private static final int HEADER_TYPE = 0; private static final int FOOTER_TYPE = 1; private static final int STREAM_STATISTICS_HOLDER_TYPE = 0x1000; private static final int STREAM_PLAYLIST_HOLDER_TYPE = 0x1001; private static final int STREAM_STATISTICS_GRID_HOLDER_TYPE = 0x1002; private static final int STREAM_STATISTICS_CARD_HOLDER_TYPE = 0x1003; private static final int STREAM_PLAYLIST_GRID_HOLDER_TYPE = 0x1004; private static final int STREAM_PLAYLIST_CARD_HOLDER_TYPE = 0x1005; private static final int LOCAL_PLAYLIST_HOLDER_TYPE = 0x2000; private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2001; private static final int LOCAL_PLAYLIST_CARD_HOLDER_TYPE = 0x2002; private static final int LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE = 0x2003; private static final int REMOTE_PLAYLIST_HOLDER_TYPE = 0x3000; private static final int REMOTE_PLAYLIST_GRID_HOLDER_TYPE = 0x3001; private static final int REMOTE_PLAYLIST_CARD_HOLDER_TYPE = 0x3002; private static final int REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE = 0x3003; private final LocalItemBuilder localItemBuilder; private final ArrayList localItems; private final HistoryRecordManager recordManager; private final DateTimeFormatter dateTimeFormatter; private boolean showFooter = false; private Supplier headerSupplier = null; private View footer = null; private ItemViewMode itemViewMode = ItemViewMode.LIST; private boolean useItemHandle = false; public LocalItemListAdapter(final Context context) { recordManager = new HistoryRecordManager(context); localItemBuilder = new LocalItemBuilder(context); localItems = new ArrayList<>(); dateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT) .withLocale(Localization.getPreferredLocale(context)); } public void setSelectedListener(final OnClickGesture listener) { localItemBuilder.setOnItemSelectedListener(listener); } public void unsetSelectedListener() { localItemBuilder.setOnItemSelectedListener(null); } public void addItems(@Nullable final List data) { if (data == null) { return; } if (DEBUG) { Log.d(TAG, "addItems() before > localItems.size() = " + localItems.size() + ", data.size() = " + data.size()); } final int offsetStart = sizeConsideringHeader(); localItems.addAll(data); if (DEBUG) { Log.d(TAG, "addItems() after > offsetStart = " + offsetStart + ", " + "localItems.size() = " + localItems.size() + ", " + "header = " + hasHeader() + ", footer = " + footer + ", " + "showFooter = " + showFooter); } notifyItemRangeInserted(offsetStart, data.size()); if (footer != null && showFooter) { final int footerNow = sizeConsideringHeader(); notifyItemMoved(offsetStart, footerNow); if (DEBUG) { Log.d(TAG, "addItems() footer from " + offsetStart + " to " + footerNow); } } } public void removeItem(final LocalItem data) { final int index = localItems.indexOf(data); if (index != -1) { localItems.remove(index); notifyItemRemoved(index + (hasHeader() ? 1 : 0)); } else { // this happens when // 1) removeItem is called on infoItemDuplicate as in showStreamItemDialog of // LocalPlaylistFragment in this case need to implement delete object by it's duplicate // OR // 2)data not in itemList and UI is still not updated so notifyDataSetChanged() notifyDataSetChanged(); } } public boolean swapItems(final int fromAdapterPosition, final int toAdapterPosition) { final int actualFrom = adapterOffsetWithoutHeader(fromAdapterPosition); final int actualTo = adapterOffsetWithoutHeader(toAdapterPosition); if (actualFrom < 0 || actualTo < 0) { return false; } if (actualFrom >= localItems.size() || actualTo >= localItems.size()) { return false; } localItems.add(actualTo, localItems.remove(actualFrom)); notifyItemMoved(fromAdapterPosition, toAdapterPosition); return true; } public void clearStreamItemList() { if (localItems.isEmpty()) { return; } localItems.clear(); notifyDataSetChanged(); } public void setItemViewMode(final ItemViewMode itemViewMode) { this.itemViewMode = itemViewMode; } public void setUseItemHandle(final boolean useItemHandle) { this.useItemHandle = useItemHandle; } public void setHeaderSupplier(@Nullable final Supplier headerSupplier) { final boolean changed = headerSupplier != this.headerSupplier; this.headerSupplier = headerSupplier; if (changed) { notifyDataSetChanged(); } } public void setFooter(final View view) { this.footer = view; } protected boolean hasHeader() { return this.headerSupplier != null; } @Deprecated(since = "Calling this method with `true` may cause crashes, see " + "https://github.com/TeamNewPipe/NewPipe/pull/12996#pullrequestreview-3713317115") public void showFooter(final boolean show) { if (DEBUG) { Log.d(TAG, "showFooter() called with: show = [" + show + "]"); } if (show == showFooter) { return; } showFooter = show; if (show) { Log.w(TAG, "Calling LocalItemListAdapter.showFooter(true) may cause crashes, see https" + "://github.com/TeamNewPipe/NewPipe/pull/12996#pullrequestreview-3713317115"); notifyItemInserted(sizeConsideringHeader()); } else { notifyItemRemoved(sizeConsideringHeader()); } } private int adapterOffsetWithoutHeader(final int offset) { return offset - (hasHeader() ? 1 : 0); } private int sizeConsideringHeader() { return localItems.size() + (hasHeader() ? 1 : 0); } public ArrayList getItemsList() { return localItems; } @Override public int getItemCount() { int count = localItems.size(); if (hasHeader()) { count++; } if (footer != null && showFooter) { count++; } if (DEBUG) { Log.d(TAG, "getItemCount() called, count = " + count + ", " + "localItems.size() = " + localItems.size() + ", " + "header = " + hasHeader() + ", footer = " + footer + ", " + "showFooter = " + showFooter); } return count; } @SuppressWarnings("FinalParameters") @Override public int getItemViewType(int position) { if (DEBUG) { Log.d(TAG, "getItemViewType() called with: position = [" + position + "]"); } if (hasHeader() && position == 0) { return HEADER_TYPE; } else if (hasHeader()) { position--; } if (footer != null && position == localItems.size() && showFooter) { return FOOTER_TYPE; } final LocalItem item = localItems.get(position); switch (item.getLocalItemType()) { case PLAYLIST_LOCAL_ITEM: if (useItemHandle) { return LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE; } else if (itemViewMode == ItemViewMode.CARD) { return LOCAL_PLAYLIST_CARD_HOLDER_TYPE; } else if (itemViewMode == ItemViewMode.GRID) { return LOCAL_PLAYLIST_GRID_HOLDER_TYPE; } else { return LOCAL_PLAYLIST_HOLDER_TYPE; } case PLAYLIST_REMOTE_ITEM: if (useItemHandle) { return REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE; } else if (itemViewMode == ItemViewMode.CARD) { return REMOTE_PLAYLIST_CARD_HOLDER_TYPE; } else if (itemViewMode == ItemViewMode.GRID) { return REMOTE_PLAYLIST_GRID_HOLDER_TYPE; } else { return REMOTE_PLAYLIST_HOLDER_TYPE; } case PLAYLIST_STREAM_ITEM: if (itemViewMode == ItemViewMode.CARD) { return STREAM_PLAYLIST_CARD_HOLDER_TYPE; } else if (itemViewMode == ItemViewMode.GRID) { return STREAM_PLAYLIST_GRID_HOLDER_TYPE; } else { return STREAM_PLAYLIST_HOLDER_TYPE; } case STATISTIC_STREAM_ITEM: if (itemViewMode == ItemViewMode.CARD) { return STREAM_STATISTICS_CARD_HOLDER_TYPE; } else if (itemViewMode == ItemViewMode.GRID) { return STREAM_STATISTICS_GRID_HOLDER_TYPE; } else { return STREAM_STATISTICS_HOLDER_TYPE; } default: Log.e(TAG, "No holder type has been considered for item: [" + item.getLocalItemType() + "]"); return -1; } } @NonNull @Override public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int type) { if (DEBUG) { Log.d(TAG, "onCreateViewHolder() called with: " + "parent = [" + parent + "], type = [" + type + "]"); } switch (type) { case HEADER_TYPE: return new HeaderFooterHolder(headerSupplier.get()); case FOOTER_TYPE: return new HeaderFooterHolder(footer); case LOCAL_PLAYLIST_HOLDER_TYPE: return new LocalPlaylistItemHolder(localItemBuilder, parent); case LOCAL_PLAYLIST_GRID_HOLDER_TYPE: return new LocalPlaylistGridItemHolder(localItemBuilder, parent); case LOCAL_PLAYLIST_CARD_HOLDER_TYPE: return new LocalPlaylistCardItemHolder(localItemBuilder, parent); case LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE: return new LocalBookmarkPlaylistItemHolder(localItemBuilder, parent); case REMOTE_PLAYLIST_HOLDER_TYPE: return new RemotePlaylistItemHolder(localItemBuilder, parent); case REMOTE_PLAYLIST_GRID_HOLDER_TYPE: return new RemotePlaylistGridItemHolder(localItemBuilder, parent); case REMOTE_PLAYLIST_CARD_HOLDER_TYPE: return new RemotePlaylistCardItemHolder(localItemBuilder, parent); case REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE: return new RemoteBookmarkPlaylistItemHolder(localItemBuilder, parent); case STREAM_PLAYLIST_HOLDER_TYPE: return new LocalPlaylistStreamItemHolder(localItemBuilder, parent); case STREAM_PLAYLIST_GRID_HOLDER_TYPE: return new LocalPlaylistStreamGridItemHolder(localItemBuilder, parent); case STREAM_PLAYLIST_CARD_HOLDER_TYPE: return new LocalPlaylistStreamCardItemHolder(localItemBuilder, parent); case STREAM_STATISTICS_HOLDER_TYPE: return new LocalStatisticStreamItemHolder(localItemBuilder, parent); case STREAM_STATISTICS_GRID_HOLDER_TYPE: return new LocalStatisticStreamGridItemHolder(localItemBuilder, parent); case STREAM_STATISTICS_CARD_HOLDER_TYPE: return new LocalStatisticStreamCardItemHolder(localItemBuilder, parent); default: Log.e(TAG, "No view type has been considered for holder: [" + type + "]"); return new FallbackViewHolder(new View(parent.getContext())); } } @SuppressWarnings("FinalParameters") @Override public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, int position) { if (DEBUG) { Log.d(TAG, "onBindViewHolder() called with: " + "holder = [" + holder.getClass().getSimpleName() + "], " + "position = [" + position + "]"); } if (holder instanceof LocalItemHolder) { // If header isn't null, offset the items by -1 if (hasHeader()) { position--; } ((LocalItemHolder) holder) .updateFromItem(localItems.get(position), recordManager, dateTimeFormatter); } else if (holder instanceof HeaderFooterHolder && position == 0 && hasHeader()) { ((HeaderFooterHolder) holder).view = headerSupplier.get(); } else if (holder instanceof HeaderFooterHolder && position == sizeConsideringHeader() && footer != null && showFooter) { ((HeaderFooterHolder) holder).view = footer; } } @Override public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position, @NonNull final List payloads) { if (!payloads.isEmpty() && holder instanceof LocalItemHolder) { for (final Object payload : payloads) { if (payload instanceof StreamStateEntity) { ((LocalItemHolder) holder).updateState(localItems .get(hasHeader() ? position - 1 : position), recordManager); } else if (payload instanceof Boolean) { ((LocalItemHolder) holder).updateState(localItems .get(hasHeader() ? position - 1 : position), recordManager); } } } else { onBindViewHolder(holder, position); } } public GridLayoutManager.SpanSizeLookup getSpanSizeLookup(final int spanCount) { return new GridLayoutManager.SpanSizeLookup() { @Override public int getSpanSize(final int position) { final int type = getItemViewType(position); return type == HEADER_TYPE || type == FOOTER_TYPE ? spanCount : 1; } }; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java ================================================ package org.schabi.newpipe.local.bookmark; import static org.schabi.newpipe.local.bookmark.MergedPlaylistManager.getMergedOrderedPlaylists; import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout; import android.content.DialogInterface; import android.os.Bundle; import android.os.Parcelable; import android.text.InputType; import android.util.Log; import android.util.Pair; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.FragmentManager; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; import com.evernote.android.state.State; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.database.AppDatabase; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.playlist.PlaylistLocalItem; import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; import org.schabi.newpipe.databinding.DialogEditTextBinding; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.local.holder.LocalBookmarkPlaylistItemHolder; import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder; import org.schabi.newpipe.local.playlist.LocalPlaylistManager; import org.schabi.newpipe.local.playlist.RemotePlaylistManager; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.debounce.DebounceSavable; import org.schabi.newpipe.util.debounce.DebounceSaver; import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; public final class BookmarkFragment extends BaseLocalListFragment, Void> implements DebounceSavable { private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12; @State Parcelable itemsListState; private Subscription databaseSubscription; private CompositeDisposable disposables = new CompositeDisposable(); private LocalPlaylistManager localPlaylistManager; private RemotePlaylistManager remotePlaylistManager; private ItemTouchHelper itemTouchHelper; /* Have the bookmarked playlists been fully loaded from db */ private AtomicBoolean isLoadingComplete; /* Gives enough time to avoid interrupting user sorting operations */ @Nullable private DebounceSaver debounceSaver; private List> deletedItems; /////////////////////////////////////////////////////////////////////////// // Fragment LifeCycle - Creation /////////////////////////////////////////////////////////////////////////// @Override public void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); if (activity == null) { return; } final AppDatabase database = NewPipeDatabase.getInstance(activity); localPlaylistManager = new LocalPlaylistManager(database); remotePlaylistManager = new RemotePlaylistManager(database); disposables = new CompositeDisposable(); isLoadingComplete = new AtomicBoolean(); debounceSaver = new DebounceSaver(3000, this); deletedItems = new ArrayList<>(); } @Nullable @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, final Bundle savedInstanceState) { if (!useAsFrontPage) { setTitle(activity.getString(R.string.tab_bookmarks)); } return inflater.inflate(R.layout.fragment_bookmarks, container, false); } @Override public void onResume() { super.onResume(); if (activity != null) { setTitle(activity.getString(R.string.tab_bookmarks)); } } /////////////////////////////////////////////////////////////////////////// // Fragment LifeCycle - Views /////////////////////////////////////////////////////////////////////////// @Override protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); itemListAdapter.setUseItemHandle(true); } @Override protected void initListeners() { super.initListeners(); itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); itemTouchHelper.attachToRecyclerView(itemsList); itemListAdapter.setSelectedListener(new OnClickGesture<>() { @Override public void selected(final LocalItem selectedItem) { final FragmentManager fragmentManager = getFM(); if (selectedItem instanceof PlaylistMetadataEntry) { final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem); NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.getUid(), entry.getOrderingName()); } else if (selectedItem instanceof PlaylistRemoteEntity) { final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem); NavigationHelper.openPlaylistFragment( fragmentManager, entry.getServiceId(), entry.getUrl(), entry.getOrderingName()); } } @Override public void held(final LocalItem selectedItem) { if (selectedItem instanceof PlaylistMetadataEntry) { showLocalDialog((PlaylistMetadataEntry) selectedItem); } else if (selectedItem instanceof PlaylistRemoteEntity) { showRemoteDeleteDialog((PlaylistRemoteEntity) selectedItem); } } @Override public void drag(final LocalItem selectedItem, final RecyclerView.ViewHolder viewHolder) { if (itemTouchHelper != null) { itemTouchHelper.startDrag(viewHolder); } } }); } /////////////////////////////////////////////////////////////////////////// // Fragment LifeCycle - Loading /////////////////////////////////////////////////////////////////////////// @Override public void startLoading(final boolean forceLoad) { super.startLoading(forceLoad); if (debounceSaver != null) { disposables.add(debounceSaver.getDebouncedSaver()); debounceSaver.setNoChangesToSave(); } isLoadingComplete.set(false); getMergedOrderedPlaylists(localPlaylistManager, remotePlaylistManager) .onBackpressureLatest() .observeOn(AndroidSchedulers.mainThread()) .subscribe(getPlaylistsSubscriber()); } /////////////////////////////////////////////////////////////////////////// // Fragment LifeCycle - Destruction /////////////////////////////////////////////////////////////////////////// @Override public void onPause() { super.onPause(); itemsListState = itemsList.getLayoutManager().onSaveInstanceState(); // Save on exit saveImmediate(); } @Override public void onDestroyView() { super.onDestroyView(); if (disposables != null) { disposables.clear(); } if (databaseSubscription != null) { databaseSubscription.cancel(); } databaseSubscription = null; itemTouchHelper = null; } @Override public void onDestroy() { super.onDestroy(); if (debounceSaver != null) { debounceSaver.getDebouncedSaveSignal().onComplete(); } if (disposables != null) { disposables.dispose(); } debounceSaver = null; disposables = null; localPlaylistManager = null; remotePlaylistManager = null; itemsListState = null; isLoadingComplete = null; deletedItems = null; } /////////////////////////////////////////////////////////////////////////// // Subscriptions Loader /////////////////////////////////////////////////////////////////////////// private Subscriber> getPlaylistsSubscriber() { return new Subscriber<>() { @Override public void onSubscribe(final Subscription s) { showLoading(); isLoadingComplete.set(false); if (databaseSubscription != null) { databaseSubscription.cancel(); } databaseSubscription = s; databaseSubscription.request(1); } @Override public void onNext(final List subscriptions) { if (debounceSaver == null || !debounceSaver.getIsModified()) { handleResult(subscriptions); isLoadingComplete.set(true); } if (databaseSubscription != null) { databaseSubscription.request(1); } } @Override public void onError(final Throwable exception) { showError(new ErrorInfo(exception, UserAction.REQUESTED_BOOKMARK, "Loading playlists")); } @Override public void onComplete() { } }; } @Override public void handleResult(@NonNull final List result) { super.handleResult(result); itemListAdapter.clearStreamItemList(); if (result.isEmpty()) { showEmptyState(); return; } itemListAdapter.addItems(result); if (itemsListState != null) { itemsList.getLayoutManager().onRestoreInstanceState(itemsListState); itemsListState = null; } hideLoading(); } /////////////////////////////////////////////////////////////////////////// // Fragment Error Handling /////////////////////////////////////////////////////////////////////////// @Override protected void resetFragment() { super.resetFragment(); if (disposables != null) { disposables.clear(); } } /*////////////////////////////////////////////////////////////////////////// // Playlist Metadata Manipulation //////////////////////////////////////////////////////////////////////////*/ private void changeLocalPlaylistName(final long id, final String name) { if (localPlaylistManager == null) { return; } if (DEBUG) { Log.d(TAG, "Updating playlist id=[" + id + "] " + "with new name=[" + name + "] items"); } final Disposable disposable = localPlaylistManager.renamePlaylist(id, name) .observeOn(AndroidSchedulers.mainThread()) .subscribe(longs -> { /*Do nothing on success*/ }, throwable -> showError( new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, "Changing playlist name"))); disposables.add(disposable); } private void deleteItem(final PlaylistLocalItem item) { if (itemListAdapter == null) { return; } itemListAdapter.removeItem(item); if (item instanceof PlaylistMetadataEntry) { deletedItems.add(new Pair<>(item.getUid(), LocalItem.LocalItemType.PLAYLIST_LOCAL_ITEM)); } else if (item instanceof PlaylistRemoteEntity) { deletedItems.add(new Pair<>(item.getUid(), LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM)); } if (debounceSaver != null) { debounceSaver.setHasChangesToSave(); saveImmediate(); } } @Override public void saveImmediate() { if (itemListAdapter == null) { return; } // List must be loaded and modified in order to save if (isLoadingComplete == null || debounceSaver == null || !isLoadingComplete.get() || !debounceSaver.getIsModified()) { return; } final List items = itemListAdapter.getItemsList(); final List localItemsUpdate = new ArrayList<>(); final List localItemsDeleteUid = new ArrayList<>(); final List remoteItemsUpdate = new ArrayList<>(); final List remoteItemsDeleteUid = new ArrayList<>(); // Calculate display index for (int i = 0; i < items.size(); i++) { final LocalItem item = items.get(i); if (item instanceof PlaylistMetadataEntry && ((PlaylistMetadataEntry) item).getDisplayIndex() != i) { ((PlaylistMetadataEntry) item).setDisplayIndex((long) i); localItemsUpdate.add((PlaylistMetadataEntry) item); } else if (item instanceof PlaylistRemoteEntity && ((PlaylistRemoteEntity) item).getDisplayIndex() != i) { ((PlaylistRemoteEntity) item).setDisplayIndex((long) i); remoteItemsUpdate.add((PlaylistRemoteEntity) item); } } // Find deleted items for (final Pair item : deletedItems) { if (item.second.equals(LocalItem.LocalItemType.PLAYLIST_LOCAL_ITEM)) { localItemsDeleteUid.add(item.first); } else if (item.second.equals(LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM)) { remoteItemsDeleteUid.add(item.first); } } deletedItems.clear(); // 1. Update local playlists // 2. Update remote playlists // 3. Set NoChangesToSave disposables.add(localPlaylistManager.updatePlaylists(localItemsUpdate, localItemsDeleteUid) .mergeWith(remotePlaylistManager.updatePlaylists( remoteItemsUpdate, remoteItemsDeleteUid)) .observeOn(AndroidSchedulers.mainThread()) .subscribe(() -> { if (debounceSaver != null) { debounceSaver.setNoChangesToSave(); } }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, "Saving playlist")) )); } private ItemTouchHelper.SimpleCallback getItemTouchCallback() { int directions = ItemTouchHelper.UP | ItemTouchHelper.DOWN; if (shouldUseGridLayout(requireContext())) { directions |= ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; } return new ItemTouchHelper.SimpleCallback(directions, ItemTouchHelper.ACTION_STATE_IDLE) { @Override public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView, final int viewSize, final int viewSizeOutOfBounds, final int totalSize, final long msSinceStartScroll) { final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll); final int minimumAbsVelocity = Math.max(MINIMUM_INITIAL_DRAG_VELOCITY, Math.abs(standardSpeed)); return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); } @Override public boolean onMove(@NonNull final RecyclerView recyclerView, @NonNull final RecyclerView.ViewHolder source, @NonNull final RecyclerView.ViewHolder target) { // Allow swap LocalBookmarkPlaylistItemHolder and RemoteBookmarkPlaylistItemHolder. if (itemListAdapter == null || source.getItemViewType() != target.getItemViewType() && !( ( (source instanceof LocalBookmarkPlaylistItemHolder) || (source instanceof RemoteBookmarkPlaylistItemHolder) ) && ( (target instanceof LocalBookmarkPlaylistItemHolder) || (target instanceof RemoteBookmarkPlaylistItemHolder) )) ) { return false; } final int sourceIndex = source.getBindingAdapterPosition(); final int targetIndex = target.getBindingAdapterPosition(); final boolean isSwapped = itemListAdapter.swapItems(sourceIndex, targetIndex); if (isSwapped && debounceSaver != null) { debounceSaver.setHasChangesToSave(); } return isSwapped; } @Override public boolean isLongPressDragEnabled() { return false; } @Override public boolean isItemViewSwipeEnabled() { return false; } @Override public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, final int swipeDir) { // Do nothing. } }; } /////////////////////////////////////////////////////////////////////////// // Utils /////////////////////////////////////////////////////////////////////////// private void showRemoteDeleteDialog(final PlaylistRemoteEntity item) { showDeleteDialog(item.getOrderingName(), item); } private void showLocalDialog(final PlaylistMetadataEntry selectedItem) { final String rename = getString(R.string.rename); final String delete = getString(R.string.delete); final String unsetThumbnail = getString(R.string.unset_playlist_thumbnail); final boolean isThumbnailPermanent = localPlaylistManager .getIsPlaylistThumbnailPermanent(selectedItem.getUid()); final ArrayList items = new ArrayList<>(); items.add(rename); items.add(delete); if (isThumbnailPermanent) { items.add(unsetThumbnail); } final DialogInterface.OnClickListener action = (d, index) -> { if (items.get(index).equals(rename)) { showRenameDialog(selectedItem); } else if (items.get(index).equals(delete)) { showDeleteDialog(selectedItem.getOrderingName(), selectedItem); } else if (isThumbnailPermanent && items.get(index).equals(unsetThumbnail)) { final long thumbnailStreamId = localPlaylistManager .getAutomaticPlaylistThumbnailStreamId(selectedItem.getUid()); localPlaylistManager .changePlaylistThumbnail(selectedItem.getUid(), thumbnailStreamId, false) .observeOn(AndroidSchedulers.mainThread()) .subscribe(); } }; new AlertDialog.Builder(activity) .setItems(items.toArray(new String[0]), action) .show(); } private void showRenameDialog(final PlaylistMetadataEntry selectedItem) { final DialogEditTextBinding dialogBinding = DialogEditTextBinding.inflate(getLayoutInflater()); dialogBinding.dialogEditText.setHint(R.string.name); dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT); dialogBinding.dialogEditText.setText(selectedItem.getOrderingName()); new AlertDialog.Builder(activity) .setView(dialogBinding.getRoot()) .setPositiveButton(R.string.rename_playlist, (dialog, which) -> changeLocalPlaylistName( selectedItem.getUid(), dialogBinding.dialogEditText.getText().toString())) .setNegativeButton(R.string.cancel, null) .show(); } private void showDeleteDialog(final String name, final PlaylistLocalItem item) { if (activity == null || disposables == null) { return; } new AlertDialog.Builder(activity) .setTitle(name) .setMessage(R.string.delete_playlist_prompt) .setCancelable(true) .setPositiveButton(R.string.delete, (dialog, i) -> deleteItem(item)) .setNegativeButton(R.string.cancel, null) .show(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java ================================================ package org.schabi.newpipe.local.bookmark; import org.schabi.newpipe.database.playlist.PlaylistLocalItem; import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; import org.schabi.newpipe.local.playlist.LocalPlaylistManager; import org.schabi.newpipe.local.playlist.RemotePlaylistManager; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; import io.reactivex.rxjava3.core.Flowable; /** * Takes care of remote and local playlists at once, hence "merged". */ public final class MergedPlaylistManager { private MergedPlaylistManager() { } public static Flowable> getMergedOrderedPlaylists( final LocalPlaylistManager localPlaylistManager, final RemotePlaylistManager remotePlaylistManager) { return Flowable.combineLatest( localPlaylistManager.getPlaylists(), remotePlaylistManager.getPlaylists(), MergedPlaylistManager::merge ); } /** * Merge localPlaylists and remotePlaylists by the display index. * If two items have the same display index, sort them in {@code CASE_INSENSITIVE_ORDER}. * * @param localPlaylists local playlists, already sorted by display index * @param remotePlaylists remote playlists, already sorted by display index * @return merged playlists */ public static List merge( final List localPlaylists, final List remotePlaylists) { // This algorithm is similar to the merge operation in merge sort. final List result = new ArrayList<>( localPlaylists.size() + remotePlaylists.size()); final List itemsWithSameIndex = new ArrayList<>(); int i = 0; int j = 0; while (i < localPlaylists.size()) { while (j < remotePlaylists.size()) { if (remotePlaylists.get(j).getDisplayIndex() <= localPlaylists.get(i).getDisplayIndex()) { addItem(result, remotePlaylists.get(j), itemsWithSameIndex); j++; } else { break; } } addItem(result, localPlaylists.get(i), itemsWithSameIndex); i++; } while (j < remotePlaylists.size()) { addItem(result, remotePlaylists.get(j), itemsWithSameIndex); j++; } addItemsWithSameIndex(result, itemsWithSameIndex); return result; } private static void addItem(final List result, final PlaylistLocalItem item, final List itemsWithSameIndex) { if (!itemsWithSameIndex.isEmpty() && itemsWithSameIndex.get(0).getDisplayIndex() != item.getDisplayIndex()) { // The new item has a different display index, add previous items with same // index to the result. addItemsWithSameIndex(result, itemsWithSameIndex); itemsWithSameIndex.clear(); } itemsWithSameIndex.add(item); } private static void addItemsWithSameIndex(final List result, final List itemsWithSameIndex) { Collections.sort(itemsWithSameIndex, Comparator.comparing(PlaylistLocalItem::getOrderingName, Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER))); result.addAll(itemsWithSameIndex); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java ================================================ package org.schabi.newpipe.local.dialog; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.DEFAULT_THUMBNAIL_ID; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry; import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.local.LocalItemListAdapter; import org.schabi.newpipe.local.playlist.LocalPlaylistManager; import java.util.List; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.CompositeDisposable; public final class PlaylistAppendDialog extends PlaylistDialog { private static final String TAG = PlaylistAppendDialog.class.getCanonicalName(); private RecyclerView playlistRecyclerView; private LocalItemListAdapter playlistAdapter; private TextView playlistDuplicateIndicator; private final CompositeDisposable playlistDisposables = new CompositeDisposable(); /** * Create a new instance of {@link PlaylistAppendDialog}. * * @param streamEntities a list of {@link StreamEntity} to be added to playlists * @return a new instance of {@link PlaylistAppendDialog} */ public static PlaylistAppendDialog newInstance(final List streamEntities) { final PlaylistAppendDialog dialog = new PlaylistAppendDialog(); dialog.setStreamEntities(streamEntities); return dialog; } /*////////////////////////////////////////////////////////////////////////// // LifeCycle - Creation //////////////////////////////////////////////////////////////////////////*/ @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { return inflater.inflate(R.layout.dialog_playlists, container); } @Override public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); final LocalPlaylistManager playlistManager = new LocalPlaylistManager(NewPipeDatabase.getInstance(requireContext())); playlistAdapter = new LocalItemListAdapter(getActivity()); playlistAdapter.setSelectedListener(selectedItem -> { final List entities = getStreamEntities(); if (selectedItem instanceof PlaylistDuplicatesEntry && entities != null) { onPlaylistSelected(playlistManager, (PlaylistDuplicatesEntry) selectedItem, entities); } }); playlistRecyclerView = view.findViewById(R.id.playlist_list); playlistRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); playlistRecyclerView.setAdapter(playlistAdapter); playlistDuplicateIndicator = view.findViewById(R.id.playlist_duplicate); final View newPlaylistButton = view.findViewById(R.id.newPlaylist); newPlaylistButton.setOnClickListener(ignored -> openCreatePlaylistDialog()); playlistDisposables.add(playlistManager .getPlaylistDuplicates(getStreamEntities().get(0).getUrl()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(this::onPlaylistsReceived)); } /*////////////////////////////////////////////////////////////////////////// // LifeCycle - Destruction //////////////////////////////////////////////////////////////////////////*/ @Override public void onDestroyView() { super.onDestroyView(); playlistDisposables.dispose(); if (playlistAdapter != null) { playlistAdapter.unsetSelectedListener(); } playlistDisposables.clear(); playlistRecyclerView = null; playlistAdapter = null; } /*////////////////////////////////////////////////////////////////////////// // Helper //////////////////////////////////////////////////////////////////////////*/ /** Display create playlist dialog. */ public void openCreatePlaylistDialog() { if (getStreamEntities() == null || !isAdded()) { return; } final PlaylistCreationDialog playlistCreationDialog = PlaylistCreationDialog.newInstance(getStreamEntities()); // Move the dismissListener to the new dialog. playlistCreationDialog.setOnDismissListener(this.getOnDismissListener()); this.setOnDismissListener(null); playlistCreationDialog.show(getParentFragmentManager(), TAG); requireDialog().dismiss(); } private void onPlaylistsReceived(@NonNull final List playlists) { if (playlistAdapter != null && playlistRecyclerView != null && playlistDuplicateIndicator != null) { playlistAdapter.clearStreamItemList(); playlistAdapter.addItems(playlists); playlistRecyclerView.setVisibility(View.VISIBLE); playlistDuplicateIndicator.setVisibility( anyPlaylistContainsDuplicates(playlists) ? View.VISIBLE : View.GONE); } } private boolean anyPlaylistContainsDuplicates(final List playlists) { return playlists.stream() .anyMatch(playlist -> playlist.getTimesStreamIsContained() > 0); } private void onPlaylistSelected(@NonNull final LocalPlaylistManager manager, @NonNull final PlaylistDuplicatesEntry playlist, @NonNull final List streams) { final String toastText; if (playlist.getTimesStreamIsContained() > 0) { toastText = getString(R.string.playlist_add_stream_success_duplicate, playlist.getTimesStreamIsContained()); } else { toastText = getString(R.string.playlist_add_stream_success); } final Toast successToast = Toast.makeText(getContext(), toastText, Toast.LENGTH_SHORT); playlistDisposables.add(manager.appendToPlaylist(playlist.getUid(), streams) .observeOn(AndroidSchedulers.mainThread()) .subscribe(ignored -> { successToast.show(); if (playlist.getThumbnailStreamId() != null && playlist.getThumbnailStreamId() == DEFAULT_THUMBNAIL_ID ) { playlistDisposables.add(manager .changePlaylistThumbnail(playlist.getUid(), streams.get(0).getUid(), false) .observeOn(AndroidSchedulers.mainThread()) .subscribe(ignore -> successToast.show())); } })); requireDialog().dismiss(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.java ================================================ package org.schabi.newpipe.local.dialog; import android.app.Dialog; import android.os.Bundle; import android.text.InputType; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog.Builder; import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.databinding.DialogEditTextBinding; import org.schabi.newpipe.local.playlist.LocalPlaylistManager; import org.schabi.newpipe.util.ThemeHelper; import java.util.List; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; public final class PlaylistCreationDialog extends PlaylistDialog { /** * Create a new instance of {@link PlaylistCreationDialog}. * * @param streamEntities a list of {@link StreamEntity} to be added to playlists * @return a new instance of {@link PlaylistCreationDialog} */ public static PlaylistCreationDialog newInstance(final List streamEntities) { final PlaylistCreationDialog dialog = new PlaylistCreationDialog(); dialog.setStreamEntities(streamEntities); return dialog; } /*////////////////////////////////////////////////////////////////////////// // Dialog //////////////////////////////////////////////////////////////////////////*/ @NonNull @Override public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { if (getStreamEntities() == null) { return super.onCreateDialog(savedInstanceState); } final DialogEditTextBinding dialogBinding = DialogEditTextBinding.inflate(getLayoutInflater()); dialogBinding.getRoot().getContext().setTheme(ThemeHelper.getDialogTheme(requireContext())); dialogBinding.dialogEditText.setHint(R.string.name); dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT); final Builder dialogBuilder = new Builder(requireContext(), ThemeHelper.getDialogTheme(requireContext())) .setTitle(R.string.create_playlist) .setView(dialogBinding.getRoot()) .setCancelable(true) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.create, (dialogInterface, i) -> { final String name = dialogBinding.dialogEditText.getText().toString(); final LocalPlaylistManager playlistManager = new LocalPlaylistManager(NewPipeDatabase.getInstance(requireContext())); final Toast successToast = Toast.makeText(getActivity(), R.string.playlist_creation_success, Toast.LENGTH_SHORT); playlistManager.createPlaylist(name, getStreamEntities()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(longs -> successToast.show()); }); return dialogBuilder.create(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java ================================================ package org.schabi.newpipe.local.dialog; import android.app.Dialog; import android.content.Context; import android.content.DialogInterface; import android.os.Bundle; import android.view.Window; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.FragmentManager; import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.local.playlist.LocalPlaylistManager; import org.schabi.newpipe.player.Player; import org.schabi.newpipe.util.StateSaver; import java.util.List; import java.util.Objects; import java.util.Queue; import java.util.function.Consumer; import java.util.stream.Collectors; import java.util.stream.Stream; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.Disposable; public abstract class PlaylistDialog extends DialogFragment implements StateSaver.WriteRead { @Nullable private DialogInterface.OnDismissListener onDismissListener = null; private List streamEntities; private org.schabi.newpipe.util.SavedState savedState; /*////////////////////////////////////////////////////////////////////////// // LifeCycle //////////////////////////////////////////////////////////////////////////*/ @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); savedState = StateSaver.tryToRestore(savedInstanceState, this); } @Override public void onDestroy() { super.onDestroy(); StateSaver.onDestroy(savedState); } public List getStreamEntities() { return streamEntities; } @NonNull @Override public Dialog onCreateDialog(final Bundle savedInstanceState) { final Dialog dialog = super.onCreateDialog(savedInstanceState); //remove title final Window window = dialog.getWindow(); if (window != null) { window.requestFeature(Window.FEATURE_NO_TITLE); } return dialog; } @Override public void onDismiss(@NonNull final DialogInterface dialog) { super.onDismiss(dialog); if (onDismissListener != null) { onDismissListener.onDismiss(dialog); } } /*////////////////////////////////////////////////////////////////////////// // State Saving //////////////////////////////////////////////////////////////////////////*/ @Override public String generateSuffix() { final int size = streamEntities == null ? 0 : streamEntities.size(); return "." + size + ".list"; } @Override public void writeTo(final Queue objectsToSave) { objectsToSave.add(streamEntities); } @Override @SuppressWarnings("unchecked") public void readFrom(@NonNull final Queue savedObjects) { streamEntities = (List) savedObjects.poll(); } @Override public void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); if (getActivity() != null) { savedState = StateSaver.tryToSave(getActivity().isChangingConfigurations(), savedState, outState, this); } } /*////////////////////////////////////////////////////////////////////////// // Getter + Setter //////////////////////////////////////////////////////////////////////////*/ @Nullable public DialogInterface.OnDismissListener getOnDismissListener() { return onDismissListener; } public void setOnDismissListener( @Nullable final DialogInterface.OnDismissListener onDismissListener ) { this.onDismissListener = onDismissListener; } protected void setStreamEntities(final List streamEntities) { this.streamEntities = streamEntities; } /*////////////////////////////////////////////////////////////////////////// // Dialog creation //////////////////////////////////////////////////////////////////////////*/ /** * Creates a {@link PlaylistAppendDialog} when playlists exists, * otherwise a {@link PlaylistCreationDialog}. * * @param context context used for accessing the database * @param streamEntities used for crating the dialog * @param onExec execution that should occur after a dialog got created, e.g. showing it * @return the disposable that was created */ public static Disposable createCorrespondingDialog( final Context context, final List streamEntities, final Consumer onExec) { return new LocalPlaylistManager(NewPipeDatabase.getInstance(context)) .hasPlaylists() .observeOn(AndroidSchedulers.mainThread()) .subscribe(hasPlaylists -> onExec.accept(hasPlaylists ? PlaylistAppendDialog.newInstance(streamEntities) : PlaylistCreationDialog.newInstance(streamEntities)) ); } /** * Creates a {@link PlaylistAppendDialog} when playlists exists, * otherwise a {@link PlaylistCreationDialog}. If the player's play queue is null or empty, no * dialog will be created. * * @param player the player from which to extract the context and the play queue * @param fragmentManager the fragment manager to use to show the dialog * @return the disposable that was created */ public static Disposable showForPlayQueue( final Player player, @NonNull final FragmentManager fragmentManager) { final List streamEntities = Stream.of(player.getPlayQueue()) .filter(Objects::nonNull) .flatMap(playQueue -> playQueue.getStreams().stream()) .map(StreamEntity::new) .collect(Collectors.toList()); if (streamEntities.isEmpty()) { return Disposable.empty(); } return PlaylistDialog.createCorrespondingDialog(player.getContext(), streamEntities, dialog -> dialog.show(fragmentManager, "PlaylistDialog")); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt ================================================ package org.schabi.newpipe.local.feed import android.content.Context import android.util.Log import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Maybe import io.reactivex.rxjava3.schedulers.Schedulers import java.time.LocalDate import java.time.OffsetDateTime import java.time.ZoneOffset import org.schabi.newpipe.MainActivity.DEBUG import org.schabi.newpipe.NewPipeDatabase import org.schabi.newpipe.database.feed.model.FeedEntity import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity import org.schabi.newpipe.database.stream.StreamWithState import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.local.subscription.FeedGroupIcon class FeedDatabaseManager(context: Context) { private val database = NewPipeDatabase.getInstance(context) private val feedTable = database.feedDAO() private val feedGroupTable = database.feedGroupDAO() private val streamTable = database.streamDAO() companion object { /** * Only items that are newer than this will be saved. */ val FEED_OLDEST_ALLOWED_DATE: OffsetDateTime = LocalDate.now().minusWeeks(13) .atStartOfDay().atOffset(ZoneOffset.UTC) } fun groups() = feedGroupTable.getAll() fun database() = database fun getStreams( groupId: Long, includePlayedStreams: Boolean, includePartiallyPlayedStreams: Boolean, includeFutureStreams: Boolean ): Maybe> { return feedTable.getStreams( groupId, includePlayedStreams, includePartiallyPlayedStreams, if (includeFutureStreams) null else OffsetDateTime.now() ) } fun outdatedSubscriptions(outdatedThreshold: OffsetDateTime) = feedTable.getAllOutdated(outdatedThreshold) fun outdatedSubscriptionsWithNotificationMode( outdatedThreshold: OffsetDateTime, @NotificationMode notificationMode: Int ) = feedTable.getOutdatedWithNotificationMode(outdatedThreshold, notificationMode) fun notLoadedCount(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable { return when (groupId) { FeedGroupEntity.GROUP_ALL_ID -> feedTable.notLoadedCount() else -> feedTable.notLoadedCountForGroup(groupId) } } fun outdatedSubscriptionsForGroup( groupId: Long = FeedGroupEntity.GROUP_ALL_ID, outdatedThreshold: OffsetDateTime ) = feedTable.getAllOutdatedForGroup(groupId, outdatedThreshold) fun markAsOutdated(subscriptionId: Long) = feedTable .setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null)) fun doesStreamExist(stream: StreamInfoItem): Boolean { return streamTable.exists(stream.serviceId, stream.url) } fun upsertAll( subscriptionId: Long, items: List, oldestAllowedDate: OffsetDateTime = FEED_OLDEST_ALLOWED_DATE ) { val itemsToInsert = items.mapNotNull { stream -> val uploadDate = stream.uploadDate when { uploadDate == null && stream.streamType == StreamType.LIVE_STREAM -> stream uploadDate != null && uploadDate.offsetDateTime() >= oldestAllowedDate -> stream else -> null } } feedTable.unlinkOldLivestreams(subscriptionId) if (itemsToInsert.isNotEmpty()) { val streamEntities = itemsToInsert.map { StreamEntity(it) } val streamIds = streamTable.upsertAll(streamEntities) val feedEntities = streamIds.map { FeedEntity(it, subscriptionId) } feedTable.insertAll(feedEntities) } feedTable.setLastUpdatedForSubscription( FeedLastUpdatedEntity(subscriptionId, OffsetDateTime.now(ZoneOffset.UTC)) ) } fun removeOrphansOrOlderStreams(oldestAllowedDate: OffsetDateTime = FEED_OLDEST_ALLOWED_DATE) { feedTable.unlinkStreamsOlderThan(oldestAllowedDate) streamTable.deleteOrphans() } fun clear() { feedTable.deleteAll() val deletedOrphans = streamTable.deleteOrphans() if (DEBUG) { Log.d( this::class.java.simpleName, "clear() → streamTable.deleteOrphans() → $deletedOrphans" ) } } // ///////////////////////////////////////////////////////////////////////// // Feed Groups // ///////////////////////////////////////////////////////////////////////// fun subscriptionIdsForGroup(groupId: Long): Flowable> { return feedGroupTable.getSubscriptionIdsFor(groupId) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) } fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List): Completable { return Completable .fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) } fun createGroup(name: String, icon: FeedGroupIcon): Maybe { return Maybe.fromCallable { feedGroupTable.insert(FeedGroupEntity(0, name, icon)) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) } fun getGroup(groupId: Long): Maybe { return feedGroupTable.getGroup(groupId) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) } fun updateGroup(feedGroupEntity: FeedGroupEntity): Completable { return Completable.fromCallable { feedGroupTable.update(feedGroupEntity) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) } fun deleteGroup(groupId: Long): Completable { return Completable.fromCallable { feedGroupTable.delete(groupId) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) } fun updateGroupsOrder(groupIdList: List): Completable { var index = 0L val orderMap = groupIdList.associateBy({ it }, { index++ }) return Completable.fromCallable { feedGroupTable.updateOrder(orderMap) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) } fun oldestSubscriptionUpdate(groupId: Long): Flowable> { return when (groupId) { FeedGroupEntity.GROUP_ALL_ID -> feedTable.oldestSubscriptionUpdateFromAll() else -> feedTable.oldestSubscriptionUpdate(groupId) } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt ================================================ /* * Copyright 2019 Mauricio Colli * FeedFragment.kt is part of NewPipe * * License: GPL-3.0+ * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package org.schabi.newpipe.local.feed import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.graphics.Typeface import android.graphics.drawable.LayerDrawable import android.os.Bundle import android.os.Parcelable import android.util.Log import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.Button import androidx.appcompat.app.AlertDialog import androidx.core.content.edit import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.lifecycle.ViewModelProvider import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import com.evernote.android.state.State import com.xwray.groupie.GroupieAdapter import com.xwray.groupie.Item import com.xwray.groupie.OnItemClickListener import com.xwray.groupie.OnItemLongClickListener import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.schedulers.Schedulers import java.time.OffsetDateTime import java.util.function.Consumer import org.schabi.newpipe.NewPipeDatabase import org.schabi.newpipe.R import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.databinding.FragmentFeedBinding import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.ErrorUtil import org.schabi.newpipe.error.UserAction import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty import org.schabi.newpipe.fragments.BaseStateFragment import org.schabi.newpipe.info_list.ItemViewMode import org.schabi.newpipe.info_list.dialog.InfoItemDialog import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling import org.schabi.newpipe.ktx.slideUp import org.schabi.newpipe.local.feed.item.StreamItem import org.schabi.newpipe.local.feed.service.FeedLoadService import org.schabi.newpipe.local.subscription.SubscriptionManager import org.schabi.newpipe.util.DeviceUtils import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountStreams import org.schabi.newpipe.util.ThemeHelper.getItemViewMode import org.schabi.newpipe.util.ThemeHelper.resolveDrawable import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout class FeedFragment : BaseStateFragment() { private var _feedBinding: FragmentFeedBinding? = null private val feedBinding get() = _feedBinding!! private val disposables = CompositeDisposable() private lateinit var viewModel: FeedViewModel @State @JvmField var listState: Parcelable? = null private var groupId = FeedGroupEntity.GROUP_ALL_ID private var groupName = "" private var oldestSubscriptionUpdate: OffsetDateTime? = null private lateinit var groupAdapter: GroupieAdapter private var onSettingsChangeListener: SharedPreferences.OnSharedPreferenceChangeListener? = null private var updateListViewModeOnResume = false private var isRefreshing = false private var lastNewItemsCount = 0 init { setHasOptionsMenu(true) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) groupId = arguments?.getLong(KEY_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID) ?: FeedGroupEntity.GROUP_ALL_ID groupName = arguments?.getString(KEY_GROUP_NAME) ?: "" onSettingsChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> if (getString(R.string.list_view_mode_key).equals(key)) { updateListViewModeOnResume = true } } PreferenceManager.getDefaultSharedPreferences(activity) .registerOnSharedPreferenceChangeListener(onSettingsChangeListener) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return inflater.inflate(R.layout.fragment_feed, container, false) } override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) { // super.onViewCreated() calls initListeners() which require the binding to be initialized _feedBinding = FragmentFeedBinding.bind(rootView) super.onViewCreated(rootView, savedInstanceState) val factory = FeedViewModel.getFactory(requireContext(), groupId) viewModel = ViewModelProvider(this, factory)[FeedViewModel::class.java] viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(::handleResult) } groupAdapter = GroupieAdapter().apply { setOnItemClickListener(listenerStreamItem) setOnItemLongClickListener(listenerStreamItem) } feedBinding.itemsList.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { // Check if we scrolled to the top if (newState == RecyclerView.SCROLL_STATE_IDLE && !recyclerView.canScrollVertically(-1) ) { if (tryGetNewItemsLoadedButton()?.isVisible == true) { hideNewItemsLoaded(true) } } } }) feedBinding.itemsList.adapter = groupAdapter setupListViewMode() } override fun onPause() { super.onPause() listState = feedBinding.itemsList.layoutManager?.onSaveInstanceState() } override fun onResume() { super.onResume() updateRelativeTimeViews() if (updateListViewModeOnResume) { updateListViewModeOnResume = false setupListViewMode() if (viewModel.stateLiveData.value != null) { handleResult(viewModel.stateLiveData.value!!) } } } private fun setupListViewMode() { // does everything needed to setup the layouts for grid or list modes groupAdapter.spanCount = if (shouldUseGridLayout(context)) getGridSpanCountStreams(context) else 1 feedBinding.itemsList.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply { spanSizeLookup = groupAdapter.spanSizeLookup } } override fun initListeners() { super.initListeners() feedBinding.refreshRootView.setOnClickListener { reloadContent() } feedBinding.swipeRefreshLayout.setOnRefreshListener { reloadContent() } feedBinding.newItemsLoadedButton.setOnClickListener { hideNewItemsLoaded(true) feedBinding.itemsList.scrollToPosition(0) } } // ///////////////////////////////////////////////////////////////////////// // Menu // ///////////////////////////////////////////////////////////////////////// override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) activity.supportActionBar?.setDisplayShowTitleEnabled(true) activity.supportActionBar?.setTitle(R.string.fragment_feed_title) activity.supportActionBar?.subtitle = groupName inflater.inflate(R.menu.menu_feed_fragment, menu) } override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == R.id.menu_item_feed_help) { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) val usingDedicatedMethod = sharedPreferences .getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false) val enableDisableButtonText = when { usingDedicatedMethod -> R.string.feed_use_dedicated_fetch_method_disable_button else -> R.string.feed_use_dedicated_fetch_method_enable_button } AlertDialog.Builder(requireContext()) .setMessage(R.string.feed_use_dedicated_fetch_method_help_text) .setNeutralButton(enableDisableButtonText) { _, _ -> sharedPreferences.edit { putBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), !usingDedicatedMethod) } } .setPositiveButton(resources.getString(R.string.ok), null) .show() return true } else if (item.itemId == R.id.menu_item_feed_toggle_played_items) { showStreamVisibilityDialog() } return super.onOptionsItemSelected(item) } private fun showStreamVisibilityDialog() { val dialogItems = arrayOf( getString(R.string.feed_show_watched), getString(R.string.feed_show_partially_watched), getString(R.string.feed_show_upcoming) ) val checkedDialogItems = booleanArrayOf( viewModel.getShowPlayedItemsFromPreferences(), viewModel.getShowPartiallyPlayedItemsFromPreferences(), viewModel.getShowFutureItemsFromPreferences() ) AlertDialog.Builder(requireContext()) .setTitle(R.string.feed_hide_streams_title) .setMultiChoiceItems(dialogItems, checkedDialogItems) { _, which, isChecked -> checkedDialogItems[which] = isChecked } .setPositiveButton(R.string.ok) { _, _ -> viewModel.setSaveShowPlayedItems(checkedDialogItems[0]) viewModel.setSaveShowPartiallyPlayedItems(checkedDialogItems[1]) viewModel.setSaveShowFutureItems(checkedDialogItems[2]) } .setNegativeButton(R.string.cancel, null) .show() } override fun onDestroyOptionsMenu() { super.onDestroyOptionsMenu() if ( (groupName != "") && (activity?.supportActionBar?.subtitle == groupName) ) { activity?.supportActionBar?.subtitle = null } } override fun onDestroy() { disposables.dispose() if (onSettingsChangeListener != null) { PreferenceManager.getDefaultSharedPreferences(activity) .unregisterOnSharedPreferenceChangeListener(onSettingsChangeListener) onSettingsChangeListener = null } super.onDestroy() if ( (groupName != "") && (activity?.supportActionBar?.subtitle == groupName) ) { activity?.supportActionBar?.subtitle = null } } override fun onDestroyView() { // Ensure that all animations are canceled tryGetNewItemsLoadedButton()?.clearAnimation() feedBinding.itemsList.adapter = null _feedBinding = null super.onDestroyView() } // ////////////////////////////////////////////////////////////////////////// // Handling // ////////////////////////////////////////////////////////////////////////// override fun showLoading() { super.showLoading() feedBinding.itemsList.animateHideRecyclerViewAllowingScrolling() feedBinding.refreshRootView.animate(false, 0) feedBinding.loadingProgressText.animate(true, 200) feedBinding.swipeRefreshLayout.isRefreshing = true isRefreshing = true } override fun hideLoading() { super.hideLoading() feedBinding.itemsList.animate(true, 0) feedBinding.refreshRootView.animate(true, 200) feedBinding.loadingProgressText.animate(false, 0) feedBinding.swipeRefreshLayout.isRefreshing = false isRefreshing = false } override fun showEmptyState() { super.showEmptyState() feedBinding.itemsList.animateHideRecyclerViewAllowingScrolling() feedBinding.refreshRootView.animate(true, 200) feedBinding.loadingProgressText.animate(false, 0) feedBinding.swipeRefreshLayout.isRefreshing = false } override fun handleResult(result: FeedState) { when (result) { is FeedState.ProgressState -> handleProgressState(result) is FeedState.LoadedState -> handleLoadedState(result) is FeedState.ErrorState -> if (handleErrorState(result)) return } updateRefreshViewState() } override fun handleError() { super.handleError() feedBinding.itemsList.animateHideRecyclerViewAllowingScrolling() feedBinding.refreshRootView.animate(false, 0) feedBinding.loadingProgressText.animate(false, 0) feedBinding.swipeRefreshLayout.isRefreshing = false isRefreshing = false } private fun handleProgressState(progressState: FeedState.ProgressState) { showLoading() val isIndeterminate = progressState.currentProgress == -1 && progressState.maxProgress == -1 feedBinding.loadingProgressText.text = if (!isIndeterminate) { "${progressState.currentProgress}/${progressState.maxProgress}" } else if (progressState.progressMessage > 0) { getString(progressState.progressMessage) } else { "∞/∞" } feedBinding.loadingProgressBar.isIndeterminate = isIndeterminate || (progressState.maxProgress > 0 && progressState.currentProgress == 0) feedBinding.loadingProgressBar.progress = progressState.currentProgress feedBinding.loadingProgressBar.max = progressState.maxProgress } private fun showInfoItemDialog(item: StreamInfoItem) { val context = context val activity: Activity? = getActivity() if (context == null || context.resources == null || activity == null) return InfoItemDialog.Builder(activity, context, this, item).create().show() } private val listenerStreamItem = object : OnItemClickListener, OnItemLongClickListener { override fun onItemClick(item: Item<*>, view: View) { if (item is StreamItem && !isRefreshing) { val stream = item.streamWithState.stream NavigationHelper.openVideoDetailFragment( requireContext(), fm, stream.serviceId, stream.url, stream.title, null, false ) } } override fun onItemLongClick(item: Item<*>, view: View): Boolean { if (item is StreamItem && !isRefreshing) { showInfoItemDialog(item.streamWithState.stream.toStreamInfoItem()) return true } return false } } @SuppressLint("StringFormatMatches") private fun handleLoadedState(loadedState: FeedState.LoadedState) { val itemVersion = when (getItemViewMode(requireContext())) { ItemViewMode.GRID -> StreamItem.ItemVersion.GRID ItemViewMode.CARD -> StreamItem.ItemVersion.CARD else -> StreamItem.ItemVersion.NORMAL } loadedState.items.forEach { it.itemVersion = itemVersion } // This need to be saved in a variable as the update occurs async val oldOldestSubscriptionUpdate = oldestSubscriptionUpdate groupAdapter.updateAsync(loadedState.items, false) { oldOldestSubscriptionUpdate?.run { highlightNewItemsAfter(oldOldestSubscriptionUpdate) } } listState?.run { feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState) listState = null } val feedsNotLoaded = loadedState.notLoadedCount > 0 feedBinding.refreshSubtitleText.isVisible = feedsNotLoaded if (feedsNotLoaded) { feedBinding.refreshSubtitleText.text = getString( R.string.feed_subscription_not_loaded_count, loadedState.notLoadedCount ) } if (oldestSubscriptionUpdate != loadedState.oldestUpdate || (oldestSubscriptionUpdate == null && loadedState.oldestUpdate == null) ) { // ignore errors if they have already been handled for the current update handleItemsErrors(loadedState.itemsErrors) } oldestSubscriptionUpdate = loadedState.oldestUpdate if (loadedState.items.isEmpty()) { showEmptyState() } else { hideLoading() } } private fun handleErrorState(errorState: FeedState.ErrorState): Boolean { return if (errorState.error == null) { hideLoading() false } else { showError(ErrorInfo(errorState.error, UserAction.REQUESTED_FEED, "Loading feed")) true } } private fun handleItemsErrors(errors: List) { errors.forEachIndexed { i, t -> if (t is FeedLoadService.RequestException && t.cause is ContentNotAvailableException ) { disposables.add( Single.fromCallable { NewPipeDatabase.getInstance(requireContext()).subscriptionDAO() .getSubscription(t.subscriptionId) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( { subscriptionEntity -> handleFeedNotAvailable( subscriptionEntity, t.cause, errors.subList(i + 1, errors.size) ) }, { throwable -> Log.e(TAG, "Unable to process", throwable) } ) ) // this will be called on the remaining errors by handleFeedNotAvailable() return@handleItemsErrors } } if (errors.isNotEmpty()) { // if no error was a ContentNotAvailableException, show a general error snackbar ErrorUtil.showSnackbar(this, ErrorInfo(errors, UserAction.REQUESTED_FEED, "")) } } private fun handleFeedNotAvailable( subscriptionEntity: SubscriptionEntity, cause: Throwable?, nextItemsErrors: List ) { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) val isFastFeedModeEnabled = sharedPreferences.getBoolean( getString(R.string.feed_use_dedicated_fetch_method_key), false ) val builder = AlertDialog.Builder(requireContext()) .setTitle(R.string.feed_load_error) .setPositiveButton(R.string.unsubscribe) { _, _ -> SubscriptionManager(requireContext()) .deleteSubscription(subscriptionEntity.serviceId, subscriptionEntity.url!!) .subscribe() handleItemsErrors(nextItemsErrors) } .setNegativeButton(R.string.cancel, null) var message = getString(R.string.feed_load_error_account_info, subscriptionEntity.name) if (cause is AccountTerminatedException) { message += "\n" + getString(R.string.feed_load_error_terminated) } else if (cause is ContentNotAvailableException) { if (isFastFeedModeEnabled) { message += "\n" + getString(R.string.feed_load_error_fast_unknown) builder.setNeutralButton(R.string.feed_use_dedicated_fetch_method_disable_button) { _, _ -> sharedPreferences.edit { putBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false) } } } else if (!isNullOrEmpty(cause.message)) { message += "\n" + cause.message } } builder.setMessage(message) .show() } private fun updateRelativeTimeViews() { updateRefreshViewState() groupAdapter.notifyItemRangeChanged( 0, groupAdapter.itemCount, StreamItem.UPDATE_RELATIVE_TIME ) } private fun updateRefreshViewState() { feedBinding.refreshText.text = getString( R.string.feed_oldest_subscription_update, oldestSubscriptionUpdate?.let { Localization.relativeTime(it) } ?: "—" ) } /** * Highlights all items that are after the specified time */ private fun highlightNewItemsAfter(updateTime: OffsetDateTime) { var highlightCount = 0 var doCheck = true for (i in 0 until groupAdapter.itemCount) { val item = groupAdapter.getItem(i) as StreamItem var typeface = Typeface.DEFAULT var backgroundSupplier = { ctx: Context -> resolveDrawable(ctx, android.R.attr.selectableItemBackground) } if (doCheck) { // If the uploadDate is null or true we should highlight the item if (item.streamWithState.stream.uploadDate?.isAfter(updateTime) != false) { highlightCount++ typeface = Typeface.DEFAULT_BOLD backgroundSupplier = { ctx: Context -> // Merge the drawables together. Otherwise we would lose the "select" effect LayerDrawable( arrayOf( resolveDrawable(ctx, R.attr.dashed_border), resolveDrawable(ctx, android.R.attr.selectableItemBackground) ) ) } } else { // Decreases execution time due to the order of the items (newest always on top) // Once a item is is before the updateTime we can skip all following items doCheck = false } } // The highlighter has to be always set // When it's only set on items that are highlighted it will highlight all items // due to the fact that itemRoot is getting recycled item.execBindEnd = Consumer { viewBinding -> val context = viewBinding.itemRoot.context viewBinding.itemRoot.background = backgroundSupplier.invoke(context) viewBinding.itemVideoTitleView.typeface = typeface } } // Force updates all items so that the highlighting is correct // If this isn't done visible items that are already highlighted will stay in a highlighted // state until the user scrolls them out of the visible area which causes a update/bind-call groupAdapter.notifyItemRangeChanged( 0, highlightCount.coerceIn(lastNewItemsCount, groupAdapter.itemCount) ) if (highlightCount > 0) { showNewItemsLoaded() } lastNewItemsCount = highlightCount } private fun showNewItemsLoaded() { tryGetNewItemsLoadedButton()?.clearAnimation() tryGetNewItemsLoadedButton() ?.slideUp( 250L, delay = 100, execOnEnd = { // Disabled animations would result in immediately hiding the button // after it showed up // Context can be null in some cases, so we have to make sure it is not null in // order to avoid a NullPointerException context?.let { if (DeviceUtils.hasAnimationsAnimatorDurationEnabled(it)) { // Hide the new items button after 10s hideNewItemsLoaded(true, 10000) } } } ) } private fun hideNewItemsLoaded(animate: Boolean, delay: Long = 0) { tryGetNewItemsLoadedButton()?.clearAnimation() if (animate) { tryGetNewItemsLoadedButton()?.animate( false, 200, delay = delay, execOnEnd = { // Make the layout invisible so that the onScroll toTop method // only does necessary work tryGetNewItemsLoadedButton()?.isVisible = false } ) } else { tryGetNewItemsLoadedButton()?.isVisible = false } } /** * The view/button can be disposed/set to null under certain circumstances. * E.g. when the animation is still in progress but the view got destroyed. * This method is a helper for such states and can be used in affected code blocks. */ private fun tryGetNewItemsLoadedButton(): Button? { return _feedBinding?.newItemsLoadedButton } // ///////////////////////////////////////////////////////////////////////// // Load Service Handling // ///////////////////////////////////////////////////////////////////////// override fun doInitialLoadLogic() {} override fun reloadContent() { hideNewItemsLoaded(false) getActivity()?.startService( Intent(requireContext(), FeedLoadService::class.java).apply { putExtra(FeedLoadService.EXTRA_GROUP_ID, groupId) } ) listState = null } companion object { const val KEY_GROUP_ID = "ARG_GROUP_ID" const val KEY_GROUP_NAME = "ARG_GROUP_NAME" @JvmStatic fun newInstance(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, groupName: String? = null): FeedFragment { val feedFragment = FeedFragment() feedFragment.arguments = bundleOf(KEY_GROUP_ID to groupId, KEY_GROUP_NAME to groupName) return feedFragment } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt ================================================ package org.schabi.newpipe.local.feed import androidx.annotation.StringRes import java.time.OffsetDateTime import org.schabi.newpipe.local.feed.item.StreamItem sealed class FeedState { data class ProgressState( val currentProgress: Int = -1, val maxProgress: Int = -1, @StringRes val progressMessage: Int = 0 ) : FeedState() data class LoadedState( val items: List, val oldestUpdate: OffsetDateTime?, val notLoadedCount: Long, val itemsErrors: List ) : FeedState() data class ErrorState( val error: Throwable? = null ) : FeedState() } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt ================================================ package org.schabi.newpipe.local.feed import android.app.Application import android.content.Context import androidx.core.content.edit import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import androidx.preference.PreferenceManager import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.functions.Function6 import io.reactivex.rxjava3.processors.BehaviorProcessor import io.reactivex.rxjava3.schedulers.Schedulers import java.time.OffsetDateTime import java.util.concurrent.TimeUnit import org.schabi.newpipe.App import org.schabi.newpipe.R import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.stream.StreamWithState import org.schabi.newpipe.local.feed.item.StreamItem import org.schabi.newpipe.local.feed.service.FeedEventManager import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ProgressEvent import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.SuccessResultEvent import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT class FeedViewModel( private val application: Application, groupId: Long = FeedGroupEntity.GROUP_ALL_ID, initialShowPlayedItems: Boolean, initialShowPartiallyPlayedItems: Boolean, initialShowFutureItems: Boolean ) : ViewModel() { private val feedDatabaseManager = FeedDatabaseManager(application) private val showPlayedItems = BehaviorProcessor.create() private val showPlayedItemsFlowable = showPlayedItems .startWithItem(initialShowPlayedItems) .distinctUntilChanged() private val showPartiallyPlayedItems = BehaviorProcessor.create() private val showPartiallyPlayedItemsFlowable = showPartiallyPlayedItems .startWithItem(initialShowPartiallyPlayedItems) .distinctUntilChanged() private val showFutureItems = BehaviorProcessor.create() private val showFutureItemsFlowable = showFutureItems .startWithItem(initialShowFutureItems) .distinctUntilChanged() private val mutableStateLiveData = MutableLiveData() val stateLiveData: LiveData = mutableStateLiveData private var combineDisposable = Flowable .combineLatest( FeedEventManager.events(), showPlayedItemsFlowable, showPartiallyPlayedItemsFlowable, showFutureItemsFlowable, feedDatabaseManager.notLoadedCount(groupId), feedDatabaseManager.oldestSubscriptionUpdate(groupId), Function6 { t1: FeedEventManager.Event, t2: Boolean, t3: Boolean, t4: Boolean, t5: Long, t6: List -> return@Function6 CombineResultEventHolder(t1, t2, t3, t4, t5, t6.firstOrNull()) } ) .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS) .subscribeOn(Schedulers.io()) .observeOn(Schedulers.io()) .map { (event, showPlayedItems, showPartiallyPlayedItems, showFutureItems, notLoadedCount, oldestUpdate) -> val streamItems = if (event is SuccessResultEvent || event is IdleEvent) { feedDatabaseManager .getStreams(groupId, showPlayedItems, showPartiallyPlayedItems, showFutureItems) .blockingGet(arrayListOf()) } else { arrayListOf() } CombineResultDataHolder(event, streamItems, notLoadedCount, oldestUpdate) } .observeOn(AndroidSchedulers.mainThread()) .subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) -> mutableStateLiveData.postValue( when (event) { is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, listOf()) is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage) is SuccessResultEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, event.itemsErrors) is ErrorResultEvent -> FeedState.ErrorState(event.error) } ) if (event is ErrorResultEvent || event is SuccessResultEvent) { FeedEventManager.reset() } } override fun onCleared() { super.onCleared() combineDisposable.dispose() } private data class CombineResultEventHolder( val t1: FeedEventManager.Event, val t2: Boolean, val t3: Boolean, val t4: Boolean, val t5: Long, val t6: OffsetDateTime? ) private data class CombineResultDataHolder( val t1: FeedEventManager.Event, val t2: List, val t3: Long, val t4: OffsetDateTime? ) fun setSaveShowPlayedItems(showPlayedItems: Boolean) { this.showPlayedItems.onNext(showPlayedItems) PreferenceManager.getDefaultSharedPreferences(application).edit { putBoolean(application.getString(R.string.feed_show_watched_items_key), showPlayedItems) } } fun getShowPlayedItemsFromPreferences() = getShowPlayedItemsFromPreferences(application) fun setSaveShowPartiallyPlayedItems(showPartiallyPlayedItems: Boolean) { this.showPartiallyPlayedItems.onNext(showPartiallyPlayedItems) PreferenceManager.getDefaultSharedPreferences(application).edit { putBoolean(application.getString(R.string.feed_show_partially_watched_items_key), showPartiallyPlayedItems) } } fun getShowPartiallyPlayedItemsFromPreferences() = getShowPartiallyPlayedItemsFromPreferences(application) fun setSaveShowFutureItems(showFutureItems: Boolean) { this.showFutureItems.onNext(showFutureItems) PreferenceManager.getDefaultSharedPreferences(application).edit { putBoolean(application.getString(R.string.feed_show_future_items_key), showFutureItems) } } fun getShowFutureItemsFromPreferences() = getShowFutureItemsFromPreferences(application) companion object { private fun getShowPlayedItemsFromPreferences(context: Context) = PreferenceManager.getDefaultSharedPreferences(context) .getBoolean(context.getString(R.string.feed_show_watched_items_key), true) private fun getShowPartiallyPlayedItemsFromPreferences(context: Context) = PreferenceManager.getDefaultSharedPreferences(context) .getBoolean(context.getString(R.string.feed_show_partially_watched_items_key), true) private fun getShowFutureItemsFromPreferences(context: Context) = PreferenceManager.getDefaultSharedPreferences(context) .getBoolean(context.getString(R.string.feed_show_future_items_key), true) fun getFactory(context: Context, groupId: Long) = viewModelFactory { initializer { FeedViewModel( App.instance, groupId, // Read initial value from preferences getShowPlayedItemsFromPreferences(context.applicationContext), getShowPartiallyPlayedItemsFromPreferences(context.applicationContext), getShowFutureItemsFromPreferences(context.applicationContext) ) } } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt ================================================ package org.schabi.newpipe.local.feed.item import android.content.Context import android.text.TextUtils import android.view.View import androidx.core.content.ContextCompat import androidx.preference.PreferenceManager import com.xwray.groupie.viewbinding.BindableItem import java.util.concurrent.TimeUnit import java.util.function.Consumer import org.schabi.newpipe.MainActivity import org.schabi.newpipe.R import org.schabi.newpipe.database.stream.StreamWithState import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.databinding.ListStreamItemBinding import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_STREAM import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_AUDIO_STREAM import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_STREAM import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.StreamTypeUtil import org.schabi.newpipe.util.image.CoilHelper data class StreamItem( val streamWithState: StreamWithState, var itemVersion: ItemVersion = ItemVersion.NORMAL ) : BindableItem() { companion object { const val UPDATE_RELATIVE_TIME = 1 } private val stream: StreamEntity = streamWithState.stream private val stateProgressTime: Long? = streamWithState.stateProgressMillis /** * Will be executed at the end of the [StreamItem.bind] (with (ListStreamItemBinding,Int)). * Can be used e.g. for highlighting a item. */ var execBindEnd: Consumer? = null override fun getId(): Long = stream.uid enum class ItemVersion { NORMAL, MINI, GRID, CARD } override fun getLayout(): Int = when (itemVersion) { ItemVersion.NORMAL -> R.layout.list_stream_item ItemVersion.MINI -> R.layout.list_stream_mini_item ItemVersion.GRID -> R.layout.list_stream_grid_item ItemVersion.CARD -> R.layout.list_stream_card_item } override fun initializeViewBinding(view: View) = ListStreamItemBinding.bind(view) override fun bind(viewBinding: ListStreamItemBinding, position: Int, payloads: MutableList) { if (payloads.contains(UPDATE_RELATIVE_TIME)) { if (itemVersion != ItemVersion.MINI) { viewBinding.itemAdditionalDetails.text = getStreamInfoDetailLine(viewBinding.itemAdditionalDetails.context) } return } super.bind(viewBinding, position, payloads) } override fun bind(viewBinding: ListStreamItemBinding, position: Int) { viewBinding.itemVideoTitleView.text = stream.title viewBinding.itemUploaderView.text = stream.uploader if (stream.duration > 0) { viewBinding.itemDurationView.text = Localization.getDurationString(stream.duration) viewBinding.itemDurationView.setBackgroundColor( ContextCompat.getColor( viewBinding.itemDurationView.context, R.color.duration_background_color ) ) viewBinding.itemDurationView.visibility = View.VISIBLE if (stateProgressTime != null) { viewBinding.itemProgressView.visibility = View.VISIBLE viewBinding.itemProgressView.max = stream.duration.toInt() viewBinding.itemProgressView.progress = TimeUnit.MILLISECONDS.toSeconds(stateProgressTime).toInt() } else { viewBinding.itemProgressView.visibility = View.GONE } } else if (StreamTypeUtil.isLiveStream(stream.streamType)) { viewBinding.itemDurationView.setText(R.string.duration_live) viewBinding.itemDurationView.setBackgroundColor( ContextCompat.getColor( viewBinding.itemDurationView.context, R.color.live_duration_background_color ) ) viewBinding.itemDurationView.visibility = View.VISIBLE viewBinding.itemProgressView.visibility = View.GONE } else { viewBinding.itemDurationView.visibility = View.GONE viewBinding.itemProgressView.visibility = View.GONE } CoilHelper.loadThumbnail(viewBinding.itemThumbnailView, stream.thumbnailUrl) if (itemVersion != ItemVersion.MINI) { viewBinding.itemAdditionalDetails.text = getStreamInfoDetailLine(viewBinding.itemAdditionalDetails.context) } execBindEnd?.accept(viewBinding) } override fun isLongClickable() = when (stream.streamType) { AUDIO_STREAM, VIDEO_STREAM, LIVE_STREAM, AUDIO_LIVE_STREAM, POST_LIVE_STREAM, POST_LIVE_AUDIO_STREAM -> true else -> false } private fun getStreamInfoDetailLine(context: Context): String { var viewsAndDate = "" val viewCount = stream.viewCount if (viewCount != null && viewCount >= 0) { viewsAndDate = when (stream.streamType) { AUDIO_LIVE_STREAM -> Localization.listeningCount(context, viewCount) LIVE_STREAM -> Localization.shortWatchingCount(context, viewCount) else -> Localization.shortViewCount(context, viewCount) } } val uploadDate = getFormattedRelativeUploadDate(context) return when { !TextUtils.isEmpty(uploadDate) -> when { viewsAndDate.isEmpty() -> uploadDate!! else -> Localization.concatenateStrings(viewsAndDate, uploadDate) } else -> viewsAndDate } } private fun getFormattedRelativeUploadDate(context: Context): String? { val uploadDate = stream.uploadDate return if (uploadDate != null) { var formattedRelativeTime = Localization.relativeTime(uploadDate) if (MainActivity.DEBUG) { val key = context.getString(R.string.show_original_time_ago_key) if (PreferenceManager.getDefaultSharedPreferences(context).getBoolean(key, false)) { formattedRelativeTime += " (" + stream.textualUploadDate + ")" } } formattedRelativeTime } else { stream.textualUploadDate } } override fun getSpanSize(spanCount: Int, position: Int): Int { return if (itemVersion == ItemVersion.GRID) 1 else spanCount } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt ================================================ package org.schabi.newpipe.local.feed.notifications import android.app.Notification import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.net.Uri import android.os.Build import android.provider.Settings import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.core.net.toUri import androidx.preference.PreferenceManager import org.schabi.newpipe.R import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.local.feed.service.FeedUpdateInfo import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.image.CoilHelper /** * Helper for everything related to show notifications about new streams to the user. */ class NotificationHelper(val context: Context) { private val manager = NotificationManagerCompat.from(context) /** * Show notifications for new streams from a single channel. The individual notifications are * expandable on Android 7.0 and later. * * Opening the summary notification will open the corresponding channel page. Opening the * individual notifications will open the corresponding video. */ fun displayNewStreamsNotifications(data: FeedUpdateInfo) { val newStreams = data.newStreams val summary = context.resources.getQuantityString( R.plurals.new_streams, newStreams.size, newStreams.size ) val summaryBuilder = NotificationCompat.Builder( context, context.getString(R.string.streams_notification_channel_id) ) .setContentTitle(data.name) .setContentText(summary) .setNumber(newStreams.size) .setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setSmallIcon(R.drawable.ic_newpipe_triangle_white) .setColor(ContextCompat.getColor(context, R.color.ic_launcher_background)) .setColorized(true) .setAutoCancel(true) .setCategory(NotificationCompat.CATEGORY_SOCIAL) .setGroupSummary(true) .setGroup(data.url) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) // Build a summary notification for Android versions < 7.0 val style = NotificationCompat.InboxStyle() .setBigContentTitle(data.name) newStreams.forEach { style.addLine(it.name) } summaryBuilder.setStyle(style) // open the channel page when clicking on the summary notification val intent = NavigationHelper .getChannelIntent(context, data.serviceId, data.url) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) summaryBuilder.setContentIntent( PendingIntentCompat.getActivity(context, data.pseudoId, intent, 0, false) ) val avatarIcon = CoilHelper.loadBitmapBlocking(context, data.avatarUrl, R.drawable.ic_newpipe_triangle_white) summaryBuilder.setLargeIcon(avatarIcon) // Show individual stream notifications, set channel icon only if there is actually one showStreamNotifications(newStreams, data.serviceId, avatarIcon) // Show summary notification if (manager.areNotificationsEnabled()) { manager.notify(data.pseudoId, summaryBuilder.build()) } } private fun showStreamNotifications( newStreams: List, serviceId: Int, channelIcon: Bitmap? ) { if (manager.areNotificationsEnabled()) { newStreams.forEach { stream -> val notification = createStreamNotification(stream, serviceId, channelIcon) manager.notify(stream.url.hashCode(), notification) } } } private fun createStreamNotification( item: StreamInfoItem, serviceId: Int, channelIcon: Bitmap? ): Notification { return NotificationCompat.Builder( context, context.getString(R.string.streams_notification_channel_id) ) .setSmallIcon(R.drawable.ic_newpipe_triangle_white) .setLargeIcon(channelIcon) .setContentTitle(item.name) .setContentText(item.uploaderName) .setGroup(item.uploaderUrl) .setColor(ContextCompat.getColor(context, R.color.ic_launcher_background)) .setColorized(true) .setAutoCancel(true) .setCategory(NotificationCompat.CATEGORY_SOCIAL) .setContentIntent( // Open the stream link in the player when clicking on the notification. PendingIntentCompat.getActivity( context, item.url.hashCode(), NavigationHelper.getStreamIntent(context, serviceId, item.url, item.name), PendingIntent.FLAG_UPDATE_CURRENT, false ) ) .setSilent(true) // Avoid creating noise for individual stream notifications. .build() } companion object { /** * Check whether notifications are enabled on the device. * Users can disable them via the system settings for a single app. * If this is the case, the app cannot create any notifications * and display them to the user. *
* On Android 26 and above, notification channels are used by NewPipe. * These can be configured by the user, too. * The notification channel for new streams is also checked by this method. * * @param context Context * @return true if notifications are allowed and can be displayed; * false otherwise */ fun areNotificationsEnabledOnDevice(context: Context): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channelId = context.getString(R.string.streams_notification_channel_id) val manager = context.getSystemService()!! val enabled = manager.areNotificationsEnabled() val channel = manager.getNotificationChannel(channelId) enabled && channel?.importance != NotificationManager.IMPORTANCE_NONE } else { NotificationManagerCompat.from(context).areNotificationsEnabled() } } /** * Whether the user enabled the notifications for new streams in the app settings. */ @JvmStatic fun areNewStreamsNotificationsEnabled(context: Context): Boolean { return ( PreferenceManager.getDefaultSharedPreferences(context) .getBoolean(context.getString(R.string.enable_streams_notifications), false) && areNotificationsEnabledOnDevice(context) ) } /** * Open the system's notification settings for NewPipe on Android Oreo (API 26) and later. * Open the system's app settings for NewPipe on previous Android versions. */ fun openNewPipeSystemNotificationSettings(context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) .putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(intent) } else { val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) intent.data = "package:${context.packageName}".toUri() context.startActivity(intent) } } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt ================================================ package org.schabi.newpipe.local.feed.notifications import android.content.Context import android.content.pm.ServiceInfo import android.os.Build import android.util.Log import androidx.core.app.NotificationCompat import androidx.work.Constraints import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ForegroundInfo import androidx.work.NetworkType import androidx.work.OneTimeWorkRequestBuilder import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.rxjava3.RxWorker import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single import java.util.concurrent.TimeUnit import org.schabi.newpipe.App 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.local.feed.service.FeedLoadManager import org.schabi.newpipe.local.feed.service.FeedLoadService /* * Worker which checks for new streams of subscribed channels * in intervals which can be set by the user in the settings. */ class NotificationWorker( appContext: Context, workerParams: WorkerParameters ) : RxWorker(appContext, workerParams) { private val notificationHelper by lazy { NotificationHelper(appContext) } private val feedLoadManager = FeedLoadManager(appContext) override fun createWork(): Single = if (areNotificationsEnabled(applicationContext)) { feedLoadManager.startLoading( ignoreOutdatedThreshold = true, groupId = FeedLoadManager.GROUP_NOTIFICATION_ENABLED ) .doOnSubscribe { showLoadingFeedForegroundNotification() } .map { feed -> // filter out feedUpdateInfo items (i.e. channels) with nothing new feed.mapNotNull { it.value?.takeIf { feedUpdateInfo -> feedUpdateInfo.newStreams.isNotEmpty() } } } .observeOn(AndroidSchedulers.mainThread()) // Picasso requires calls from main thread .map { feedUpdateInfoList -> // display notifications for each feedUpdateInfo (i.e. channel) feedUpdateInfoList.forEach { feedUpdateInfo -> notificationHelper.displayNewStreamsNotifications(feedUpdateInfo) } return@map Result.success() } .doOnError { throwable -> Log.e(TAG, "Error while displaying streams notifications", throwable) ErrorUtil.createNotification( applicationContext, ErrorInfo(throwable, UserAction.NEW_STREAMS_NOTIFICATIONS, "main worker") ) } .onErrorReturnItem(Result.failure()) } else { // the user can disable streams notifications in the device's app settings Single.just(Result.success()) } private fun showLoadingFeedForegroundNotification() { val notification = NotificationCompat.Builder( applicationContext, applicationContext.getString(R.string.notification_channel_id) ).setOngoing(true) .setProgress(-1, -1, true) .setSmallIcon(R.drawable.ic_newpipe_triangle_white) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setPriority(NotificationCompat.PRIORITY_LOW) .setContentTitle(applicationContext.getString(R.string.feed_notification_loading)) .build() // ServiceInfo constants are not used below Android Q, so 0 is set here val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0 setForegroundAsync(ForegroundInfo(FeedLoadService.NOTIFICATION_ID, notification, serviceType)) } companion object { private val TAG = NotificationWorker::class.java.simpleName private const val WORK_TAG = App.PACKAGE_NAME + "_streams_notifications" private fun areNotificationsEnabled(context: Context) = NotificationHelper.areNewStreamsNotificationsEnabled(context) && NotificationHelper.areNotificationsEnabledOnDevice(context) /** * Schedules a task for the [NotificationWorker] * if the (device and in-app) notifications are enabled, * otherwise [cancel]s all scheduled tasks. */ @JvmStatic fun initialize(context: Context) { if (areNotificationsEnabled(context)) { schedule(context) } else { cancel(context) } } /** * @param context the context to use * @param options configuration options for the scheduler * @param force Force the scheduler to use the new options * by replacing the previously used worker. */ fun schedule(context: Context, options: ScheduleOptions, force: Boolean = false) { val constraints = Constraints.Builder() .setRequiredNetworkType( if (options.isRequireNonMeteredNetwork) { NetworkType.UNMETERED } else { NetworkType.CONNECTED } ).build() val request = PeriodicWorkRequest.Builder( NotificationWorker::class.java, options.interval, TimeUnit.MILLISECONDS ).setConstraints(constraints) .addTag(WORK_TAG) .build() WorkManager.getInstance(context) .enqueueUniquePeriodicWork( WORK_TAG, if (force) { ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE } else { ExistingPeriodicWorkPolicy.KEEP }, request ) } @JvmStatic fun schedule(context: Context) = schedule(context, ScheduleOptions.from(context)) /** * Check for new streams immediately */ @JvmStatic fun runNow(context: Context) { val request = OneTimeWorkRequestBuilder() .addTag(WORK_TAG) .build() WorkManager.getInstance(context).enqueue(request) } /** * Cancels all current work related to the [NotificationWorker]. */ @JvmStatic fun cancel(context: Context) { WorkManager.getInstance(context).cancelAllWorkByTag(WORK_TAG) } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/feed/notifications/ScheduleOptions.kt ================================================ package org.schabi.newpipe.local.feed.notifications import android.content.Context import androidx.preference.PreferenceManager import java.util.concurrent.TimeUnit import org.schabi.newpipe.R import org.schabi.newpipe.ktx.getStringSafe /** * Information for the Scheduler which checks for new streams. * See [NotificationWorker] */ data class ScheduleOptions( val interval: Long, val isRequireNonMeteredNetwork: Boolean ) { companion object { fun from(context: Context): ScheduleOptions { val preferences = PreferenceManager.getDefaultSharedPreferences(context) return ScheduleOptions( interval = TimeUnit.SECONDS.toMillis( preferences.getStringSafe( context.getString(R.string.streams_notifications_interval_key), context.getString(R.string.streams_notifications_interval_default) ).toLong() ), isRequireNonMeteredNetwork = preferences.getString( context.getString(R.string.streams_notifications_network_key), context.getString(R.string.streams_notifications_network_default) ) == context.getString(R.string.streams_notifications_network_wifi) ) } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt ================================================ package org.schabi.newpipe.local.feed.service import androidx.annotation.StringRes import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.processors.BehaviorProcessor import java.util.concurrent.atomic.AtomicBoolean import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent object FeedEventManager { private var processor: BehaviorProcessor = BehaviorProcessor.create() private var ignoreUpstream = AtomicBoolean() private var eventsFlowable = processor.startWithItem(IdleEvent) fun postEvent(event: Event) { processor.onNext(event) } fun events(): Flowable { return eventsFlowable.filter { !ignoreUpstream.get() } } fun reset() { ignoreUpstream.set(true) postEvent(IdleEvent) ignoreUpstream.set(false) } sealed class Event { data object IdleEvent : Event() data class ProgressEvent(val currentProgress: Int = -1, val maxProgress: Int = -1, @StringRes val progressMessage: Int = 0) : Event() { constructor(@StringRes progressMessage: Int) : this(-1, -1, progressMessage) } data class SuccessResultEvent(val itemsErrors: List = emptyList()) : Event() data class ErrorResultEvent(val error: Throwable) : Event() } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt ================================================ package org.schabi.newpipe.local.feed.service import android.content.Context import android.content.SharedPreferences import androidx.preference.PreferenceManager import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Notification import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.functions.Consumer import io.reactivex.rxjava3.processors.PublishProcessor import io.reactivex.rxjava3.schedulers.Schedulers import java.time.OffsetDateTime import java.time.ZoneOffset import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import org.schabi.newpipe.R import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.extractor.Info import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.feed.FeedInfo import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.ktx.getStringSafe import org.schabi.newpipe.local.feed.FeedDatabaseManager import org.schabi.newpipe.local.subscription.SubscriptionManager import org.schabi.newpipe.util.ChannelTabHelper import org.schabi.newpipe.util.ExtractorHelper.getChannelInfo import org.schabi.newpipe.util.ExtractorHelper.getChannelTab import org.schabi.newpipe.util.ExtractorHelper.getMoreChannelTabItems class FeedLoadManager(private val context: Context) { private val subscriptionManager = SubscriptionManager(context) private val feedDatabaseManager = FeedDatabaseManager(context) private val notificationUpdater = PublishProcessor.create() private val currentProgress = AtomicInteger(-1) private val maxProgress = AtomicInteger(-1) private val cancelSignal = AtomicBoolean() private val feedResultsHolder = FeedResultsHolder() val notification: Flowable = notificationUpdater.map { description -> FeedLoadState(description, maxProgress.get(), currentProgress.get()) } /** * Start checking for new streams of a subscription group. * @param groupId The ID of the subscription group to load. When using * [FeedGroupEntity.GROUP_ALL_ID], all subscriptions are loaded. When using * [GROUP_NOTIFICATION_ENABLED], only subscriptions with enabled notifications for new streams * are loaded. Using an id of a group created by the user results in that specific group to be * loaded. * @param ignoreOutdatedThreshold When `false`, only subscriptions which have not been updated * within the `feed_update_threshold` are checked for updates. This threshold can be set by * the user in the app settings. When `true`, all subscriptions are checked for new streams. */ fun startLoading( groupId: Long = FeedGroupEntity.GROUP_ALL_ID, ignoreOutdatedThreshold: Boolean = false ): Single>> { val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) val useFeedExtractor = defaultSharedPreferences.getBoolean( context.getString(R.string.feed_use_dedicated_fetch_method_key), false ) val outdatedThreshold = if (ignoreOutdatedThreshold) { OffsetDateTime.now(ZoneOffset.UTC) } else { val thresholdOutdatedSeconds = defaultSharedPreferences.getStringSafe( context.getString(R.string.feed_update_threshold_key), context.getString(R.string.feed_update_threshold_default_value) ).toInt() OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(thresholdOutdatedSeconds.toLong()) } /** * subscriptions which have not been updated within the feed updated threshold */ val outdatedSubscriptions = when (groupId) { FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions( outdatedThreshold ) GROUP_NOTIFICATION_ENABLED -> feedDatabaseManager.outdatedSubscriptionsWithNotificationMode( outdatedThreshold, NotificationMode.ENABLED ) else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold) } // like `currentProgress`, but counts the number of YouTube extractions that have begun, so // they can be properly throttled every once in a while (see doOnNext below) val youtubeExtractionCount = AtomicInteger() return outdatedSubscriptions .take(1) .doOnNext { currentProgress.set(0) maxProgress.set(it.size) } .filter { it.isNotEmpty() } .observeOn(AndroidSchedulers.mainThread()) .doOnNext { notificationUpdater.onNext("") broadcastProgress() } .observeOn(Schedulers.io()) // Randomize user subscription ordering to attempt to resist fingerprinting .flatMap { Flowable.fromIterable(it.shuffled()) } .takeWhile { !cancelSignal.get() } .doOnNext { subscriptionEntity -> // throttle YouTube extractions once every BATCH_SIZE to avoid being rate limited if (subscriptionEntity.serviceId == ServiceList.YouTube.serviceId) { val previousCount = youtubeExtractionCount.getAndIncrement() if (previousCount != 0 && previousCount % BATCH_SIZE == 0) { Thread.sleep(DELAY_BETWEEN_BATCHES_MILLIS.random()) } } } .parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2) .runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2) .filter { !cancelSignal.get() } .map { subscriptionEntity -> loadStreams(subscriptionEntity, useFeedExtractor, defaultSharedPreferences) } .sequential() .observeOn(AndroidSchedulers.mainThread()) .doOnNext(NotificationConsumer()) .observeOn(Schedulers.io()) .buffer(BUFFER_COUNT_BEFORE_INSERT) .doOnNext(DatabaseConsumer()) .subscribeOn(Schedulers.io()) .toList() .flatMap { x -> postProcessFeed().toSingleDefault(x.flatten()) } } fun cancel() { cancelSignal.set(true) } private fun broadcastProgress() { FeedEventManager.postEvent( FeedEventManager.Event.ProgressEvent( currentProgress.get(), maxProgress.get() ) ) } private fun loadStreams( subscriptionEntity: SubscriptionEntity, useFeedExtractor: Boolean, defaultSharedPreferences: SharedPreferences ): Notification { var error: Throwable? = null val storeOriginalErrorAndRethrow = { e: Throwable -> // keep original to prevent blockingGet() from wrapping it into RuntimeException error = e throw e } try { // check for and load new streams // either by using the dedicated feed method or by getting the channel info var originalInfo: Info? = null var streams: List? = null val errors = ArrayList() if (useFeedExtractor) { NewPipe.getService(subscriptionEntity.serviceId) .getFeedExtractor(subscriptionEntity.url) ?.also { feedExtractor -> // the user wants to use a feed extractor and there is one, use it val feedInfo = FeedInfo.getInfo(feedExtractor) errors.addAll(feedInfo.errors) originalInfo = feedInfo streams = feedInfo.relatedItems } } if (originalInfo == null) { // use the normal channel tabs extractor if either the user wants it, or // the current service does not have a dedicated feed extractor val channelInfo = getChannelInfo( subscriptionEntity.serviceId, subscriptionEntity.url, true ) .onErrorReturn(storeOriginalErrorAndRethrow) .blockingGet() errors.addAll(channelInfo.errors) originalInfo = channelInfo streams = channelInfo.tabs .filter { tab -> ChannelTabHelper.fetchFeedChannelTab( context, defaultSharedPreferences, tab ) } .map { Pair( getChannelTab(subscriptionEntity.serviceId, it, true) .onErrorReturn(storeOriginalErrorAndRethrow) .blockingGet(), it ) } .flatMap { (channelTabInfo, linkHandler) -> errors.addAll(channelTabInfo.errors) if (channelTabInfo.relatedItems.isEmpty() && channelTabInfo.nextPage != null ) { val infoItemsPage = getMoreChannelTabItems( subscriptionEntity.serviceId, linkHandler, channelTabInfo.nextPage ) .blockingGet() errors.addAll(infoItemsPage.errors) return@flatMap infoItemsPage.items } else { return@flatMap channelTabInfo.relatedItems } } .filterIsInstance() } return Notification.createOnNext( FeedUpdateInfo( subscriptionEntity, originalInfo!!, streams!!, errors ) ) } catch (e: Throwable) { val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}" val wrapper = FeedLoadService.RequestException( subscriptionEntity.uid, request, // do this to prevent blockingGet() from wrapping into RuntimeException error ?: e ) return Notification.createOnError(wrapper) } } /** * Keep the feed and the stream tables small * to reduce loading times when trying to display the feed. *
* Remove streams from the feed which are older than [FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE]. * Remove streams from the database which are not linked / used by any table. */ private fun postProcessFeed() = Completable.fromRunnable { FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message)) feedDatabaseManager.removeOrphansOrOlderStreams() FeedEventManager.postEvent(FeedEventManager.Event.SuccessResultEvent(feedResultsHolder.itemsErrors)) }.doOnSubscribe { currentProgress.set(-1) maxProgress.set(-1) notificationUpdater.onNext(context.getString(R.string.feed_processing_message)) FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message)) }.subscribeOn(Schedulers.io()) private inner class NotificationConsumer : Consumer> { override fun accept(item: Notification) { currentProgress.incrementAndGet() notificationUpdater.onNext(item.value?.name.orEmpty()) broadcastProgress() } } private inner class DatabaseConsumer : Consumer>> { override fun accept(list: List>) { feedDatabaseManager.database().runInTransaction { for (notification in list) { when { notification.isOnNext -> { val info = notification.value!! notification.value!!.newStreams = filterNewStreams(info.streams) feedDatabaseManager.upsertAll(info.uid, info.streams) subscriptionManager.updateFromInfo(info) if (info.errors.isNotEmpty()) { feedResultsHolder.addErrors( info.errors.map { FeedLoadService.RequestException( info.uid, "${info.serviceId}:${info.url}", it ) } ) feedDatabaseManager.markAsOutdated(info.uid) } } notification.isOnError -> { val error = notification.error feedResultsHolder.addError(error!!) if (error is FeedLoadService.RequestException) { feedDatabaseManager.markAsOutdated(error.subscriptionId) } } } } } } private fun filterNewStreams(list: List): List { return list.filter { !feedDatabaseManager.doesStreamExist(it) && it.uploadDate != null && // Streams older than this date are automatically removed from the feed. // Therefore, streams which are not in the database, // but older than this date, are considered old. it.uploadDate!!.offsetDateTime().isAfter( FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE ) } } } companion object { /** * Constant used to check for updates of subscriptions with [NotificationMode.ENABLED]. */ const val GROUP_NOTIFICATION_ENABLED = -2L /** * How many extractions will be running in parallel. */ private const val PARALLEL_EXTRACTIONS = 3 /** * How many YouTube extractions to perform before waiting [DELAY_BETWEEN_BATCHES_MILLIS] * to avoid being rate limited */ private const val BATCH_SIZE = 50 /** * Wait a random delay in this range once every [BATCH_SIZE] YouTube extractions to avoid * being rate limited */ private val DELAY_BETWEEN_BATCHES_MILLIS = (6000L..12000L) /** * Number of items to buffer to mass-insert in the database. */ private const val BUFFER_COUNT_BEFORE_INSERT = 20 } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt ================================================ /* * Copyright 2019 Mauricio Colli * FeedLoadService.kt is part of NewPipe * * License: GPL-3.0+ * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package org.schabi.newpipe.local.feed.service import android.app.Service import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.Build import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.functions.Function import java.util.concurrent.TimeUnit import org.schabi.newpipe.App import org.schabi.newpipe.MainActivity.DEBUG import org.schabi.newpipe.R import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent class FeedLoadService : Service() { companion object { private val TAG = FeedLoadService::class.java.simpleName const val NOTIFICATION_ID = 7293450 private const val ACTION_CANCEL = App.PACKAGE_NAME + ".local.feed.service.FeedLoadService.CANCEL" /** * How often the notification will be updated. */ private const val NOTIFICATION_SAMPLING_PERIOD = 1500 const val EXTRA_GROUP_ID: String = "FeedLoadService.EXTRA_GROUP_ID" } private var loadingDisposable: Disposable? = null private var notificationDisposable: Disposable? = null private lateinit var feedLoadManager: FeedLoadManager // ///////////////////////////////////////////////////////////////////////// // Lifecycle // ///////////////////////////////////////////////////////////////////////// override fun onCreate() { super.onCreate() feedLoadManager = FeedLoadManager(this) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (DEBUG) { Log.d( TAG, "onStartCommand() called with: intent = [" + intent + "]," + " flags = [" + flags + "], startId = [" + startId + "]" ) } if (intent == null || loadingDisposable != null) { return START_NOT_STICKY } setupNotification() setupBroadcastReceiver() val groupId = intent.getLongExtra(EXTRA_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID) loadingDisposable = feedLoadManager.startLoading(groupId) .observeOn(AndroidSchedulers.mainThread()) .doOnSubscribe { startForeground(NOTIFICATION_ID, notificationBuilder.build()) } .subscribe { _, error: Throwable? -> // explicitly mark error as nullable if (error != null) { Log.e(TAG, "Error while storing result", error) handleError(error) return@subscribe } stopService() } return START_NOT_STICKY } private fun disposeAll() { unregisterReceiver(broadcastReceiver) loadingDisposable?.dispose() notificationDisposable?.dispose() } private fun stopService() { disposeAll() ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) stopSelf() } override fun onBind(intent: Intent): IBinder? { return null } // ///////////////////////////////////////////////////////////////////////// // Loading & Handling // ///////////////////////////////////////////////////////////////////////// class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) // ///////////////////////////////////////////////////////////////////////// // Notification // ///////////////////////////////////////////////////////////////////////// private lateinit var notificationManager: NotificationManagerCompat private lateinit var notificationBuilder: NotificationCompat.Builder private fun createNotification(): NotificationCompat.Builder { val cancelActionIntent = PendingIntentCompat .getBroadcast(this, NOTIFICATION_ID, Intent(ACTION_CANCEL), 0, false) return NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) .setOngoing(true) .setProgress(-1, -1, true) .setSmallIcon(R.drawable.ic_newpipe_triangle_white) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .addAction(0, getString(R.string.cancel), cancelActionIntent) .setContentTitle(getString(R.string.feed_notification_loading)) } private fun setupNotification() { notificationManager = NotificationManagerCompat.from(this) notificationBuilder = createNotification() val throttleAfterFirstEmission = Function { flow: Flowable -> flow.take(1).concatWith(flow.skip(1).throttleLatest(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS)) } notificationDisposable = feedLoadManager.notification .publish(throttleAfterFirstEmission) .observeOn(AndroidSchedulers.mainThread()) .doOnTerminate { notificationManager.cancel(NOTIFICATION_ID) } .subscribe(this::updateNotificationProgress) } private fun updateNotificationProgress(state: FeedLoadState) { notificationBuilder.setProgress(state.maxProgress, state.currentProgress, state.maxProgress == -1) if (state.maxProgress == -1) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) notificationBuilder.setContentInfo(null) if (state.updateDescription.isNotEmpty()) notificationBuilder.setContentText(state.updateDescription) notificationBuilder.setContentText(state.updateDescription) } else { val progressText = state.currentProgress.toString() + "/" + state.maxProgress if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { if (state.updateDescription.isNotEmpty()) { notificationBuilder.setContentText("${state.updateDescription} ($progressText)") } } else { notificationBuilder.setContentInfo(progressText) if (state.updateDescription.isNotEmpty()) { notificationBuilder.setContentText(state.updateDescription) } } } if (notificationManager.areNotificationsEnabled()) { notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()) } } // ///////////////////////////////////////////////////////////////////////// // Notification Actions // ///////////////////////////////////////////////////////////////////////// private lateinit var broadcastReceiver: BroadcastReceiver private fun setupBroadcastReceiver() { broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent?.action == ACTION_CANCEL) { feedLoadManager.cancel() } } } ContextCompat.registerReceiver(this, broadcastReceiver, IntentFilter(ACTION_CANCEL), ContextCompat.RECEIVER_NOT_EXPORTED) } // ///////////////////////////////////////////////////////////////////////// // Error handling // ///////////////////////////////////////////////////////////////////////// private fun handleError(error: Throwable) { postEvent(ErrorResultEvent(error)) stopService() } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadState.kt ================================================ package org.schabi.newpipe.local.feed.service data class FeedLoadState( val updateDescription: String, val maxProgress: Int, val currentProgress: Int ) ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/feed/service/FeedResultsHolder.kt ================================================ package org.schabi.newpipe.local.feed.service class FeedResultsHolder { /** * List of errors that may have happen during loading. */ val itemsErrors: List get() = itemsErrorsHolder private val itemsErrorsHolder: MutableList = ArrayList() fun addError(error: Throwable) { itemsErrorsHolder.add(error) } fun addErrors(errors: List) { itemsErrorsHolder.addAll(errors) } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt ================================================ package org.schabi.newpipe.local.feed.service import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.extractor.Info import org.schabi.newpipe.extractor.channel.ChannelInfo import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.util.image.ImageStrategy /** * Instances of this class might stay around in memory for some time while fetching the feed, * because of [FeedLoadManager.BUFFER_COUNT_BEFORE_INSERT]. Therefore this class should contain * as little data as possible to avoid out of memory errors. In particular, avoid storing whole * [ChannelInfo] objects, as they might contain raw JSON info in ready channel tabs link handlers. */ data class FeedUpdateInfo( val uid: Long, @NotificationMode val notificationMode: Int, val name: String, val avatarUrl: String?, val url: String, val serviceId: Int, // description and subscriberCount are null if the constructor info is from the fast feed method val description: String?, val subscriberCount: Long?, val streams: List, val errors: List ) { constructor( subscription: SubscriptionEntity, info: Info, streams: List, errors: List ) : this( uid = subscription.uid, notificationMode = subscription.notificationMode, name = info.name, avatarUrl = (info as? ChannelInfo)?.avatars?.let { // if the newly fetched info is not from fast feed, then it contains updated avatars ImageStrategy.imageListToDbUrl(it) } ?: subscription.avatarUrl, url = info.url, serviceId = info.serviceId, // there is no description and subscriberCount in the fast feed description = (info as? ChannelInfo)?.description, subscriberCount = (info as? ChannelInfo)?.subscriberCount, streams = streams, errors = errors ) /** * Integer id, can be used as notification id, etc. */ val pseudoId: Int get() = url.hashCode() lateinit var newStreams: List } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java ================================================ package org.schabi.newpipe.local.history; /* * Copyright (C) Mauricio Colli 2018 * HistoryRecordManager.java is part of NewPipe. * * NewPipe is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * NewPipe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ import android.content.Context; import android.content.SharedPreferences; import androidx.annotation.NonNull; import androidx.preference.PreferenceManager; import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.database.AppDatabase; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.history.dao.SearchHistoryDAO; import org.schabi.newpipe.database.history.dao.StreamHistoryDAO; import org.schabi.newpipe.database.history.model.SearchHistoryEntry; import org.schabi.newpipe.database.history.model.StreamHistoryEntity; import org.schabi.newpipe.database.history.model.StreamHistoryEntry; import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity; import org.schabi.newpipe.database.stream.StreamStatisticsEntry; import org.schabi.newpipe.database.stream.dao.StreamDAO; import org.schabi.newpipe.database.stream.dao.StreamStateDAO; import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.local.feed.FeedViewModel; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.util.ExtractorHelper; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.core.Flowable; import io.reactivex.rxjava3.core.Maybe; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.schedulers.Schedulers; public class HistoryRecordManager { private final AppDatabase database; private final StreamDAO streamTable; private final StreamHistoryDAO streamHistoryTable; private final SearchHistoryDAO searchHistoryTable; private final StreamStateDAO streamStateTable; private final SharedPreferences sharedPreferences; private final String searchHistoryKey; private final String streamHistoryKey; public HistoryRecordManager(final Context context) { database = NewPipeDatabase.getInstance(context); streamTable = database.streamDAO(); streamHistoryTable = database.streamHistoryDAO(); searchHistoryTable = database.searchHistoryDAO(); streamStateTable = database.streamStateDAO(); sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); searchHistoryKey = context.getString(R.string.enable_search_history_key); streamHistoryKey = context.getString(R.string.enable_watch_history_key); } /////////////////////////////////////////////////////// // Watch History /////////////////////////////////////////////////////// /** * Marks a stream item as watched such that it is hidden from the feed if watched videos are * hidden. Adds a history entry and updates the stream progress to 100%. * * @see FeedViewModel#setSaveShowPlayedItems * @param info the item to mark as watched * @return a Maybe containing the ID of the item if successful */ public Maybe markAsWatched(final StreamInfoItem info) { if (!isStreamHistoryEnabled()) { return Maybe.empty(); } final OffsetDateTime currentTime = OffsetDateTime.now(ZoneOffset.UTC); return Maybe.fromCallable(() -> database.runInTransaction(() -> { final long streamId; final long duration; // 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( info.getServiceId(), info.getUrl(), false ) .subscribeOn(Schedulers.io()) .blockingGet(); duration = completeInfo.getDuration(); streamId = streamTable.upsert(new StreamEntity(completeInfo)); } else { duration = info.getDuration(); streamId = streamTable.upsert(new StreamEntity(info)); } // Update the stream progress to the full duration of the video final StreamStateEntity entity = new StreamStateEntity( streamId, duration * 1000 ); streamStateTable.upsert(entity); // Add a history entry final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId); if (latestEntry == null) { // never actually viewed: add history entry but with 0 views return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 0)); } else { return 0L; } })).subscribeOn(Schedulers.io()); } public Maybe onViewed(final StreamInfo info) { if (!isStreamHistoryEnabled()) { return Maybe.empty(); } final OffsetDateTime currentTime = OffsetDateTime.now(ZoneOffset.UTC); return Maybe.fromCallable(() -> database.runInTransaction(() -> { final long streamId = streamTable.upsert(new StreamEntity(info)); final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId); if (latestEntry != null) { streamHistoryTable.delete(latestEntry); latestEntry.setAccessDate(currentTime); latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1); return streamHistoryTable.insert(latestEntry); } else { // just viewed for the first time: set 1 view return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 1)); } })).subscribeOn(Schedulers.io()); } public Completable deleteStreamHistoryAndState(final long streamId) { return Completable.fromAction(() -> { streamStateTable.deleteState(streamId); streamHistoryTable.deleteStreamHistory(streamId); }).subscribeOn(Schedulers.io()); } public Single deleteWholeStreamHistory() { return Single.fromCallable(streamHistoryTable::deleteAll) .subscribeOn(Schedulers.io()); } public Single deleteCompleteStreamStateHistory() { return Single.fromCallable(streamStateTable::deleteAll) .subscribeOn(Schedulers.io()); } public Flowable> getStreamHistorySortedById() { return streamHistoryTable.getHistorySortedById().subscribeOn(Schedulers.io()); } public Flowable> getStreamStatistics() { return streamHistoryTable.getStatistics().subscribeOn(Schedulers.io()); } private boolean isStreamHistoryEnabled() { return sharedPreferences.getBoolean(streamHistoryKey, false); } /////////////////////////////////////////////////////// // Search History /////////////////////////////////////////////////////// public Maybe onSearched(final int serviceId, final String search) { if (!isSearchHistoryEnabled()) { return Maybe.empty(); } final OffsetDateTime currentTime = OffsetDateTime.now(ZoneOffset.UTC); final SearchHistoryEntry newEntry = new SearchHistoryEntry(currentTime, serviceId, search); return Maybe.fromCallable(() -> database.runInTransaction(() -> { final SearchHistoryEntry latestEntry = searchHistoryTable.getLatestEntry(); if (latestEntry != null && latestEntry.hasEqualValues(newEntry)) { latestEntry.setCreationDate(currentTime); return (long) searchHistoryTable.update(latestEntry); } else { return searchHistoryTable.insert(newEntry); } })).subscribeOn(Schedulers.io()); } public Single deleteSearchHistory(final String search) { return Single.fromCallable(() -> searchHistoryTable.deleteAllWhereQuery(search)) .subscribeOn(Schedulers.io()); } public Single deleteCompleteSearchHistory() { return Single.fromCallable(searchHistoryTable::deleteAll) .subscribeOn(Schedulers.io()); } public Flowable> getRelatedSearches(final String query, final int similarQueryLimit, final int uniqueQueryLimit) { return query.length() > 0 ? searchHistoryTable.getSimilarEntries(query, similarQueryLimit) : searchHistoryTable.getUniqueEntries(uniqueQueryLimit); } private boolean isSearchHistoryEnabled() { return sharedPreferences.getBoolean(searchHistoryKey, false); } /////////////////////////////////////////////////////// // Stream State History /////////////////////////////////////////////////////// public Maybe loadStreamState(final PlayQueueItem queueItem) { return queueItem.getStream() .map(info -> streamTable.upsert(new StreamEntity(info))) .flatMapPublisher(streamStateTable::getState) .firstElement() .flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0))) .filter(state -> state.isValid(queueItem.getDuration())) .subscribeOn(Schedulers.io()); } public Maybe loadStreamState(final StreamInfo info) { return Single.fromCallable(() -> streamTable.upsert(new StreamEntity(info))) .flatMapPublisher(streamStateTable::getState) .firstElement() .flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0))) .filter(state -> state.isValid(info.getDuration())) .subscribeOn(Schedulers.io()); } public Completable saveStreamState(@NonNull final StreamInfo info, final long progressMillis) { return Completable.fromAction(() -> database.runInTransaction(() -> { final long streamId = streamTable.upsert(new StreamEntity(info)); final StreamStateEntity state = new StreamStateEntity(streamId, progressMillis); if (state.isValid(info.getDuration())) { streamStateTable.upsert(state); } })).subscribeOn(Schedulers.io()); } public Single loadStreamState(final InfoItem info) { return Single.fromCallable(() -> { final List entities = streamTable .getStream(info.getServiceId(), info.getUrl()).blockingFirst(); if (entities.isEmpty()) { return new StreamStateEntity[]{null}; } final List states = streamStateTable .getState(entities.get(0).getUid()).blockingFirst(); if (states.isEmpty()) { return new StreamStateEntity[]{null}; } return new StreamStateEntity[]{states.get(0)}; }).subscribeOn(Schedulers.io()); } public Single> loadLocalStreamStateBatch( final List items) { return Single.fromCallable(() -> { final List result = new ArrayList<>(items.size()); for (final LocalItem item : items) { final long streamId; if (item instanceof StreamStatisticsEntry) { streamId = ((StreamStatisticsEntry) item).getStreamId(); } else if (item instanceof PlaylistStreamEntity) { streamId = ((PlaylistStreamEntity) item).getStreamUid(); } else if (item instanceof PlaylistStreamEntry) { streamId = ((PlaylistStreamEntry) item).getStreamId(); } else { result.add(null); continue; } final List states = streamStateTable.getState(streamId) .blockingFirst(); if (states.isEmpty()) { result.add(null); } else { result.add(states.get(0)); } } return result; }).subscribeOn(Schedulers.io()); } /////////////////////////////////////////////////////// // Utility /////////////////////////////////////////////////////// public Single removeOrphanedRecords() { return Single.fromCallable(streamTable::deleteOrphans).subscribeOn(Schedulers.io()); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java ================================================ package org.schabi.newpipe.local.history; import android.content.Context; import android.os.Bundle; import android.os.Parcelable; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.evernote.android.state.State; import com.google.android.material.snackbar.Snackbar; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; import org.schabi.newpipe.R; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.stream.StreamStatisticsEntry; import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.databinding.PlaylistControlBinding; import org.schabi.newpipe.databinding.StatisticPlaylistControlBinding; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; import org.schabi.newpipe.info_list.dialog.InfoItemDialog; import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.settings.HistorySettingsFragment; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.PlayButtonHelper; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.function.Supplier; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; public class StatisticsPlaylistFragment extends BaseLocalListFragment, Void> implements PlaylistControlViewHolder { private final CompositeDisposable disposables = new CompositeDisposable(); @State Parcelable itemsListState; private StatisticSortMode sortMode = StatisticSortMode.LAST_PLAYED; private StatisticPlaylistControlBinding headerBinding; private PlaylistControlBinding playlistControlBinding; /* Used for independent events */ private Subscription databaseSubscription; private HistoryRecordManager recordManager; private List processResult(final List results) { final Comparator comparator; switch (sortMode) { case LAST_PLAYED: comparator = Comparator.comparing(StreamStatisticsEntry::getLatestAccessDate); break; case MOST_PLAYED: comparator = Comparator.comparingLong(StreamStatisticsEntry::getWatchCount); break; default: return null; } Collections.sort(results, comparator.reversed()); return results; } /////////////////////////////////////////////////////////////////////////// // Fragment LifeCycle - Creation /////////////////////////////////////////////////////////////////////////// @Override public void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); recordManager = new HistoryRecordManager(getContext()); } @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_playlist, container, false); } @Override public void onResume() { super.onResume(); if (activity != null) { setTitle(activity.getString(R.string.title_activity_history)); } } @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); inflater.inflate(R.menu.menu_history, menu); } /////////////////////////////////////////////////////////////////////////// // Fragment LifeCycle - Views /////////////////////////////////////////////////////////////////////////// @Override protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); if (!useAsFrontPage) { setTitle(getString(R.string.title_last_played)); } } @Override protected Supplier getListHeaderSupplier() { headerBinding = StatisticPlaylistControlBinding.inflate(activity.getLayoutInflater(), itemsList, false); playlistControlBinding = headerBinding.playlistControl; return headerBinding::getRoot; } @Override protected void initListeners() { super.initListeners(); itemListAdapter.setSelectedListener(new OnClickGesture<>() { @Override public void selected(final LocalItem selectedItem) { if (selectedItem instanceof StreamStatisticsEntry) { final StreamEntity item = ((StreamStatisticsEntry) selectedItem).getStreamEntity(); NavigationHelper.openVideoDetailFragment(requireContext(), getFM(), item.getServiceId(), item.getUrl(), item.getTitle(), null, false); } } @Override public void held(final LocalItem selectedItem) { if (selectedItem instanceof StreamStatisticsEntry) { showInfoItemDialog((StreamStatisticsEntry) selectedItem); } } }); } @Override public boolean onOptionsItemSelected(final MenuItem item) { if (item.getItemId() == R.id.action_history_clear) { HistorySettingsFragment .openDeleteWatchHistoryDialog(requireContext(), recordManager, disposables); } else { return super.onOptionsItemSelected(item); } return true; } /////////////////////////////////////////////////////////////////////////// // Fragment LifeCycle - Loading /////////////////////////////////////////////////////////////////////////// @Override public void startLoading(final boolean forceLoad) { super.startLoading(forceLoad); recordManager.getStreamStatistics() .observeOn(AndroidSchedulers.mainThread()) .subscribe(getHistoryObserver()); } /////////////////////////////////////////////////////////////////////////// // Fragment LifeCycle - Destruction /////////////////////////////////////////////////////////////////////////// @Override public void onPause() { super.onPause(); itemsListState = Objects.requireNonNull(itemsList.getLayoutManager()).onSaveInstanceState(); } @Override public void onDestroyView() { super.onDestroyView(); if (itemListAdapter != null) { itemListAdapter.unsetSelectedListener(); } headerBinding = null; playlistControlBinding = null; if (databaseSubscription != null) { databaseSubscription.cancel(); } databaseSubscription = null; } @Override public void onDestroy() { super.onDestroy(); recordManager = null; itemsListState = null; } /////////////////////////////////////////////////////////////////////////// // Statistics Loader /////////////////////////////////////////////////////////////////////////// private Subscriber> getHistoryObserver() { return new Subscriber>() { @Override public void onSubscribe(final Subscription s) { showLoading(); if (databaseSubscription != null) { databaseSubscription.cancel(); } databaseSubscription = s; databaseSubscription.request(1); } @Override public void onNext(final List streams) { handleResult(streams); if (databaseSubscription != null) { databaseSubscription.request(1); } } @Override public void onError(final Throwable exception) { showError( new ErrorInfo(exception, UserAction.SOMETHING_ELSE, "History Statistics")); } @Override public void onComplete() { } }; } @Override public void handleResult(@NonNull final List result) { super.handleResult(result); if (itemListAdapter == null) { return; } playlistControlBinding.getRoot().setVisibility(View.VISIBLE); itemListAdapter.clearStreamItemList(); if (result.isEmpty()) { showEmptyState(); return; } itemListAdapter.addItems(processResult(result)); if (itemsListState != null && itemsList.getLayoutManager() != null) { itemsList.getLayoutManager().onRestoreInstanceState(itemsListState); itemsListState = null; } PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this); headerBinding.sortButton.setOnClickListener(view -> toggleSortMode()); hideLoading(); } /////////////////////////////////////////////////////////////////////////// // Fragment Error Handling /////////////////////////////////////////////////////////////////////////// @Override protected void resetFragment() { super.resetFragment(); if (databaseSubscription != null) { databaseSubscription.cancel(); } } /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ private void toggleSortMode() { if (sortMode == StatisticSortMode.LAST_PLAYED) { sortMode = StatisticSortMode.MOST_PLAYED; setTitle(getString(R.string.title_most_played)); headerBinding.sortButtonIcon.setImageResource(R.drawable.ic_history); headerBinding.sortButtonText.setText(R.string.title_last_played); } else { sortMode = StatisticSortMode.LAST_PLAYED; setTitle(getString(R.string.title_last_played)); headerBinding.sortButtonIcon.setImageResource( R.drawable.ic_filter_list); headerBinding.sortButtonText.setText(R.string.title_most_played); } startLoading(true); } private PlayQueue getPlayQueueStartingAt(final StreamStatisticsEntry infoItem) { return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0)); } private void showInfoItemDialog(final StreamStatisticsEntry item) { final Context context = getContext(); final StreamInfoItem infoItem = item.toStreamInfoItem(); try { final InfoItemDialog.Builder dialogBuilder = new InfoItemDialog.Builder(getActivity(), context, this, infoItem); // set entries in the middle; the others are added automatically dialogBuilder .addEntry(StreamDialogDefaultEntry.DELETE) .setAction( StreamDialogDefaultEntry.DELETE, (f, i) -> deleteEntry( Math.max(itemListAdapter.getItemsList().indexOf(item), 0))) .create() .show(); } catch (final IllegalArgumentException e) { InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem); } } private void deleteEntry(final int index) { final LocalItem infoItem = itemListAdapter.getItemsList().get(index); if (infoItem instanceof StreamStatisticsEntry) { final StreamStatisticsEntry entry = (StreamStatisticsEntry) infoItem; final Disposable onDelete = recordManager .deleteStreamHistoryAndState(entry.getStreamId()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( () -> { if (getView() != null) { Snackbar.make(getView(), R.string.one_item_deleted, Snackbar.LENGTH_SHORT).show(); } else { Toast.makeText(getContext(), R.string.one_item_deleted, Toast.LENGTH_SHORT).show(); } }, throwable -> showSnackBarError(new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, "Deleting item"))); disposables.add(onDelete); } } @Override public PlayQueue getPlayQueue() { return getPlayQueue(0); } private PlayQueue getPlayQueue(final int index) { if (itemListAdapter == null) { return new SinglePlayQueue(Collections.emptyList(), 0); } final List infoItems = itemListAdapter.getItemsList(); final List streamInfoItems = new ArrayList<>(infoItems.size()); for (final LocalItem item : infoItems) { if (item instanceof StreamStatisticsEntry) { streamInfoItems.add(((StreamStatisticsEntry) item).toStreamInfoItem()); } } return new SinglePlayQueue(streamInfoItems, index); } private enum StatisticSortMode { LAST_PLAYED, MOST_PLAYED, } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.java ================================================ package org.schabi.newpipe.local.holder; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import org.schabi.newpipe.R; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; import java.time.format.DateTimeFormatter; public class LocalBookmarkPlaylistItemHolder extends LocalPlaylistItemHolder { private final View itemHandleView; public LocalBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) { this(infoItemBuilder, R.layout.list_playlist_bookmark_item, parent); } LocalBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, final ViewGroup parent) { super(infoItemBuilder, layoutId, parent); itemHandleView = itemView.findViewById(R.id.itemHandle); } @Override public void updateFromItem(final LocalItem localItem, final HistoryRecordManager historyRecordManager, final DateTimeFormatter dateTimeFormatter) { if (!(localItem instanceof PlaylistMetadataEntry)) { return; } final PlaylistMetadataEntry item = (PlaylistMetadataEntry) localItem; itemHandleView.setOnTouchListener(getOnTouchListener(item)); super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter); } private View.OnTouchListener getOnTouchListener(final PlaylistMetadataEntry item) { return (view, motionEvent) -> { view.performClick(); if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null && motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { itemBuilder.getOnItemSelectedListener().drag(item, LocalBookmarkPlaylistItemHolder.this); } return false; }; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/holder/LocalItemHolder.java ================================================ package org.schabi.newpipe.local.holder; import android.view.LayoutInflater; import android.view.ViewGroup; import androidx.recyclerview.widget.RecyclerView; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; import java.time.format.DateTimeFormatter; /* * Created by Christian Schabesberger on 12.02.17. * * Copyright (C) Christian Schabesberger 2016 * InfoItemHolder.java is part of NewPipe. * * NewPipe is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * NewPipe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ public abstract class LocalItemHolder extends RecyclerView.ViewHolder { protected final LocalItemBuilder itemBuilder; public LocalItemHolder(final LocalItemBuilder itemBuilder, final int layoutId, final ViewGroup parent) { super(LayoutInflater.from(itemBuilder.getContext()).inflate(layoutId, parent, false)); this.itemBuilder = itemBuilder; } public abstract void updateFromItem(LocalItem item, HistoryRecordManager historyRecordManager, DateTimeFormatter dateTimeFormatter); public void updateState(final LocalItem localItem, final HistoryRecordManager historyRecordManager) { } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistCardItemHolder.java ================================================ package org.schabi.newpipe.local.holder; import android.view.ViewGroup; import org.schabi.newpipe.R; import org.schabi.newpipe.local.LocalItemBuilder; /** * Playlist card layout. */ public class LocalPlaylistCardItemHolder extends LocalPlaylistItemHolder { public LocalPlaylistCardItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, R.layout.list_playlist_card_item, parent); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistGridItemHolder.java ================================================ package org.schabi.newpipe.local.holder; import android.view.ViewGroup; import org.schabi.newpipe.R; import org.schabi.newpipe.local.LocalItemBuilder; public class LocalPlaylistGridItemHolder extends LocalPlaylistItemHolder { public LocalPlaylistGridItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, R.layout.list_playlist_grid_item, parent); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java ================================================ package org.schabi.newpipe.local.holder; import android.view.View; import android.view.ViewGroup; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry; import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.image.CoilHelper; import java.time.format.DateTimeFormatter; public class LocalPlaylistItemHolder extends PlaylistItemHolder { private static final float GRAYED_OUT_ALPHA = 0.6f; public LocalPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, parent); } LocalPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, final ViewGroup parent) { super(infoItemBuilder, layoutId, parent); } @Override public void updateFromItem(final LocalItem localItem, final HistoryRecordManager historyRecordManager, final DateTimeFormatter dateTimeFormatter) { if (!(localItem instanceof PlaylistMetadataEntry item)) { return; } itemTitleView.setText(item.getOrderingName()); itemStreamCountView.setText(Localization.localizeStreamCountMini( itemStreamCountView.getContext(), item.getStreamCount())); itemUploaderView.setVisibility(View.INVISIBLE); CoilHelper.INSTANCE.loadPlaylistThumbnail(itemThumbnailView, item.getThumbnailUrl()); if (item instanceof PlaylistDuplicatesEntry && ((PlaylistDuplicatesEntry) item).getTimesStreamIsContained() > 0) { itemView.setAlpha(GRAYED_OUT_ALPHA); } else { itemView.setAlpha(1.0f); } super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamCardItemHolder.java ================================================ package org.schabi.newpipe.local.holder; import android.view.ViewGroup; import org.schabi.newpipe.R; import org.schabi.newpipe.local.LocalItemBuilder; /** * Local playlist stream UI. This also includes a handle to rearrange the videos. */ public class LocalPlaylistStreamCardItemHolder extends LocalPlaylistStreamItemHolder { public LocalPlaylistStreamCardItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, R.layout.list_stream_playlist_card_item, parent); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamGridItemHolder.java ================================================ package org.schabi.newpipe.local.holder; import android.view.ViewGroup; import org.schabi.newpipe.R; import org.schabi.newpipe.local.LocalItemBuilder; public class LocalPlaylistStreamGridItemHolder extends LocalPlaylistStreamItemHolder { public LocalPlaylistStreamGridItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, R.layout.list_stream_playlist_grid_item, parent); // TODO } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java ================================================ package org.schabi.newpipe.local.holder; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import androidx.core.content.ContextCompat; import org.schabi.newpipe.R; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; import org.schabi.newpipe.ktx.ViewUtils; import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.image.CoilHelper; import org.schabi.newpipe.views.AnimatedProgressBar; import java.time.format.DateTimeFormatter; import java.util.concurrent.TimeUnit; public class LocalPlaylistStreamItemHolder extends LocalItemHolder { public final ImageView itemThumbnailView; public final TextView itemVideoTitleView; private final TextView itemAdditionalDetailsView; public final TextView itemDurationView; private final View itemHandleView; private final AnimatedProgressBar itemProgressView; LocalPlaylistStreamItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, final ViewGroup parent) { super(infoItemBuilder, layoutId, parent); itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); itemVideoTitleView = itemView.findViewById(R.id.itemVideoTitleView); itemAdditionalDetailsView = itemView.findViewById(R.id.itemAdditionalDetails); itemDurationView = itemView.findViewById(R.id.itemDurationView); itemHandleView = itemView.findViewById(R.id.itemHandle); itemProgressView = itemView.findViewById(R.id.itemProgressView); } public LocalPlaylistStreamItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) { this(infoItemBuilder, R.layout.list_stream_playlist_item, parent); } @Override public void updateFromItem(final LocalItem localItem, final HistoryRecordManager historyRecordManager, final DateTimeFormatter dateTimeFormatter) { if (!(localItem instanceof PlaylistStreamEntry)) { return; } final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem; itemVideoTitleView.setText(item.getStreamEntity().getTitle()); itemAdditionalDetailsView.setText(Localization .concatenateStrings(item.getStreamEntity().getUploader(), ServiceHelper.getNameOfServiceById(item.getStreamEntity().getServiceId()))); if (item.getStreamEntity().getDuration() > 0) { itemDurationView.setText(Localization .getDurationString(item.getStreamEntity().getDuration())); itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), R.color.duration_background_color)); itemDurationView.setVisibility(View.VISIBLE); if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext()) && item.getProgressMillis() > 0) { itemProgressView.setVisibility(View.VISIBLE); itemProgressView.setMax((int) item.getStreamEntity().getDuration()); itemProgressView.setProgress((int) TimeUnit.MILLISECONDS .toSeconds(item.getProgressMillis())); } else { itemProgressView.setVisibility(View.GONE); } } else { itemDurationView.setVisibility(View.GONE); } // Default thumbnail is shown on error, while loading and if the url is empty CoilHelper.INSTANCE.loadThumbnail(itemThumbnailView, item.getStreamEntity().getThumbnailUrl()); itemView.setOnClickListener(view -> { if (itemBuilder.getOnItemSelectedListener() != null) { itemBuilder.getOnItemSelectedListener().selected(item); } }); itemView.setLongClickable(true); itemView.setOnLongClickListener(view -> { if (itemBuilder.getOnItemSelectedListener() != null) { itemBuilder.getOnItemSelectedListener().held(item); } return true; }); itemHandleView.setOnTouchListener(getOnTouchListener(item)); } @Override public void updateState(final LocalItem localItem, final HistoryRecordManager historyRecordManager) { if (!(localItem instanceof PlaylistStreamEntry)) { return; } final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem; if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext()) && item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) { itemProgressView.setMax((int) item.getStreamEntity().getDuration()); if (itemProgressView.getVisibility() == View.VISIBLE) { itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS .toSeconds(item.getProgressMillis())); } else { itemProgressView.setProgress((int) TimeUnit.MILLISECONDS .toSeconds(item.getProgressMillis())); ViewUtils.animate(itemProgressView, true, 500); } } else if (itemProgressView.getVisibility() == View.VISIBLE) { ViewUtils.animate(itemProgressView, false, 500); } } private View.OnTouchListener getOnTouchListener(final PlaylistStreamEntry item) { return (view, motionEvent) -> { view.performClick(); if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null && motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { itemBuilder.getOnItemSelectedListener().drag(item, LocalPlaylistStreamItemHolder.this); } return false; }; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamCardItemHolder.java ================================================ package org.schabi.newpipe.local.holder; import android.view.ViewGroup; import org.schabi.newpipe.R; import org.schabi.newpipe.local.LocalItemBuilder; public class LocalStatisticStreamCardItemHolder extends LocalStatisticStreamItemHolder { public LocalStatisticStreamCardItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, R.layout.list_stream_card_item, parent); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamGridItemHolder.java ================================================ package org.schabi.newpipe.local.holder; import android.view.ViewGroup; import org.schabi.newpipe.R; import org.schabi.newpipe.local.LocalItemBuilder; public class LocalStatisticStreamGridItemHolder extends LocalStatisticStreamItemHolder { public LocalStatisticStreamGridItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, R.layout.list_stream_grid_item, parent); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java ================================================ package org.schabi.newpipe.local.holder; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; import org.schabi.newpipe.R; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.stream.StreamStatisticsEntry; import org.schabi.newpipe.ktx.ViewUtils; import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.image.CoilHelper; import org.schabi.newpipe.views.AnimatedProgressBar; import java.time.format.DateTimeFormatter; import java.util.concurrent.TimeUnit; /* * Created by Christian Schabesberger on 01.08.16. *

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

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

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

* You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ public class LocalStatisticStreamItemHolder extends LocalItemHolder { public final ImageView itemThumbnailView; public final TextView itemVideoTitleView; public final TextView itemUploaderView; public final TextView itemDurationView; @Nullable public final TextView itemAdditionalDetails; private final AnimatedProgressBar itemProgressView; public LocalStatisticStreamItemHolder(final LocalItemBuilder itemBuilder, final ViewGroup parent) { this(itemBuilder, R.layout.list_stream_item, parent); } LocalStatisticStreamItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, final ViewGroup parent) { super(infoItemBuilder, layoutId, parent); itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); itemVideoTitleView = itemView.findViewById(R.id.itemVideoTitleView); itemUploaderView = itemView.findViewById(R.id.itemUploaderView); itemDurationView = itemView.findViewById(R.id.itemDurationView); itemAdditionalDetails = itemView.findViewById(R.id.itemAdditionalDetails); itemProgressView = itemView.findViewById(R.id.itemProgressView); } private String getStreamInfoDetailLine(final StreamStatisticsEntry entry, final DateTimeFormatter dateTimeFormatter) { return Localization.concatenateStrings( // watchCount Localization.shortViewCount(itemBuilder.getContext(), entry.getWatchCount()), dateTimeFormatter.format(entry.getLatestAccessDate()), // serviceName ServiceHelper.getNameOfServiceById(entry.getStreamEntity().getServiceId())); } @Override public void updateFromItem(final LocalItem localItem, final HistoryRecordManager historyRecordManager, final DateTimeFormatter dateTimeFormatter) { if (!(localItem instanceof StreamStatisticsEntry)) { return; } final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem; itemVideoTitleView.setText(item.getStreamEntity().getTitle()); itemUploaderView.setText(item.getStreamEntity().getUploader()); if (item.getStreamEntity().getDuration() > 0) { itemDurationView. setText(Localization.getDurationString(item.getStreamEntity().getDuration())); itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), R.color.duration_background_color)); itemDurationView.setVisibility(View.VISIBLE); if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext()) && item.getProgressMillis() > 0) { itemProgressView.setVisibility(View.VISIBLE); itemProgressView.setMax((int) item.getStreamEntity().getDuration()); itemProgressView.setProgress((int) TimeUnit.MILLISECONDS .toSeconds(item.getProgressMillis())); } else { itemProgressView.setVisibility(View.GONE); } } else { itemDurationView.setVisibility(View.GONE); itemProgressView.setVisibility(View.GONE); } if (itemAdditionalDetails != null) { itemAdditionalDetails.setText(getStreamInfoDetailLine(item, dateTimeFormatter)); } // Default thumbnail is shown on error, while loading and if the url is empty CoilHelper.INSTANCE.loadThumbnail(itemThumbnailView, item.getStreamEntity().getThumbnailUrl()); itemView.setOnClickListener(view -> { if (itemBuilder.getOnItemSelectedListener() != null) { itemBuilder.getOnItemSelectedListener().selected(item); } }); itemView.setLongClickable(true); itemView.setOnLongClickListener(view -> { if (itemBuilder.getOnItemSelectedListener() != null) { itemBuilder.getOnItemSelectedListener().held(item); } return true; }); } @Override public void updateState(final LocalItem localItem, final HistoryRecordManager historyRecordManager) { if (!(localItem instanceof StreamStatisticsEntry)) { return; } final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem; if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext()) && item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) { itemProgressView.setMax((int) item.getStreamEntity().getDuration()); if (itemProgressView.getVisibility() == View.VISIBLE) { itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS .toSeconds(item.getProgressMillis())); } else { itemProgressView.setProgress((int) TimeUnit.MILLISECONDS .toSeconds(item.getProgressMillis())); ViewUtils.animate(itemProgressView, true, 500); } } else if (itemProgressView.getVisibility() == View.VISIBLE) { ViewUtils.animate(itemProgressView, false, 500); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/holder/PlaylistItemHolder.java ================================================ package org.schabi.newpipe.local.holder; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import org.schabi.newpipe.R; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; import java.time.format.DateTimeFormatter; public abstract class PlaylistItemHolder extends LocalItemHolder { public final ImageView itemThumbnailView; final TextView itemStreamCountView; public final TextView itemTitleView; public final TextView itemUploaderView; public PlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, final ViewGroup parent) { super(infoItemBuilder, layoutId, parent); itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); itemTitleView = itemView.findViewById(R.id.itemTitleView); itemStreamCountView = itemView.findViewById(R.id.itemStreamCountView); itemUploaderView = itemView.findViewById(R.id.itemUploaderView); } public PlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) { this(infoItemBuilder, R.layout.list_playlist_mini_item, parent); } @Override public void updateFromItem(final LocalItem localItem, final HistoryRecordManager historyRecordManager, final DateTimeFormatter dateTimeFormatter) { itemView.setOnClickListener(view -> { if (itemBuilder.getOnItemSelectedListener() != null) { itemBuilder.getOnItemSelectedListener().selected(localItem); } }); itemView.setLongClickable(true); itemView.setOnLongClickListener(view -> { if (itemBuilder.getOnItemSelectedListener() != null) { itemBuilder.getOnItemSelectedListener().held(localItem); } return true; }); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.java ================================================ package org.schabi.newpipe.local.holder; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import org.schabi.newpipe.R; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; import java.time.format.DateTimeFormatter; public class RemoteBookmarkPlaylistItemHolder extends RemotePlaylistItemHolder { private final View itemHandleView; public RemoteBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) { this(infoItemBuilder, R.layout.list_playlist_bookmark_item, parent); } RemoteBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, final ViewGroup parent) { super(infoItemBuilder, layoutId, parent); itemHandleView = itemView.findViewById(R.id.itemHandle); } @Override public void updateFromItem(final LocalItem localItem, final HistoryRecordManager historyRecordManager, final DateTimeFormatter dateTimeFormatter) { if (!(localItem instanceof PlaylistRemoteEntity)) { return; } final PlaylistRemoteEntity item = (PlaylistRemoteEntity) localItem; itemHandleView.setOnTouchListener(getOnTouchListener(item)); super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter); } private View.OnTouchListener getOnTouchListener(final PlaylistRemoteEntity item) { return (view, motionEvent) -> { view.performClick(); if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null && motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { itemBuilder.getOnItemSelectedListener().drag(item, RemoteBookmarkPlaylistItemHolder.this); } return false; }; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistCardItemHolder.java ================================================ package org.schabi.newpipe.local.holder; import android.view.ViewGroup; import org.schabi.newpipe.R; import org.schabi.newpipe.local.LocalItemBuilder; /** * Playlist card UI for list item. */ public class RemotePlaylistCardItemHolder extends RemotePlaylistItemHolder { public RemotePlaylistCardItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, R.layout.list_playlist_card_item, parent); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistGridItemHolder.java ================================================ package org.schabi.newpipe.local.holder; import android.view.ViewGroup; import org.schabi.newpipe.R; import org.schabi.newpipe.local.LocalItemBuilder; public class RemotePlaylistGridItemHolder extends RemotePlaylistItemHolder { public RemotePlaylistGridItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, R.layout.list_playlist_grid_item, parent); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java ================================================ package org.schabi.newpipe.local.holder; import android.text.TextUtils; import android.view.ViewGroup; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.image.CoilHelper; import java.time.format.DateTimeFormatter; public class RemotePlaylistItemHolder extends PlaylistItemHolder { public RemotePlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, parent); } RemotePlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId, final ViewGroup parent) { super(infoItemBuilder, layoutId, parent); } @Override public void updateFromItem(final LocalItem localItem, final HistoryRecordManager historyRecordManager, final DateTimeFormatter dateTimeFormatter) { if (!(localItem instanceof PlaylistRemoteEntity item)) { return; } itemTitleView.setText(item.getOrderingName()); itemStreamCountView.setText(Localization.localizeStreamCountMini( itemStreamCountView.getContext(), item.getStreamCount())); // Here is where the uploader name is set in the bookmarked playlists library if (!TextUtils.isEmpty(item.getUploader())) { itemUploaderView.setText(Localization.concatenateStrings(item.getUploader(), ServiceHelper.getNameOfServiceById(item.getServiceId()))); } else { itemUploaderView.setText(ServiceHelper.getNameOfServiceById(item.getServiceId())); } CoilHelper.INSTANCE.loadPlaylistThumbnail(itemThumbnailView, item.getThumbnailUrl()); super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/playlist/ExportPlaylist.kt ================================================ /* * SPDX-FileCopyrightText: 2025 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.local.playlist import android.content.Context import org.schabi.newpipe.R import org.schabi.newpipe.database.playlist.PlaylistStreamEntry import org.schabi.newpipe.extractor.exceptions.ParsingException import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeStreamLinkHandlerFactory import org.schabi.newpipe.local.playlist.PlayListShareMode.JUST_URLS import org.schabi.newpipe.local.playlist.PlayListShareMode.WITH_TITLES import org.schabi.newpipe.local.playlist.PlayListShareMode.YOUTUBE_TEMP_PLAYLIST fun export( shareMode: PlayListShareMode, playlist: List, context: Context ): String { return when (shareMode) { WITH_TITLES -> exportWithTitles(playlist, context) JUST_URLS -> exportJustUrls(playlist) YOUTUBE_TEMP_PLAYLIST -> exportAsYoutubeTempPlaylist(playlist) } } private fun exportWithTitles(playlist: List, context: Context): String { return playlist.asSequence() .map { it.streamEntity } .map { entity -> context.getString( R.string.video_details_list_item, entity.title, entity.url ) } .joinToString(separator = "\n") } private fun exportJustUrls(playlist: List): String { return playlist.joinToString(separator = "\n") { it.streamEntity.url } } private fun exportAsYoutubeTempPlaylist(playlist: List): String { val videoIDs = playlist.asReversed().asSequence() .mapNotNull { getYouTubeId(it.streamEntity.url) } .take(50) // YouTube limitation: temp playlists can't have more than 50 items .toList() .asReversed() .joinToString(separator = ",") return "https://www.youtube.com/watch_videos?video_ids=$videoIDs" } private val linkHandler: YoutubeStreamLinkHandlerFactory = YoutubeStreamLinkHandlerFactory.getInstance() /** * Gets the video id from a YouTube URL. * * @param url YouTube URL * @return the video id */ private fun getYouTubeId(url: String): String? { return runCatching { linkHandler.getId(url) }.getOrNull() } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java ================================================ package org.schabi.newpipe.local.playlist; import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; import static org.schabi.newpipe.error.ErrorUtil.showUiErrorSnackbar; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.local.playlist.ExportPlaylistKt.export; import static org.schabi.newpipe.local.playlist.PlayListShareMode.JUST_URLS; import static org.schabi.newpipe.local.playlist.PlayListShareMode.WITH_TITLES; import static org.schabi.newpipe.local.playlist.PlayListShareMode.YOUTUBE_TEMP_PLAYLIST; import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout; import android.content.Context; import android.os.Bundle; import android.os.Parcelable; import android.text.InputType; import android.text.TextUtils; import android.util.Log; import android.util.Pair; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.LinearLayout; import android.widget.LinearLayout.LayoutParams; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; import com.evernote.android.state.State; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.history.model.StreamHistoryEntry; import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; import org.schabi.newpipe.database.playlist.model.PlaylistEntity; import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.databinding.DialogEditTextBinding; import org.schabi.newpipe.databinding.LocalPlaylistHeaderBinding; import org.schabi.newpipe.databinding.PlaylistControlBinding; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.MainFragment; import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; import org.schabi.newpipe.info_list.dialog.InfoItemDialog; import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.PlayButtonHelper; import org.schabi.newpipe.util.debounce.DebounceSavable; import org.schabi.newpipe.util.debounce.DebounceSaver; import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Supplier; import java.util.stream.Collectors; 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; public class LocalPlaylistFragment extends BaseLocalListFragment, Void> implements PlaylistControlViewHolder, DebounceSavable { private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12; @State protected Long playlistId; @State protected String name; @State Parcelable itemsListState; private LocalPlaylistHeaderBinding headerBinding; private PlaylistControlBinding playlistControlBinding; private ItemTouchHelper itemTouchHelper; private LocalPlaylistManager playlistManager; private Subscription databaseSubscription; private CompositeDisposable disposables; /** Whether the playlist has been fully loaded from db. */ private AtomicBoolean isLoadingComplete; /** Used to debounce saving playlist edits to disk. */ private DebounceSaver debounceSaver; /** Flag to prevent simultaneous rewrites of the playlist. */ private boolean isRewritingPlaylist = false; /** * The pager adapter that the fragment is created from when it is used as frontpage, i.e. * {@link #useAsFrontPage} is {@link true}. */ @Nullable private MainFragment.SelectedTabsPagerAdapter tabsPagerAdapter = null; public static LocalPlaylistFragment getInstance(final long playlistId, final String name) { final var instance = new LocalPlaylistFragment(); instance.setInitialData(playlistId, name); return instance; } /////////////////////////////////////////////////////////////////////////// // Fragment LifeCycle - Creation /////////////////////////////////////////////////////////////////////////// @Override public void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); playlistManager = new LocalPlaylistManager(NewPipeDatabase.getInstance(requireContext())); disposables = new CompositeDisposable(); isLoadingComplete = new AtomicBoolean(); debounceSaver = new DebounceSaver(this); } @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_playlist, container, false); } /////////////////////////////////////////////////////////////////////////// // Fragment Lifecycle - Views /////////////////////////////////////////////////////////////////////////// @Override public void setTitle(final String title) { super.setTitle(title); if (headerBinding != null) { headerBinding.playlistTitleView.setText(title); } } @Override protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); setTitle(name); } @Override protected Supplier getListHeaderSupplier() { headerBinding = LocalPlaylistHeaderBinding.inflate(activity.getLayoutInflater(), itemsList, false); playlistControlBinding = headerBinding.playlistControl; headerBinding.playlistTitleView.setSelected(true); return headerBinding::getRoot; } @Override protected void initListeners() { super.initListeners(); headerBinding.playlistTitleView.setOnClickListener(view -> createRenameDialog()); itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); itemTouchHelper.attachToRecyclerView(itemsList); itemListAdapter.setSelectedListener(new OnClickGesture<>() { @Override public void selected(final LocalItem selectedItem) { if (selectedItem instanceof PlaylistStreamEntry entry) { final StreamEntity item = entry.getStreamEntity(); NavigationHelper.openVideoDetailFragment(requireContext(), getFM(), item.getServiceId(), item.getUrl(), item.getTitle(), null, false); } } @Override public void held(final LocalItem selectedItem) { if (selectedItem instanceof PlaylistStreamEntry) { showInfoItemDialog((PlaylistStreamEntry) selectedItem); } } @Override public void drag(final LocalItem selectedItem, final RecyclerView.ViewHolder viewHolder) { if (itemTouchHelper != null) { itemTouchHelper.startDrag(viewHolder); } } }); } /////////////////////////////////////////////////////////////////////////// // Fragment Lifecycle - Loading /////////////////////////////////////////////////////////////////////////// @Override public void showLoading() { super.showLoading(); if (headerBinding != null) { animate(headerBinding.getRoot(), false, 200); animate(playlistControlBinding.getRoot(), false, 200); } } @Override public void hideLoading() { super.hideLoading(); if (headerBinding != null) { animate(headerBinding.getRoot(), true, 200); animate(playlistControlBinding.getRoot(), true, 200); } } @Override public void startLoading(final boolean forceLoad) { super.startLoading(forceLoad); if (disposables != null) { disposables.clear(); } if (debounceSaver != null) { disposables.add(debounceSaver.getDebouncedSaver()); debounceSaver.setNoChangesToSave(); } isLoadingComplete.set(false); playlistManager.getPlaylistStreams(playlistId) .onBackpressureLatest() .observeOn(AndroidSchedulers.mainThread()) .subscribe(getPlaylistObserver()); } /////////////////////////////////////////////////////////////////////////// // Fragment Lifecycle - Destruction /////////////////////////////////////////////////////////////////////////// @Override public void onPause() { super.onPause(); itemsListState = itemsList.getLayoutManager().onSaveInstanceState(); // Save on exit saveImmediate(); } @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { if (DEBUG) { Log.d(TAG, "onCreateOptionsMenu() called with: " + "menu = [" + menu + "], inflater = [" + inflater + "]"); } super.onCreateOptionsMenu(menu, inflater); inflater.inflate(R.menu.menu_local_playlist, menu); } @Override public void onDestroyView() { super.onDestroyView(); if (itemListAdapter != null) { itemListAdapter.unsetSelectedListener(); } headerBinding = null; playlistControlBinding = null; if (databaseSubscription != null) { databaseSubscription.cancel(); } if (disposables != null) { disposables.clear(); } databaseSubscription = null; itemTouchHelper = null; } @Override public void onDestroy() { super.onDestroy(); if (debounceSaver != null) { debounceSaver.getDebouncedSaveSignal().onComplete(); } if (disposables != null) { disposables.dispose(); } if (tabsPagerAdapter != null) { tabsPagerAdapter.getLocalPlaylistFragments().remove(this); } debounceSaver = null; playlistManager = null; disposables = null; isLoadingComplete = null; } /////////////////////////////////////////////////////////////////////////// // Playlist Stream Loader /////////////////////////////////////////////////////////////////////////// private Subscriber> getPlaylistObserver() { return new Subscriber<>() { @Override public void onSubscribe(final Subscription s) { showLoading(); isLoadingComplete.set(false); if (databaseSubscription != null) { databaseSubscription.cancel(); } databaseSubscription = s; databaseSubscription.request(1); } @Override public void onNext(final List streams) { // Skip handling the result after it has been modified if (debounceSaver == null || !debounceSaver.getIsModified()) { handleResult(streams); isLoadingComplete.set(true); } if (databaseSubscription != null) { databaseSubscription.request(1); } } @Override public void onError(final Throwable exception) { showError(new ErrorInfo(exception, UserAction.REQUESTED_BOOKMARK, "Loading local playlist")); } @Override public void onComplete() { } }; } @Override public boolean onOptionsItemSelected(final MenuItem item) { if (item.getItemId() == R.id.menu_item_share_playlist) { createShareConfirmationDialog(); } else if (item.getItemId() == R.id.menu_item_rename_playlist) { createRenameDialog(); } else if (item.getItemId() == R.id.menu_item_remove_watched) { if (!isRewritingPlaylist) { openRemoveWatchedConfirmationDialog(); } } else if (item.getItemId() == R.id.menu_item_remove_duplicates) { if (!isRewritingPlaylist) { openRemoveDuplicatesDialog(); } } else { return super.onOptionsItemSelected(item); } return true; } /** * Shares the playlist in one of 3 ways, depending on the value of {@code shareMode}: *

    *
  • {@code JUST_URLS}: shares the URLs only.
  • *
  • {@code WITH_TITLES}: each entry in the list is accompanied by its title.
  • *
  • {@code YOUTUBE_TEMP_PLAYLIST}: shares as a YouTube temporary playlist.
  • *
* * @param shareMode The way the playlist should be shared. */ private void sharePlaylist(final PlayListShareMode shareMode) { final Context context = requireContext(); disposables.add(playlistManager.getPlaylistStreams(playlistId) .flatMapSingle(playlist -> Single.just(export( shareMode, playlist, context ))) .observeOn(AndroidSchedulers.mainThread()) .subscribe( urlsText -> { final String content = shareMode == WITH_TITLES ? context.getString(R.string.share_playlist_content_details, name, urlsText ) : urlsText; ShareUtils.shareText(context, name, content); }, throwable -> showUiErrorSnackbar(this, "Sharing playlist", throwable) ) ); } public void removeWatchedStreams(final boolean removePartiallyWatched) { if (isRewritingPlaylist) { return; } isRewritingPlaylist = true; showLoading(); final var recordManager = new HistoryRecordManager(getContext()); final var historyIdsMaybe = recordManager.getStreamHistorySortedById() .firstElement() // already sorted by ^ getStreamHistorySortedById(), binary search can be used .map(historyList -> historyList.stream().map(StreamHistoryEntry::getStreamId) .collect(Collectors.toList())); final var streamsMaybe = playlistManager.getPlaylistStreams(playlistId) .firstElement() .zipWith(historyIdsMaybe, (playlist, historyStreamIds) -> { // Remove Watched, Functionality data final List itemsToKeep = new ArrayList<>(); final boolean isThumbnailPermanent = playlistManager .getIsPlaylistThumbnailPermanent(playlistId); boolean thumbnailVideoRemoved = false; final var streamStates = recordManager .loadLocalStreamStateBatch(playlist).blockingGet(); for (int i = 0; i < playlist.size(); i++) { final var playlistItem = playlist.get(i); final var streamStateEntity = streamStates.get(i); final int indexInHistory = Collections.binarySearch(historyStreamIds, playlistItem.getStreamId()); final long duration = playlistItem.toStreamInfoItem().getDuration(); if (indexInHistory < 0 // stream is not in history // stream is in history but the streamStateEntity is null // if the stream was played for less than 5 seconds, see // StreamStateEntity#PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS || streamStateEntity == null || (!removePartiallyWatched && !streamStateEntity.isFinished(duration))) { itemsToKeep.add(playlistItem); } else if (!isThumbnailPermanent && !thumbnailVideoRemoved && playlistManager.getPlaylistThumbnailStreamId(playlistId) == playlistItem.getStreamEntity().getUid()) { thumbnailVideoRemoved = true; } } return new Pair<>(itemsToKeep, thumbnailVideoRemoved); }); disposables.add(streamsMaybe.subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(flow -> { final List itemsToKeep = flow.first; final boolean thumbnailVideoRemoved = flow.second; itemListAdapter.clearStreamItemList(); itemListAdapter.addItems(itemsToKeep); debounceSaver.setHasChangesToSave(); saveImmediate(); if (thumbnailVideoRemoved) { updateThumbnailUrl(); } final long videoCount = itemListAdapter.getItemsList().size(); setStreamCountAndOverallDuration(itemListAdapter.getItemsList()); if (videoCount == 0) { showEmptyState(); } hideLoading(); isRewritingPlaylist = false; }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, "Removing watched videos, partially watched=" + removePartiallyWatched)))); } @Override public void handleResult(@NonNull final List result) { super.handleResult(result); if (itemListAdapter == null) { return; } itemListAdapter.clearStreamItemList(); if (result.isEmpty()) { showEmptyState(); return; } itemListAdapter.addItems(result); if (itemsListState != null) { itemsList.getLayoutManager().onRestoreInstanceState(itemsListState); itemsListState = null; } setStreamCountAndOverallDuration(itemListAdapter.getItemsList()); PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this); hideLoading(); } /////////////////////////////////////////////////////////////////////////// // Fragment Error Handling /////////////////////////////////////////////////////////////////////////// @Override protected void resetFragment() { super.resetFragment(); if (databaseSubscription != null) { databaseSubscription.cancel(); } } /*////////////////////////////////////////////////////////////////////////// // Playlist Metadata/Streams Manipulation //////////////////////////////////////////////////////////////////////////*/ private void createRenameDialog() { if (playlistId == null || name == null || getContext() == null) { return; } final var dialogBinding = DialogEditTextBinding.inflate(getLayoutInflater()); dialogBinding.dialogEditText.setHint(R.string.name); dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT); dialogBinding.dialogEditText.setSelection(dialogBinding.dialogEditText.getText().length()); dialogBinding.dialogEditText.setText(name); new AlertDialog.Builder(getContext()) .setTitle(R.string.rename_playlist) .setView(dialogBinding.getRoot()) .setCancelable(true) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.rename, (dialogInterface, i) -> changePlaylistName(dialogBinding.dialogEditText.getText().toString())) .show(); } private void changePlaylistName(final String title) { if (playlistManager == null) { return; } this.name = title; setTitle(title); if (DEBUG) { Log.d(TAG, "Updating playlist id=[" + playlistId + "] " + "with new title=[" + title + "] items"); } final Disposable disposable = playlistManager.renamePlaylist(playlistId, title) .observeOn(AndroidSchedulers.mainThread()) .subscribe(longs -> { /*Do nothing on success*/ }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, "Renaming playlist"))); disposables.add(disposable); } private void changeThumbnailStreamId(final long thumbnailStreamId, final boolean isPermanent) { if (playlistManager == null || (!isPermanent && playlistManager .getIsPlaylistThumbnailPermanent(playlistId))) { return; } final Toast successToast = Toast.makeText(getActivity(), R.string.playlist_thumbnail_change_success, Toast.LENGTH_SHORT); if (DEBUG) { Log.d(TAG, "Updating playlist id=[" + playlistId + "] " + "with new thumbnail stream id=[" + thumbnailStreamId + "]"); } final Disposable disposable = playlistManager .changePlaylistThumbnail(playlistId, thumbnailStreamId, isPermanent) .observeOn(AndroidSchedulers.mainThread()) .subscribe(ignore -> successToast.show(), throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, "Changing playlist thumbnail"))); disposables.add(disposable); } private void updateThumbnailUrl() { if (playlistManager.getIsPlaylistThumbnailPermanent(playlistId)) { return; } final long thumbnailStreamId; if (!itemListAdapter.getItemsList().isEmpty()) { thumbnailStreamId = ((PlaylistStreamEntry) itemListAdapter.getItemsList().get(0)) .getStreamEntity().getUid(); } else { thumbnailStreamId = PlaylistEntity.DEFAULT_THUMBNAIL_ID; } changeThumbnailStreamId(thumbnailStreamId, false); } private void openRemoveDuplicatesDialog() { new AlertDialog.Builder(this.getActivity()) .setTitle(R.string.remove_duplicates_title) .setMessage(R.string.remove_duplicates_message) .setPositiveButton(R.string.ok, (dialog, i) -> removeDuplicatesInPlaylist()) .setNeutralButton(R.string.cancel, null) .show(); } private void removeDuplicatesInPlaylist() { if (isRewritingPlaylist) { return; } isRewritingPlaylist = true; showLoading(); final var streamsMaybe = playlistManager .getDistinctPlaylistStreams(playlistId).firstElement(); disposables.add(streamsMaybe.subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(itemsToKeep -> { itemListAdapter.clearStreamItemList(); itemListAdapter.addItems(itemsToKeep); setStreamCountAndOverallDuration(itemListAdapter.getItemsList()); debounceSaver.setHasChangesToSave(); saveImmediate(); hideLoading(); isRewritingPlaylist = false; }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, "Removing duplicated streams")))); } private void deleteItem(final PlaylistStreamEntry item) { if (itemListAdapter == null) { return; } itemListAdapter.removeItem(item); if (playlistManager.getPlaylistThumbnailStreamId(playlistId) == item.getStreamId()) { updateThumbnailUrl(); } setStreamCountAndOverallDuration(itemListAdapter.getItemsList()); debounceSaver.setHasChangesToSave(); saveImmediate(); } /** *

Commit changes immediately if the playlist has been modified.

* Delete operations and other modifications will be committed to ensure that the database * is up to date, e.g. when the user adds the just deleted stream from another fragment. */ @Override public void saveImmediate() { if (playlistManager == null || itemListAdapter == null) { return; } // List must be loaded and modified in order to save if (isLoadingComplete == null || debounceSaver == null || !isLoadingComplete.get() || !debounceSaver.getIsModified()) { return; } final List items = itemListAdapter.getItemsList(); final List streamIds = new ArrayList<>(items.size()); for (final LocalItem item : items) { if (item instanceof PlaylistStreamEntry entry) { streamIds.add(entry.getStreamId()); } } if (DEBUG) { Log.d(TAG, "Updating playlist id=[" + playlistId + "] " + "with [" + streamIds.size() + "] items"); } final Disposable disposable = playlistManager.updateJoin(playlistId, streamIds) .observeOn(AndroidSchedulers.mainThread()) .subscribe( () -> { if (debounceSaver != null) { debounceSaver.setNoChangesToSave(); } }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, "Saving playlist")) ); disposables.add(disposable); } private ItemTouchHelper.SimpleCallback getItemTouchCallback() { int directions = ItemTouchHelper.UP | ItemTouchHelper.DOWN; if (shouldUseGridLayout(requireContext())) { directions |= ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT; } return new ItemTouchHelper.SimpleCallback(directions, ItemTouchHelper.ACTION_STATE_IDLE) { @Override public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView, final int viewSize, final int viewSizeOutOfBounds, final int totalSize, final long msSinceStartScroll) { final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll); final int minimumAbsVelocity = Math.max(MINIMUM_INITIAL_DRAG_VELOCITY, Math.abs(standardSpeed)); return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); } @Override public boolean onMove(@NonNull final RecyclerView recyclerView, @NonNull final RecyclerView.ViewHolder source, @NonNull final RecyclerView.ViewHolder target) { if (source.getItemViewType() != target.getItemViewType() || itemListAdapter == null) { return false; } final int sourceIndex = source.getBindingAdapterPosition(); final int targetIndex = target.getBindingAdapterPosition(); final boolean isSwapped = itemListAdapter.swapItems(sourceIndex, targetIndex); if (isSwapped) { debounceSaver.setHasChangesToSave(); } return isSwapped; } @Override public void clearView(@NonNull final RecyclerView recyclerView, @NonNull final RecyclerView.ViewHolder viewHolder) { super.clearView(recyclerView, viewHolder); saveImmediate(); } @Override public boolean isLongPressDragEnabled() { return false; } @Override public boolean isItemViewSwipeEnabled() { return false; } @Override public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, final int swipeDir) { } }; } /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ private PlayQueue getPlayQueueStartingAt(final PlaylistStreamEntry infoItem) { return getPlayQueue(Math.max(itemListAdapter.getItemsList().indexOf(infoItem), 0)); } protected void showInfoItemDialog(final PlaylistStreamEntry item) { final StreamInfoItem infoItem = item.toStreamInfoItem(); try { final Context context = getContext(); final InfoItemDialog.Builder dialogBuilder = new InfoItemDialog.Builder(getActivity(), context, this, infoItem); // add entries in the middle dialogBuilder.addAllEntries( StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL, StreamDialogDefaultEntry.DELETE ); // set custom actions // all entries modified below have already been added within the builder dialogBuilder .setAction( StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND, (f, i) -> NavigationHelper.playOnBackgroundPlayer( context, getPlayQueueStartingAt(item), true)) .setAction( StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL, (f, i) -> changeThumbnailStreamId(item.getStreamEntity().getUid(), true)) .setAction( StreamDialogDefaultEntry.DELETE, (f, i) -> deleteItem(item)) .create() .show(); } catch (final IllegalArgumentException e) { InfoItemDialog.Builder.reportErrorDuringInitialization(e, infoItem); } } private void setInitialData(final long pid, final String title) { this.playlistId = pid; this.name = !TextUtils.isEmpty(title) ? title : ""; } private void setStreamCountAndOverallDuration(final ArrayList itemsList) { if (activity != null && headerBinding != null) { final long streamCount = itemsList.size(); final long playlistOverallDurationSeconds = itemsList.stream() .filter(PlaylistStreamEntry.class::isInstance) .map(PlaylistStreamEntry.class::cast) .map(PlaylistStreamEntry::getStreamEntity) .mapToLong(StreamEntity::getDuration) .sum(); headerBinding.playlistStreamCount.setText( Localization.concatenateStrings( Localization.localizeStreamCount(activity, streamCount), Localization.getDurationString(playlistOverallDurationSeconds, true, true)) ); } } @Override public PlayQueue getPlayQueue() { return getPlayQueue(0); } private PlayQueue getPlayQueue(final int index) { if (itemListAdapter == null) { return new SinglePlayQueue(Collections.emptyList(), 0); } final List infoItems = itemListAdapter.getItemsList(); final List streamInfoItems = new ArrayList<>(infoItems.size()); for (final LocalItem item : infoItems) { if (item instanceof PlaylistStreamEntry) { streamInfoItems.add(((PlaylistStreamEntry) item).toStreamInfoItem()); } } return new SinglePlayQueue(streamInfoItems, index); } /** * Creates a dialog to confirm whether the user wants to share the playlist * with the playlist details or just the list of stream URLs. * After the user has made a choice, the playlist is shared. */ private void createShareConfirmationDialog() { new AlertDialog.Builder(requireContext()) .setTitle(R.string.share_playlist) .setCancelable(true) .setPositiveButton(R.string.share_playlist_with_titles, (dialog, which) -> sharePlaylist(WITH_TITLES) ) .setNeutralButton(R.string.share_playlist_as_youtube_temporary_playlist, (dialog, which) -> sharePlaylist(YOUTUBE_TEMP_PLAYLIST) ) .setNegativeButton(R.string.share_playlist_with_list, (dialog, which) -> sharePlaylist(JUST_URLS) ) .show(); } /** * Opens a confirmation dialog to remove watched streams from the playlist. * The user can also choose to remove partially watched streams. */ private void openRemoveWatchedConfirmationDialog() { final android.widget.CheckBox removePartiallyWatchedCheckbox = new android.widget.CheckBox(requireContext()); removePartiallyWatchedCheckbox.setText( R.string.remove_watched_popup_partially_watched_streams); // Wrap the checkbox in a container with dialog-like horizontal padding // so it aligns with the dialog title and message on the start side. final LinearLayout checkboxContainer = new LinearLayout(requireContext()); checkboxContainer.setOrientation(LinearLayout.VERTICAL); final int padding = DeviceUtils.dpToPx(20, requireContext()); checkboxContainer.setPadding(padding, padding, padding, 0); checkboxContainer.addView(removePartiallyWatchedCheckbox, new LayoutParams(MATCH_PARENT, WRAP_CONTENT)); new AlertDialog.Builder(requireContext()) .setMessage(R.string.remove_watched_popup_warning) .setTitle(R.string.remove_watched_popup_title) .setView(checkboxContainer) .setPositiveButton(R.string.yes, (d, id) -> removeWatchedStreams(removePartiallyWatchedCheckbox.isChecked())) .setNegativeButton(R.string.cancel, (d, id) -> d.cancel()) .show(); } public void setTabsPagerAdapter( @Nullable final MainFragment.SelectedTabsPagerAdapter tabsPagerAdapter) { this.tabsPagerAdapter = tabsPagerAdapter; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java ================================================ package org.schabi.newpipe.local.playlist; import androidx.annotation.Nullable; import org.schabi.newpipe.database.AppDatabase; import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry; import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; import org.schabi.newpipe.database.playlist.dao.PlaylistDAO; import org.schabi.newpipe.database.playlist.dao.PlaylistStreamDAO; import org.schabi.newpipe.database.playlist.model.PlaylistEntity; import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity; import org.schabi.newpipe.database.stream.dao.StreamDAO; import org.schabi.newpipe.database.stream.model.StreamEntity; import java.util.ArrayList; import java.util.List; import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.core.Flowable; import io.reactivex.rxjava3.core.Maybe; import io.reactivex.rxjava3.schedulers.Schedulers; public class LocalPlaylistManager { private static final long THUMBNAIL_ID_LEAVE_UNCHANGED = -2; private final AppDatabase database; private final StreamDAO streamTable; private final PlaylistDAO playlistTable; private final PlaylistStreamDAO playlistStreamTable; public LocalPlaylistManager(final AppDatabase db) { database = db; streamTable = db.streamDAO(); playlistTable = db.playlistDAO(); playlistStreamTable = db.playlistStreamDAO(); } public Maybe> createPlaylist(final String name, final List streams) { // Disallow creation of empty playlists if (streams.isEmpty()) { return Maybe.empty(); } // Save to the database directly. // Make sure the new playlist is always on the top of bookmark. // The index will be reassigned to non-negative number in BookmarkFragment. return Maybe.fromCallable(() -> database.runInTransaction(() -> { final List streamIds = streamTable.upsertAll(streams); final PlaylistEntity newPlaylist = new PlaylistEntity(name, false, streamIds.get(0), -1); return insertJoinEntities(playlistTable.insert(newPlaylist), streamIds, 0); } )).subscribeOn(Schedulers.io()); } public Maybe> appendToPlaylist(final long playlistId, final List streams) { return playlistStreamTable.getMaximumIndexOf(playlistId) .firstElement() .map(maxJoinIndex -> database.runInTransaction(() -> { final List streamIds = streamTable.upsertAll(streams); return insertJoinEntities(playlistId, streamIds, maxJoinIndex + 1); } )).subscribeOn(Schedulers.io()); } private List insertJoinEntities(final long playlistId, final List streamIds, final int indexOffset) { final List joinEntities = new ArrayList<>(streamIds.size()); for (int index = 0; index < streamIds.size(); index++) { joinEntities.add(new PlaylistStreamEntity(playlistId, streamIds.get(index), index + indexOffset)); } return playlistStreamTable.insertAll(joinEntities); } public Completable updateJoin(final long playlistId, final List streamIds) { final List joinEntities = new ArrayList<>(streamIds.size()); for (int i = 0; i < streamIds.size(); i++) { joinEntities.add(new PlaylistStreamEntity(playlistId, streamIds.get(i), i)); } return Completable.fromRunnable(() -> database.runInTransaction(() -> { playlistStreamTable.deleteBatch(playlistId); playlistStreamTable.insertAll(joinEntities); })).subscribeOn(Schedulers.io()); } public Completable updatePlaylists(final List updateItems, final List deletedItems) { final List items = new ArrayList<>(updateItems.size()); for (final PlaylistMetadataEntry item : updateItems) { items.add(new PlaylistEntity(item)); } return Completable.fromRunnable(() -> database.runInTransaction(() -> { for (final Long uid : deletedItems) { playlistTable.deletePlaylist(uid); } for (final PlaylistEntity item : items) { playlistTable.upsertPlaylist(item); } })).subscribeOn(Schedulers.io()); } public Flowable> getDistinctPlaylistStreams(final long playlistId) { return playlistStreamTable .getStreamsWithoutDuplicates(playlistId).subscribeOn(Schedulers.io()); } /** * Get playlists with attached information about how many times the provided stream is already * contained in each playlist. * * @param streamUrl the stream url for which to check for duplicates * @return a list of {@link PlaylistDuplicatesEntry} */ public Flowable> getPlaylistDuplicates(final String streamUrl) { return playlistStreamTable.getPlaylistDuplicatesMetadata(streamUrl) .subscribeOn(Schedulers.io()); } public Flowable> getPlaylists() { return playlistStreamTable.getPlaylistMetadata().subscribeOn(Schedulers.io()); } public Flowable> getPlaylistStreams(final long playlistId) { return playlistStreamTable.getOrderedStreamsOf(playlistId).subscribeOn(Schedulers.io()); } public Maybe renamePlaylist(final long playlistId, final String name) { return modifyPlaylist(playlistId, name, THUMBNAIL_ID_LEAVE_UNCHANGED, false); } public Maybe changePlaylistThumbnail(final long playlistId, final long thumbnailStreamId, final boolean isPermanent) { return modifyPlaylist(playlistId, null, thumbnailStreamId, isPermanent); } public long getPlaylistThumbnailStreamId(final long playlistId) { return playlistTable.getPlaylist(playlistId).blockingFirst().get(0).getThumbnailStreamId(); } public boolean getIsPlaylistThumbnailPermanent(final long playlistId) { return playlistTable.getPlaylist(playlistId).blockingFirst().get(0) .isThumbnailPermanent(); } public long getAutomaticPlaylistThumbnailStreamId(final long playlistId) { final long streamId = playlistStreamTable.getAutomaticThumbnailStreamId(playlistId) .blockingFirst(); if (streamId < 0) { return PlaylistEntity.DEFAULT_THUMBNAIL_ID; } return streamId; } private Maybe modifyPlaylist(final long playlistId, @Nullable final String name, final long thumbnailStreamId, final boolean isPermanent) { return playlistTable.getPlaylist(playlistId) .firstElement() .filter(playlistEntities -> !playlistEntities.isEmpty()) .map(playlistEntities -> { final PlaylistEntity playlist = playlistEntities.get(0); if (name != null) { playlist.setName(name); } if (thumbnailStreamId != THUMBNAIL_ID_LEAVE_UNCHANGED) { playlist.setThumbnailStreamId(thumbnailStreamId); playlist.setThumbnailPermanent(isPermanent); } return playlistTable.update(playlist); }).subscribeOn(Schedulers.io()); } public Maybe hasPlaylists() { return playlistTable.getCount() .firstElement() .map(count -> count > 0) .subscribeOn(Schedulers.io()); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/playlist/PlayListShareMode.kt ================================================ /* * SPDX-FileCopyrightText: 2025 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.local.playlist enum class PlayListShareMode { JUST_URLS, WITH_TITLES, YOUTUBE_TEMP_PLAYLIST } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.kt ================================================ /* * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.local.playlist import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.database.AppDatabase import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity import org.schabi.newpipe.extractor.playlist.PlaylistInfo class RemotePlaylistManager(private val database: AppDatabase) { private val playlistRemoteTable = database.playlistRemoteDAO() val playlists: Flowable> get() = playlistRemoteTable.playlists.subscribeOn(Schedulers.io()) fun getPlaylist(playlistId: Long): Flowable { return playlistRemoteTable.getPlaylist(playlistId).subscribeOn(Schedulers.io()) } fun getPlaylist(info: PlaylistInfo): Flowable> { return playlistRemoteTable.getPlaylist(info.serviceId.toLong(), info.url) .subscribeOn(Schedulers.io()) } fun deletePlaylist(playlistId: Long): Single { return Single.fromCallable { playlistRemoteTable.deletePlaylist(playlistId) } .subscribeOn(Schedulers.io()) } fun updatePlaylists( updateItems: List, deletedItems: List ): Completable { return Completable.fromRunnable { database.runInTransaction { deletedItems.forEach { playlistRemoteTable.deletePlaylist(it) } updateItems.forEach { playlistRemoteTable.upsert(it) } } }.subscribeOn(Schedulers.io()) } fun onBookmark(playlistInfo: PlaylistInfo): Single { return Single.fromCallable { val playlist = PlaylistRemoteEntity(playlistInfo) playlistRemoteTable.upsert(playlist) }.subscribeOn(Schedulers.io()) } fun onUpdate(playlistId: Long, playlistInfo: PlaylistInfo): Single { return Single.fromCallable { val playlist = PlaylistRemoteEntity(playlistInfo).apply { uid = playlistId } playlistRemoteTable.update(playlist) }.subscribeOn(Schedulers.io()) } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt ================================================ package org.schabi.newpipe.local.subscription import androidx.annotation.DrawableRes import org.schabi.newpipe.R enum class FeedGroupIcon( /** * The id that will be used to store and retrieve icons from some persistent storage (e.g. DB). */ val id: Int, /** * The drawable resource. */ @DrawableRes val drawableResource: Int ) { ALL(0, R.drawable.ic_asterisk), MUSIC(1, R.drawable.ic_music_note), EDUCATION(2, R.drawable.ic_school), FITNESS(3, R.drawable.ic_fitness_center), SPACE(4, R.drawable.ic_telescope), COMPUTER(5, R.drawable.ic_computer), GAMING(6, R.drawable.ic_videogame_asset), SPORTS(7, R.drawable.ic_directions_bike), NEWS(8, R.drawable.ic_campaign), FAVORITES(9, R.drawable.ic_favorite), CAR(10, R.drawable.ic_directions_car), MOTORCYCLE(11, R.drawable.ic_motorcycle), TREND(12, R.drawable.ic_trending_up), MOVIE(13, R.drawable.ic_movie), BACKUP(14, R.drawable.ic_backup), ART(15, R.drawable.ic_palette), PERSON(16, R.drawable.ic_person), PEOPLE(17, R.drawable.ic_people), MONEY(18, R.drawable.ic_attach_money), KIDS(19, R.drawable.ic_child_care), FOOD(20, R.drawable.ic_fastfood), SMILE(21, R.drawable.ic_insert_emoticon), EXPLORE(22, R.drawable.ic_explore), RESTAURANT(23, R.drawable.ic_restaurant), MIC(24, R.drawable.ic_mic), HEADSET(25, R.drawable.ic_headset), RADIO(26, R.drawable.ic_radio), SHOPPING_CART(27, R.drawable.ic_shopping_cart), WATCH_LATER(28, R.drawable.ic_watch_later), WORK(29, R.drawable.ic_work), HOT(30, R.drawable.ic_whatshot), CHANNEL(31, R.drawable.ic_tv), BOOKMARK(32, R.drawable.ic_bookmark), PETS(33, R.drawable.ic_pets), WORLD(34, R.drawable.ic_public), STAR(35, R.drawable.ic_stars), SUN(36, R.drawable.ic_wb_sunny), RSS(37, R.drawable.ic_rss_feed), WHATS_NEW(38, R.drawable.ic_subscriptions); @DrawableRes fun getDrawableRes(): Int { return drawableResource } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java ================================================ package org.schabi.newpipe.local.subscription; import android.app.Dialog; import android.os.Bundle; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.core.os.BundleCompat; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.Fragment; import androidx.work.Constraints; import androidx.work.ExistingWorkPolicy; import androidx.work.NetworkType; import androidx.work.OneTimeWorkRequest; import androidx.work.OutOfQuotaPolicy; import androidx.work.WorkManager; import com.livefront.bridge.Bridge; import org.schabi.newpipe.R; import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput; import org.schabi.newpipe.local.subscription.workers.SubscriptionImportWorker; public class ImportConfirmationDialog extends DialogFragment { private static final String INPUT = "input"; public static void show(@NonNull final Fragment fragment, final SubscriptionImportInput input) { final var confirmationDialog = new ImportConfirmationDialog(); final var arguments = new Bundle(); arguments.putParcelable(INPUT, input); confirmationDialog.setArguments(arguments); confirmationDialog.show(fragment.getParentFragmentManager(), null); } @NonNull @Override public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { final var context = requireContext(); return new AlertDialog.Builder(context) .setMessage(R.string.import_network_expensive_warning) .setCancelable(true) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok, (dialogInterface, i) -> { final var constraints = new Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build(); final var input = BundleCompat.getParcelable(requireArguments(), INPUT, SubscriptionImportInput.class); final var req = new OneTimeWorkRequest.Builder(SubscriptionImportWorker.class) .setInputData(input.toData()) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .setConstraints(constraints) .build(); WorkManager.getInstance(context) .enqueueUniqueWork(SubscriptionImportWorker.WORK_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, req); dismiss(); }) .create(); } @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); Bridge.restoreInstanceState(this, savedInstanceState); } @Override public void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); Bridge.saveInstanceState(this, outState); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt ================================================ package org.schabi.newpipe.local.subscription import android.content.Context import android.content.DialogInterface import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.SubMenu import android.view.View import android.view.ViewGroup import android.webkit.MimeTypeMap import android.widget.Toast import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager import com.evernote.android.state.State import com.xwray.groupie.Group import com.xwray.groupie.GroupAdapter import com.xwray.groupie.Section import com.xwray.groupie.viewbinding.GroupieViewHolder import io.reactivex.rxjava3.disposables.CompositeDisposable import org.schabi.newpipe.R import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.GROUP_ALL_ID import org.schabi.newpipe.databinding.DialogTitleBinding import org.schabi.newpipe.databinding.FeedItemCarouselBinding import org.schabi.newpipe.databinding.FragmentSubscriptionBinding import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.UserAction import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.channel.ChannelInfoItem import org.schabi.newpipe.fragments.BaseStateFragment import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.local.subscription.SubscriptionViewModel.SubscriptionState import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialog import org.schabi.newpipe.local.subscription.item.ChannelItem import org.schabi.newpipe.local.subscription.item.FeedGroupAddNewGridItem import org.schabi.newpipe.local.subscription.item.FeedGroupAddNewItem import org.schabi.newpipe.local.subscription.item.FeedGroupCardGridItem import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem import org.schabi.newpipe.local.subscription.item.GroupsHeader import org.schabi.newpipe.local.subscription.item.Header import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.OnClickGesture import org.schabi.newpipe.util.ServiceHelper import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountChannels import org.schabi.newpipe.util.external_communication.ShareUtils class SubscriptionFragment : BaseStateFragment() { private var _binding: FragmentSubscriptionBinding? = null private val binding get() = _binding!! private lateinit var viewModel: SubscriptionViewModel private lateinit var subscriptionManager: SubscriptionManager private lateinit var importExportHelper: SubscriptionsImportExportHelper private val disposables: CompositeDisposable = CompositeDisposable() private val groupAdapter = GroupAdapter>() private lateinit var carouselAdapter: GroupAdapter> private lateinit var feedGroupsCarousel: FeedGroupCarouselItem private lateinit var feedGroupsSortMenuItem: GroupsHeader private val subscriptionsSection = Section() @State @JvmField var itemsListState: Parcelable? = null @State @JvmField var feedGroupsCarouselState: Parcelable? = null init { setHasOptionsMenu(true) } // ///////////////////////////////////////////////////////////////////////// // Fragment LifeCycle // ///////////////////////////////////////////////////////////////////////// override fun onAttach(context: Context) { super.onAttach(context) subscriptionManager = SubscriptionManager(requireContext()) importExportHelper = SubscriptionsImportExportHelper(this) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return inflater.inflate(R.layout.fragment_subscription, container, false) } override fun onPause() { super.onPause() itemsListState = binding.itemsList.layoutManager?.onSaveInstanceState() feedGroupsCarouselState = feedGroupsCarousel.onSaveInstanceState() } override fun onDestroyView() { super.onDestroyView() _binding = null } override fun onDestroy() { super.onDestroy() disposables.dispose() } // //////////////////////////////////////////////////////////////////////// // Menu // //////////////////////////////////////////////////////////////////////// override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) activity.supportActionBar?.setDisplayShowTitleEnabled(true) activity.supportActionBar?.setTitle(R.string.tab_subscriptions) buildImportExportMenu(menu) } private fun buildImportExportMenu(menu: Menu) { // -- Import -- val importSubMenu = menu.addSubMenu(R.string.import_from) addMenuItemToSubmenu(importSubMenu, R.string.previous_export) { importExportHelper.onImportPreviousSelected() } .setIcon(R.drawable.ic_backup) for (service in ServiceList.all()) { val subscriptionExtractor = service.subscriptionExtractor ?: continue val supportedSources = subscriptionExtractor.supportedSources if (supportedSources.isEmpty()) continue addMenuItemToSubmenu(importSubMenu, service.serviceInfo.name) { onImportFromServiceSelected(service.serviceId) } .setIcon(ServiceHelper.getIcon(service.serviceId)) } // -- Export -- val exportSubMenu = menu.addSubMenu(R.string.export_to) addMenuItemToSubmenu(exportSubMenu, R.string.file) { importExportHelper.onExportSelected() } .setIcon(R.drawable.ic_save) } private fun addMenuItemToSubmenu( subMenu: SubMenu, @StringRes title: Int, onClick: Runnable ): MenuItem { return setClickListenerToMenuItem(subMenu.add(title), onClick) } private fun addMenuItemToSubmenu( subMenu: SubMenu, title: String, onClick: Runnable ): MenuItem { return setClickListenerToMenuItem(subMenu.add(title), onClick) } private fun setClickListenerToMenuItem( menuItem: MenuItem, onClick: Runnable ): MenuItem { menuItem.setOnMenuItemClickListener { onClick.run() true } return menuItem } private fun onImportFromServiceSelected(serviceId: Int) { val fragmentManager = fm NavigationHelper.openSubscriptionsImportFragment(fragmentManager, serviceId) } private fun openReorderDialog() { FeedGroupReorderDialog().show(parentFragmentManager, null) } // //////////////////////////////////////////////////////////////////////// // Fragment Views // //////////////////////////////////////////////////////////////////////// override fun initViews(rootView: View, savedInstanceState: Bundle?) { super.initViews(rootView, savedInstanceState) _binding = FragmentSubscriptionBinding.bind(rootView) groupAdapter.spanCount = if (SubscriptionViewModel.shouldUseGridForSubscription(requireContext())) getGridSpanCountChannels(context) else 1 binding.itemsList.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply { spanSizeLookup = groupAdapter.spanSizeLookup } binding.itemsList.adapter = groupAdapter binding.itemsList.itemAnimator = null viewModel = ViewModelProvider(this)[SubscriptionViewModel::class.java] viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(this::handleResult) } viewModel.feedGroupsLiveData.observe(viewLifecycleOwner) { it?.let { (groups, listViewMode) -> handleFeedGroups(groups, listViewMode) } } setupInitialLayout() } private fun setupInitialLayout() { Section().apply { carouselAdapter = GroupAdapter>() carouselAdapter.setOnItemClickListener { item, _ -> when (item) { is FeedGroupCardItem -> NavigationHelper.openFeedFragment(fm, item.groupId, item.name) is FeedGroupCardGridItem -> NavigationHelper.openFeedFragment(fm, item.groupId, item.name) is FeedGroupAddNewItem -> FeedGroupDialog.newInstance().show(fm, null) is FeedGroupAddNewGridItem -> FeedGroupDialog.newInstance().show(fm, null) } } carouselAdapter.setOnItemLongClickListener { item, _ -> if ((item is FeedGroupCardItem && item.groupId == GROUP_ALL_ID) || (item is FeedGroupCardGridItem && item.groupId == GROUP_ALL_ID) ) { return@setOnItemLongClickListener false } when (item) { is FeedGroupCardItem -> FeedGroupDialog.newInstance(item.groupId).show(fm, null) is FeedGroupCardGridItem -> FeedGroupDialog.newInstance(item.groupId).show(fm, null) } return@setOnItemLongClickListener true } feedGroupsCarousel = FeedGroupCarouselItem( carouselAdapter = carouselAdapter, listViewMode = viewModel.getListViewMode() ) feedGroupsSortMenuItem = GroupsHeader( title = getString(R.string.feed_groups_header_title), onSortClicked = ::openReorderDialog, onToggleListViewModeClicked = ::toggleListViewMode, listViewMode = viewModel.getListViewMode() ) add(Section(feedGroupsSortMenuItem, listOf(feedGroupsCarousel))) groupAdapter.clear() groupAdapter.add(this) } subscriptionsSection.setPlaceholder(ImportSubscriptionsHintPlaceholderItem()) subscriptionsSection.setHideWhenEmpty(true) groupAdapter.add( Section( Header(getString(R.string.tab_subscriptions)), listOf(subscriptionsSection) ) ) } private fun toggleListViewMode() { viewModel.setListViewMode(!viewModel.getListViewMode()) } private fun showLongTapDialog(selectedItem: ChannelInfoItem) { val commands = arrayOf( getString(R.string.share), getString(R.string.open_in_browser), getString(R.string.unsubscribe) ) val actions = DialogInterface.OnClickListener { _, i -> when (i) { 0 -> ShareUtils.shareText( requireContext(), selectedItem.name, selectedItem.url, selectedItem.thumbnails ) 1 -> ShareUtils.openUrlInBrowser(requireContext(), selectedItem.url) 2 -> deleteChannel(selectedItem) } } val dialogTitleBinding = DialogTitleBinding.inflate(LayoutInflater.from(requireContext())) dialogTitleBinding.root.isSelected = true dialogTitleBinding.itemTitleView.text = selectedItem.name dialogTitleBinding.itemAdditionalDetails.visibility = View.GONE AlertDialog.Builder(requireContext()) .setCustomTitle(dialogTitleBinding.root) .setItems(commands, actions) .show() } private fun deleteChannel(selectedItem: ChannelInfoItem) { disposables.add( subscriptionManager.deleteSubscription(selectedItem.serviceId, selectedItem.url).subscribe { Toast.makeText(requireContext(), getString(R.string.channel_unsubscribed), Toast.LENGTH_SHORT).show() } ) } override fun doInitialLoadLogic() = Unit override fun startLoading(forceLoad: Boolean) = Unit private val listenerChannelItem = object : OnClickGesture { override fun selected(selectedItem: ChannelInfoItem) = NavigationHelper.openChannelFragment( fm, selectedItem.serviceId, selectedItem.url, selectedItem.name ) override fun held(selectedItem: ChannelInfoItem) = showLongTapDialog(selectedItem) } override fun handleResult(result: SubscriptionState) { super.handleResult(result) when (result) { is SubscriptionState.LoadedState -> { result.subscriptions.forEach { if (it is ChannelItem) { it.gesturesListener = listenerChannelItem it.itemVersion = if (SubscriptionViewModel.shouldUseGridForSubscription(requireContext())) { ChannelItem.ItemVersion.GRID } else { ChannelItem.ItemVersion.MINI } } } subscriptionsSection.update(result.subscriptions) subscriptionsSection.setHideWhenEmpty(false) if (itemsListState != null) { binding.itemsList.layoutManager?.onRestoreInstanceState(itemsListState) itemsListState = null } } is SubscriptionState.ErrorState -> { result.error?.let { showError(ErrorInfo(result.error, UserAction.SOMETHING_ELSE, "Subscriptions")) } } } } private fun handleFeedGroups(groups: List, listViewMode: Boolean) { if (feedGroupsCarouselState != null) { feedGroupsCarousel.onRestoreInstanceState(feedGroupsCarouselState) feedGroupsCarouselState = null } binding.itemsList.post { if (context == null) { // since this part was posted to the next UI cycle, the fragment might have been // removed in the meantime return@post } feedGroupsCarousel.listViewMode = listViewMode feedGroupsSortMenuItem.showSortButton = groups.size > 1 feedGroupsSortMenuItem.listViewMode = listViewMode feedGroupsCarousel.notifyChanged(FeedGroupCarouselItem.PAYLOAD_UPDATE_LIST_VIEW_MODE) feedGroupsSortMenuItem.notifyChanged(GroupsHeader.PAYLOAD_UPDATE_ICONS) // update items here to prevent flickering carouselAdapter.apply { clear() if (listViewMode) { add(FeedGroupAddNewItem()) add(FeedGroupCardItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.WHATS_NEW)) } else { add(FeedGroupAddNewGridItem()) add(FeedGroupCardGridItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.WHATS_NEW)) } addAll(groups) } } } // ///////////////////////////////////////////////////////////////////////// // Contract // ///////////////////////////////////////////////////////////////////////// override fun showLoading() { super.showLoading() binding.itemsList.animate(false, 100) } override fun hideLoading() { super.hideLoading() binding.itemsList.animate(true, 200) } companion object { val JSON_MIME_TYPE = MimeTypeMap.getSingleton() .getMimeTypeFromExtension("json") ?: "application/octet-stream" } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt ================================================ package org.schabi.newpipe.local.subscription import android.content.Context import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.NewPipeDatabase import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.database.subscription.SubscriptionDAO import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.extractor.channel.ChannelInfo import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.local.feed.FeedDatabaseManager import org.schabi.newpipe.local.feed.service.FeedUpdateInfo import org.schabi.newpipe.util.ExtractorHelper import org.schabi.newpipe.util.image.ImageStrategy class SubscriptionManager(context: Context) { private val database = NewPipeDatabase.getInstance(context) private val subscriptionTable = database.subscriptionDAO() private val feedDatabaseManager = FeedDatabaseManager(context) fun subscriptionTable(): SubscriptionDAO = subscriptionTable fun subscriptions() = subscriptionTable.getAll() fun getSubscriptions( currentGroupId: Long = FeedGroupEntity.GROUP_ALL_ID, filterQuery: String = "", showOnlyUngrouped: Boolean = false ): Flowable> { return when { filterQuery.isNotEmpty() -> { return if (showOnlyUngrouped) { subscriptionTable.getSubscriptionsOnlyUngroupedFiltered( currentGroupId, filterQuery ) } else { subscriptionTable.getSubscriptionsFiltered(filterQuery) } } showOnlyUngrouped -> subscriptionTable.getSubscriptionsOnlyUngrouped(currentGroupId) else -> subscriptionTable.getAll() } } fun upsertAll(infoList: List>) { val listEntities = infoList.map { SubscriptionEntity.from(it.first) } subscriptionTable.upsertAll(listEntities) database.runInTransaction { infoList.forEachIndexed { index, info -> val streams = info.second.relatedItems.filterIsInstance() feedDatabaseManager.upsertAll(listEntities[index].uid, streams) } } } fun updateChannelInfo(info: ChannelInfo): Completable = subscriptionTable.getSubscription(info.serviceId, info.url) .flatMapCompletable { Completable.fromRunnable { it.apply { name = info.name avatarUrl = ImageStrategy.imageListToDbUrl(info.avatars) description = info.description subscriberCount = info.subscriberCount } subscriptionTable.update(it) } } fun updateNotificationMode(serviceId: Int, url: String, @NotificationMode mode: Int): Completable { return subscriptionTable().getSubscription(serviceId, url) .flatMapCompletable { entity: SubscriptionEntity -> Completable.fromAction { entity.notificationMode = mode subscriptionTable().update(entity) }.apply { if (mode != NotificationMode.DISABLED) { // notifications have just been enabled, mark all streams as "old" andThen(rememberAllStreams(entity)) } } } } fun updateFromInfo(info: FeedUpdateInfo) { val subscriptionEntity = subscriptionTable.getSubscription(info.uid) subscriptionEntity.name = info.name // some services do not provide an avatar URL info.avatarUrl?.let { subscriptionEntity.avatarUrl = it } // these two fields are null if the feed info was fetched using the fast feed method info.description?.let { subscriptionEntity.description = it } info.subscriberCount?.let { subscriptionEntity.subscriberCount = it } subscriptionTable.update(subscriptionEntity) } fun deleteSubscription(serviceId: Int, url: String): Completable { return Completable.fromCallable { subscriptionTable.deleteSubscription(serviceId, url) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) } fun insertSubscription(subscriptionEntity: SubscriptionEntity) { subscriptionTable.insert(subscriptionEntity) } fun deleteSubscription(subscriptionEntity: SubscriptionEntity) { subscriptionTable.delete(subscriptionEntity) } /** * Fetches the list of videos for the provided channel and saves them in the database, so that * they will be considered as "old"/"already seen" streams and the user will never be notified * about any one of them. */ private fun rememberAllStreams(subscription: SubscriptionEntity): Completable { return ExtractorHelper.getChannelInfo(subscription.serviceId, subscription.url, false) .flatMap { info -> ExtractorHelper.getChannelTab(subscription.serviceId, info.tabs.first(), false) } .map { channel -> channel.relatedItems.filterIsInstance().map { stream -> StreamEntity(stream) } } .flatMapCompletable { entities -> Completable.fromAction { database.streamDAO().upsertAll(entities) } }.onErrorComplete() } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt ================================================ package org.schabi.newpipe.local.subscription import android.app.Application import android.content.Context import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import com.xwray.groupie.Group import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.processors.BehaviorProcessor import io.reactivex.rxjava3.schedulers.Schedulers import java.util.concurrent.TimeUnit import org.schabi.newpipe.info_list.ItemViewMode import org.schabi.newpipe.local.feed.FeedDatabaseManager import org.schabi.newpipe.local.subscription.item.ChannelItem import org.schabi.newpipe.local.subscription.item.FeedGroupCardGridItem import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT import org.schabi.newpipe.util.ThemeHelper.getItemViewMode class SubscriptionViewModel(application: Application) : AndroidViewModel(application) { private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application) private var subscriptionManager = SubscriptionManager(application) // true -> list view, false -> grid view private val listViewMode = BehaviorProcessor.createDefault( !shouldUseGridForSubscription(application) ) private val listViewModeFlowable = listViewMode.distinctUntilChanged() private val mutableStateLiveData = MutableLiveData() private val mutableFeedGroupsLiveData = MutableLiveData, Boolean>>() val stateLiveData: LiveData = mutableStateLiveData val feedGroupsLiveData: LiveData, Boolean>> = mutableFeedGroupsLiveData private var feedGroupItemsDisposable = Flowable .combineLatest( feedDatabaseManager.groups(), listViewModeFlowable, ::Pair ) .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS) .map { (feedGroups, listViewMode) -> Pair( feedGroups.map(if (listViewMode) ::FeedGroupCardItem else ::FeedGroupCardGridItem), listViewMode ) } .subscribeOn(Schedulers.io()) .subscribe( { mutableFeedGroupsLiveData.postValue(it) }, { mutableStateLiveData.postValue(SubscriptionState.ErrorState(it)) } ) private var stateItemsDisposable = subscriptionManager.subscriptions() .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS) .map { it.map { entity -> ChannelItem(entity.toChannelInfoItem(), entity.uid, ChannelItem.ItemVersion.MINI) } } .subscribeOn(Schedulers.io()) .subscribe( { mutableStateLiveData.postValue(SubscriptionState.LoadedState(it)) }, { mutableStateLiveData.postValue(SubscriptionState.ErrorState(it)) } ) override fun onCleared() { super.onCleared() stateItemsDisposable.dispose() feedGroupItemsDisposable.dispose() } fun setListViewMode(newListViewMode: Boolean) { listViewMode.onNext(newListViewMode) } fun getListViewMode(): Boolean { return listViewMode.value ?: true } sealed class SubscriptionState { data class LoadedState(val subscriptions: List) : SubscriptionState() data class ErrorState(val error: Throwable? = null) : SubscriptionState() } companion object { /** * Returns whether to use GridLayout mode for Subscription Fragment. * * ### Current mapping: * * | ItemViewMode | ItemVersion | Span count | * |---|---|---| * | AUTO | MINI | 1 | * | LIST | MINI | 1 | * | CARD | GRID | > 1 (ThemeHelper defined) | * | GRID | GRID | > 1 (ThemeHelper defined) | * * @see [SubscriptionViewModel.shouldUseGridForSubscription] to modify Layout Manager */ fun shouldUseGridForSubscription(context: Context): Boolean { val itemViewMode = getItemViewMode(context) return itemViewMode == ItemViewMode.GRID || itemViewMode == ItemViewMode.CARD } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportExportHelper.kt ================================================ package org.schabi.newpipe.local.subscription import android.app.Activity import android.content.Context import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.fragment.app.Fragment import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import org.schabi.newpipe.local.subscription.SubscriptionFragment.Companion.JSON_MIME_TYPE import org.schabi.newpipe.local.subscription.workers.SubscriptionExportWorker import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard import org.schabi.newpipe.streams.io.StoredFileHelper /** * This class has to be created in onAttach() or onCreate(). * * It contains registerForActivityResult calls and those * calls are only allowed before a fragment/activity is created. */ class SubscriptionsImportExportHelper( val fragment: Fragment ) { val context: Context = fragment.requireContext() companion object { val TAG: String = SubscriptionsImportExportHelper::class.java.simpleName + "@" + Integer.toHexString( hashCode() ) } private val requestExportLauncher = fragment.registerForActivityResult(StartActivityForResult(), this::requestExportResult) private val requestImportLauncher = fragment.registerForActivityResult(StartActivityForResult(), this::requestImportResult) private fun requestExportResult(result: ActivityResult) { val data = result.data?.data if (data != null && result.resultCode == Activity.RESULT_OK) { SubscriptionExportWorker.schedule(context, data) } } private fun requestImportResult(result: ActivityResult) { val data = result.data?.dataString if (data != null && result.resultCode == Activity.RESULT_OK) { ImportConfirmationDialog.show( fragment, SubscriptionImportInput.PreviousExportMode(data) ) } } fun onExportSelected() { val date = SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(Date()) val exportName = "newpipe_subscriptions_$date.json" NoFileManagerSafeGuard.launchSafe( requestExportLauncher, StoredFileHelper.getNewPicker( context, exportName, JSON_MIME_TYPE, null ), TAG, context ) } fun onImportPreviousSelected() { NoFileManagerSafeGuard.launchSafe( requestImportLauncher, StoredFileHelper.getPicker(context, JSON_MIME_TYPE), TAG, context ) } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java ================================================ package org.schabi.newpipe.local.subscription; import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL; import android.app.Activity; import android.content.Intent; import android.os.Bundle; import android.text.TextUtils; import android.text.util.Linkify; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.Button; import android.widget.EditText; import android.widget.TextView; import androidx.activity.result.ActivityResult; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.app.ActionBar; import androidx.core.text.util.LinkifyCompat; import com.evernote.android.state.State; import org.schabi.newpipe.BaseFragment; 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.NewPipe; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput; import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard; import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.ServiceHelper; import java.util.Collections; import java.util.List; public class SubscriptionsImportFragment extends BaseFragment { @State int currentServiceId = Constants.NO_SERVICE_ID; private List supportedSources; private String relatedUrl; @StringRes private int instructionsString; /*////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////*/ private TextView infoTextView; private EditText inputText; private Button inputButton; private final ActivityResultLauncher requestImportFileLauncher = registerForActivityResult(new StartActivityForResult(), this::requestImportFileResult); public static SubscriptionsImportFragment getInstance(final int serviceId) { final SubscriptionsImportFragment instance = new SubscriptionsImportFragment(); instance.setInitialData(serviceId); return instance; } private void setInitialData(final int serviceId) { this.currentServiceId = serviceId; } /////////////////////////////////////////////////////////////////////////// // Fragment LifeCycle /////////////////////////////////////////////////////////////////////////// @Override public void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); setupServiceVariables(); if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) { ErrorUtil.showSnackbar(activity, new ErrorInfo(new String[]{}, UserAction.SUBSCRIPTION_IMPORT_EXPORT, "Service does not support importing subscriptions", currentServiceId, R.string.general_error)); activity.finish(); } } @Override public void onResume() { super.onResume(); setTitle(getString(R.string.import_title)); } @Nullable @Override public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, final Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_import, container, false); } /*///////////////////////////////////////////////////////////////////////// // Fragment Views /////////////////////////////////////////////////////////////////////////*/ @Override protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); inputButton = rootView.findViewById(R.id.input_button); inputText = rootView.findViewById(R.id.input_text); infoTextView = rootView.findViewById(R.id.info_text_view); // TODO: Support services that can import from more than one source // (show the option to the user) if (supportedSources.contains(CHANNEL_URL)) { inputButton.setText(R.string.import_title); inputText.setVisibility(View.VISIBLE); inputText.setHint(ServiceHelper.getImportInstructionsHint(currentServiceId)); } else { inputButton.setText(R.string.import_file_title); } if (instructionsString != 0) { if (TextUtils.isEmpty(relatedUrl)) { setInfoText(getString(instructionsString)); } else { setInfoText(getString(instructionsString, relatedUrl)); } } else { setInfoText(""); } final ActionBar supportActionBar = activity.getSupportActionBar(); if (supportActionBar != null) { supportActionBar.setDisplayShowTitleEnabled(true); setTitle(getString(R.string.import_title)); } } @Override protected void initListeners() { super.initListeners(); inputButton.setOnClickListener(v -> onImportClicked()); } private void onImportClicked() { if (inputText.getVisibility() == View.VISIBLE) { final String value = inputText.getText().toString(); if (!value.isEmpty()) { onImportUrl(value); } } else { onImportFile(); } } public void onImportUrl(final String value) { ImportConfirmationDialog.show(this, new SubscriptionImportInput.ChannelUrlMode(currentServiceId, value)); } public void onImportFile() { NoFileManagerSafeGuard.launchSafe( requestImportFileLauncher, // leave */* mime type to support all services // with different mime types and file extensions StoredFileHelper.getPicker(activity, "*/*"), TAG, getContext() ); } private void requestImportFileResult(final ActivityResult result) { final String data = result.getData() != null ? result.getData().getDataString() : null; if (result.getResultCode() == Activity.RESULT_OK && data != null) { ImportConfirmationDialog.show(this, new SubscriptionImportInput.InputStreamMode(currentServiceId, data)); } } /////////////////////////////////////////////////////////////////////////// // Subscriptions /////////////////////////////////////////////////////////////////////////// private void setupServiceVariables() { if (currentServiceId != Constants.NO_SERVICE_ID) { try { final SubscriptionExtractor extractor = NewPipe.getService(currentServiceId) .getSubscriptionExtractor(); supportedSources = extractor.getSupportedSources(); relatedUrl = extractor.getRelatedUrl(); instructionsString = ServiceHelper.getImportInstructions(currentServiceId); return; } catch (final ExtractionException ignored) { } } supportedSources = Collections.emptyList(); relatedUrl = null; instructionsString = 0; } private void setInfoText(final String infoString) { infoTextView.setText(infoString); LinkifyCompat.addLinks(infoTextView, Linkify.WEB_URLS); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt ================================================ package org.schabi.newpipe.local.subscription.dialog import android.app.Dialog import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.core.content.getSystemService import androidx.core.os.bundleOf import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged import androidx.fragment.app.DialogFragment import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import com.evernote.android.state.State import com.livefront.bridge.Bridge import com.xwray.groupie.GroupieAdapter import com.xwray.groupie.OnItemClickListener import com.xwray.groupie.Section import java.io.Serializable import org.schabi.newpipe.R import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.databinding.DialogFeedGroupCreateBinding import org.schabi.newpipe.databinding.ToolbarSearchLayoutBinding import org.schabi.newpipe.fragments.BackPressable import org.schabi.newpipe.local.subscription.FeedGroupIcon import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.DeleteScreen import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.IconPickerScreen import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.InitialScreen import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.SubscriptionsPickerScreen import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.ProcessingEvent import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.SuccessEvent import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem import org.schabi.newpipe.local.subscription.item.PickerIconItem import org.schabi.newpipe.local.subscription.item.PickerSubscriptionItem import org.schabi.newpipe.util.DeviceUtils import org.schabi.newpipe.util.ThemeHelper class FeedGroupDialog : DialogFragment(), BackPressable { private var _feedGroupCreateBinding: DialogFeedGroupCreateBinding? = null private val feedGroupCreateBinding get() = _feedGroupCreateBinding!! private var _searchLayoutBinding: ToolbarSearchLayoutBinding? = null private val searchLayoutBinding get() = _searchLayoutBinding!! private lateinit var viewModel: FeedGroupDialogViewModel private var groupId: Long = NO_GROUP_SELECTED private var groupIcon: FeedGroupIcon? = null private var groupSortOrder: Long = -1 sealed class ScreenState : Serializable { data object InitialScreen : ScreenState() data object IconPickerScreen : ScreenState() data object SubscriptionsPickerScreen : ScreenState() data object DeleteScreen : ScreenState() } @State @JvmField var selectedIcon: FeedGroupIcon? = null @State @JvmField var selectedSubscriptions: HashSet = HashSet() @State @JvmField var wasSubscriptionSelectionChanged: Boolean = false @State @JvmField var currentScreen: ScreenState = InitialScreen @State @JvmField var subscriptionsListState: Parcelable? = null @State @JvmField var iconsListState: Parcelable? = null @State @JvmField var wasSearchSubscriptionsVisible = false @State @JvmField var subscriptionsCurrentSearchQuery = "" @State @JvmField var subscriptionsShowOnlyUngrouped = false private val subscriptionMainSection = Section() private val subscriptionEmptyFooter = Section() private lateinit var subscriptionGroupAdapter: GroupieAdapter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Bridge.restoreInstanceState(this, savedInstanceState) setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext())) groupId = arguments?.getLong(KEY_GROUP_ID, NO_GROUP_SELECTED) ?: NO_GROUP_SELECTED } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { return inflater.inflate(R.layout.dialog_feed_group_create, container) } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { return object : Dialog(requireActivity(), theme) { override fun onBackPressed() { if (!this@FeedGroupDialog.onBackPressed()) { super.onBackPressed() } } } } override fun onPause() { super.onPause() wasSearchSubscriptionsVisible = isSearchVisible() } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) iconsListState = feedGroupCreateBinding.iconSelector.layoutManager?.onSaveInstanceState() subscriptionsListState = feedGroupCreateBinding.subscriptionsSelectorList.layoutManager?.onSaveInstanceState() Bridge.saveInstanceState(this, outState) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) _feedGroupCreateBinding = DialogFeedGroupCreateBinding.bind(view) _searchLayoutBinding = feedGroupCreateBinding.subscriptionsHeaderSearchContainer viewModel = ViewModelProvider( this, FeedGroupDialogViewModel.getFactory( requireContext(), groupId, subscriptionsCurrentSearchQuery, subscriptionsShowOnlyUngrouped ) )[FeedGroupDialogViewModel::class.java] viewModel.groupLiveData.observe(viewLifecycleOwner, Observer(::handleGroup)) viewModel.subscriptionsLiveData.observe(viewLifecycleOwner) { setupSubscriptionPicker(it.first, it.second) } viewModel.dialogEventLiveData.observe(viewLifecycleOwner) { when (it) { ProcessingEvent -> disableInput() SuccessEvent -> dismiss() } } subscriptionGroupAdapter = GroupieAdapter().apply { add(subscriptionMainSection) add(subscriptionEmptyFooter) spanCount = 4 } feedGroupCreateBinding.subscriptionsSelectorList.apply { // Disable animations, too distracting. itemAnimator = null adapter = subscriptionGroupAdapter layoutManager = GridLayoutManager( requireContext(), subscriptionGroupAdapter.spanCount, RecyclerView.VERTICAL, false ).apply { spanSizeLookup = subscriptionGroupAdapter.spanSizeLookup } } setupIconPicker() setupListeners() showScreen(currentScreen) if (currentScreen == SubscriptionsPickerScreen && wasSearchSubscriptionsVisible) { showSearch() } else if (currentScreen == InitialScreen && groupId == NO_GROUP_SELECTED) { showKeyboard() } } override fun onDestroyView() { super.onDestroyView() feedGroupCreateBinding.subscriptionsSelectorList.adapter = null feedGroupCreateBinding.iconSelector.adapter = null _feedGroupCreateBinding = null _searchLayoutBinding = null } /*/​////////////////////////////////////////////////////////////////////////// // Setup //​//////////////////////////////////////////////////////////////////////// */ override fun onBackPressed(): Boolean { if (currentScreen is SubscriptionsPickerScreen && isSearchVisible()) { hideSearch() return true } else if (currentScreen !is InitialScreen) { showScreen(InitialScreen) return true } return false } private fun setupListeners() { feedGroupCreateBinding.deleteButton.setOnClickListener { showScreen(DeleteScreen) } feedGroupCreateBinding.cancelButton.setOnClickListener { when (currentScreen) { InitialScreen -> dismiss() else -> showScreen(InitialScreen) } } feedGroupCreateBinding.groupNameInputContainer.error = null feedGroupCreateBinding.groupNameInput.doOnTextChanged { text, _, _, _ -> if (feedGroupCreateBinding.groupNameInputContainer.isErrorEnabled && !text.isNullOrBlank()) { feedGroupCreateBinding.groupNameInputContainer.error = null } } feedGroupCreateBinding.confirmButton.setOnClickListener { handlePositiveButton() } feedGroupCreateBinding.selectChannelButton.setOnClickListener { feedGroupCreateBinding.subscriptionsSelectorList.scrollToPosition(0) showScreen(SubscriptionsPickerScreen) } val headerMenu = feedGroupCreateBinding.subscriptionsHeaderToolbar.menu requireActivity().menuInflater.inflate(R.menu.menu_feed_group_dialog, headerMenu) headerMenu.findItem(R.id.action_search).setOnMenuItemClickListener { showSearch() true } headerMenu.findItem(R.id.feed_group_toggle_show_only_ungrouped_subscriptions).apply { isChecked = subscriptionsShowOnlyUngrouped setOnMenuItemClickListener { subscriptionsShowOnlyUngrouped = !subscriptionsShowOnlyUngrouped it.isChecked = subscriptionsShowOnlyUngrouped viewModel.toggleShowOnlyUngrouped(subscriptionsShowOnlyUngrouped) true } } searchLayoutBinding.toolbarSearchClear.setOnClickListener { if (searchLayoutBinding.toolbarSearchEditText.text.isNullOrEmpty()) { hideSearch() return@setOnClickListener } resetSearch() showKeyboardSearch() } searchLayoutBinding.toolbarSearchEditText.setOnClickListener { if (DeviceUtils.isTv(context)) { showKeyboardSearch() } } searchLayoutBinding.toolbarSearchEditText.doOnTextChanged { _, _, _, _ -> val newQuery: String = searchLayoutBinding.toolbarSearchEditText.text.toString() subscriptionsCurrentSearchQuery = newQuery viewModel.filterSubscriptionsBy(newQuery) } subscriptionGroupAdapter.setOnItemClickListener(subscriptionPickerItemListener) } private fun handlePositiveButton() = when { currentScreen is InitialScreen -> handlePositiveButtonInitialScreen() currentScreen is DeleteScreen -> viewModel.deleteGroup() currentScreen is SubscriptionsPickerScreen && isSearchVisible() -> hideSearch() else -> showScreen(InitialScreen) } private fun handlePositiveButtonInitialScreen() { val name = feedGroupCreateBinding.groupNameInput.text.toString().trim() val icon = selectedIcon ?: groupIcon ?: FeedGroupIcon.ALL if (name.isBlank()) { feedGroupCreateBinding.groupNameInputContainer.error = getString(R.string.feed_group_dialog_empty_name) feedGroupCreateBinding.groupNameInput.text = null feedGroupCreateBinding.groupNameInput.requestFocus() return } else { feedGroupCreateBinding.groupNameInputContainer.error = null } if (selectedSubscriptions.isEmpty()) { Toast.makeText(requireContext(), getString(R.string.feed_group_dialog_empty_selection), Toast.LENGTH_SHORT).show() return } when (groupId) { NO_GROUP_SELECTED -> viewModel.createGroup(name, icon, selectedSubscriptions) else -> viewModel.updateGroup(name, icon, selectedSubscriptions, groupSortOrder) } } private fun handleGroup(feedGroupEntity: FeedGroupEntity? = null) { val icon = feedGroupEntity?.icon ?: FeedGroupIcon.ALL val name = feedGroupEntity?.name ?: "" groupIcon = feedGroupEntity?.icon groupSortOrder = feedGroupEntity?.sortOrder ?: -1 val feedGroupIcon = selectedIcon ?: icon feedGroupCreateBinding.iconPreview.setImageResource(feedGroupIcon.getDrawableRes()) if (feedGroupCreateBinding.groupNameInput.text.isNullOrBlank()) { feedGroupCreateBinding.groupNameInput.setText(name) } } private val subscriptionPickerItemListener = OnItemClickListener { item, view -> if (item is PickerSubscriptionItem) { val subscriptionId = item.subscriptionEntity.uid wasSubscriptionSelectionChanged = true val isSelected = if (this.selectedSubscriptions.contains(subscriptionId)) { this.selectedSubscriptions.remove(subscriptionId) false } else { this.selectedSubscriptions.add(subscriptionId) true } item.updateSelected(view, isSelected) updateSubscriptionSelectedCount() } } private fun setupSubscriptionPicker( subscriptions: List, selectedSubscriptions: Set ) { if (!wasSubscriptionSelectionChanged) { this.selectedSubscriptions.addAll(selectedSubscriptions) } updateSubscriptionSelectedCount() if (subscriptions.isEmpty()) { subscriptionEmptyFooter.clear() subscriptionEmptyFooter.add(ImportSubscriptionsHintPlaceholderItem()) } else { subscriptionEmptyFooter.clear() } subscriptions.forEach { it.isSelected = this@FeedGroupDialog.selectedSubscriptions .contains(it.subscriptionEntity.uid) } subscriptionMainSection.update(subscriptions, false) if (subscriptionsListState != null) { feedGroupCreateBinding.subscriptionsSelectorList.layoutManager?.onRestoreInstanceState(subscriptionsListState) subscriptionsListState = null } else { feedGroupCreateBinding.subscriptionsSelectorList.scrollToPosition(0) } } private fun updateSubscriptionSelectedCount() { val selectedCount = this.selectedSubscriptions.size val selectedCountText = resources.getQuantityString( R.plurals.feed_group_dialog_selection_count, selectedCount, selectedCount ) feedGroupCreateBinding.selectedSubscriptionCountView.text = selectedCountText feedGroupCreateBinding.subscriptionsHeaderInfo.text = selectedCountText } private fun setupIconPicker() { val groupAdapter = GroupieAdapter() groupAdapter.addAll(FeedGroupIcon.entries.map { PickerIconItem(it) }) feedGroupCreateBinding.iconSelector.apply { layoutManager = GridLayoutManager(requireContext(), 7, RecyclerView.VERTICAL, false) adapter = groupAdapter if (iconsListState != null) { layoutManager?.onRestoreInstanceState(iconsListState) iconsListState = null } } groupAdapter.setOnItemClickListener { item, _ -> when (item) { is PickerIconItem -> { selectedIcon = item.icon feedGroupCreateBinding.iconPreview.setImageResource(item.iconRes) showScreen(InitialScreen) } } } feedGroupCreateBinding.iconPreview.setOnClickListener { feedGroupCreateBinding.iconSelector.scrollToPosition(0) showScreen(IconPickerScreen) } if (groupId == NO_GROUP_SELECTED) { val icon = selectedIcon ?: FeedGroupIcon.ALL feedGroupCreateBinding.iconPreview.setImageResource(icon.getDrawableRes()) } } /*/​////////////////////////////////////////////////////////////////////////// // Screen Selector //​//////////////////////////////////////////////////////////////////////// */ private fun showScreen(screen: ScreenState) { currentScreen = screen feedGroupCreateBinding.optionsRoot.onlyVisibleIn(InitialScreen) feedGroupCreateBinding.iconSelector.onlyVisibleIn(IconPickerScreen) feedGroupCreateBinding.subscriptionsSelector.onlyVisibleIn(SubscriptionsPickerScreen) feedGroupCreateBinding.deleteScreenMessage.onlyVisibleIn(DeleteScreen) feedGroupCreateBinding.separator.onlyVisibleIn(SubscriptionsPickerScreen, IconPickerScreen) feedGroupCreateBinding.cancelButton.onlyVisibleIn(InitialScreen, DeleteScreen) feedGroupCreateBinding.confirmButton.setText( when { currentScreen == InitialScreen && groupId == NO_GROUP_SELECTED -> R.string.create else -> R.string.ok } ) feedGroupCreateBinding.deleteButton.isGone = currentScreen != InitialScreen || groupId == NO_GROUP_SELECTED hideKeyboard() hideSearch() } private fun View.onlyVisibleIn(vararg screens: ScreenState) { isVisible = currentScreen in screens } /*/​////////////////////////////////////////////////////////////////////////// // Utils //​//////////////////////////////////////////////////////////////////////// */ private fun isSearchVisible() = _searchLayoutBinding?.root?.visibility == View.VISIBLE private fun resetSearch() { searchLayoutBinding.toolbarSearchEditText.setText("") subscriptionsCurrentSearchQuery = "" viewModel.clearSubscriptionsFilter() } private fun hideSearch() { resetSearch() searchLayoutBinding.root.visibility = View.GONE feedGroupCreateBinding.subscriptionsHeaderInfoContainer.visibility = View.VISIBLE feedGroupCreateBinding.subscriptionsHeaderToolbar.menu.findItem(R.id.action_search).isVisible = true hideKeyboardSearch() } private fun showSearch() { searchLayoutBinding.root.visibility = View.VISIBLE feedGroupCreateBinding.subscriptionsHeaderInfoContainer.visibility = View.GONE feedGroupCreateBinding.subscriptionsHeaderToolbar.menu.findItem(R.id.action_search).isVisible = false showKeyboardSearch() } private val inputMethodManager by lazy { requireActivity().getSystemService()!! } private fun showKeyboardSearch() { if (searchLayoutBinding.toolbarSearchEditText.requestFocus()) { inputMethodManager.showSoftInput( searchLayoutBinding.toolbarSearchEditText, InputMethodManager.SHOW_IMPLICIT ) } } private fun hideKeyboardSearch() { inputMethodManager.hideSoftInputFromWindow( searchLayoutBinding.toolbarSearchEditText.windowToken, InputMethodManager.HIDE_NOT_ALWAYS ) searchLayoutBinding.toolbarSearchEditText.clearFocus() } private fun showKeyboard() { if (feedGroupCreateBinding.groupNameInput.requestFocus()) { inputMethodManager.showSoftInput( feedGroupCreateBinding.groupNameInput, InputMethodManager.SHOW_IMPLICIT ) } } private fun hideKeyboard() { inputMethodManager.hideSoftInputFromWindow( feedGroupCreateBinding.groupNameInput.windowToken, InputMethodManager.HIDE_NOT_ALWAYS ) feedGroupCreateBinding.groupNameInput.clearFocus() } private fun disableInput() { _feedGroupCreateBinding?.deleteButton?.isEnabled = false _feedGroupCreateBinding?.confirmButton?.isEnabled = false _feedGroupCreateBinding?.cancelButton?.isEnabled = false isCancelable = false hideKeyboard() } companion object { private const val KEY_GROUP_ID = "KEY_GROUP_ID" private const val NO_GROUP_SELECTED = -1L fun newInstance(groupId: Long = NO_GROUP_SELECTED): FeedGroupDialog { val dialog = FeedGroupDialog() dialog.arguments = bundleOf(KEY_GROUP_ID to groupId) return dialog } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt ================================================ package org.schabi.newpipe.local.subscription.dialog import android.content.Context import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewmodel.initializer import androidx.lifecycle.viewmodel.viewModelFactory import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.processors.BehaviorProcessor import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.local.feed.FeedDatabaseManager import org.schabi.newpipe.local.subscription.FeedGroupIcon import org.schabi.newpipe.local.subscription.SubscriptionManager import org.schabi.newpipe.local.subscription.item.PickerSubscriptionItem class FeedGroupDialogViewModel( applicationContext: Context, private val groupId: Long = FeedGroupEntity.GROUP_ALL_ID, initialQuery: String = "", initialShowOnlyUngrouped: Boolean = false ) : ViewModel() { private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext) private var subscriptionManager = SubscriptionManager(applicationContext) private var filterSubscriptions = BehaviorProcessor.create() private var toggleShowOnlyUngrouped = BehaviorProcessor.create() private var subscriptionsFlowable = Flowable .combineLatest( filterSubscriptions.startWithItem(initialQuery), toggleShowOnlyUngrouped.startWithItem(initialShowOnlyUngrouped) ) { t1: String, t2: Boolean -> Filter(t1, t2) } .distinctUntilChanged() .switchMap { (query, showOnlyUngrouped) -> subscriptionManager.getSubscriptions(groupId, query, showOnlyUngrouped) }.map { list -> list.map { PickerSubscriptionItem(it) } } private val mutableGroupLiveData = MutableLiveData() private val mutableSubscriptionsLiveData = MutableLiveData, Set>>() private val mutableDialogEventLiveData = MutableLiveData() val groupLiveData: LiveData = mutableGroupLiveData val subscriptionsLiveData: LiveData, Set>> = mutableSubscriptionsLiveData val dialogEventLiveData: LiveData = mutableDialogEventLiveData private var actionProcessingDisposable: Disposable? = null private var feedGroupDisposable = feedDatabaseManager.getGroup(groupId) .subscribeOn(Schedulers.io()) .subscribe(mutableGroupLiveData::postValue) private var subscriptionsDisposable = Flowable .combineLatest( subscriptionsFlowable, feedDatabaseManager.subscriptionIdsForGroup(groupId) ) { t1: List, t2: List -> t1 to t2.toSet() } .subscribeOn(Schedulers.io()) .subscribe(mutableSubscriptionsLiveData::postValue) override fun onCleared() { super.onCleared() actionProcessingDisposable?.dispose() subscriptionsDisposable.dispose() feedGroupDisposable.dispose() } fun createGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set) { doAction( feedDatabaseManager.createGroup(name, selectedIcon) .flatMapCompletable { feedDatabaseManager.updateSubscriptionsForGroup(it, selectedSubscriptions.toList()) } ) } fun updateGroup(name: String, selectedIcon: FeedGroupIcon, selectedSubscriptions: Set, sortOrder: Long) { doAction( feedDatabaseManager.updateSubscriptionsForGroup(groupId, selectedSubscriptions.toList()) .andThen(feedDatabaseManager.updateGroup(FeedGroupEntity(groupId, name, selectedIcon, sortOrder))) ) } fun deleteGroup() { doAction(feedDatabaseManager.deleteGroup(groupId)) } private fun doAction(completable: Completable) { if (actionProcessingDisposable == null) { mutableDialogEventLiveData.value = DialogEvent.ProcessingEvent actionProcessingDisposable = completable .subscribeOn(Schedulers.io()) .subscribe { mutableDialogEventLiveData.postValue(DialogEvent.SuccessEvent) } } } fun filterSubscriptionsBy(query: String) { filterSubscriptions.onNext(query) } fun clearSubscriptionsFilter() { filterSubscriptions.onNext("") } fun toggleShowOnlyUngrouped(showOnlyUngrouped: Boolean) { toggleShowOnlyUngrouped.onNext(showOnlyUngrouped) } sealed class DialogEvent { data object ProcessingEvent : DialogEvent() data object SuccessEvent : DialogEvent() } data class Filter(val query: String, val showOnlyUngrouped: Boolean) companion object { fun getFactory( context: Context, groupId: Long, initialQuery: String, initialShowOnlyUngrouped: Boolean ) = viewModelFactory { initializer { FeedGroupDialogViewModel( context.applicationContext, groupId, initialQuery, initialShowOnlyUngrouped ) } } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt ================================================ package org.schabi.newpipe.local.subscription.dialog import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.DialogFragment import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper.SimpleCallback import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.evernote.android.state.State import com.livefront.bridge.Bridge import com.xwray.groupie.GroupieAdapter import com.xwray.groupie.TouchCallback import java.util.Collections import org.schabi.newpipe.R import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.databinding.DialogFeedGroupReorderBinding import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialogViewModel.DialogEvent.ProcessingEvent import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialogViewModel.DialogEvent.SuccessEvent import org.schabi.newpipe.local.subscription.item.FeedGroupReorderItem import org.schabi.newpipe.util.ThemeHelper class FeedGroupReorderDialog : DialogFragment() { private var _binding: DialogFeedGroupReorderBinding? = null private val binding get() = _binding!! private lateinit var viewModel: FeedGroupReorderDialogViewModel @State @JvmField var groupOrderedIdList = ArrayList() private val groupAdapter = GroupieAdapter() private val itemTouchHelper = ItemTouchHelper(getItemTouchCallback()) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Bridge.restoreInstanceState(this, savedInstanceState) setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext())) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return inflater.inflate(R.layout.dialog_feed_group_reorder, container) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) _binding = DialogFeedGroupReorderBinding.bind(view) viewModel = ViewModelProvider(this).get(FeedGroupReorderDialogViewModel::class.java) viewModel.groupsLiveData.observe(viewLifecycleOwner, Observer(::handleGroups)) viewModel.dialogEventLiveData.observe(viewLifecycleOwner) { when (it) { ProcessingEvent -> disableInput() SuccessEvent -> dismiss() } } binding.feedGroupsList.layoutManager = LinearLayoutManager(requireContext()) binding.feedGroupsList.adapter = groupAdapter itemTouchHelper.attachToRecyclerView(binding.feedGroupsList) binding.confirmButton.setOnClickListener { viewModel.updateOrder(groupOrderedIdList) } } override fun onDestroyView() { _binding = null super.onDestroyView() } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) Bridge.saveInstanceState(this, outState) } private fun handleGroups(list: List) { val groupList: List if (groupOrderedIdList.isEmpty()) { groupList = list groupOrderedIdList.addAll(groupList.map { it.uid }) } else { groupList = list.sortedBy { groupOrderedIdList.indexOf(it.uid) } } groupAdapter.update(groupList.map { FeedGroupReorderItem(it, itemTouchHelper) }) } private fun disableInput() { _binding?.confirmButton?.isEnabled = false isCancelable = false } private fun getItemTouchCallback(): SimpleCallback { return object : TouchCallback() { override fun onMove( recyclerView: RecyclerView, source: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean { val sourceIndex = source.bindingAdapterPosition val targetIndex = target.bindingAdapterPosition groupAdapter.notifyItemMoved(sourceIndex, targetIndex) Collections.swap(groupOrderedIdList, sourceIndex, targetIndex) return true } override fun isLongPressDragEnabled(): Boolean = false override fun isItemViewSwipeEnabled(): Boolean = false override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) {} } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialogViewModel.kt ================================================ package org.schabi.newpipe.local.subscription.dialog import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.local.feed.FeedDatabaseManager class FeedGroupReorderDialogViewModel(application: Application) : AndroidViewModel(application) { private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application) private val mutableGroupsLiveData = MutableLiveData>() private val mutableDialogEventLiveData = MutableLiveData() val groupsLiveData: LiveData> = mutableGroupsLiveData val dialogEventLiveData: LiveData = mutableDialogEventLiveData private var actionProcessingDisposable: Disposable? = null private var groupsDisposable = feedDatabaseManager.groups() .take(1) .subscribeOn(Schedulers.io()) .subscribe(mutableGroupsLiveData::postValue) override fun onCleared() { super.onCleared() actionProcessingDisposable?.dispose() groupsDisposable.dispose() } fun updateOrder(groupIdList: List) { doAction(feedDatabaseManager.updateGroupsOrder(groupIdList)) } private fun doAction(completable: Completable) { if (actionProcessingDisposable == null) { mutableDialogEventLiveData.value = DialogEvent.ProcessingEvent actionProcessingDisposable = completable .subscribeOn(Schedulers.io()) .subscribe { mutableDialogEventLiveData.postValue(DialogEvent.SuccessEvent) } } } sealed class DialogEvent { object ProcessingEvent : DialogEvent() object SuccessEvent : DialogEvent() } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt ================================================ package org.schabi.newpipe.local.subscription.item import android.content.Context import android.widget.ImageView import android.widget.TextView import com.xwray.groupie.GroupieViewHolder import com.xwray.groupie.Item import org.schabi.newpipe.R import org.schabi.newpipe.extractor.channel.ChannelInfoItem import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.OnClickGesture import org.schabi.newpipe.util.image.CoilHelper class ChannelItem( private val infoItem: ChannelInfoItem, private val subscriptionId: Long = -1L, var itemVersion: ItemVersion = ItemVersion.NORMAL, var gesturesListener: OnClickGesture? = null ) : Item() { override fun getId(): Long = if (subscriptionId == -1L) super.getId() else subscriptionId enum class ItemVersion { NORMAL, MINI, GRID } override fun getLayout(): Int = when (itemVersion) { ItemVersion.NORMAL -> R.layout.list_channel_item ItemVersion.MINI -> R.layout.list_channel_mini_item ItemVersion.GRID -> R.layout.list_channel_grid_item } override fun bind(viewHolder: GroupieViewHolder, position: Int) { val itemTitleView = viewHolder.root.findViewById(R.id.itemTitleView) val itemAdditionalDetails = viewHolder.root.findViewById(R.id.itemAdditionalDetails) val itemChannelDescriptionView = viewHolder.root.findViewById(R.id.itemChannelDescriptionView) val itemThumbnailView = viewHolder.root.findViewById(R.id.itemThumbnailView) itemTitleView.text = infoItem.name itemAdditionalDetails.text = getDetailLine(viewHolder.root.context) if (itemVersion == ItemVersion.NORMAL) { itemChannelDescriptionView.text = infoItem.description } CoilHelper.loadAvatar(itemThumbnailView, infoItem.thumbnails) gesturesListener?.run { viewHolder.root.setOnClickListener { selected(infoItem) } viewHolder.root.setOnLongClickListener { held(infoItem) true } } } private fun getDetailLine(context: Context): String { var details = if (infoItem.subscriberCount >= 0) { Localization.shortSubscriberCount(context, infoItem.subscriberCount) } else { context.getString(R.string.subscribers_count_not_available) } if (itemVersion == ItemVersion.NORMAL && infoItem.streamCount >= 0) { val formattedVideoAmount = Localization.localizeStreamCount(context, infoItem.streamCount) details = Localization.concatenateStrings(details, formattedVideoAmount) } return details } override fun getSpanSize(spanCount: Int, position: Int): Int { return if (itemVersion == ItemVersion.GRID) 1 else spanCount } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddNewGridItem.kt ================================================ package org.schabi.newpipe.local.subscription.item import android.view.View import com.xwray.groupie.viewbinding.BindableItem import org.schabi.newpipe.R import org.schabi.newpipe.databinding.FeedGroupAddNewGridItemBinding class FeedGroupAddNewGridItem : BindableItem() { override fun getLayout(): Int = R.layout.feed_group_add_new_grid_item override fun initializeViewBinding(view: View) = FeedGroupAddNewGridItemBinding.bind(view) override fun bind(viewBinding: FeedGroupAddNewGridItemBinding, position: Int) { // this is a static item, nothing to do here } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddNewItem.kt ================================================ package org.schabi.newpipe.local.subscription.item import android.view.View import com.xwray.groupie.viewbinding.BindableItem import org.schabi.newpipe.R import org.schabi.newpipe.databinding.FeedGroupAddNewItemBinding class FeedGroupAddNewItem : BindableItem() { override fun getLayout(): Int = R.layout.feed_group_add_new_item override fun initializeViewBinding(view: View) = FeedGroupAddNewItemBinding.bind(view) override fun bind(viewBinding: FeedGroupAddNewItemBinding, position: Int) { // this is a static item, nothing to do here } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardGridItem.kt ================================================ package org.schabi.newpipe.local.subscription.item import android.view.View import com.xwray.groupie.viewbinding.BindableItem import org.schabi.newpipe.R import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.databinding.FeedGroupCardGridItemBinding import org.schabi.newpipe.local.subscription.FeedGroupIcon data class FeedGroupCardGridItem( val groupId: Long = FeedGroupEntity.GROUP_ALL_ID, val name: String, val icon: FeedGroupIcon ) : BindableItem() { constructor (feedGroupEntity: FeedGroupEntity) : this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon) override fun getId(): Long { return when (groupId) { FeedGroupEntity.GROUP_ALL_ID -> super.getId() else -> groupId } } override fun getLayout(): Int = R.layout.feed_group_card_grid_item override fun bind(viewBinding: FeedGroupCardGridItemBinding, position: Int) { viewBinding.title.text = name viewBinding.icon.setImageResource(icon.getDrawableRes()) } override fun initializeViewBinding(view: View) = FeedGroupCardGridItemBinding.bind(view) } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt ================================================ package org.schabi.newpipe.local.subscription.item import android.view.View import com.xwray.groupie.viewbinding.BindableItem import org.schabi.newpipe.R import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.databinding.FeedGroupCardItemBinding import org.schabi.newpipe.local.subscription.FeedGroupIcon data class FeedGroupCardItem( val groupId: Long = FeedGroupEntity.GROUP_ALL_ID, val name: String, val icon: FeedGroupIcon ) : BindableItem() { constructor (feedGroupEntity: FeedGroupEntity) : this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon) override fun getId(): Long { return when (groupId) { FeedGroupEntity.GROUP_ALL_ID -> super.getId() else -> groupId } } override fun getLayout(): Int = R.layout.feed_group_card_item override fun bind(viewBinding: FeedGroupCardItemBinding, position: Int) { viewBinding.title.text = name viewBinding.icon.setImageResource(icon.getDrawableRes()) } override fun initializeViewBinding(view: View) = FeedGroupCardItemBinding.bind(view) } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt ================================================ package org.schabi.newpipe.local.subscription.item import android.os.Parcelable import android.view.View import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import com.xwray.groupie.GroupAdapter import com.xwray.groupie.viewbinding.BindableItem import com.xwray.groupie.viewbinding.GroupieViewHolder import org.schabi.newpipe.R import org.schabi.newpipe.databinding.FeedItemCarouselBinding import org.schabi.newpipe.util.DeviceUtils import org.schabi.newpipe.util.ThemeHelper.getGridSpanCount class FeedGroupCarouselItem( private val carouselAdapter: GroupAdapter>, var listViewMode: Boolean ) : BindableItem() { companion object { const val PAYLOAD_UPDATE_LIST_VIEW_MODE = 2 } private var carouselLayoutManager: LinearLayoutManager? = null private var listState: Parcelable? = null override fun getLayout() = R.layout.feed_item_carousel fun onSaveInstanceState(): Parcelable? { listState = carouselLayoutManager?.onSaveInstanceState() return listState } fun onRestoreInstanceState(state: Parcelable?) { carouselLayoutManager?.onRestoreInstanceState(state) listState = state } override fun initializeViewBinding(view: View): FeedItemCarouselBinding { val viewBinding = FeedItemCarouselBinding.bind(view) updateViewMode(viewBinding) return viewBinding } override fun bind( viewBinding: FeedItemCarouselBinding, position: Int, payloads: MutableList ) { if (payloads.contains(PAYLOAD_UPDATE_LIST_VIEW_MODE)) { updateViewMode(viewBinding) return } super.bind(viewBinding, position, payloads) } override fun bind(viewBinding: FeedItemCarouselBinding, position: Int) { viewBinding.recyclerView.apply { adapter = carouselAdapter } carouselLayoutManager?.onRestoreInstanceState(listState) } override fun unbind(viewHolder: GroupieViewHolder) { super.unbind(viewHolder) listState = carouselLayoutManager?.onSaveInstanceState() } private fun updateViewMode(viewBinding: FeedItemCarouselBinding) { viewBinding.recyclerView.apply { adapter = carouselAdapter } val context = viewBinding.root.context carouselLayoutManager = if (listViewMode) { LinearLayoutManager(context) } else { GridLayoutManager(context, getGridSpanCount(context, DeviceUtils.dpToPx(112, context))) } viewBinding.recyclerView.apply { layoutManager = carouselLayoutManager adapter = carouselAdapter } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupReorderItem.kt ================================================ package org.schabi.newpipe.local.subscription.item import android.view.MotionEvent import android.view.View import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper.DOWN import androidx.recyclerview.widget.ItemTouchHelper.UP import com.xwray.groupie.viewbinding.BindableItem import com.xwray.groupie.viewbinding.GroupieViewHolder import org.schabi.newpipe.R import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.databinding.FeedGroupReorderItemBinding import org.schabi.newpipe.local.subscription.FeedGroupIcon data class FeedGroupReorderItem( val groupId: Long = FeedGroupEntity.GROUP_ALL_ID, val name: String, val icon: FeedGroupIcon, val dragCallback: ItemTouchHelper ) : BindableItem() { constructor (feedGroupEntity: FeedGroupEntity, dragCallback: ItemTouchHelper) : this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon, dragCallback) override fun getId(): Long { return when (groupId) { FeedGroupEntity.GROUP_ALL_ID -> super.getId() else -> groupId } } override fun getLayout(): Int = R.layout.feed_group_reorder_item override fun bind(viewBinding: FeedGroupReorderItemBinding, position: Int) { viewBinding.groupName.text = name viewBinding.groupIcon.setImageResource(icon.getDrawableRes()) } override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList) { super.bind(viewHolder, position, payloads) viewHolder.binding.handle.setOnTouchListener { _, event -> if (event.actionMasked == MotionEvent.ACTION_DOWN) { dragCallback.startDrag(viewHolder) return@setOnTouchListener true } false } } override fun getDragDirs(): Int { return UP or DOWN } override fun initializeViewBinding(view: View) = FeedGroupReorderItemBinding.bind(view) } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/item/GroupsHeader.kt ================================================ package org.schabi.newpipe.local.subscription.item import android.view.View import androidx.core.view.isVisible import com.xwray.groupie.viewbinding.BindableItem import org.schabi.newpipe.R import org.schabi.newpipe.databinding.SubscriptionGroupsHeaderBinding class GroupsHeader( private val title: String, private val onSortClicked: () -> Unit, private val onToggleListViewModeClicked: () -> Unit, var showSortButton: Boolean = true, var listViewMode: Boolean = true ) : BindableItem() { companion object { const val PAYLOAD_UPDATE_ICONS = 1 } override fun getLayout(): Int = R.layout.subscription_groups_header override fun bind( viewBinding: SubscriptionGroupsHeaderBinding, position: Int, payloads: MutableList ) { if (payloads.contains(PAYLOAD_UPDATE_ICONS)) { updateIcons(viewBinding) return } super.bind(viewBinding, position, payloads) } override fun bind(viewBinding: SubscriptionGroupsHeaderBinding, position: Int) { viewBinding.headerTitle.text = title viewBinding.headerSort.setOnClickListener { onSortClicked() } viewBinding.headerToggleViewMode.setOnClickListener { onToggleListViewModeClicked() } updateIcons(viewBinding) } override fun initializeViewBinding(view: View) = SubscriptionGroupsHeaderBinding.bind(view) private fun updateIcons(viewBinding: SubscriptionGroupsHeaderBinding) { viewBinding.headerToggleViewMode.setImageResource( if (listViewMode) R.drawable.ic_apps else R.drawable.ic_list ) viewBinding.headerSort.isVisible = showSortButton } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/item/Header.kt ================================================ package org.schabi.newpipe.local.subscription.item import android.view.View import com.xwray.groupie.viewbinding.BindableItem import org.schabi.newpipe.R import org.schabi.newpipe.databinding.SubscriptionHeaderBinding class Header(private val title: String) : BindableItem() { override fun getLayout(): Int = R.layout.subscription_header override fun bind(viewBinding: SubscriptionHeaderBinding, position: Int) { viewBinding.root.text = title } override fun initializeViewBinding(view: View) = SubscriptionHeaderBinding.bind(view) } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/item/ImportSubscriptionsHintPlaceholderItem.kt ================================================ package org.schabi.newpipe.local.subscription.item import android.view.View import com.xwray.groupie.viewbinding.BindableItem import org.schabi.newpipe.R import org.schabi.newpipe.databinding.ListEmptyViewBinding /** * When there are no subscriptions, show a hint to the user about how to import subscriptions */ class ImportSubscriptionsHintPlaceholderItem : BindableItem() { override fun getLayout(): Int = R.layout.list_empty_view_subscriptions override fun bind(viewBinding: ListEmptyViewBinding, position: Int) {} override fun getSpanSize(spanCount: Int, position: Int): Int = spanCount override fun initializeViewBinding(view: View) = ListEmptyViewBinding.bind(view) } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt ================================================ package org.schabi.newpipe.local.subscription.item import android.view.View import androidx.annotation.DrawableRes import com.xwray.groupie.viewbinding.BindableItem import org.schabi.newpipe.R import org.schabi.newpipe.databinding.PickerIconItemBinding import org.schabi.newpipe.local.subscription.FeedGroupIcon class PickerIconItem( val icon: FeedGroupIcon ) : BindableItem() { @DrawableRes val iconRes: Int = icon.getDrawableRes() override fun getLayout(): Int = R.layout.picker_icon_item override fun bind(viewBinding: PickerIconItemBinding, position: Int) { viewBinding.iconView.setImageResource(iconRes) } override fun initializeViewBinding(view: View) = PickerIconItemBinding.bind(view) } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt ================================================ package org.schabi.newpipe.local.subscription.item import android.view.View import androidx.core.view.isGone import androidx.core.view.isVisible import com.xwray.groupie.viewbinding.BindableItem import com.xwray.groupie.viewbinding.GroupieViewHolder import org.schabi.newpipe.R import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.databinding.PickerSubscriptionItemBinding import org.schabi.newpipe.ktx.AnimationType import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.util.image.CoilHelper data class PickerSubscriptionItem( val subscriptionEntity: SubscriptionEntity, var isSelected: Boolean = false ) : BindableItem() { override fun getId(): Long = subscriptionEntity.uid override fun getLayout(): Int = R.layout.picker_subscription_item override fun getSpanSize(spanCount: Int, position: Int): Int = 1 override fun bind(viewBinding: PickerSubscriptionItemBinding, position: Int) { CoilHelper.loadAvatar(viewBinding.thumbnailView, subscriptionEntity.avatarUrl) viewBinding.titleView.text = subscriptionEntity.name viewBinding.selectedHighlight.isVisible = isSelected } override fun unbind(viewHolder: GroupieViewHolder) { super.unbind(viewHolder) viewHolder.binding.selectedHighlight.apply { animate().setListener(null).cancel() isGone = true alpha = 1F } } override fun initializeViewBinding(view: View) = PickerSubscriptionItemBinding.bind(view) fun updateSelected(containerView: View, isSelected: Boolean) { this.isSelected = isSelected PickerSubscriptionItemBinding.bind(containerView).selectedHighlight .animate(isSelected, 150, AnimationType.LIGHT_SCALE_AND_ALPHA) } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/workers/ImportExportJsonHelper.kt ================================================ /* * Copyright 2018 Mauricio Colli * ImportExportJsonHelper.java is part of NewPipe * * License: GPL-3.0+ * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package org.schabi.newpipe.local.subscription.workers import java.io.InputStream import java.io.OutputStream import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.decodeFromStream import kotlinx.serialization.json.encodeToStream import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException /** * A JSON implementation capable of importing and exporting subscriptions, it has the advantage * of being able to transfer subscriptions to any device. */ object ImportExportJsonHelper { private val json = Json { encodeDefaults = true } /** * Read a JSON source through the input stream. * * @param in the input stream (e.g. a file) * @return the parsed subscription items */ @JvmStatic @Throws(InvalidSourceException::class) fun readFrom(`in`: InputStream?): List { if (`in` == null) { throw InvalidSourceException("input is null") } try { @OptIn(ExperimentalSerializationApi::class) return json.decodeFromStream(`in`).subscriptions } catch (e: Throwable) { throw InvalidSourceException("Couldn't parse json", e) } } /** * Write the subscriptions items list as JSON to the output. * * @param items the list of subscriptions items * @param out the output stream (e.g. a file) */ @OptIn(ExperimentalSerializationApi::class) @JvmStatic fun writeTo( items: List, out: OutputStream ) { json.encodeToStream(SubscriptionData(items), out) } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionData.kt ================================================ package org.schabi.newpipe.local.subscription.workers import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.schabi.newpipe.BuildConfig @Serializable class SubscriptionData( val subscriptions: List ) { @SerialName("app_version") private val appVersion = BuildConfig.VERSION_NAME @SerialName("app_version_int") private val appVersionInt = BuildConfig.VERSION_CODE } @Serializable data class SubscriptionItem( @SerialName("service_id") val serviceId: Int, val url: String, val name: String ) ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionExportWorker.kt ================================================ package org.schabi.newpipe.local.subscription.workers import android.content.Context import android.content.pm.ServiceInfo import android.net.Uri import android.os.Build import android.util.Log import android.widget.Toast import androidx.core.app.NotificationCompat import androidx.core.net.toUri import androidx.work.CoroutineWorker import androidx.work.ExistingWorkPolicy import androidx.work.ForegroundInfo import androidx.work.OneTimeWorkRequestBuilder import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.workDataOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.reactive.awaitFirst import kotlinx.coroutines.withContext import org.schabi.newpipe.BuildConfig import org.schabi.newpipe.NewPipeDatabase import org.schabi.newpipe.R class SubscriptionExportWorker( appContext: Context, params: WorkerParameters ) : CoroutineWorker(appContext, params) { // This is needed for API levels < 31 (Android S). override suspend fun getForegroundInfo(): ForegroundInfo { return createForegroundInfo(applicationContext.getString(R.string.export_ongoing)) } override suspend fun doWork(): Result { return try { val uri = inputData.getString(EXPORT_PATH)!!.toUri() val table = NewPipeDatabase.getInstance(applicationContext).subscriptionDAO() val subscriptions = table.getAll() .awaitFirst() .map { SubscriptionItem(it.serviceId, it.url ?: "", it.name ?: "") } val qty = subscriptions.size val title = applicationContext.resources.getQuantityString(R.plurals.export_subscriptions, qty, qty) setForeground(createForegroundInfo(title)) withContext(Dispatchers.IO) { // Truncate file if it already exists applicationContext.contentResolver.openOutputStream(uri, "wt")?.use { ImportExportJsonHelper.writeTo(subscriptions, it) } } if (BuildConfig.DEBUG) { Log.i(TAG, "Exported $qty subscriptions") } withContext(Dispatchers.Main) { Toast .makeText(applicationContext, R.string.export_complete_toast, Toast.LENGTH_SHORT) .show() } Result.success() } catch (e: Exception) { if (BuildConfig.DEBUG) { Log.e(TAG, "Error while exporting subscriptions", e) } withContext(Dispatchers.Main) { Toast .makeText(applicationContext, R.string.subscriptions_export_unsuccessful, Toast.LENGTH_SHORT) .show() } return Result.failure() } } private fun createForegroundInfo(title: String): ForegroundInfo { val notification = NotificationCompat .Builder(applicationContext, NOTIFICATION_CHANNEL_ID) .setSmallIcon(R.drawable.ic_newpipe_triangle_white) .setOngoing(true) .setProgress(-1, -1, true) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) .setContentTitle(title) .build() val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0 return ForegroundInfo(NOTIFICATION_ID, notification, serviceType) } companion object { private const val TAG = "SubscriptionExportWork" private const val NOTIFICATION_ID = 4567 private const val NOTIFICATION_CHANNEL_ID = "newpipe" private const val WORK_NAME = "exportSubscriptions" private const val EXPORT_PATH = "exportPath" fun schedule( context: Context, uri: Uri ) { val data = workDataOf(EXPORT_PATH to uri.toString()) val workRequest = OneTimeWorkRequestBuilder() .setInputData(data) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() WorkManager .getInstance(context) .enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequest) } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/local/subscription/workers/SubscriptionImportWorker.kt ================================================ package org.schabi.newpipe.local.subscription.workers import android.content.Context import android.content.pm.ServiceInfo import android.os.Build import android.os.Parcelable import android.util.Log import android.webkit.MimeTypeMap import android.widget.Toast import androidx.core.app.NotificationCompat import androidx.core.net.toUri import androidx.work.CoroutineWorker import androidx.work.Data import androidx.work.ForegroundInfo import androidx.work.WorkManager import androidx.work.WorkerParameters import androidx.work.workDataOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.rx3.await import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.parcelize.Parcelize import org.schabi.newpipe.BuildConfig import org.schabi.newpipe.R import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.local.subscription.SubscriptionManager import org.schabi.newpipe.util.ExtractorHelper class SubscriptionImportWorker( appContext: Context, params: WorkerParameters ) : CoroutineWorker(appContext, params) { // This is needed for API levels < 31 (Android S). override suspend fun getForegroundInfo(): ForegroundInfo { return createForegroundInfo(applicationContext.getString(R.string.import_ongoing), null, 0, 0) } override suspend fun doWork(): Result { val subscriptions = try { loadSubscriptionsFromInput(SubscriptionImportInput.fromData(inputData)) } catch (e: Exception) { if (BuildConfig.DEBUG) { Log.e(TAG, "Error while loading subscriptions from path", e) } withContext(Dispatchers.Main) { Toast .makeText(applicationContext, R.string.subscriptions_import_unsuccessful, Toast.LENGTH_SHORT) .show() } return Result.failure() } val mutex = Mutex() var index = 1 val qty = subscriptions.size var title = applicationContext.resources.getQuantityString(R.plurals.load_subscriptions, qty, qty) val channelInfoList = try { withContext(Dispatchers.IO.limitedParallelism(PARALLEL_EXTRACTIONS)) { subscriptions .map { async { val channelInfo = ExtractorHelper.getChannelInfo(it.serviceId, it.url, true).await() val channelTab = ExtractorHelper.getChannelTab(it.serviceId, channelInfo.tabs[0], true).await() val currentIndex = mutex.withLock { index++ } setForeground(createForegroundInfo(title, channelInfo.name, currentIndex, qty)) channelInfo to channelTab } }.awaitAll() } } catch (e: Exception) { if (BuildConfig.DEBUG) { Log.e(TAG, "Error while loading subscription data", e) } withContext(Dispatchers.Main) { Toast.makeText(applicationContext, R.string.subscriptions_import_unsuccessful, Toast.LENGTH_SHORT) .show() } return Result.failure() } title = applicationContext.resources.getQuantityString(R.plurals.import_subscriptions, qty, qty) setForeground(createForegroundInfo(title, null, 0, 0)) index = 0 val subscriptionManager = SubscriptionManager(applicationContext) for (chunk in channelInfoList.chunked(BUFFER_COUNT_BEFORE_INSERT)) { withContext(Dispatchers.IO) { subscriptionManager.upsertAll(chunk) } index += chunk.size setForeground(createForegroundInfo(title, null, index, qty)) } withContext(Dispatchers.Main) { Toast.makeText(applicationContext, R.string.import_complete_toast, Toast.LENGTH_SHORT) .show() } return Result.success() } private suspend fun loadSubscriptionsFromInput(input: SubscriptionImportInput): List { return withContext(Dispatchers.IO) { when (input) { is SubscriptionImportInput.ChannelUrlMode -> NewPipe.getService(input.serviceId).subscriptionExtractor .fromChannelUrl(input.url) .map { SubscriptionItem(it.serviceId, it.url, it.name) } is SubscriptionImportInput.InputStreamMode -> applicationContext.contentResolver.openInputStream(input.url.toUri())?.use { val contentType = MimeTypeMap.getFileExtensionFromUrl(input.url).ifEmpty { DEFAULT_MIME } NewPipe.getService(input.serviceId).subscriptionExtractor .fromInputStream(it, contentType) .map { SubscriptionItem(it.serviceId, it.url, it.name) } } is SubscriptionImportInput.PreviousExportMode -> applicationContext.contentResolver.openInputStream(input.url.toUri())?.use { ImportExportJsonHelper.readFrom(it) } } ?: emptyList() } } private fun createForegroundInfo( title: String, text: String?, currentProgress: Int, maxProgress: Int ): ForegroundInfo { val notification = NotificationCompat .Builder(applicationContext, NOTIFICATION_CHANNEL_ID) .setSmallIcon(R.drawable.ic_newpipe_triangle_white) .setOngoing(true) .setProgress(maxProgress, currentProgress, currentProgress == 0) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) .setContentTitle(title) .setContentText(text) .addAction( R.drawable.ic_close, applicationContext.getString(R.string.cancel), WorkManager.getInstance(applicationContext).createCancelPendingIntent(id) ).apply { if (currentProgress > 0 && maxProgress > 0) { val progressText = "$currentProgress/$maxProgress" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { setSubText(progressText) } else { setContentInfo(progressText) } } }.build() val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0 return ForegroundInfo(NOTIFICATION_ID, notification, serviceType) } companion object { // Log tag length is limited to 23 characters on API levels < 24. private const val TAG = "SubscriptionImport" private const val NOTIFICATION_ID = 4568 private const val NOTIFICATION_CHANNEL_ID = "newpipe" private const val DEFAULT_MIME = "application/octet-stream" private const val PARALLEL_EXTRACTIONS = 8 private const val BUFFER_COUNT_BEFORE_INSERT = 50 const val WORK_NAME = "SubscriptionImportWorker" } } sealed class SubscriptionImportInput : Parcelable { @Parcelize data class ChannelUrlMode(val serviceId: Int, val url: String) : SubscriptionImportInput() @Parcelize data class InputStreamMode(val serviceId: Int, val url: String) : SubscriptionImportInput() @Parcelize data class PreviousExportMode(val url: String) : SubscriptionImportInput() fun toData(): Data { val (mode, serviceId, url) = when (this) { is ChannelUrlMode -> Triple(CHANNEL_URL_MODE, serviceId, url) is InputStreamMode -> Triple(INPUT_STREAM_MODE, serviceId, url) is PreviousExportMode -> Triple(PREVIOUS_EXPORT_MODE, null, url) } return workDataOf("mode" to mode, "service_id" to serviceId, "url" to url) } companion object { private const val CHANNEL_URL_MODE = 0 private const val INPUT_STREAM_MODE = 1 private const val PREVIOUS_EXPORT_MODE = 2 fun fromData(data: Data): SubscriptionImportInput { val mode = data.getInt("mode", PREVIOUS_EXPORT_MODE) when (mode) { CHANNEL_URL_MODE -> { val serviceId = data.getInt("service_id", -1) if (serviceId == -1) { throw IllegalArgumentException("No service id provided") } val url = data.getString("url")!! return ChannelUrlMode(serviceId, url) } INPUT_STREAM_MODE -> { val serviceId = data.getInt("service_id", -1) if (serviceId == -1) { throw IllegalArgumentException("No service id provided") } val url = data.getString("url")!! return InputStreamMode(serviceId, url) } PREVIOUS_EXPORT_MODE -> { val url = data.getString("url")!! return PreviousExportMode(url) } else -> throw IllegalArgumentException("Unknown mode: $mode") } } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/AudioServiceLeakFix.java ================================================ package org.schabi.newpipe.player; import android.content.Context; import android.content.ContextWrapper; /** * Fixes a leak caused by AudioManager using an Activity context. * Tracked at https://android-review.googlesource.com/#/c/140481/1 and * https://github.com/square/leakcanary/issues/205 * Source: * https://gist.github.com/jankovd/891d96f476f7a9ce24e2 */ public class AudioServiceLeakFix extends ContextWrapper { AudioServiceLeakFix(final Context base) { super(base); } public static ContextWrapper preventLeakOf(final Context base) { return new AudioServiceLeakFix(base); } @Override public Object getSystemService(final String name) { if (Context.AUDIO_SERVICE.equals(name)) { return getApplicationContext().getSystemService(name); } return super.getSystemService(name); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java ================================================ package org.schabi.newpipe.player; import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu; import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; import android.content.ComponentName; import android.content.Intent; import android.content.ServiceConnection; import android.os.Bundle; import android.os.IBinder; import android.provider.Settings; import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.view.SubMenu; import android.view.View; import android.view.ViewGroup; import android.widget.ImageButton; import android.widget.SeekBar; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.google.android.exoplayer2.PlaybackParameters; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.ActivityPlayerQueueControlBinding; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.player.event.PlayerEventListener; import org.schabi.newpipe.player.helper.PlaybackParameterDialog; import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueAdapter; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.player.playqueue.PlayQueueItemBuilder; import org.schabi.newpipe.player.playqueue.PlayQueueItemHolder; import org.schabi.newpipe.player.playqueue.PlayQueueItemTouchCallback; 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.ThemeHelper; import java.util.List; import java.util.Optional; public final class PlayQueueActivity extends AppCompatActivity implements PlayerEventListener, SeekBar.OnSeekBarChangeListener, View.OnClickListener, PlaybackParameterDialog.Callback { private static final String TAG = PlayQueueActivity.class.getSimpleName(); private static final int SMOOTH_SCROLL_MAXIMUM_DISTANCE = 80; private static final int MENU_ID_AUDIO_TRACK = 71; private Player player; private boolean serviceBound; private ServiceConnection serviceConnection; private boolean seeking; //////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////// private ActivityPlayerQueueControlBinding queueControlBinding; private ItemTouchHelper itemTouchHelper; private Menu menu; //////////////////////////////////////////////////////////////////////////// // Activity Lifecycle //////////////////////////////////////////////////////////////////////////// @Override protected void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this)); queueControlBinding = ActivityPlayerQueueControlBinding.inflate(getLayoutInflater()); setContentView(queueControlBinding.getRoot()); setSupportActionBar(queueControlBinding.toolbar); if (getSupportActionBar() != null) { getSupportActionBar().setDisplayHomeAsUpEnabled(true); getSupportActionBar().setTitle(R.string.title_activity_play_queue); } serviceConnection = getServiceConnection(); bind(); } @Override public boolean onCreateOptionsMenu(final Menu m) { this.menu = m; getMenuInflater().inflate(R.menu.menu_play_queue, m); getMenuInflater().inflate(R.menu.menu_play_queue_bg, m); buildAudioTrackMenu(); onMaybeMuteChanged(); // to avoid null reference if (player != null) { onPlaybackParameterChanged(player.getPlaybackParameters()); } return true; } // Allow to setup visibility of menuItems @Override public boolean onPrepareOptionsMenu(final Menu m) { if (player != null) { menu.findItem(R.id.action_switch_popup) .setVisible(!player.popupPlayerSelected()); menu.findItem(R.id.action_switch_background) .setVisible(!player.audioPlayerSelected()); } return super.onPrepareOptionsMenu(m); } @Override public boolean onOptionsItemSelected(final MenuItem item) { final int itemId = item.getItemId(); if (itemId == android.R.id.home) { finish(); return true; } else if (itemId == R.id.action_settings) { NavigationHelper.openSettings(this); return true; } else if (itemId == R.id.action_append_playlist) { PlaylistDialog.showForPlayQueue(player, getSupportFragmentManager()); return true; } else if (itemId == R.id.action_playback_speed) { openPlaybackParameterDialog(); return true; } else if (itemId == R.id.action_mute) { player.toggleMute(); return true; } else if (itemId == R.id.action_system_audio) { startActivity(new Intent(Settings.ACTION_SOUND_SETTINGS)); return true; } else if (itemId == R.id.action_switch_main) { this.player.setRecovery(); NavigationHelper.playOnMainPlayer(this, player.getPlayQueue(), true); return true; } else if (itemId == R.id.action_switch_popup) { if (PermissionHelper.isPopupEnabledElseAsk(this)) { this.player.setRecovery(); NavigationHelper.playOnPopupPlayer(this, player.getPlayQueue(), true); } return true; } else if (itemId == R.id.action_switch_background) { this.player.setRecovery(); NavigationHelper.playOnBackgroundPlayer(this, player.getPlayQueue(), true); return true; } if (item.getGroupId() == MENU_ID_AUDIO_TRACK) { onAudioTrackClick(item.getItemId()); return true; } return super.onOptionsItemSelected(item); } @Override protected void onDestroy() { super.onDestroy(); unbind(); } //////////////////////////////////////////////////////////////////////////// // Service Connection //////////////////////////////////////////////////////////////////////////// private void bind() { // Note: this code should not really exist, and PlayerHolder should be used instead, but // it will be rewritten when NewPlayer will replace the current player. final Intent bindIntent = new Intent(this, PlayerService.class); bindIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION); final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE); if (!success) { unbindService(serviceConnection); } serviceBound = success; } private void unbind() { if (serviceBound) { unbindService(serviceConnection); serviceBound = false; if (player != null) { player.removeActivityListener(this); } onQueueUpdate(null); if (itemTouchHelper != null) { itemTouchHelper.attachToRecyclerView(null); } itemTouchHelper = null; player = null; } } private ServiceConnection getServiceConnection() { return new ServiceConnection() { @Override public void onServiceDisconnected(final ComponentName name) { Log.d(TAG, "Player service is disconnected"); } @Override public void onServiceConnected(final ComponentName name, final IBinder service) { Log.d(TAG, "Player service is connected"); if (service instanceof PlayerService.LocalBinder) { player = ((PlayerService.LocalBinder) service).getService().getPlayer(); } if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) { unbind(); } else { onQueueUpdate(player.getPlayQueue()); buildComponents(); if (player != null) { player.setActivityListener(PlayQueueActivity.this); } } } }; } //////////////////////////////////////////////////////////////////////////// // Component Building //////////////////////////////////////////////////////////////////////////// private void buildComponents() { buildQueue(); buildMetadata(); buildSeekBar(); buildControls(); } private void buildQueue() { queueControlBinding.playQueue.setLayoutManager(new LinearLayoutManager(this)); queueControlBinding.playQueue.setClickable(true); queueControlBinding.playQueue.setLongClickable(true); queueControlBinding.playQueue.clearOnScrollListeners(); queueControlBinding.playQueue.addOnScrollListener(getQueueScrollListener()); itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); itemTouchHelper.attachToRecyclerView(queueControlBinding.playQueue); } private void buildMetadata() { queueControlBinding.metadata.setOnClickListener(this); queueControlBinding.songName.setSelected(true); queueControlBinding.artistName.setSelected(true); } private void buildSeekBar() { queueControlBinding.seekBar.setOnSeekBarChangeListener(this); queueControlBinding.liveSync.setOnClickListener(this); } private void buildControls() { queueControlBinding.controlRepeat.setOnClickListener(this); queueControlBinding.controlBackward.setOnClickListener(this); queueControlBinding.controlFastRewind.setOnClickListener(this); queueControlBinding.controlPlayPause.setOnClickListener(this); queueControlBinding.controlFastForward.setOnClickListener(this); queueControlBinding.controlForward.setOnClickListener(this); queueControlBinding.controlShuffle.setOnClickListener(this); } //////////////////////////////////////////////////////////////////////////// // Component Helpers //////////////////////////////////////////////////////////////////////////// private OnScrollBelowItemsListener getQueueScrollListener() { return new OnScrollBelowItemsListener() { @Override public void onScrolledDown(final RecyclerView recyclerView) { if (player != null && player.getPlayQueue() != null && !player.getPlayQueue().isComplete()) { player.getPlayQueue().fetch(); } else { queueControlBinding.playQueue.clearOnScrollListeners(); } } }; } private ItemTouchHelper.SimpleCallback getItemTouchCallback() { return new PlayQueueItemTouchCallback() { @Override public void onMove(final int sourceIndex, final int targetIndex) { if (player != null) { player.getPlayQueue().move(sourceIndex, targetIndex); } } @Override public void onSwiped(final int index) { if (index != -1) { player.getPlayQueue().remove(index); } } }; } private PlayQueueItemBuilder.OnSelectedListener getOnSelectedListener() { return new PlayQueueItemBuilder.OnSelectedListener() { @Override public void selected(final PlayQueueItem item, final View view) { if (player != null) { player.selectQueueItem(item); } } @Override public void held(final PlayQueueItem item, final View view) { if (player != null && player.getPlayQueue().indexOf(item) != -1) { openPopupMenu(player.getPlayQueue(), item, view, false, getSupportFragmentManager(), PlayQueueActivity.this); } } @Override public void onStartDrag(final PlayQueueItemHolder viewHolder) { if (itemTouchHelper != null) { itemTouchHelper.startDrag(viewHolder); } } }; } private void scrollToSelected() { if (player == null) { return; } final int currentPlayingIndex = player.getPlayQueue().getIndex(); final int currentVisibleIndex; if (queueControlBinding.playQueue.getLayoutManager() instanceof LinearLayoutManager) { final LinearLayoutManager layout = (LinearLayoutManager) queueControlBinding.playQueue.getLayoutManager(); currentVisibleIndex = layout.findFirstVisibleItemPosition(); } else { currentVisibleIndex = 0; } final int distance = Math.abs(currentPlayingIndex - currentVisibleIndex); if (distance < SMOOTH_SCROLL_MAXIMUM_DISTANCE) { queueControlBinding.playQueue.smoothScrollToPosition(currentPlayingIndex); } else { queueControlBinding.playQueue.scrollToPosition(currentPlayingIndex); } } //////////////////////////////////////////////////////////////////////////// // Component On-Click Listener //////////////////////////////////////////////////////////////////////////// @Override public void onClick(final View view) { if (player == null) { return; } if (view.getId() == queueControlBinding.controlRepeat.getId()) { player.cycleNextRepeatMode(); } else if (view.getId() == queueControlBinding.controlBackward.getId()) { player.playPrevious(); } else if (view.getId() == queueControlBinding.controlFastRewind.getId()) { player.fastRewind(); } else if (view.getId() == queueControlBinding.controlPlayPause.getId()) { player.playPause(); } else if (view.getId() == queueControlBinding.controlFastForward.getId()) { player.fastForward(); } else if (view.getId() == queueControlBinding.controlForward.getId()) { player.playNext(); } else if (view.getId() == queueControlBinding.controlShuffle.getId()) { player.toggleShuffleModeEnabled(); } else if (view.getId() == queueControlBinding.metadata.getId()) { scrollToSelected(); } else if (view.getId() == queueControlBinding.liveSync.getId()) { player.seekToDefault(); } } //////////////////////////////////////////////////////////////////////////// // Playback Parameters //////////////////////////////////////////////////////////////////////////// private void openPlaybackParameterDialog() { if (player == null) { return; } PlaybackParameterDialog.newInstance(player.getPlaybackSpeed(), player.getPlaybackPitch(), player.getPlaybackSkipSilence(), this).show(getSupportFragmentManager(), TAG); } @Override public void onPlaybackParameterChanged(final float playbackTempo, final float playbackPitch, final boolean playbackSkipSilence) { if (player != null) { player.setPlaybackParameters(playbackTempo, playbackPitch, playbackSkipSilence); onPlaybackParameterChanged(player.getPlaybackParameters()); } } //////////////////////////////////////////////////////////////////////////// // Seekbar Listener //////////////////////////////////////////////////////////////////////////// @Override public void onProgressChanged(final SeekBar seekBar, final int progress, final boolean fromUser) { if (fromUser) { final String seekTime = Localization.getDurationString(progress / 1000); queueControlBinding.currentTime.setText(seekTime); queueControlBinding.seekDisplay.setText(seekTime); } } @Override public void onStartTrackingTouch(final SeekBar seekBar) { seeking = true; queueControlBinding.seekDisplay.setVisibility(View.VISIBLE); } @Override public void onStopTrackingTouch(final SeekBar seekBar) { if (player != null) { player.seekTo(seekBar.getProgress()); } queueControlBinding.seekDisplay.setVisibility(View.GONE); seeking = false; } //////////////////////////////////////////////////////////////////////////// // Binding Service Listener //////////////////////////////////////////////////////////////////////////// @Override public void onQueueUpdate(@Nullable final PlayQueue queue) { if (queue == null) { queueControlBinding.playQueue.setAdapter(null); } else { final PlayQueueAdapter adapter = new PlayQueueAdapter(this, queue); adapter.setSelectedListener(getOnSelectedListener()); queueControlBinding.playQueue.setAdapter(adapter); } } @Override public void onPlaybackUpdate(final int state, final int repeatMode, final boolean shuffled, final PlaybackParameters parameters) { onStateChanged(state); onPlayModeChanged(repeatMode, shuffled); onPlaybackParameterChanged(parameters); onMaybeMuteChanged(); } @Override public void onProgressUpdate(final int currentProgress, final int duration, final int bufferPercent) { // Set buffer progress queueControlBinding.seekBar.setSecondaryProgress((int) (queueControlBinding.seekBar.getMax() * ((float) bufferPercent / 100))); // Set Duration queueControlBinding.seekBar.setMax(duration); queueControlBinding.endTime.setText(Localization.getDurationString(duration / 1000)); // Set current time if not seeking if (!seeking) { queueControlBinding.seekBar.setProgress(currentProgress); queueControlBinding.currentTime.setText(Localization .getDurationString(currentProgress / 1000)); } if (player != null) { queueControlBinding.liveSync.setClickable(!player.isLiveEdge()); } // this will make sure progressCurrentTime has the same width as progressEndTime final ViewGroup.LayoutParams currentTimeParams = queueControlBinding.currentTime.getLayoutParams(); currentTimeParams.width = queueControlBinding.endTime.getWidth(); queueControlBinding.currentTime.setLayoutParams(currentTimeParams); } @Override public void onMetadataUpdate(final StreamInfo info, final PlayQueue queue) { if (info != null) { queueControlBinding.songName.setText(info.getName()); queueControlBinding.artistName.setText(info.getUploaderName()); queueControlBinding.endTime.setVisibility(View.GONE); queueControlBinding.liveSync.setVisibility(View.GONE); switch (info.getStreamType()) { case LIVE_STREAM: case AUDIO_LIVE_STREAM: queueControlBinding.liveSync.setVisibility(View.VISIBLE); break; default: queueControlBinding.endTime.setVisibility(View.VISIBLE); break; } scrollToSelected(); } } @Override public void onServiceStopped() { unbind(); finish(); } //////////////////////////////////////////////////////////////////////////// // Binding Service Helper //////////////////////////////////////////////////////////////////////////// private void onStateChanged(final int state) { final ImageButton playPauseButton = queueControlBinding.controlPlayPause; switch (state) { case Player.STATE_PAUSED: playPauseButton.setImageResource(R.drawable.ic_play_arrow); playPauseButton.setContentDescription(getString(R.string.play)); break; case Player.STATE_PLAYING: playPauseButton.setImageResource(R.drawable.ic_pause); playPauseButton.setContentDescription(getString(R.string.pause)); break; case Player.STATE_COMPLETED: playPauseButton.setImageResource(R.drawable.ic_replay); playPauseButton.setContentDescription(getString(R.string.replay)); break; default: break; } switch (state) { case Player.STATE_PAUSED: case Player.STATE_PLAYING: case Player.STATE_COMPLETED: queueControlBinding.controlPlayPause.setClickable(true); queueControlBinding.controlPlayPause.setVisibility(View.VISIBLE); queueControlBinding.controlProgressBar.setVisibility(View.GONE); break; default: queueControlBinding.controlPlayPause.setClickable(false); queueControlBinding.controlPlayPause.setVisibility(View.INVISIBLE); queueControlBinding.controlProgressBar.setVisibility(View.VISIBLE); break; } } private void onPlayModeChanged(final int repeatMode, final boolean shuffled) { switch (repeatMode) { case com.google.android.exoplayer2.Player.REPEAT_MODE_OFF: queueControlBinding.controlRepeat.setImageResource( com.google.android.exoplayer2.ui.R.drawable.exo_controls_repeat_off); break; case com.google.android.exoplayer2.Player.REPEAT_MODE_ONE: queueControlBinding.controlRepeat.setImageResource( com.google.android.exoplayer2.ui.R.drawable.exo_controls_repeat_one); break; case com.google.android.exoplayer2.Player.REPEAT_MODE_ALL: queueControlBinding.controlRepeat.setImageResource( com.google.android.exoplayer2.ui.R.drawable.exo_controls_repeat_all); break; } final int shuffleAlpha = shuffled ? 255 : 77; queueControlBinding.controlShuffle.setImageAlpha(shuffleAlpha); } private void onPlaybackParameterChanged(@Nullable final PlaybackParameters parameters) { if (parameters != null && menu != null && player != null) { final MenuItem item = menu.findItem(R.id.action_playback_speed); item.setTitle(formatSpeed(parameters.speed)); } } private void onMaybeMuteChanged() { if (menu != null && player != null) { final MenuItem item = menu.findItem(R.id.action_mute); //Change the mute-button item in ActionBar //1) Text change: item.setTitle(player.isMuted() ? R.string.unmute : R.string.mute); //2) Icon change accordingly to current App Theme // using rootView.getContext() because getApplicationContext() didn't work item.setIcon(player.isMuted() ? R.drawable.ic_volume_off : R.drawable.ic_volume_up); } } @Override public void onAudioTrackUpdate() { buildAudioTrackMenu(); } private void buildAudioTrackMenu() { if (menu == null) { return; } final MenuItem audioTrackSelector = menu.findItem(R.id.action_audio_track); final List availableStreams = Optional.ofNullable(player) .map(Player::getCurrentMetadata) .flatMap(MediaItemTag::getMaybeAudioTrack) .map(MediaItemTag.AudioTrack::getAudioStreams) .orElse(null); final Optional selectedAudioStream = Optional.ofNullable(player) .flatMap(Player::getSelectedAudioStream); if (availableStreams == null || availableStreams.size() < 2 || selectedAudioStream.isEmpty()) { audioTrackSelector.setVisible(false); } else { final SubMenu audioTrackMenu = audioTrackSelector.getSubMenu(); audioTrackMenu.clear(); for (int i = 0; i < availableStreams.size(); i++) { final AudioStream audioStream = availableStreams.get(i); audioTrackMenu.add(MENU_ID_AUDIO_TRACK, i, Menu.NONE, Localization.audioTrackName(this, audioStream)); } final AudioStream s = selectedAudioStream.get(); final String trackName = Localization.audioTrackName(this, s); audioTrackSelector.setTitle( getString(R.string.play_queue_audio_track, trackName)); final String shortName = s.getAudioLocale() != null ? s.getAudioLocale().getLanguage() : trackName; audioTrackSelector.setTitleCondensed( shortName.substring(0, Math.min(shortName.length(), 2))); audioTrackSelector.setVisible(true); } } /** * Called when an item from the audio track selector is selected. * * @param itemId index of the selected item */ private void onAudioTrackClick(final int itemId) { if (player.getCurrentMetadata() == null) { return; } player.getCurrentMetadata().getMaybeAudioTrack().ifPresent(audioTrack -> { final List availableStreams = audioTrack.getAudioStreams(); final int selectedStreamIndex = audioTrack.getSelectedAudioStreamIndex(); if (selectedStreamIndex == itemId || availableStreams.size() <= itemId) { return; } final String newAudioTrack = availableStreams.get(itemId).getAudioTrackId(); player.setAudioTrack(newAudioTrack); }); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/Player.java ================================================ package org.schabi.newpipe.player; import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW; import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS; import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED; import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND; import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE; import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED; import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT; import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_NO_PERMISSION; import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE; import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_UNSPECIFIED; import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED; import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED; import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED; import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED; import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_TIMEOUT; import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_UNSPECIFIED; import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_AUTO_TRANSITION; import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_INTERNAL; import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_REMOVE; import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK; import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT; import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SKIP; import static com.google.android.exoplayer2.Player.DiscontinuityReason; import static com.google.android.exoplayer2.Player.Listener; import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF; import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; import static com.google.android.exoplayer2.Player.RepeatMode; import static org.schabi.newpipe.extractor.ServiceList.YouTube; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; import static org.schabi.newpipe.player.helper.PlayerHelper.retrievePlaybackParametersFromPrefs; import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences; import static org.schabi.newpipe.player.helper.PlayerHelper.savePlaybackParametersToPrefs; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_FORWARD; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_REWIND; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_NEXT; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PREVIOUS; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_RECREATE_NOTIFICATION; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_REPEAT; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_SHUFFLE; import static org.schabi.newpipe.util.ListHelper.getPopupResolutionIndex; import static org.schabi.newpipe.util.ListHelper.getResolutionIndex; import static java.util.concurrent.TimeUnit.MILLISECONDS; import static coil3.Image_androidKt.toBitmap; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.graphics.Bitmap; import android.media.AudioManager; import android.support.v4.media.session.MediaSessionCompat; import android.util.Log; import android.view.LayoutInflater; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; import androidx.core.content.IntentCompat; import androidx.core.math.MathUtils; import androidx.preference.PreferenceManager; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.DefaultRenderersFactory; 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.Timeline; import com.google.android.exoplayer2.Tracks; import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.text.CueGroup; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.MappingTrackSelector; import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; import com.google.android.exoplayer2.video.VideoSize; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; 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.Image; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.fragments.detail.VideoDetailFragment; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.player.event.PlayerEventListener; import org.schabi.newpipe.player.event.PlayerServiceEventListener; import org.schabi.newpipe.player.helper.AudioReactor; import org.schabi.newpipe.player.helper.CustomRenderersFactory; import org.schabi.newpipe.player.helper.LoadController; import org.schabi.newpipe.player.helper.PlayerDataSource; import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi; import org.schabi.newpipe.player.notification.NotificationPlayerUi; import org.schabi.newpipe.player.playback.MediaSourceManager; import org.schabi.newpipe.player.playback.PlaybackListener; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.player.resolver.AudioPlaybackResolver; import org.schabi.newpipe.player.resolver.VideoPlaybackResolver; import org.schabi.newpipe.player.resolver.VideoPlaybackResolver.SourceType; import org.schabi.newpipe.player.ui.BackgroundPlayerUi; import org.schabi.newpipe.player.ui.MainPlayerUi; import org.schabi.newpipe.player.ui.PlayerUi; import org.schabi.newpipe.player.ui.PlayerUiList; import org.schabi.newpipe.player.ui.PopupPlayerUi; import org.schabi.newpipe.player.ui.VideoPlayerUi; import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.SerializedCache; import org.schabi.newpipe.util.StreamTypeUtil; import org.schabi.newpipe.util.image.CoilHelper; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.stream.IntStream; import coil3.target.Target; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.SerialDisposable; import io.reactivex.rxjava3.schedulers.Schedulers; public final class Player implements PlaybackListener, Listener { public static final boolean DEBUG = MainActivity.DEBUG; public static final String TAG = Player.class.getSimpleName(); /*////////////////////////////////////////////////////////////////////////// // States //////////////////////////////////////////////////////////////////////////*/ public static final int STATE_PREFLIGHT = -1; public static final int STATE_BLOCKED = 123; public static final int STATE_PLAYING = 124; public static final int STATE_BUFFERING = 125; public static final int STATE_PAUSED = 126; public static final int STATE_PAUSED_SEEK = 127; public static final int STATE_COMPLETED = 128; /*////////////////////////////////////////////////////////////////////////// // Intent //////////////////////////////////////////////////////////////////////////*/ public static final String PLAYBACK_QUALITY = "playback_quality"; public static final String PLAY_QUEUE_KEY = "play_queue_key"; public static final String RESUME_PLAYBACK = "resume_playback"; public static final String PLAY_WHEN_READY = "play_when_ready"; public static final String PLAYER_TYPE = "player_type"; public static final String PLAYER_INTENT_TYPE = "player_intent_type"; public static final String PLAYER_INTENT_DATA = "player_intent_data"; /*////////////////////////////////////////////////////////////////////////// // Time constants //////////////////////////////////////////////////////////////////////////*/ public static final int PLAY_PREV_ACTIVATION_LIMIT_MILLIS = 5000; // 5 seconds public static final int PROGRESS_LOOP_INTERVAL_MILLIS = 1000; // 1 second /*////////////////////////////////////////////////////////////////////////// // Other constants //////////////////////////////////////////////////////////////////////////*/ public static final int RENDERER_UNAVAILABLE = -1; /*////////////////////////////////////////////////////////////////////////// // Playback //////////////////////////////////////////////////////////////////////////*/ // play queue might be null e.g. while player is starting @Nullable private PlayQueue playQueue; @Nullable private MediaSourceManager playQueueManager; @Nullable private PlayQueueItem currentItem; @Nullable private MediaItemTag currentMetadata; @Nullable private Bitmap currentThumbnail; @Nullable private coil3.request.Disposable thumbnailDisposable; /*////////////////////////////////////////////////////////////////////////// // Player //////////////////////////////////////////////////////////////////////////*/ private ExoPlayer simpleExoPlayer; private AudioReactor audioReactor; @NonNull private final DefaultTrackSelector trackSelector; @NonNull private final LoadController loadController; @NonNull private final DefaultRenderersFactory renderFactory; @NonNull private final VideoPlaybackResolver videoResolver; @NonNull private final AudioPlaybackResolver audioResolver; private final PlayerService service; //TODO try to remove and replace everything with context /*////////////////////////////////////////////////////////////////////////// // Player states //////////////////////////////////////////////////////////////////////////*/ private PlayerType playerType = PlayerType.MAIN; private int currentState = STATE_PREFLIGHT; // audio only mode does not mean that player type is background, but that the player was // minimized to background but will resume automatically to the original player type private boolean isAudioOnly = false; private boolean isPrepared = false; /*////////////////////////////////////////////////////////////////////////// // UIs, listeners and disposables //////////////////////////////////////////////////////////////////////////*/ @SuppressWarnings({"MemberName", "java:S116"}) // keep the unusual member name private final PlayerUiList UIs; private BroadcastReceiver broadcastReceiver; private IntentFilter intentFilter; @Nullable private PlayerServiceEventListener fragmentListener = null; @Nullable private PlayerEventListener activityListener = null; @NonNull private final SerialDisposable progressUpdateDisposable = new SerialDisposable(); @NonNull private final CompositeDisposable databaseUpdateDisposable = new CompositeDisposable(); @NonNull private final CompositeDisposable streamItemDisposable = new CompositeDisposable(); /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ @NonNull private final Context context; @NonNull private final SharedPreferences prefs; @NonNull private final HistoryRecordManager recordManager; private boolean screenOn = true; /*////////////////////////////////////////////////////////////////////////// // Constructor //////////////////////////////////////////////////////////////////////////*/ //region Constructor /** * @param service the service this player resides in * @param mediaSession used to build the {@link MediaSessionPlayerUi}, lives in the service and * could possibly be reused with multiple player instances * @param sessionConnector used to build the {@link MediaSessionPlayerUi}, lives in the service * and could possibly be reused with multiple player instances */ public Player(@NonNull final PlayerService service, @NonNull final MediaSessionCompat mediaSession, @NonNull final MediaSessionConnector sessionConnector) { this.service = service; context = service; prefs = PreferenceManager.getDefaultSharedPreferences(context); recordManager = new HistoryRecordManager(context); setupBroadcastReceiver(); trackSelector = new DefaultTrackSelector(context, PlayerHelper.getQualitySelector()); final PlayerDataSource dataSource = new PlayerDataSource(context, new DefaultBandwidthMeter.Builder(context).build()); loadController = new LoadController(); renderFactory = prefs.getBoolean( context.getString( R.string.always_use_exoplayer_set_output_surface_workaround_key), false) ? new CustomRenderersFactory(context) : new DefaultRenderersFactory(context); renderFactory.setEnableDecoderFallback( prefs.getBoolean( context.getString( R.string.use_exoplayer_decoder_fallback_key), false)); videoResolver = new VideoPlaybackResolver(context, dataSource, getQualityResolver()); audioResolver = new AudioPlaybackResolver(context, dataSource); // The UIs added here should always be present. They will be initialized when the player // reaches the initialization step. Make sure the media session ui is before the // notification ui in the UIs list, since the notification depends on the media session in // PlayerUi#initPlayer(), and UIs.call() guarantees UI order is preserved. UIs = new PlayerUiList( new MediaSessionPlayerUi(this, mediaSession, sessionConnector), new NotificationPlayerUi(this) ); } private VideoPlaybackResolver.QualityResolver getQualityResolver() { return new VideoPlaybackResolver.QualityResolver() { @Override public int getDefaultResolutionIndex(final List sortedVideos) { return videoPlayerSelected() ? ListHelper.getDefaultResolutionIndex(context, sortedVideos) : ListHelper.getPopupDefaultResolutionIndex(context, sortedVideos); } @Override public int getOverrideResolutionIndex(final List sortedVideos, final String playbackQuality) { return videoPlayerSelected() ? getResolutionIndex(context, sortedVideos, playbackQuality) : getPopupResolutionIndex(context, sortedVideos, playbackQuality); } }; } //endregion /*////////////////////////////////////////////////////////////////////////// // Playback initialization via intent //////////////////////////////////////////////////////////////////////////*/ //region Playback initialization via intent @SuppressWarnings("MethodLength") public void handleIntent(@NonNull final Intent intent) { final var playerIntentType = IntentCompat.getSerializableExtra(intent, PLAYER_INTENT_TYPE, PlayerIntentType.class); if (playerIntentType == null) { return; } // TODO: this should be in the second switch below, but I’m not sure whether I // can move the initUIs stuff without breaking the setup for edge cases somehow. // when playing from a timestamp, keep the current player as-is. if (playerIntentType != PlayerIntentType.TimestampChange) { playerType = IntentCompat.getSerializableExtra(intent, PLAYER_TYPE, PlayerType.class); } initUIsForCurrentPlayerType(); isAudioOnly = audioPlayerSelected(); if (intent.hasExtra(PLAYBACK_QUALITY)) { videoResolver.setPlaybackQuality(intent.getStringExtra(PLAYBACK_QUALITY)); } final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true); switch (playerIntentType) { case Enqueue -> { if (playQueue != null) { final PlayQueue newQueue = getPlayQueueFromCache(intent); if (newQueue == null) { return; } playQueue.append(newQueue.getStreams()); return; } // TODO: This falls through to the old logic, there was no playQueue // yet so we should start the player and add the new video break; } case EnqueueNext -> { if (playQueue != null) { final PlayQueue newQueue = getPlayQueueFromCache(intent); if (newQueue == null) { return; } final PlayQueueItem newItem = newQueue.getStreams().get(0); playQueue.enqueueNext(newItem, false); return; } // TODO: This falls through to the old logic, there was no playQueue // yet so we should start the player and add the new video break; } case TimestampChange -> { final var data = Objects.requireNonNull(IntentCompat.getParcelableExtra(intent, PLAYER_INTENT_DATA, TimestampChangeData.class)); final Single single = ExtractorHelper.getStreamInfo(data.getServiceId(), data.getUrl(), false); streamItemDisposable.add(single.subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(info -> { final @Nullable PlayQueue oldPlayQueue = playQueue; info.setStartPosition(data.getSeconds()); final PlayQueueItem playQueueItem = new PlayQueueItem(info); // If the stream is already playing, // we can just seek to the appropriate timestamp if (oldPlayQueue != null && playQueueItem.isSameItem(oldPlayQueue.getItem())) { // Player can have state = IDLE when playback is stopped or failed // and we should retry in this case if (simpleExoPlayer.getPlaybackState() == com.google.android.exoplayer2.Player.STATE_IDLE) { simpleExoPlayer.prepare(); } simpleExoPlayer.seekTo(oldPlayQueue.getIndex(), data.getSeconds() * 1000L); simpleExoPlayer.setPlayWhenReady(playWhenReady); } else { final PlayQueue newPlayQueue; // If there is no queue yet, just add our item if (oldPlayQueue == null) { newPlayQueue = new SinglePlayQueue(playQueueItem); // else we add the timestamped stream behind the current video // and start playing it. } else { oldPlayQueue.enqueueNext(playQueueItem, true); oldPlayQueue.offsetIndex(1); newPlayQueue = oldPlayQueue; } initPlayback(newPlayQueue, playWhenReady); } }, throwable -> { // This will only show a snackbar if the passed context has a root view: // otherwise it will resort to showing a notification, so we are safe // here. final var info = new ErrorInfo(throwable, UserAction.PLAY_ON_POPUP, data.getUrl(), null, data.getUrl()); ErrorUtil.createNotification(context, info); })); return; } case AllOthers -> { // fallthrough; TODO: put other intent data in separate cases } } final PlayQueue newQueue = getPlayQueueFromCache(intent); if (newQueue == null) { return; } // branching parameters for below final boolean samePlayQueue = playQueue != null && playQueue.equalStreamsAndIndex(newQueue); /* * TODO As seen in #7427 this does not work: * There are 3 situations when playback shouldn't be started from scratch (zero timestamp): * 1. User pressed on a timestamp link and the same video should be rewound to the timestamp * 2. User changed a player from, for example. main to popup, or from audio to main, etc * 3. User chose to resume a video based on a saved timestamp from history of played videos * In those cases time will be saved because re-init of the play queue is a not an instant * task and requires network calls * */ // seek to timestamp if stream is already playing if (!exoPlayerIsNull() && newQueue.size() == 1 && newQueue.getItem() != null && playQueue != null && playQueue.size() == 1 && playQueue.getItem() != null && newQueue.getItem().isSameItem(playQueue.getItem()) && newQueue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) { // Player can have state = IDLE when playback is stopped or failed // and we should retry in this case if (simpleExoPlayer.getPlaybackState() == com.google.android.exoplayer2.Player.STATE_IDLE) { simpleExoPlayer.prepare(); } simpleExoPlayer.seekTo(playQueue.getIndex(), newQueue.getItem().getRecoveryPosition()); simpleExoPlayer.setPlayWhenReady(playWhenReady); } else if (!exoPlayerIsNull() && samePlayQueue && playQueue != null && !playQueue.isDisposed()) { // Do not re-init the same PlayQueue. Save time // Player can have state = IDLE when playback is stopped or failed // and we should retry in this case if (simpleExoPlayer.getPlaybackState() == com.google.android.exoplayer2.Player.STATE_IDLE) { simpleExoPlayer.prepare(); } simpleExoPlayer.setPlayWhenReady(playWhenReady); } else if (intent.getBooleanExtra(RESUME_PLAYBACK, false) && DependentPreferenceHelper.getResumePlaybackEnabled(context) // !samePlayQueue && (playQueue == null || !playQueue.equalStreamsAndIndex(newQueue)) && !newQueue.isEmpty() && newQueue.getItem() != null && newQueue.getItem().getRecoveryPosition() == PlayQueueItem.RECOVERY_UNSET) { databaseUpdateDisposable.add(recordManager.loadStreamState(newQueue.getItem()) .observeOn(AndroidSchedulers.mainThread()) // Do not place initPlayback() in doFinally() because // it restarts playback after destroy() //.doFinally() .subscribe( state -> { if (!state.isFinished(newQueue.getItem().getDuration())) { // resume playback only if the stream was not played to the end newQueue.setRecovery(newQueue.getIndex(), state.getProgressMillis()); } initPlayback(newQueue, playWhenReady); }, error -> { if (DEBUG) { Log.w(TAG, "Failed to start playback", error); } // In case any error we can start playback without history initPlayback(newQueue, playWhenReady); }, () -> { // Completed but not found in history initPlayback(newQueue, playWhenReady); } )); } else { // Good to go... // In a case of equal PlayQueues we can re-init old one but only when it is disposed initPlayback(samePlayQueue ? playQueue : newQueue, playWhenReady); } } public void handleIntentPost(final PlayerType oldPlayerType) { if (oldPlayerType != playerType && playQueue != null) { // If playerType changes from one to another we should reload the player // (to disable/enable video stream or to set quality) reloadPlayQueueManager(); } UIs.call(PlayerUi::setupAfterIntent); NavigationHelper.sendPlayerStartedEvent(context); } @Nullable private static PlayQueue getPlayQueueFromCache(@NonNull final Intent intent) { final String queueCache = intent.getStringExtra(PLAY_QUEUE_KEY); if (queueCache == null) { return null; } return SerializedCache.getInstance().take(queueCache, PlayQueue.class); } private void initUIsForCurrentPlayerType() { if ((UIs.get(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN) || (UIs.get(BackgroundPlayerUi.class).isPresent() && playerType == PlayerType.AUDIO) || (UIs.get(PopupPlayerUi.class).isPresent() && playerType == PlayerType.POPUP)) { // correct UI already in place return; } // try to reuse binding if possible final PlayerBinding binding = UIs.get(VideoPlayerUi.class).map(VideoPlayerUi::getBinding) .orElseGet(() -> { if (playerType == PlayerType.AUDIO) { return null; } else { return PlayerBinding.inflate(LayoutInflater.from(context)); } }); switch (playerType) { case MAIN: UIs.destroyAll(PopupPlayerUi.class); UIs.destroyAll(BackgroundPlayerUi.class); UIs.addAndPrepare(new MainPlayerUi(this, binding)); break; case POPUP: UIs.destroyAll(MainPlayerUi.class); UIs.destroyAll(BackgroundPlayerUi.class); UIs.addAndPrepare(new PopupPlayerUi(this, binding)); break; case AUDIO: UIs.destroyAll(VideoPlayerUi.class); // destroys both MainPlayerUi and PopupPlayerUi UIs.addAndPrepare(new BackgroundPlayerUi(this)); break; } } private void initPlayback(@NonNull final PlayQueue queue, final boolean playOnReady) { destroyPlayer(); initPlayer(playOnReady); final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString( R.string.playback_skip_silence_key), getPlaybackSkipSilence()); final PlaybackParameters savedParameters = retrievePlaybackParametersFromPrefs(this); setPlaybackParameters(savedParameters.speed, savedParameters.pitch, playbackSkipSilence); playQueue = queue; playQueue.init(); reloadPlayQueueManager(); UIs.call(PlayerUi::initPlayback); simpleExoPlayer.setVolume(isMuted() ? 0 : 1); notifyQueueUpdateToListeners(); } private void initPlayer(final boolean playOnReady) { if (DEBUG) { Log.d(TAG, "initPlayer() called with: playOnReady = [" + playOnReady + "]"); } simpleExoPlayer = new ExoPlayer.Builder(context, renderFactory) .setTrackSelector(trackSelector) .setLoadControl(loadController) .setUsePlatformDiagnostics(false) .build(); simpleExoPlayer.addListener(this); simpleExoPlayer.setPlayWhenReady(playOnReady); simpleExoPlayer.setSeekParameters(PlayerHelper.getSeekParameters(context)); simpleExoPlayer.setWakeMode(C.WAKE_MODE_NETWORK); simpleExoPlayer.setHandleAudioBecomingNoisy(true); audioReactor = new AudioReactor(context, simpleExoPlayer); registerBroadcastReceiver(); // Setup UIs UIs.call(PlayerUi::initPlayer); // Disable media tunneling if requested by the user from ExoPlayer settings if (!PreferenceManager.getDefaultSharedPreferences(context) .getBoolean(context.getString(R.string.disable_media_tunneling_key), false)) { trackSelector.setParameters(trackSelector.buildUponParameters() .setTunnelingEnabled(true)); } } //endregion /*////////////////////////////////////////////////////////////////////////// // Destroy and recovery //////////////////////////////////////////////////////////////////////////*/ //region Destroy and recovery private void destroyPlayer() { if (DEBUG) { Log.d(TAG, "destroyPlayer() called"); } UIs.call(PlayerUi::destroyPlayer); if (!exoPlayerIsNull()) { simpleExoPlayer.removeListener(this); simpleExoPlayer.stop(); simpleExoPlayer.release(); } if (isProgressLoopRunning()) { stopProgressLoop(); } if (playQueue != null) { playQueue.dispose(); } if (audioReactor != null) { audioReactor.dispose(); } if (playQueueManager != null) { playQueueManager.dispose(); } } public void destroy() { if (DEBUG) { Log.d(TAG, "destroy() called"); } saveStreamProgressState(); setRecovery(); stopActivityBinding(); destroyPlayer(); unregisterBroadcastReceiver(); databaseUpdateDisposable.clear(); progressUpdateDisposable.set(null); streamItemDisposable.clear(); UIs.destroyAll(Object.class); // destroy every UI: obviously every UI extends Object } public void setRecovery() { if (playQueue == null || exoPlayerIsNull()) { return; } final int queuePos = playQueue.getIndex(); final long windowPos = simpleExoPlayer.getCurrentPosition(); final long duration = simpleExoPlayer.getDuration(); // No checks due to https://github.com/TeamNewPipe/NewPipe/pull/7195#issuecomment-962624380 setRecovery(queuePos, MathUtils.clamp(windowPos, 0, duration)); } private void setRecovery(final int queuePos, final long windowPos) { if (playQueue == null || playQueue.size() <= queuePos) { return; } if (DEBUG) { Log.d(TAG, "Setting recovery, queue: " + queuePos + ", pos: " + windowPos); } playQueue.setRecovery(queuePos, windowPos); } public void reloadPlayQueueManager() { if (playQueueManager != null) { playQueueManager.dispose(); } if (playQueue != null) { playQueueManager = new MediaSourceManager(this, playQueue); } } @Override // own playback listener public void onPlaybackShutdown() { if (DEBUG) { Log.d(TAG, "onPlaybackShutdown() called"); } // destroys the service, which in turn will destroy the player service.destroyPlayerAndStopService(); } public void smoothStopForImmediateReusing() { // Pausing would make transition from one stream to a new stream not smooth, so only stop simpleExoPlayer.stop(); setRecovery(); UIs.call(PlayerUi::smoothStopForImmediateReusing); } //endregion /*////////////////////////////////////////////////////////////////////////// // Broadcast receiver //////////////////////////////////////////////////////////////////////////*/ //region Broadcast receiver /** * This function prepares the broadcast receiver and is called only in the constructor. * Therefore if you want any PlayerUi to receive a broadcast action, you should add it here, * even if that player ui might never be added to the player. In that case the received * broadcast would not do anything. */ private void setupBroadcastReceiver() { if (DEBUG) { Log.d(TAG, "setupBroadcastReceiver() called"); } broadcastReceiver = new BroadcastReceiver() { @Override public void onReceive(final Context ctx, final Intent intent) { onBroadcastReceived(intent); } }; intentFilter = new IntentFilter(); intentFilter.addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY); intentFilter.addAction(ACTION_CLOSE); intentFilter.addAction(ACTION_PLAY_PAUSE); intentFilter.addAction(ACTION_PLAY_PREVIOUS); intentFilter.addAction(ACTION_PLAY_NEXT); intentFilter.addAction(ACTION_FAST_REWIND); intentFilter.addAction(ACTION_FAST_FORWARD); intentFilter.addAction(ACTION_REPEAT); intentFilter.addAction(ACTION_SHUFFLE); intentFilter.addAction(ACTION_RECREATE_NOTIFICATION); intentFilter.addAction(VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED); intentFilter.addAction(VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED); intentFilter.addAction(Intent.ACTION_CONFIGURATION_CHANGED); intentFilter.addAction(Intent.ACTION_SCREEN_ON); intentFilter.addAction(Intent.ACTION_SCREEN_OFF); intentFilter.addAction(Intent.ACTION_HEADSET_PLUG); } private void onBroadcastReceived(final Intent intent) { if (intent == null || intent.getAction() == null) { return; } if (DEBUG) { Log.d(TAG, "onBroadcastReceived() called with: intent = [" + intent + "]"); } switch (intent.getAction()) { case AudioManager.ACTION_AUDIO_BECOMING_NOISY: pause(); break; case ACTION_CLOSE: service.destroyPlayerAndStopService(); break; case ACTION_PLAY_PAUSE: playPause(); break; case ACTION_PLAY_PREVIOUS: playPrevious(); break; case ACTION_PLAY_NEXT: playNext(); break; case ACTION_FAST_REWIND: fastRewind(); break; case ACTION_FAST_FORWARD: fastForward(); break; case ACTION_REPEAT: cycleNextRepeatMode(); break; case ACTION_SHUFFLE: toggleShuffleModeEnabled(); break; case Intent.ACTION_SCREEN_OFF: screenOn = false; break; case Intent.ACTION_SCREEN_ON: screenOn = true; break; case Intent.ACTION_CONFIGURATION_CHANGED: if (DEBUG) { Log.d(TAG, "ACTION_CONFIGURATION_CHANGED received"); } break; } UIs.call(playerUi -> playerUi.onBroadcastReceived(intent)); } private void registerBroadcastReceiver() { // Try to unregister current first unregisterBroadcastReceiver(); ContextCompat.registerReceiver(context, broadcastReceiver, intentFilter, ContextCompat.RECEIVER_EXPORTED); } private void unregisterBroadcastReceiver() { try { context.unregisterReceiver(broadcastReceiver); } catch (final IllegalArgumentException unregisteredException) { Log.w(TAG, "Broadcast receiver already unregistered: " + unregisteredException.getMessage()); } } //endregion /*////////////////////////////////////////////////////////////////////////// // Thumbnail loading //////////////////////////////////////////////////////////////////////////*/ //region Thumbnail loading private void loadCurrentThumbnail(final List thumbnails) { if (DEBUG) { Log.d(TAG, "Thumbnail - loadCurrentThumbnail() called with thumbnails = [" + thumbnails.size() + "]"); } // Cancel any ongoing image loading if (thumbnailDisposable != null) { thumbnailDisposable.dispose(); } // Unset currentThumbnail, since it is now outdated. This ensures it is not used in media // session metadata while the new thumbnail is being loaded by Coil. onThumbnailLoaded(null); if (thumbnails.isEmpty()) { return; } // scale down the notification thumbnail for performance final var thumbnailTarget = new Target() { @Override public void onError(@Nullable final coil3.Image error) { Log.e(TAG, "Thumbnail - onError() called"); // there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too. onThumbnailLoaded(null); } @Override public void onStart(@Nullable final coil3.Image placeholder) { if (DEBUG) { Log.d(TAG, "Thumbnail - onStart() called"); } } @Override public void onSuccess(@NonNull final coil3.Image result) { if (DEBUG) { Log.d(TAG, "Thumbnail - onSuccess() called with: drawable = [" + result + "]"); } // there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too. onThumbnailLoaded(toBitmap(result)); } }; thumbnailDisposable = CoilHelper.INSTANCE .loadScaledDownThumbnail(context, thumbnails, thumbnailTarget); } private void onThumbnailLoaded(@Nullable final Bitmap bitmap) { // Avoid useless thumbnail updates, if the thumbnail has not actually changed. Based on the // thumbnail loading code, this if would be skipped only when both bitmaps are `null`, since // onThumbnailLoaded won't be called twice with the same nonnull bitmap by Coil's target. if (currentThumbnail != bitmap) { currentThumbnail = bitmap; UIs.call(playerUi -> playerUi.onThumbnailLoaded(bitmap)); } } //endregion /*////////////////////////////////////////////////////////////////////////// // Playback parameters //////////////////////////////////////////////////////////////////////////*/ //region Playback parameters public float getPlaybackSpeed() { return getPlaybackParameters().speed; } public void setPlaybackSpeed(final float speed) { setPlaybackParameters(speed, getPlaybackPitch(), getPlaybackSkipSilence()); } public float getPlaybackPitch() { return getPlaybackParameters().pitch; } public boolean getPlaybackSkipSilence() { return !exoPlayerIsNull() && simpleExoPlayer.getSkipSilenceEnabled(); } public PlaybackParameters getPlaybackParameters() { if (exoPlayerIsNull()) { return PlaybackParameters.DEFAULT; } return simpleExoPlayer.getPlaybackParameters(); } /** * Sets the playback parameters of the player, and also saves them to shared preferences. * Speed and pitch are rounded up to 2 decimal places before being used or saved. * * @param speed the playback speed, will be rounded to up to 2 decimal places * @param pitch the playback pitch, will be rounded to up to 2 decimal places * @param skipSilence skip silence during playback */ public void setPlaybackParameters(final float speed, final float pitch, final boolean skipSilence) { final float roundedSpeed = Math.round(speed * 100.0f) / 100.0f; final float roundedPitch = Math.round(pitch * 100.0f) / 100.0f; savePlaybackParametersToPrefs(this, roundedSpeed, roundedPitch, skipSilence); simpleExoPlayer.setPlaybackParameters( new PlaybackParameters(roundedSpeed, roundedPitch)); simpleExoPlayer.setSkipSilenceEnabled(skipSilence); } //endregion /*////////////////////////////////////////////////////////////////////////// // Progress loop and updates //////////////////////////////////////////////////////////////////////////*/ //region Progress loop and updates private void onUpdateProgress(final int currentProgress, final int duration, final int bufferPercent) { if (isPrepared) { UIs.call(ui -> ui.onUpdateProgress(currentProgress, duration, bufferPercent)); notifyProgressUpdateToListeners(currentProgress, duration, bufferPercent); } } public void startProgressLoop() { progressUpdateDisposable.set(getProgressUpdateDisposable()); } private void stopProgressLoop() { progressUpdateDisposable.set(null); } public boolean isProgressLoopRunning() { return progressUpdateDisposable.get() != null; } public void triggerProgressUpdate() { if (exoPlayerIsNull()) { return; } onUpdateProgress(Math.max((int) simpleExoPlayer.getCurrentPosition(), 0), (int) simpleExoPlayer.getDuration(), simpleExoPlayer.getBufferedPercentage()); } private Disposable getProgressUpdateDisposable() { return Observable.interval(PROGRESS_LOOP_INTERVAL_MILLIS, MILLISECONDS, AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(ignored -> triggerProgressUpdate(), error -> Log.e(TAG, "Progress update failure: ", error)); } //endregion /*////////////////////////////////////////////////////////////////////////// // Playback states //////////////////////////////////////////////////////////////////////////*/ //region Playback states @Override public void onPlayWhenReadyChanged(final boolean playWhenReady, final int reason) { if (DEBUG) { Log.d(TAG, "ExoPlayer - onPlayWhenReadyChanged() called with: " + "playWhenReady = [" + playWhenReady + "], " + "reason = [" + reason + "]"); } final int playbackState = exoPlayerIsNull() ? com.google.android.exoplayer2.Player.STATE_IDLE : simpleExoPlayer.getPlaybackState(); updatePlaybackState(playWhenReady, playbackState); } @Override public void onPlaybackStateChanged(final int playbackState) { if (DEBUG) { Log.d(TAG, "ExoPlayer - onPlaybackStateChanged() called with: " + "playbackState = [" + playbackState + "]"); } updatePlaybackState(getPlayWhenReady(), playbackState); } private void updatePlaybackState(final boolean playWhenReady, final int playbackState) { if (DEBUG) { Log.d(TAG, "ExoPlayer - updatePlaybackState() called with: " + "playWhenReady = [" + playWhenReady + "], " + "playbackState = [" + playbackState + "]"); } if (currentState == STATE_PAUSED_SEEK) { if (DEBUG) { Log.d(TAG, "updatePlaybackState() is currently blocked"); } return; } switch (playbackState) { case com.google.android.exoplayer2.Player.STATE_IDLE: // 1 isPrepared = false; break; case com.google.android.exoplayer2.Player.STATE_BUFFERING: // 2 if (isPrepared) { changeState(STATE_BUFFERING); } break; case com.google.android.exoplayer2.Player.STATE_READY: //3 if (!isPrepared) { isPrepared = true; onPrepared(playWhenReady); } changeState(playWhenReady ? STATE_PLAYING : STATE_PAUSED); break; case com.google.android.exoplayer2.Player.STATE_ENDED: // 4 changeState(STATE_COMPLETED); saveStreamProgressStateCompleted(); isPrepared = false; break; } } @Override // exoplayer listener public void onIsLoadingChanged(final boolean isLoading) { if (!isLoading && currentState == STATE_PAUSED && isProgressLoopRunning()) { stopProgressLoop(); } else if (isLoading && !isProgressLoopRunning()) { startProgressLoop(); } } @Override // own playback listener public void onPlaybackBlock() { if (exoPlayerIsNull()) { return; } if (DEBUG) { Log.d(TAG, "Playback - onPlaybackBlock() called"); } currentItem = null; currentMetadata = null; simpleExoPlayer.stop(); isPrepared = false; changeState(STATE_BLOCKED); } @Override // own playback listener public void onPlaybackUnblock(final MediaSource mediaSource) { if (DEBUG) { Log.d(TAG, "Playback - onPlaybackUnblock() called"); } if (exoPlayerIsNull()) { return; } if (currentState == STATE_BLOCKED) { changeState(STATE_BUFFERING); } simpleExoPlayer.setMediaSource(mediaSource, false); simpleExoPlayer.prepare(); } public void changeState(final int state) { if (DEBUG) { Log.d(TAG, "changeState() called with: state = [" + state + "]"); } currentState = state; switch (state) { case STATE_BLOCKED: onBlocked(); break; case STATE_PLAYING: onPlaying(); break; case STATE_BUFFERING: onBuffering(); break; case STATE_PAUSED: onPaused(); break; case STATE_PAUSED_SEEK: onPausedSeek(); break; case STATE_COMPLETED: onCompleted(); break; } notifyPlaybackUpdateToListeners(); } private void onPrepared(final boolean playWhenReady) { if (DEBUG) { Log.d(TAG, "onPrepared() called with: playWhenReady = [" + playWhenReady + "]"); } UIs.call(PlayerUi::onPrepared); if (playWhenReady && !isMuted()) { audioReactor.requestAudioFocus(); } } private void onBlocked() { if (DEBUG) { Log.d(TAG, "onBlocked() called"); } if (!isProgressLoopRunning()) { startProgressLoop(); } UIs.call(PlayerUi::onBlocked); } private void onPlaying() { if (DEBUG) { Log.d(TAG, "onPlaying() called"); } if (!isProgressLoopRunning()) { startProgressLoop(); } UIs.call(PlayerUi::onPlaying); } private void onBuffering() { if (DEBUG) { Log.d(TAG, "onBuffering() called"); } UIs.call(PlayerUi::onBuffering); } private void onPaused() { if (DEBUG) { Log.d(TAG, "onPaused() called"); } if (isProgressLoopRunning()) { stopProgressLoop(); } UIs.call(PlayerUi::onPaused); } private void onPausedSeek() { if (DEBUG) { Log.d(TAG, "onPausedSeek() called"); } UIs.call(PlayerUi::onPausedSeek); } private void onCompleted() { if (DEBUG) { Log.d(TAG, "onCompleted() called" + (playQueue == null ? ". playQueue is null" : "")); } if (playQueue == null) { return; } UIs.call(PlayerUi::onCompleted); if (playQueue.getIndex() < playQueue.size() - 1) { playQueue.offsetIndex(+1); } if (isProgressLoopRunning()) { stopProgressLoop(); } } //endregion /*////////////////////////////////////////////////////////////////////////// // Repeat and shuffle //////////////////////////////////////////////////////////////////////////*/ //region Repeat and shuffle @RepeatMode public int getRepeatMode() { return exoPlayerIsNull() ? REPEAT_MODE_OFF : simpleExoPlayer.getRepeatMode(); } public void cycleNextRepeatMode() { if (!exoPlayerIsNull()) { @RepeatMode final int repeatMode; switch (simpleExoPlayer.getRepeatMode()) { case REPEAT_MODE_OFF: repeatMode = REPEAT_MODE_ONE; break; case REPEAT_MODE_ONE: repeatMode = REPEAT_MODE_ALL; break; case REPEAT_MODE_ALL: default: repeatMode = REPEAT_MODE_OFF; break; } simpleExoPlayer.setRepeatMode(repeatMode); } } @Override public void onRepeatModeChanged(@RepeatMode final int repeatMode) { if (DEBUG) { Log.d(TAG, "ExoPlayer - onRepeatModeChanged() called with: " + "repeatMode = [" + repeatMode + "]"); } UIs.call(playerUi -> playerUi.onRepeatModeChanged(repeatMode)); notifyPlaybackUpdateToListeners(); } @Override public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) { if (DEBUG) { Log.d(TAG, "ExoPlayer - onShuffleModeEnabledChanged() called with: " + "mode = [" + shuffleModeEnabled + "]"); } if (playQueue != null) { if (shuffleModeEnabled) { playQueue.shuffle(); } else { playQueue.unshuffle(); } } UIs.call(playerUi -> playerUi.onShuffleModeEnabledChanged(shuffleModeEnabled)); notifyPlaybackUpdateToListeners(); } public void toggleShuffleModeEnabled() { if (!exoPlayerIsNull()) { simpleExoPlayer.setShuffleModeEnabled(!simpleExoPlayer.getShuffleModeEnabled()); } } //endregion /*////////////////////////////////////////////////////////////////////////// // Mute / Unmute //////////////////////////////////////////////////////////////////////////*/ //region Mute / Unmute public void toggleMute() { final boolean wasMuted = isMuted(); simpleExoPlayer.setVolume(wasMuted ? 1 : 0); if (wasMuted) { audioReactor.requestAudioFocus(); } else { audioReactor.abandonAudioFocus(); } UIs.call(playerUi -> playerUi.onMuteUnmuteChanged(!wasMuted)); notifyPlaybackUpdateToListeners(); } public boolean isMuted() { return !exoPlayerIsNull() && simpleExoPlayer.getVolume() == 0; } //endregion /*////////////////////////////////////////////////////////////////////////// // ExoPlayer listeners (that didn't fit in other categories) //////////////////////////////////////////////////////////////////////////*/ //region ExoPlayer listeners (that didn't fit in other categories) /** *

Listens for event or state changes on ExoPlayer. When any event happens, we check for * changes in the currently-playing metadata and update the encapsulating * {@link Player}. Downstream listeners are also informed.

* *

When the renewed metadata contains any error, it is reported as a notification. * This is done because not all source resolution errors are {@link PlaybackException}, which * are also captured by {@link ExoPlayer} and stops the playback.

* * @param player The {@link com.google.android.exoplayer2.Player} whose state changed. * @param events The {@link com.google.android.exoplayer2.Player.Events} that has triggered * the player state changes. **/ @Override public void onEvents(@NonNull final com.google.android.exoplayer2.Player player, @NonNull final com.google.android.exoplayer2.Player.Events events) { Listener.super.onEvents(player, events); MediaItemTag.from(player.getCurrentMediaItem()).ifPresent(tag -> { if (tag == currentMetadata) { return; // we still have the same metadata, no need to do anything } final StreamInfo previousInfo = Optional.ofNullable(currentMetadata) .flatMap(MediaItemTag::getMaybeStreamInfo).orElse(null); final MediaItemTag.AudioTrack previousAudioTrack = Optional.ofNullable(currentMetadata) .flatMap(MediaItemTag::getMaybeAudioTrack).orElse(null); currentMetadata = tag; if (!currentMetadata.getErrors().isEmpty()) { // new errors might have been added even if previousInfo == tag.getMaybeStreamInfo() final ErrorInfo errorInfo = new ErrorInfo( currentMetadata.getErrors(), UserAction.PLAY_STREAM, "Loading failed for [" + currentMetadata.getTitle() + "]: " + currentMetadata.getStreamUrl(), currentMetadata.getServiceId(), currentMetadata.getStreamUrl()); ErrorUtil.createNotification(context, errorInfo); } currentMetadata.getMaybeStreamInfo().ifPresent(info -> { if (DEBUG) { Log.d(TAG, "ExoPlayer - onEvents() update stream info: " + info.getName()); } if (previousInfo == null || !previousInfo.getUrl().equals(info.getUrl())) { // only update with the new stream info if it has actually changed updateMetadataWith(info); } else if (previousAudioTrack == null || tag.getMaybeAudioTrack() .map(t -> t.getSelectedAudioStreamIndex() != previousAudioTrack.getSelectedAudioStreamIndex()) .orElse(false)) { notifyAudioTrackUpdateToListeners(); } }); }); } @Override public void onTracksChanged(@NonNull final Tracks tracks) { if (DEBUG) { Log.d(TAG, "ExoPlayer - onTracksChanged(), " + "track group size = " + tracks.getGroups().size()); } UIs.call(playerUi -> playerUi.onTextTracksChanged(tracks)); } @Override public void onPlaybackParametersChanged(@NonNull final PlaybackParameters playbackParameters) { if (DEBUG) { Log.d(TAG, "ExoPlayer - playbackParameters(), speed = [" + playbackParameters.speed + "], pitch = [" + playbackParameters.pitch + "]"); } UIs.call(playerUi -> playerUi.onPlaybackParametersChanged(playbackParameters)); } @Override public void onPositionDiscontinuity(@NonNull final PositionInfo oldPosition, @NonNull final PositionInfo newPosition, @DiscontinuityReason final int discontinuityReason) { if (DEBUG) { Log.d(TAG, "ExoPlayer - onPositionDiscontinuity() called with " + "oldPositionIndex = [" + oldPosition.mediaItemIndex + "], " + "oldPositionMs = [" + oldPosition.positionMs + "], " + "newPositionIndex = [" + newPosition.mediaItemIndex + "], " + "newPositionMs = [" + newPosition.positionMs + "], " + "discontinuityReason = [" + discontinuityReason + "]"); } if (playQueue == null) { return; } // Refresh the playback if there is a transition to the next video final int newIndex = newPosition.mediaItemIndex; switch (discontinuityReason) { case DISCONTINUITY_REASON_AUTO_TRANSITION: case DISCONTINUITY_REASON_REMOVE: // When player is in single repeat mode and a period transition occurs, // we need to register a view count here since no metadata has changed if (getRepeatMode() == REPEAT_MODE_ONE && newIndex == playQueue.getIndex()) { registerStreamViewed(); break; } case DISCONTINUITY_REASON_SEEK: if (DEBUG) { Log.d(TAG, "ExoPlayer - onSeekProcessed() called"); } if (isPrepared) { saveStreamProgressState(); } case DISCONTINUITY_REASON_SEEK_ADJUSTMENT: case DISCONTINUITY_REASON_INTERNAL: // Player index may be invalid when playback is blocked if (getCurrentState() != STATE_BLOCKED && newIndex != playQueue.getIndex()) { saveStreamProgressStateCompleted(); // current stream has ended playQueue.setIndex(newIndex); } break; case DISCONTINUITY_REASON_SKIP: break; // only makes Android Studio linter happy, as there are no ads } } @Override public void onRenderedFirstFrame() { UIs.call(PlayerUi::onRenderedFirstFrame); } @Override public void onCues(@NonNull final CueGroup cueGroup) { UIs.call(playerUi -> playerUi.onCues(cueGroup.cues)); } /** * To be called when the {@code PlaybackPreparer} set in the {@link MediaSessionConnector} * receives an {@code onPrepare()} call. This function allows restoring the default behavior * that would happen if there was no playback preparer set, i.e. to just call * {@code player.prepare()}. You can find the default behavior in `onPlay()` inside the * {@link MediaSessionConnector} file. */ public void onPrepare() { if (!exoPlayerIsNull()) { simpleExoPlayer.prepare(); } } //endregion /*////////////////////////////////////////////////////////////////////////// // Errors //////////////////////////////////////////////////////////////////////////*/ //region Errors /** * Process exceptions produced by {@link com.google.android.exoplayer2.ExoPlayer ExoPlayer}. *

There are multiple types of errors:

*
    *
  • {@link PlaybackException#ERROR_CODE_BEHIND_LIVE_WINDOW BEHIND_LIVE_WINDOW}: * If the playback on livestreams are lagged too far behind the current playable * window. Then we seek to the latest timestamp and restart the playback. * This error is catchable. *
  • *
  • From {@link PlaybackException#ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE BAD_IO} to * {@link PlaybackException#ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED UNSUPPORTED_FORMATS}: * If the stream source is validated by the extractor but not recognized by the player, * then we can try to recover playback by signalling an error on the {@link PlayQueue}.
  • *
  • For {@link PlaybackException#ERROR_CODE_TIMEOUT PLAYER_TIMEOUT}, * {@link PlaybackException#ERROR_CODE_IO_UNSPECIFIED MEDIA_SOURCE_RESOLVER_TIMEOUT} and * {@link PlaybackException#ERROR_CODE_IO_NETWORK_CONNECTION_FAILED NO_NETWORK}: * We can keep set the recovery record and keep to player at the current state until * it is ready to play by restarting the {@link MediaSourceManager}.
  • *
  • On any ExoPlayer specific issue internal to its device interaction, such as * {@link PlaybackException#ERROR_CODE_DECODER_INIT_FAILED DECODER_ERROR}: * We terminate the playback.
  • *
  • For any other unspecified issue internal: We set a recovery and try to restart * the playback.
  • * For any error above that is not explicitly catchable, the player will * create a notification so users are aware. *
* * @see com.google.android.exoplayer2.Player.Listener#onPlayerError(PlaybackException) */ // Any error code not explicitly covered here are either unrelated to NewPipe use case // (e.g. DRM) or not recoverable (e.g. Decoder error). In both cases, the player should // shutdown. @SuppressWarnings("SwitchIntDef") @Override public void onPlayerError(@NonNull final PlaybackException error) { Log.e(TAG, "ExoPlayer - onPlayerError() called with:", error); saveStreamProgressState(); boolean isCatchableException = false; switch (error.errorCode) { case ERROR_CODE_BEHIND_LIVE_WINDOW: isCatchableException = true; simpleExoPlayer.seekToDefaultPosition(); simpleExoPlayer.prepare(); // Inform the user that we are reloading the stream by // switching to the buffering state onBuffering(); break; case ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE: case ERROR_CODE_IO_BAD_HTTP_STATUS: case ERROR_CODE_IO_FILE_NOT_FOUND: case ERROR_CODE_IO_NO_PERMISSION: case ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED: case ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE: case ERROR_CODE_PARSING_CONTAINER_MALFORMED: case ERROR_CODE_PARSING_MANIFEST_MALFORMED: case ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED: case ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED: // Source errors, signal on playQueue and move on: if (!exoPlayerIsNull() && playQueue != null) { playQueue.error(); } break; case ERROR_CODE_TIMEOUT: case ERROR_CODE_IO_UNSPECIFIED: case ERROR_CODE_IO_NETWORK_CONNECTION_FAILED: case ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT: case ERROR_CODE_UNSPECIFIED: // Reload playback on unexpected errors: setRecovery(); reloadPlayQueueManager(); break; default: // API, remote and renderer errors belong here: onPlaybackShutdown(); break; } if (!isCatchableException) { createErrorNotification(error); } if (fragmentListener != null) { fragmentListener.onPlayerError(error, isCatchableException); } } private void createErrorNotification(@NonNull final PlaybackException error) { final ErrorInfo errorInfo; if (currentMetadata == null) { errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM, "Player error[type=" + error.getErrorCodeName() + "] occurred, currentMetadata is null"); } else { errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM, "Player error[type=" + error.getErrorCodeName() + "] occurred while playing " + currentMetadata.getStreamUrl(), currentMetadata.getServiceId(), currentMetadata.getStreamUrl()); } ErrorUtil.createNotification(context, errorInfo); } //endregion /*////////////////////////////////////////////////////////////////////////// // Playback position and seek //////////////////////////////////////////////////////////////////////////*/ //region Playback position and seek @Override // own playback listener (this is a getter) public boolean isApproachingPlaybackEdge(final long timeToEndMillis) { // If live, then not near playback edge // If not playing, then not approaching playback edge if (exoPlayerIsNull() || isLive() || !isPlaying()) { return false; } final long currentPositionMillis = simpleExoPlayer.getCurrentPosition(); final long currentDurationMillis = simpleExoPlayer.getDuration(); return currentDurationMillis - currentPositionMillis < timeToEndMillis; } /** * Checks if the current playback is a livestream AND is playing at or beyond the live edge. * * @return whether the livestream is playing at or beyond the edge */ @SuppressWarnings("BooleanMethodIsAlwaysInverted") public boolean isLiveEdge() { if (exoPlayerIsNull() || !isLive()) { return false; } final Timeline currentTimeline = simpleExoPlayer.getCurrentTimeline(); final int currentWindowIndex = simpleExoPlayer.getCurrentMediaItemIndex(); if (currentTimeline.isEmpty() || currentWindowIndex < 0 || currentWindowIndex >= currentTimeline.getWindowCount()) { return false; } final Timeline.Window timelineWindow = new Timeline.Window(); currentTimeline.getWindow(currentWindowIndex, timelineWindow); return timelineWindow.getDefaultPositionMs() <= simpleExoPlayer.getCurrentPosition(); } @Override // own playback listener public void onPlaybackSynchronize(@NonNull final PlayQueueItem item, final boolean wasBlocked) { if (DEBUG) { Log.d(TAG, "Playback - onPlaybackSynchronize(was blocked: " + wasBlocked + ") called with item=[" + item.getTitle() + "], url=[" + item.getUrl() + "]"); } if (exoPlayerIsNull() || playQueue == null || currentItem == item) { return; // nothing to synchronize } final int playQueueIndex = playQueue.indexOf(item); final int playlistIndex = simpleExoPlayer.getCurrentMediaItemIndex(); final int playlistSize = simpleExoPlayer.getCurrentTimeline().getWindowCount(); final boolean removeThumbnailBeforeSync = currentItem == null || currentItem.getServiceId() != item.getServiceId() || !currentItem.getUrl().equals(item.getUrl()); currentItem = item; if (playQueueIndex != playQueue.getIndex()) { // wrong window (this should be impossible, as this method is called with // `item=playQueue.getItem()`, so the index of that item must be equal to `getIndex()`) Log.e(TAG, "Playback - Play Queue may be not in sync: item index=[" + playQueueIndex + "], " + "queue index=[" + playQueue.getIndex() + "]"); } else if ((playlistSize > 0 && playQueueIndex >= playlistSize) || playQueueIndex < 0) { // the queue and the player's timeline are not in sync, since the play queue index // points outside of the timeline Log.e(TAG, "Playback - Trying to seek to invalid index=[" + playQueueIndex + "] with playlist length=[" + playlistSize + "]"); } else if (wasBlocked || playlistIndex != playQueueIndex || !isPlaying()) { // either the player needs to be unblocked, or the play queue index has just been // changed and needs to be synchronized, or the player is not playing if (DEBUG) { Log.d(TAG, "Playback - Rewinding to correct index=[" + playQueueIndex + "], " + "from=[" + playlistIndex + "], size=[" + playlistSize + "]."); } if (removeThumbnailBeforeSync) { // unset the current (now outdated) thumbnail to ensure it is not used during sync onThumbnailLoaded(null); } // sync the player index with the queue index, and seek to the correct position if (item.getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) { simpleExoPlayer.seekTo(playQueueIndex, item.getRecoveryPosition()); playQueue.unsetRecovery(playQueueIndex); } else { simpleExoPlayer.seekToDefaultPosition(playQueueIndex); } } } public void seekTo(final long positionMillis) { if (DEBUG) { Log.d(TAG, "seekBy() called with: position = [" + positionMillis + "]"); } if (!exoPlayerIsNull()) { // prevent invalid positions when fast-forwarding/-rewinding simpleExoPlayer.seekTo(MathUtils.clamp(positionMillis, 0, simpleExoPlayer.getDuration())); } } private void seekBy(final long offsetMillis) { if (DEBUG) { Log.d(TAG, "seekBy() called with: offsetMillis = [" + offsetMillis + "]"); } seekTo(simpleExoPlayer.getCurrentPosition() + offsetMillis); } public void seekToDefault() { if (!exoPlayerIsNull()) { simpleExoPlayer.seekToDefaultPosition(); } } //endregion /*////////////////////////////////////////////////////////////////////////// // Player actions (play, pause, previous, fast-forward, ...) //////////////////////////////////////////////////////////////////////////*/ //region Player actions (play, pause, previous, fast-forward, ...) public void play() { if (DEBUG) { Log.d(TAG, "play() called"); } if (audioReactor == null || playQueue == null || exoPlayerIsNull()) { return; } if (!isMuted()) { audioReactor.requestAudioFocus(); } if (currentState == STATE_COMPLETED) { if (playQueue.getIndex() == 0) { seekToDefault(); } else { playQueue.setIndex(0); } } simpleExoPlayer.play(); saveStreamProgressState(); } public void pause() { if (DEBUG) { Log.d(TAG, "pause() called"); } if (audioReactor == null || exoPlayerIsNull()) { return; } audioReactor.abandonAudioFocus(); simpleExoPlayer.pause(); saveStreamProgressState(); } public void playPause() { if (DEBUG) { Log.d(TAG, "onPlayPause() called"); } if (getPlayWhenReady() // When state is completed (replay button is shown) then (re)play and do not pause && currentState != STATE_COMPLETED) { pause(); } else { play(); } } public void playPrevious() { if (DEBUG) { Log.d(TAG, "onPlayPrevious() called"); } if (exoPlayerIsNull() || playQueue == null) { return; } /* If current playback has run for PLAY_PREV_ACTIVATION_LIMIT_MILLIS milliseconds, * restart current track. Also restart the track if the current track * is the first in a queue.*/ if (simpleExoPlayer.getCurrentPosition() > PLAY_PREV_ACTIVATION_LIMIT_MILLIS || playQueue.getIndex() == 0) { seekToDefault(); playQueue.offsetIndex(0); } else { saveStreamProgressState(); playQueue.offsetIndex(-1); } triggerProgressUpdate(); } public void playNext() { if (DEBUG) { Log.d(TAG, "onPlayNext() called"); } if (playQueue == null) { return; } saveStreamProgressState(); playQueue.offsetIndex(+1); triggerProgressUpdate(); } public void fastForward() { if (DEBUG) { Log.d(TAG, "fastRewind() called"); } seekBy(retrieveSeekDurationFromPreferences(this)); triggerProgressUpdate(); } public void fastRewind() { if (DEBUG) { Log.d(TAG, "fastRewind() called"); } seekBy(-retrieveSeekDurationFromPreferences(this)); triggerProgressUpdate(); } //endregion /*////////////////////////////////////////////////////////////////////////// // StreamInfo history: views and progress //////////////////////////////////////////////////////////////////////////*/ //region StreamInfo history: views and progress private void registerStreamViewed() { getCurrentStreamInfo().ifPresent(info -> databaseUpdateDisposable .add(recordManager.onViewed(info).onErrorComplete().subscribe())); } private void saveStreamProgressState(final long progressMillis) { getCurrentStreamInfo().ifPresent(info -> { if (!prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) { return; } if (DEBUG) { Log.d(TAG, "saveStreamProgressState() called with: progressMillis=" + progressMillis + ", currentMetadata=[" + info.getName() + "]"); } databaseUpdateDisposable.add(recordManager.saveStreamState(info, progressMillis) .observeOn(AndroidSchedulers.mainThread()) .doOnError(e -> { if (DEBUG) { e.printStackTrace(); } }) .onErrorComplete() .subscribe()); }); } public void saveStreamProgressState() { if (exoPlayerIsNull() || currentMetadata == null || playQueue == null || playQueue.getIndex() != simpleExoPlayer.getCurrentMediaItemIndex()) { // Make sure play queue and current window index are equal, to prevent saving state for // the wrong stream on discontinuity (e.g. when the stream just changed but the // playQueue index and currentMetadata still haven't updated) return; } // Save current position. It will help to restore this position once a user // wants to play prev or next stream from the queue playQueue.setRecovery(playQueue.getIndex(), simpleExoPlayer.getContentPosition()); saveStreamProgressState(simpleExoPlayer.getCurrentPosition()); } public void saveStreamProgressStateCompleted() { // current stream has ended, so the progress is its duration (+1 to overcome rounding) getCurrentStreamInfo().ifPresent(info -> saveStreamProgressState((info.getDuration() + 1) * 1000)); } //endregion /*////////////////////////////////////////////////////////////////////////// // Metadata //////////////////////////////////////////////////////////////////////////*/ //region Metadata private void updateMetadataWith(@NonNull final StreamInfo info) { if (DEBUG) { Log.d(TAG, "Playback - onMetadataChanged() called, playing: " + info.getName()); } if (exoPlayerIsNull()) { return; } maybeAutoQueueNextStream(info); loadCurrentThumbnail(info.getThumbnails()); registerStreamViewed(); notifyMetadataUpdateToListeners(); notifyAudioTrackUpdateToListeners(); UIs.call(playerUi -> playerUi.onMetadataChanged(info)); } @NonNull public String getVideoUrl() { return currentMetadata == null ? context.getString(R.string.unknown_content) : currentMetadata.getStreamUrl(); } @NonNull public String getVideoUrlAtCurrentTime() { final long timeSeconds = simpleExoPlayer.getCurrentPosition() / 1000; String videoUrl = getVideoUrl(); if (!isLive() && timeSeconds >= 0 && currentMetadata != null && currentMetadata.getServiceId() == YouTube.getServiceId()) { // Timestamp doesn't make sense in a live stream so drop it videoUrl += ("&t=" + timeSeconds); } return videoUrl; } @NonNull public String getVideoTitle() { return currentMetadata == null ? context.getString(R.string.unknown_content) : currentMetadata.getTitle(); } @NonNull public String getUploaderName() { return currentMetadata == null ? context.getString(R.string.unknown_content) : currentMetadata.getUploaderName(); } @Nullable public Bitmap getThumbnail() { return currentThumbnail; } //endregion /*////////////////////////////////////////////////////////////////////////// // Play queue, segments and streams //////////////////////////////////////////////////////////////////////////*/ //region Play queue, segments and streams private void maybeAutoQueueNextStream(@NonNull final StreamInfo info) { if (playQueue == null || playQueue.getIndex() != playQueue.size() - 1 || getRepeatMode() != REPEAT_MODE_OFF || !PlayerHelper.isAutoQueueEnabled(context)) { return; } // auto queue when starting playback on the last item when not repeating final PlayQueue autoQueue = PlayerHelper.autoQueueOf(info, playQueue.getStreams()); if (autoQueue != null) { playQueue.append(autoQueue.getStreams()); } } public void selectQueueItem(final PlayQueueItem item) { if (playQueue == null || exoPlayerIsNull()) { return; } final int index = playQueue.indexOf(item); if (index == -1) { return; } if (playQueue.getIndex() == index && simpleExoPlayer.getCurrentMediaItemIndex() == index) { seekToDefault(); } else { saveStreamProgressState(); } playQueue.setIndex(index); } @Override public void onPlayQueueEdited() { notifyPlaybackUpdateToListeners(); UIs.call(PlayerUi::onPlayQueueEdited); } @Override // own playback listener @Nullable public MediaSource sourceOf(final PlayQueueItem item, final StreamInfo info) { if (audioPlayerSelected()) { return audioResolver.resolve(info); } if (isAudioOnly && videoResolver.getStreamSourceType().orElse( SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY) == SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY) { // If the current info has only video streams with audio and if the stream is played as // audio, we need to use the audio resolver, otherwise the video stream will be played // in background. return audioResolver.resolve(info); } // Even if the stream is played in background, we need to use the video resolver if the // info played is separated video-only and audio-only streams; otherwise, if the audio // resolver was called when the app was in background, the app will only stream audio when // the user come back to the app and will never fetch the video stream. // Note that the video is not fetched when the app is in background because the video // renderer is fully disabled (see useVideoAndSubtitles method), except for HLS streams // (see https://github.com/google/ExoPlayer/issues/9282). return videoResolver.resolve(info); } public void disablePreloadingOfCurrentTrack() { loadController.disablePreloadingOfCurrentTrack(); } public Optional getSelectedVideoStream() { return Optional.ofNullable(currentMetadata) .flatMap(MediaItemTag::getMaybeQuality) .filter(quality -> { final int selectedStreamIndex = quality.getSelectedVideoStreamIndex(); return selectedStreamIndex >= 0 && selectedStreamIndex < quality.getSortedVideoStreams().size(); }) .map(quality -> quality.getSortedVideoStreams() .get(quality.getSelectedVideoStreamIndex())); } public Optional getSelectedAudioStream() { return Optional.ofNullable(currentMetadata) .flatMap(MediaItemTag::getMaybeAudioTrack) .map(MediaItemTag.AudioTrack::getSelectedAudioStream); } //endregion /*////////////////////////////////////////////////////////////////////////// // Captions (text tracks) //////////////////////////////////////////////////////////////////////////*/ //region Captions (text tracks) public int getCaptionRendererIndex() { if (exoPlayerIsNull()) { return RENDERER_UNAVAILABLE; } for (int t = 0; t < simpleExoPlayer.getRendererCount(); t++) { if (simpleExoPlayer.getRendererType(t) == C.TRACK_TYPE_TEXT) { return t; } } return RENDERER_UNAVAILABLE; } //endregion /*////////////////////////////////////////////////////////////////////////// // Video size //////////////////////////////////////////////////////////////////////////*/ //region Video size @Override // exoplayer listener public void onVideoSizeChanged(@NonNull final VideoSize videoSize) { if (DEBUG) { Log.d(TAG, "onVideoSizeChanged() called with: " + "width / height = [" + videoSize.width + " / " + videoSize.height + " = " + (((float) videoSize.width) / videoSize.height) + "], " + "unappliedRotationDegrees = [" + videoSize.unappliedRotationDegrees + "], " + "pixelWidthHeightRatio = [" + videoSize.pixelWidthHeightRatio + "]"); } UIs.call(playerUi -> playerUi.onVideoSizeChanged(videoSize)); } //endregion /*////////////////////////////////////////////////////////////////////////// // Activity / fragment binding //////////////////////////////////////////////////////////////////////////*/ //region Activity / fragment binding public void setFragmentListener(final PlayerServiceEventListener listener) { fragmentListener = listener; UIs.call(PlayerUi::onFragmentListenerSet); notifyQueueUpdateToListeners(); notifyMetadataUpdateToListeners(); notifyPlaybackUpdateToListeners(); triggerProgressUpdate(); } public void removeFragmentListener(final PlayerServiceEventListener listener) { if (fragmentListener == listener) { fragmentListener = null; } } void setActivityListener(final PlayerEventListener listener) { activityListener = listener; // TODO why not queue update? notifyMetadataUpdateToListeners(); notifyPlaybackUpdateToListeners(); triggerProgressUpdate(); } void removeActivityListener(final PlayerEventListener listener) { if (activityListener == listener) { activityListener = null; } } void stopActivityBinding() { if (fragmentListener != null) { fragmentListener.onServiceStopped(); fragmentListener = null; } if (activityListener != null) { activityListener.onServiceStopped(); activityListener = null; } } private void notifyQueueUpdateToListeners() { if (fragmentListener != null && playQueue != null) { fragmentListener.onQueueUpdate(playQueue); } if (activityListener != null && playQueue != null) { activityListener.onQueueUpdate(playQueue); } } private void notifyMetadataUpdateToListeners() { getCurrentStreamInfo().ifPresent(info -> { if (fragmentListener != null) { fragmentListener.onMetadataUpdate(info, playQueue); } if (activityListener != null) { activityListener.onMetadataUpdate(info, playQueue); } }); } private void notifyPlaybackUpdateToListeners() { if (fragmentListener != null && !exoPlayerIsNull() && playQueue != null) { fragmentListener.onPlaybackUpdate(currentState, getRepeatMode(), playQueue.isShuffled(), simpleExoPlayer.getPlaybackParameters()); } if (activityListener != null && !exoPlayerIsNull() && playQueue != null) { activityListener.onPlaybackUpdate(currentState, getRepeatMode(), playQueue.isShuffled(), getPlaybackParameters()); } } private void notifyProgressUpdateToListeners(final int currentProgress, final int duration, final int bufferPercent) { if (fragmentListener != null) { fragmentListener.onProgressUpdate(currentProgress, duration, bufferPercent); } if (activityListener != null) { activityListener.onProgressUpdate(currentProgress, duration, bufferPercent); } } private void notifyAudioTrackUpdateToListeners() { if (fragmentListener != null) { fragmentListener.onAudioTrackUpdate(); } if (activityListener != null) { activityListener.onAudioTrackUpdate(); } } public void useVideoAndSubtitles(final boolean videoAndSubtitlesEnabled) { if (playQueue == null) { return; } isAudioOnly = !videoAndSubtitlesEnabled; final var item = playQueue.getItem(); final boolean hasPendingRecovery = item != null && item.getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET; final boolean hasTimeline = !exoPlayerIsNull() && !simpleExoPlayer.getCurrentTimeline().isEmpty(); getCurrentStreamInfo().ifPresentOrElse(info -> { // In case we don't know the source type, fall back to either video-with-audio, or // audio-only source type final SourceType sourceType = videoResolver.getStreamSourceType() .orElse(SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY); if (hasTimeline || !hasPendingRecovery) { // making sure to save playback position before reloadPlayQueueManager() setRecovery(); } if (playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) { reloadPlayQueueManager(); } }, () -> { /* The current metadata may be null sometimes (for e.g. when using an unstable connection in livestreams) so we will be not able to execute the block above Reload the play queue manager in this case, which is the behavior when we don't know the index of the video renderer or playQueueManagerReloadingNeeded returns true */ if (hasTimeline || !hasPendingRecovery) { // making sure to save playback position before reloadPlayQueueManager() setRecovery(); } reloadPlayQueueManager(); }); // Disable or enable video and subtitles renderers depending of the // videoAndSubtitlesEnabled value trackSelector.setParameters(trackSelector.buildUponParameters() .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !videoAndSubtitlesEnabled) .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, !videoAndSubtitlesEnabled)); } /** * Return whether the play queue manager needs to be reloaded when switching player type. * *

* The play queue manager needs to be reloaded if the video renderer index is not known and if * the content is not an audio content, but also if none of the following cases is met: * *

    *
  • the content is an {@link StreamType#AUDIO_STREAM audio stream}, an * {@link StreamType#AUDIO_LIVE_STREAM audio live stream}, or a * {@link StreamType#POST_LIVE_AUDIO_STREAM ended audio live stream};
  • *
  • the content is a {@link StreamType#LIVE_STREAM live stream} and the source type is a * {@link SourceType#LIVE_STREAM live source};
  • *
  • the content's source is {@link SourceType#VIDEO_WITH_SEPARATED_AUDIO a video stream * with a separated audio source} or has no audio-only streams available and is a * {@link StreamType#VIDEO_STREAM video stream}, an * {@link StreamType#POST_LIVE_STREAM ended live stream}, or a * {@link StreamType#LIVE_STREAM live stream}. *
  • *
*

* * @param sourceType the {@link SourceType} of the stream * @param streamInfo the {@link StreamInfo} of the stream * @param videoRendererIndex the video renderer index of the video source, if that's a video * source (or {@link #RENDERER_UNAVAILABLE}) * @return whether the play queue manager needs to be reloaded */ private boolean playQueueManagerReloadingNeeded(final SourceType sourceType, @NonNull final StreamInfo streamInfo, final int videoRendererIndex) { final StreamType streamType = streamInfo.getStreamType(); final boolean isStreamTypeAudio = StreamTypeUtil.isAudio(streamType); if (videoRendererIndex == RENDERER_UNAVAILABLE && !isStreamTypeAudio) { return true; } // The content is an audio stream, an audio live stream, or a live stream with a live // source: it's not needed to reload the play queue manager because the stream source will // be the same if (isStreamTypeAudio || (streamType == StreamType.LIVE_STREAM && sourceType == SourceType.LIVE_STREAM)) { return false; } // The content's source is a video with separated audio or a video with audio -> the video // and its fetch may be disabled // The content's source is a video with embedded audio and the content has no separated // audio stream available: it's probably not needed to reload the play queue manager // because the stream source will be probably the same as the current played if (sourceType == SourceType.VIDEO_WITH_SEPARATED_AUDIO || (sourceType == SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY && isNullOrEmpty(streamInfo.getAudioStreams()))) { // It's not needed to reload the play queue manager only if the content's stream type // is a video stream, a live stream or an ended live stream return !StreamTypeUtil.isVideo(streamType); } // Other cases: the play queue manager reload is needed return true; } //endregion /*////////////////////////////////////////////////////////////////////////// // Getters //////////////////////////////////////////////////////////////////////////*/ //region Getters public Optional getCurrentStreamInfo() { return Optional.ofNullable(currentMetadata).flatMap(MediaItemTag::getMaybeStreamInfo); } public int getCurrentState() { return currentState; } public boolean exoPlayerIsNull() { return simpleExoPlayer == null; } public ExoPlayer getExoPlayer() { return simpleExoPlayer; } public boolean isStopped() { return exoPlayerIsNull() || simpleExoPlayer.getPlaybackState() == ExoPlayer.STATE_IDLE; } public boolean isPlaying() { return !exoPlayerIsNull() && simpleExoPlayer.isPlaying(); } public boolean getPlayWhenReady() { return !exoPlayerIsNull() && simpleExoPlayer.getPlayWhenReady(); } public boolean isLoading() { return !exoPlayerIsNull() && simpleExoPlayer.isLoading(); } private boolean isLive() { try { return !exoPlayerIsNull() && simpleExoPlayer.isCurrentMediaItemDynamic(); } catch (final IndexOutOfBoundsException e) { // Why would this even happen =(... but lets log it anyway, better safe than sorry if (DEBUG) { Log.d(TAG, "player.isCurrentWindowDynamic() failed: ", e); } return false; } } public void setPlaybackQuality(@Nullable final String quality) { saveStreamProgressState(); setRecovery(); videoResolver.setPlaybackQuality(quality); reloadPlayQueueManager(); } public void setAudioTrack(@Nullable final String audioTrackId) { saveStreamProgressState(); setRecovery(); videoResolver.setAudioTrack(audioTrackId); audioResolver.setAudioTrack(audioTrackId); reloadPlayQueueManager(); } @NonNull public Context getContext() { return context; } @NonNull public SharedPreferences getPrefs() { return prefs; } public PlayerType getPlayerType() { return playerType; } public boolean audioPlayerSelected() { return playerType == PlayerType.AUDIO; } public boolean videoPlayerSelected() { return playerType == PlayerType.MAIN; } public boolean popupPlayerSelected() { return playerType == PlayerType.POPUP; } @Nullable public PlayQueue getPlayQueue() { return playQueue; } public AudioReactor getAudioReactor() { return audioReactor; } public PlayerService getService() { return service; } public boolean isAudioOnly() { return isAudioOnly; } @NonNull public DefaultTrackSelector getTrackSelector() { return trackSelector; } @Nullable public MediaItemTag getCurrentMetadata() { return currentMetadata; } @Nullable public PlayQueueItem getCurrentItem() { return currentItem; } public Optional getFragmentListener() { return Optional.ofNullable(fragmentListener); } /** * @return the user interfaces connected with the player */ @SuppressWarnings("MethodName") // keep the unusual method name public PlayerUiList UIs() { return UIs; } /** * Get the video renderer index of the current playing stream. *

* This method returns the video renderer index of the current * {@link MappingTrackSelector.MappedTrackInfo} or {@link #RENDERER_UNAVAILABLE} if the current * {@link MappingTrackSelector.MappedTrackInfo} is null or if there is no video renderer index. * * @return the video renderer index or {@link #RENDERER_UNAVAILABLE} if it cannot be get */ private int getVideoRendererIndex() { final MappingTrackSelector.MappedTrackInfo mappedTrackInfo = trackSelector .getCurrentMappedTrackInfo(); if (mappedTrackInfo == null) { return RENDERER_UNAVAILABLE; } // Check every renderer return IntStream.range(0, mappedTrackInfo.getRendererCount()) // Check the renderer is a video renderer and has at least one track .filter(i -> !mappedTrackInfo.getTrackGroups(i).isEmpty() && simpleExoPlayer.getRendererType(i) == C.TRACK_TYPE_VIDEO) // Return the first index found (there is at most one renderer per renderer type) .findFirst() // No video renderer index with at least one track found: return unavailable index .orElse(RENDERER_UNAVAILABLE); } //endregion /** * @return whether the device screen is turned on. */ public boolean isScreenOn() { return screenOn; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/PlayerIntentType.kt ================================================ package org.schabi.newpipe.player import android.os.Parcelable import kotlinx.parcelize.Parcelize // We model this as an enum class plus one struct for each enum value // so we can consume it from Java properly. After converting to Kotlin, // we could switch to a sealed enum class & a proper Kotlin `when` match. enum class PlayerIntentType { Enqueue, EnqueueNext, TimestampChange, AllOthers } /** * A timestamp on the given was clicked and we should switch the playing stream to it. */ @Parcelize data class TimestampChangeData( val serviceId: Int, val url: String, val seconds: Int ) : Parcelable ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/PlayerService.java ================================================ /* * Copyright 2017 Mauricio Colli * Part of NewPipe * * License: GPL-3.0+ * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package org.schabi.newpipe.player; import android.content.Context; import android.content.Intent; import android.os.Binder; import android.os.Bundle; import android.os.IBinder; import android.support.v4.media.MediaBrowserCompat; import android.support.v4.media.session.MediaSessionCompat; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.app.ServiceCompat; import androidx.media.MediaBrowserServiceCompat; import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; import org.schabi.newpipe.ktx.BundleKt; import org.schabi.newpipe.player.mediabrowser.MediaBrowserImpl; import org.schabi.newpipe.player.mediabrowser.MediaBrowserPlaybackPreparer; import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi; import org.schabi.newpipe.player.notification.NotificationPlayerUi; import org.schabi.newpipe.player.notification.NotificationUtil; import org.schabi.newpipe.util.ThemeHelper; import java.lang.ref.WeakReference; import java.util.List; import java.util.function.Consumer; /** * One service for all players. */ public final class PlayerService extends MediaBrowserServiceCompat { private static final String TAG = PlayerService.class.getSimpleName(); private static final boolean DEBUG = Player.DEBUG; public static final String SHOULD_START_FOREGROUND_EXTRA = "should_start_foreground_extra"; public static final String BIND_PLAYER_HOLDER_ACTION = "bind_player_holder_action"; // These objects are used to cleanly separate the Service implementation (in this file) and the // media browser and playback preparer implementations. At the moment the playback preparer is // only used in conjunction with the media browser. private MediaBrowserImpl mediaBrowserImpl; private MediaBrowserPlaybackPreparer mediaBrowserPlaybackPreparer; // these are instantiated in onCreate() as per // https://developer.android.com/training/cars/media#browser_workflow private MediaSessionCompat mediaSession; private MediaSessionConnector sessionConnector; @Nullable private Player player; private final IBinder mBinder = new PlayerService.LocalBinder(this); /** * The parameter taken by this {@link Consumer} can be null to indicate the player is being * stopped. */ @Nullable private Consumer onPlayerStartedOrStopped = null; //region Service lifecycle @Override public void onCreate() { super.onCreate(); if (DEBUG) { Log.d(TAG, "onCreate() called"); } ThemeHelper.setTheme(this); mediaBrowserImpl = new MediaBrowserImpl(this, this::notifyChildrenChanged); // see https://developer.android.com/training/cars/media#browser_workflow mediaSession = new MediaSessionCompat(this, "MediaSessionPlayerServ"); setSessionToken(mediaSession.getSessionToken()); sessionConnector = new MediaSessionConnector(mediaSession); sessionConnector.setMetadataDeduplicationEnabled(true); mediaBrowserPlaybackPreparer = new MediaBrowserPlaybackPreparer( this, sessionConnector::setCustomErrorMessage, () -> sessionConnector.setCustomErrorMessage(null), (playWhenReady) -> { if (player != null) { player.onPrepare(); } } ); sessionConnector.setPlaybackPreparer(mediaBrowserPlaybackPreparer); // Note: you might be tempted to create the player instance and call startForeground here, // but be aware that the Android system might start the service just to perform media // queries. In those cases creating a player instance is a waste of resources, and calling // startForeground means creating a useless empty notification. In case it's really needed // the player instance can be created here, but startForeground() should definitely not be // called here unless the service is actually starting in the foreground, to avoid the // useless notification. } @Override public int onStartCommand(final Intent intent, final int flags, final int startId) { if (DEBUG) { Log.d(TAG, "onStartCommand() called with: intent = [" + intent + "], extras = [" + BundleKt.toDebugString(intent.getExtras()) + "], flags = [" + flags + "], startId = [" + startId + "]"); } // All internal NewPipe intents used to interact with the player, that are sent to the // PlayerService using startForegroundService(), will have SHOULD_START_FOREGROUND_EXTRA, // to ensure startForeground() is called (otherwise Android will force-crash the app). if (intent.getBooleanExtra(SHOULD_START_FOREGROUND_EXTRA, false)) { final boolean playerWasNull = (player == null); if (playerWasNull) { // make sure the player exists, in case the service was resumed player = new Player(this, mediaSession, sessionConnector); } // Be sure that the player notification is set and the service is started in foreground, // otherwise, the app may crash on Android 8+ as the service would never be put in the // foreground while we said to the system we would do so. The service is always // requested to be started in foreground, so always creating a notification if there is // no one already and starting the service in foreground should not create any issues. // If the service is already started in foreground, requesting it to be started // shouldn't do anything. player.UIs().get(NotificationPlayerUi.class) .ifPresent(NotificationPlayerUi::createNotificationAndStartForeground); if (playerWasNull && onPlayerStartedOrStopped != null) { // notify that a new player was created (but do it after creating the foreground // notification just to make sure we don't incur, due to slowness, in // "Context.startForegroundService() did not then call Service.startForeground()") onPlayerStartedOrStopped.accept(player); } } if (player == null) { // No need to process media button's actions or other system intents if the player is // not running. However, since the current intent might have been issued by the system // with `startForegroundService()` (for unknown reasons), we need to ensure that we post // a (dummy) foreground notification, otherwise we'd incur in // "Context.startForegroundService() did not then call Service.startForeground()". Then // we stop the service again. Log.d(TAG, "onStartCommand() got a useless intent, closing the service"); NotificationUtil.startForegroundWithDummyNotification(this); destroyPlayerAndStopService(); return START_NOT_STICKY; } final PlayerType oldPlayerType = player.getPlayerType(); player.handleIntent(intent); player.handleIntentPost(oldPlayerType); player.UIs().get(MediaSessionPlayerUi.class) .ifPresent(ui -> ui.handleMediaButtonIntent(intent)); return START_NOT_STICKY; } public void stopForImmediateReusing() { if (DEBUG) { Log.d(TAG, "stopForImmediateReusing() called"); } if (player != null && !player.exoPlayerIsNull()) { // Releases wifi & cpu, disables keepScreenOn, etc. // We can't just pause the player here because it will make transition // from one stream to a new stream not smooth player.smoothStopForImmediateReusing(); } } @Override public void onTaskRemoved(final Intent rootIntent) { super.onTaskRemoved(rootIntent); if (player != null && !player.videoPlayerSelected()) { return; } onDestroy(); // Unload from memory completely Runtime.getRuntime().halt(0); } @Override public void onDestroy() { if (DEBUG) { Log.d(TAG, "destroy() called"); } super.onDestroy(); cleanup(); mediaBrowserPlaybackPreparer.dispose(); mediaSession.release(); mediaBrowserImpl.dispose(); } private void cleanup() { if (player != null) { if (onPlayerStartedOrStopped != null) { // notify that the player is being destroyed onPlayerStartedOrStopped.accept(null); } player.destroy(); player = null; } // Should already be handled by MediaSessionPlayerUi, but just to be sure. mediaSession.setActive(false); // Should already be handled by NotificationUtil.cancelNotificationAndStopForeground() in // NotificationPlayerUi, but let's make sure that the foreground service is stopped. ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); } /** * Destroys the player and allows the player instance to be garbage collected. Sets the media * session to inactive. Stops the foreground service and removes the player notification * associated with it. Tries to stop the {@link PlayerService} completely, but this step will * have no effect in case some service connection still uses the service (e.g. the Android Auto * system accesses the media browser even when no player is running). */ public void destroyPlayerAndStopService() { if (DEBUG) { Log.d(TAG, "destroyPlayerAndStopService() called"); } cleanup(); // This only really stops the service if there are no other service connections (see docs): // for example the (Android Auto) media browser binder will block stopService(). // This is why we also stopForeground() above, to make sure the notification is removed. // If we were to call stopSelf(), then the service would be surely stopped (regardless of // other service connections), but this would be a waste of resources since the service // would be immediately restarted by those same connections to perform the queries. stopService(new Intent(this, PlayerService.class)); } @Override protected void attachBaseContext(final Context base) { super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base)); } //endregion //region Bind @Override public IBinder onBind(final Intent intent) { if (DEBUG) { Log.d(TAG, "onBind() called with: intent = [" + intent + "], extras = [" + BundleKt.toDebugString(intent.getExtras()) + "]"); } if (BIND_PLAYER_HOLDER_ACTION.equals(intent.getAction())) { // Note that this binder might be reused multiple times while the service is alive, even // after unbind() has been called: https://stackoverflow.com/a/8794930 . return mBinder; } else if (MediaBrowserServiceCompat.SERVICE_INTERFACE.equals(intent.getAction())) { // MediaBrowserService also uses its own binder, so for actions related to the media // browser service, pass the onBind to the superclass. return super.onBind(intent); } else { // This is an unknown request, avoid returning any binder to not leak objects. return null; } } public static class LocalBinder extends Binder { private final WeakReference playerService; LocalBinder(final PlayerService playerService) { this.playerService = new WeakReference<>(playerService); } public PlayerService getService() { return playerService.get(); } } /** * @return the current active player instance. May be null, since the player service can outlive * the player e.g. to respond to Android Auto media browser queries. */ @Nullable public Player getPlayer() { return player; } /** * Sets the listener that will be called when the player is started or stopped. If a * {@code null} listener is passed, then the current listener will be unset. The parameter taken * by the {@link Consumer} can be null to indicate that the player is stopping. * @param listener the listener to set or unset */ public void setPlayerListener(@Nullable final Consumer listener) { this.onPlayerStartedOrStopped = listener; if (listener != null) { // if there is no player, then `null` will be sent here, to ensure the state is synced listener.accept(player); } } //endregion //region Media browser @Override public BrowserRoot onGetRoot(@NonNull final String clientPackageName, final int clientUid, @Nullable final Bundle rootHints) { return mediaBrowserImpl.onGetRoot(clientPackageName, clientUid, rootHints); } @Override public void onLoadChildren(@NonNull final String parentId, @NonNull final Result> result) { mediaBrowserImpl.onLoadChildren(parentId, result); } @Override public void onSearch(@NonNull final String query, final Bundle extras, @NonNull final Result> result) { mediaBrowserImpl.onSearch(query, result); } //endregion } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/PlayerType.kt ================================================ /* * SPDX-FileCopyrightText: 2022-2026 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.player enum class PlayerType { MAIN, AUDIO, POPUP } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java ================================================ package org.schabi.newpipe.player.datasource; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; import androidx.annotation.NonNull; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.source.hls.HlsDataSourceFactory; import com.google.android.exoplayer2.upstream.ByteArrayDataSource; import com.google.android.exoplayer2.upstream.DataSource; import java.nio.charset.StandardCharsets; /** * A {@link HlsDataSourceFactory} which allows playback of non-URI media HLS playlists for * {@link com.google.android.exoplayer2.source.hls.HlsMediaSource HlsMediaSource}s. * *

* If media requests are relative, the URI from which the manifest comes from (either the * manifest URI (preferred) or the master URI (if applicable)) must be returned, otherwise the * content will be not playable, as it will be an invalid URL, or it may be treat as something * unexpected, for instance as a file for * {@link com.google.android.exoplayer2.upstream.DefaultDataSource DefaultDataSource}s. *

* *

* See {@link #createDataSource(int)} for changes and implementation details. *

*/ public final class NonUriHlsDataSourceFactory implements HlsDataSourceFactory { /** * Builder class of {@link NonUriHlsDataSourceFactory} instances. */ public static final class Builder { private DataSource.Factory dataSourceFactory; private String playlistString; /** * Set the {@link DataSource.Factory} which will be used to create non manifest contents * {@link DataSource}s. * * @param dataSourceFactoryForNonManifestContents the {@link DataSource.Factory} which will * be used to create non manifest contents * {@link DataSource}s, which cannot be null */ public void setDataSourceFactory( @NonNull final DataSource.Factory dataSourceFactoryForNonManifestContents) { this.dataSourceFactory = dataSourceFactoryForNonManifestContents; } /** * Set the HLS playlist which will be used for manifests requests. * * @param hlsPlaylistString the string which correspond to the response of the HLS * manifest, which cannot be null or empty */ public void setPlaylistString(@NonNull final String hlsPlaylistString) { this.playlistString = hlsPlaylistString; } /** * Create a new {@link NonUriHlsDataSourceFactory} with the given data source factory and * the given HLS playlist. * * @return a {@link NonUriHlsDataSourceFactory} * @throws IllegalArgumentException if the data source factory is null or if the HLS * playlist string set is null or empty */ @NonNull public NonUriHlsDataSourceFactory build() { if (dataSourceFactory == null) { throw new IllegalArgumentException( "No DataSource.Factory valid instance has been specified."); } if (isNullOrEmpty(playlistString)) { throw new IllegalArgumentException("No HLS valid playlist has been specified."); } return new NonUriHlsDataSourceFactory(dataSourceFactory, playlistString.getBytes(StandardCharsets.UTF_8)); } } private final DataSource.Factory dataSourceFactory; private final byte[] playlistStringByteArray; /** * Create a {@link NonUriHlsDataSourceFactory} instance. * * @param dataSourceFactory the {@link DataSource.Factory} which will be used to build * non manifests {@link DataSource}s, which must not be null * @param playlistStringByteArray a byte array of the HLS playlist, which must not be null */ private NonUriHlsDataSourceFactory(@NonNull final DataSource.Factory dataSourceFactory, @NonNull final byte[] playlistStringByteArray) { this.dataSourceFactory = dataSourceFactory; this.playlistStringByteArray = playlistStringByteArray; } /** * Create a {@link DataSource} for the given data type. * *

* Contrary to {@link com.google.android.exoplayer2.source.hls.DefaultHlsDataSourceFactory * ExoPlayer's default implementation}, this implementation is not always using the * {@link DataSource.Factory} passed to the * {@link com.google.android.exoplayer2.source.hls.HlsMediaSource.Factory * HlsMediaSource.Factory} constructor, only when it's not * {@link C#DATA_TYPE_MANIFEST the manifest type}. *

* *

* This change allow playback of non-URI HLS contents, when the manifest is not a master * manifest/playlist (otherwise, endless loops should be encountered because the * {@link DataSource}s created for media playlists should use the master playlist response * instead). *

* * @param dataType the data type for which the {@link DataSource} will be used, which is one of * {@link C} {@code .DATA_TYPE_*} constants * @return a {@link DataSource} for the given data type */ @NonNull @Override public DataSource createDataSource(final int dataType) { // The manifest is already downloaded and provided with playlistStringByteArray, so we // don't need to download it again and we can use a ByteArrayDataSource instead if (dataType == C.DATA_TYPE_MANIFEST) { return new ByteArrayDataSource(playlistStringByteArray); } return dataSourceFactory.createDataSource(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java ================================================ /* * Based on ExoPlayer's DefaultHttpDataSource, version 2.18.1. * * Original source code copyright (C) 2016 The Android Open Source Project, licensed under the * Apache License, Version 2.0. */ package org.schabi.newpipe.player.datasource; import static com.google.android.exoplayer2.upstream.DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS; import static com.google.android.exoplayer2.upstream.DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS; import static com.google.android.exoplayer2.upstream.HttpUtil.buildRangeRequestHeader; import static com.google.android.exoplayer2.util.Assertions.checkNotNull; import static com.google.android.exoplayer2.util.Util.castNonNull; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getAndroidUserAgent; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getIosUserAgent; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isAndroidStreamingUrl; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isIosStreamingUrl; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebStreamingUrl; import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebEmbeddedPlayerStreamingUrl; import static java.lang.Math.min; import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.PlaybackException; import com.google.android.exoplayer2.upstream.BaseDataSource; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DataSourceException; import com.google.android.exoplayer2.upstream.DataSpec; import com.google.android.exoplayer2.upstream.DataSpec.HttpMethod; import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; import com.google.android.exoplayer2.upstream.HttpDataSource; import com.google.android.exoplayer2.upstream.HttpUtil; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; import com.google.common.base.Predicate; import com.google.common.collect.ForwardingMap; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Sets; import com.google.common.net.HttpHeaders; import org.schabi.newpipe.DownloaderImpl; import java.io.IOException; import java.io.InputStream; import java.io.InterruptedIOException; import java.io.OutputStream; import java.lang.reflect.Method; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.NoRouteToHostException; import java.net.URL; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; import java.util.zip.GZIPInputStream; /** * An {@link HttpDataSource} that uses Android's {@link HttpURLConnection}, based on * {@link com.google.android.exoplayer2.upstream.DefaultHttpDataSource}, for YouTube streams. * *

* It adds more headers to {@code videoplayback} URLs, such as {@code Origin}, {@code Referer} * (only where it's relevant) and also more parameters, such as {@code rn} and replaces the use of * the {@code Range} header by the corresponding parameter ({@code range}), if enabled. *

* * There are many unused methods in this class because everything was copied from {@link * com.google.android.exoplayer2.upstream.DefaultHttpDataSource} with as little changes as possible. * SonarQube warnings were also suppressed for the same reason. */ @SuppressWarnings({"squid:S3011", "squid:S4738"}) public final class YoutubeHttpDataSource extends BaseDataSource implements HttpDataSource { /** * {@link DataSource.Factory} for {@link YoutubeHttpDataSource} instances. */ public static final class Factory implements HttpDataSource.Factory { private final RequestProperties defaultRequestProperties; @Nullable private TransferListener transferListener; @Nullable private Predicate contentTypePredicate; private int connectTimeoutMs; private int readTimeoutMs; private boolean allowCrossProtocolRedirects; private boolean keepPostFor302Redirects; private boolean rangeParameterEnabled; private boolean rnParameterEnabled; /** * Creates an instance. */ public Factory() { defaultRequestProperties = new RequestProperties(); connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MILLIS; readTimeoutMs = DEFAULT_READ_TIMEOUT_MILLIS; } @NonNull @Override public Factory setDefaultRequestProperties( @NonNull final Map defaultRequestPropertiesMap) { defaultRequestProperties.clearAndSet(defaultRequestPropertiesMap); return this; } /** * Sets the connect timeout, in milliseconds. * *

* The default is {@link DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS}. *

* * @param connectTimeoutMsValue The connect timeout, in milliseconds, that will be used. * @return This factory. */ public Factory setConnectTimeoutMs(final int connectTimeoutMsValue) { connectTimeoutMs = connectTimeoutMsValue; return this; } /** * Sets the read timeout, in milliseconds. * *

The default is {@link DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS}. * * @param readTimeoutMsValue The connect timeout, in milliseconds, that will be used. * @return This factory. */ public Factory setReadTimeoutMs(final int readTimeoutMsValue) { readTimeoutMs = readTimeoutMsValue; return this; } /** * Sets whether to allow cross protocol redirects. * *

The default is {@code false}. * * @param allowCrossProtocolRedirectsValue Whether to allow cross protocol redirects. * @return This factory. */ public Factory setAllowCrossProtocolRedirects( final boolean allowCrossProtocolRedirectsValue) { allowCrossProtocolRedirects = allowCrossProtocolRedirectsValue; return this; } /** * Sets whether the use of the {@code range} parameter instead of the {@code Range} header * to request ranges of streams is enabled. * *

* Note that it must be not enabled on streams which are using a {@link * com.google.android.exoplayer2.source.ProgressiveMediaSource}, as it will break playback * for them (some exceptions may be thrown). *

* * @param rangeParameterEnabledValue whether the use of the {@code range} parameter instead * of the {@code Range} header (must be only enabled when * non-{@code ProgressiveMediaSource}s) * @return This factory. */ public Factory setRangeParameterEnabled(final boolean rangeParameterEnabledValue) { rangeParameterEnabled = rangeParameterEnabledValue; return this; } /** * Sets whether the use of the {@code rn}, which stands for request number, parameter is * enabled. * *

* Note that it should be not enabled on streams which are using {@code /} to delimit URLs * parameters, such as the streams of HLS manifests. *

* * @param rnParameterEnabledValue whether the appending the {@code rn} parameter to * {@code videoplayback} URLs * @return This factory. */ public Factory setRnParameterEnabled(final boolean rnParameterEnabledValue) { rnParameterEnabled = rnParameterEnabledValue; return this; } /** * Sets a content type {@link Predicate}. If a content type is rejected by the predicate * then a {@link HttpDataSource.InvalidContentTypeException} is thrown from * {@link YoutubeHttpDataSource#open(DataSpec)}. * *

* The default is {@code null}. *

* * @param contentTypePredicateToSet The content type {@link Predicate}, or {@code null} to * clear a predicate that was previously set. * @return This factory. */ public Factory setContentTypePredicate( @Nullable final Predicate contentTypePredicateToSet) { this.contentTypePredicate = contentTypePredicateToSet; return this; } /** * Sets the {@link TransferListener} that will be used. * *

The default is {@code null}. * *

See {@link DataSource#addTransferListener(TransferListener)}. * * @param transferListenerToUse The listener that will be used. * @return This factory. */ public Factory setTransferListener( @Nullable final TransferListener transferListenerToUse) { this.transferListener = transferListenerToUse; return this; } /** * Sets whether we should keep the POST method and body when we have HTTP 302 redirects for * a POST request. * * @param keepPostFor302RedirectsValue Whether we should keep the POST method and body when * we have HTTP 302 redirects for a POST request. * @return This factory. */ public Factory setKeepPostFor302Redirects(final boolean keepPostFor302RedirectsValue) { this.keepPostFor302Redirects = keepPostFor302RedirectsValue; return this; } @NonNull @Override public YoutubeHttpDataSource createDataSource() { final YoutubeHttpDataSource dataSource = new YoutubeHttpDataSource( connectTimeoutMs, readTimeoutMs, allowCrossProtocolRedirects, rangeParameterEnabled, rnParameterEnabled, defaultRequestProperties, contentTypePredicate, keepPostFor302Redirects); if (transferListener != null) { dataSource.addTransferListener(transferListener); } return dataSource; } } private static final String TAG = YoutubeHttpDataSource.class.getSimpleName(); private static final int MAX_REDIRECTS = 20; // Same limit as okhttp. private static final int HTTP_STATUS_TEMPORARY_REDIRECT = 307; private static final int HTTP_STATUS_PERMANENT_REDIRECT = 308; private static final long MAX_BYTES_TO_DRAIN = 2048; private static final String RN_PARAMETER = "&rn="; private static final String YOUTUBE_BASE_URL = "https://www.youtube.com"; private static final byte[] POST_BODY = new byte[] {0x78, 0}; private final boolean allowCrossProtocolRedirects; private final boolean rangeParameterEnabled; private final boolean rnParameterEnabled; private final int connectTimeoutMillis; private final int readTimeoutMillis; @Nullable private final RequestProperties defaultRequestProperties; private final RequestProperties requestProperties; private final boolean keepPostFor302Redirects; @Nullable private final Predicate contentTypePredicate; @Nullable private DataSpec dataSpec; @Nullable private HttpURLConnection connection; @Nullable private InputStream inputStream; private boolean opened; private int responseCode; private long bytesToRead; private long bytesRead; private long requestNumber; @SuppressWarnings("checkstyle:ParameterNumber") private YoutubeHttpDataSource(final int connectTimeoutMillis, final int readTimeoutMillis, final boolean allowCrossProtocolRedirects, final boolean rangeParameterEnabled, final boolean rnParameterEnabled, @Nullable final RequestProperties defaultRequestProperties, @Nullable final Predicate contentTypePredicate, final boolean keepPostFor302Redirects) { super(true); this.connectTimeoutMillis = connectTimeoutMillis; this.readTimeoutMillis = readTimeoutMillis; this.allowCrossProtocolRedirects = allowCrossProtocolRedirects; this.rangeParameterEnabled = rangeParameterEnabled; this.rnParameterEnabled = rnParameterEnabled; this.defaultRequestProperties = defaultRequestProperties; this.contentTypePredicate = contentTypePredicate; this.requestProperties = new RequestProperties(); this.keepPostFor302Redirects = keepPostFor302Redirects; this.requestNumber = 0; } @Override @Nullable public Uri getUri() { return connection == null ? null : Uri.parse(connection.getURL().toString()); } @Override public int getResponseCode() { return connection == null || responseCode <= 0 ? -1 : responseCode; } @NonNull @Override public Map> getResponseHeaders() { if (connection == null) { return ImmutableMap.of(); } // connection.getHeaderFields() always contains a null key with a value like // ["HTTP/1.1 200 OK"]. The response code is available from // HttpURLConnection#getResponseCode() and the HTTP version is fixed when establishing the // connection. // DataSource#getResponseHeaders() doesn't allow null keys in the returned map, so we need // to remove it. // connection.getHeaderFields() returns a special unmodifiable case-insensitive Map // so we can't just remove the null key or make a copy without the null key. Instead we // wrap it in a ForwardingMap subclass that ignores and filters out null keys in the read // methods. return new NullFilteringHeadersMap(connection.getHeaderFields()); } @Override public void setRequestProperty(@NonNull final String name, @NonNull final String value) { checkNotNull(name); checkNotNull(value); requestProperties.set(name, value); } @Override public void clearRequestProperty(@NonNull final String name) { checkNotNull(name); requestProperties.remove(name); } @Override public void clearAllRequestProperties() { requestProperties.clear(); } /** * Opens the source to read the specified data. */ @Override public long open(@NonNull final DataSpec dataSpecParameter) throws HttpDataSourceException { this.dataSpec = dataSpecParameter; bytesRead = 0; bytesToRead = 0; transferInitializing(dataSpecParameter); final HttpURLConnection httpURLConnection; final String responseMessage; try { this.connection = makeConnection(dataSpec); httpURLConnection = this.connection; responseCode = httpURLConnection.getResponseCode(); responseMessage = httpURLConnection.getResponseMessage(); } catch (final IOException e) { closeConnectionQuietly(); throw HttpDataSourceException.createForIOException(e, dataSpec, HttpDataSourceException.TYPE_OPEN); } // Check for a valid response code. if (responseCode < 200 || responseCode > 299) { final Map> headers = httpURLConnection.getHeaderFields(); if (responseCode == 416) { final long documentSize = HttpUtil.getDocumentSize( httpURLConnection.getHeaderField(HttpHeaders.CONTENT_RANGE)); if (dataSpecParameter.position == documentSize) { opened = true; transferStarted(dataSpecParameter); return dataSpecParameter.length != C.LENGTH_UNSET ? dataSpecParameter.length : 0; } } final InputStream errorStream = httpURLConnection.getErrorStream(); byte[] errorResponseBody; try { errorResponseBody = errorStream != null ? Util.toByteArray(errorStream) : Util.EMPTY_BYTE_ARRAY; } catch (final IOException e) { errorResponseBody = Util.EMPTY_BYTE_ARRAY; } closeConnectionQuietly(); final IOException cause = responseCode == 416 ? new DataSourceException( PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE) : null; throw new InvalidResponseCodeException(responseCode, responseMessage, cause, headers, dataSpec, errorResponseBody); } // Check for a valid content type. final String contentType = httpURLConnection.getContentType(); if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) { closeConnectionQuietly(); throw new InvalidContentTypeException(contentType, dataSpecParameter); } final long bytesToSkip; if (!rangeParameterEnabled) { // If we requested a range starting from a non-zero position and received a 200 rather // than a 206, then the server does not support partial requests. We'll need to // manually skip to the requested position. bytesToSkip = responseCode == 200 && dataSpecParameter.position != 0 ? dataSpecParameter.position : 0; } else { bytesToSkip = 0; } // Determine the length of the data to be read, after skipping. final boolean isCompressed = isCompressed(httpURLConnection); if (!isCompressed) { if (dataSpecParameter.length != C.LENGTH_UNSET) { bytesToRead = dataSpecParameter.length; } else { final long contentLength = HttpUtil.getContentLength( httpURLConnection.getHeaderField(HttpHeaders.CONTENT_LENGTH), httpURLConnection.getHeaderField(HttpHeaders.CONTENT_RANGE)); bytesToRead = contentLength != C.LENGTH_UNSET ? (contentLength - bytesToSkip) : C.LENGTH_UNSET; } } else { // Gzip is enabled. If the server opts to use gzip then the content length in the // response will be that of the compressed data, which isn't what we want. Always use // the dataSpec length in this case. bytesToRead = dataSpecParameter.length; } try { inputStream = httpURLConnection.getInputStream(); if (isCompressed) { inputStream = new GZIPInputStream(inputStream); } } catch (final IOException e) { closeConnectionQuietly(); throw new HttpDataSourceException(e, dataSpec, PlaybackException.ERROR_CODE_IO_UNSPECIFIED, HttpDataSourceException.TYPE_OPEN); } opened = true; transferStarted(dataSpecParameter); try { skipFully(bytesToSkip, dataSpec); } catch (final IOException e) { closeConnectionQuietly(); if (e instanceof HttpDataSourceException) { throw (HttpDataSourceException) e; } throw new HttpDataSourceException(e, dataSpec, PlaybackException.ERROR_CODE_IO_UNSPECIFIED, HttpDataSourceException.TYPE_OPEN); } return bytesToRead; } @Override public int read(@NonNull final byte[] buffer, final int offset, final int length) throws HttpDataSourceException { try { return readInternal(buffer, offset, length); } catch (final IOException e) { throw HttpDataSourceException.createForIOException(e, castNonNull(dataSpec), HttpDataSourceException.TYPE_READ); } } @Override public void close() throws HttpDataSourceException { try { final InputStream connectionInputStream = this.inputStream; if (connectionInputStream != null) { final long bytesRemaining = bytesToRead == C.LENGTH_UNSET ? C.LENGTH_UNSET : bytesToRead - bytesRead; maybeTerminateInputStream(connection, bytesRemaining); try { connectionInputStream.close(); } catch (final IOException e) { throw new HttpDataSourceException(e, castNonNull(dataSpec), PlaybackException.ERROR_CODE_IO_UNSPECIFIED, HttpDataSourceException.TYPE_CLOSE); } } } finally { inputStream = null; closeConnectionQuietly(); if (opened) { opened = false; transferEnded(); } } } @NonNull private HttpURLConnection makeConnection(@NonNull final DataSpec dataSpecToUse) throws IOException { URL url = new URL(dataSpecToUse.uri.toString()); @HttpMethod int httpMethod = dataSpecToUse.httpMethod; @Nullable byte[] httpBody = dataSpecToUse.httpBody; final long position = dataSpecToUse.position; final long length = dataSpecToUse.length; final boolean allowGzip = dataSpecToUse.isFlagSet(DataSpec.FLAG_ALLOW_GZIP); if (!allowCrossProtocolRedirects && !keepPostFor302Redirects) { // HttpURLConnection disallows cross-protocol redirects, but otherwise performs // redirection automatically. This is the behavior we want, so use it. return makeConnection(url, httpMethod, httpBody, position, length, allowGzip, true, dataSpecToUse.httpRequestHeaders); } // We need to handle redirects ourselves to allow cross-protocol redirects or to keep the // POST request method for 302. int redirectCount = 0; while (redirectCount++ <= MAX_REDIRECTS) { final HttpURLConnection httpURLConnection = makeConnection(url, httpMethod, httpBody, position, length, allowGzip, false, dataSpecToUse.httpRequestHeaders); final int httpURLConnectionResponseCode = httpURLConnection.getResponseCode(); final String location = httpURLConnection.getHeaderField("Location"); if ((httpMethod == DataSpec.HTTP_METHOD_GET || httpMethod == DataSpec.HTTP_METHOD_HEAD) && (httpURLConnectionResponseCode == HttpURLConnection.HTTP_MULT_CHOICE || httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_PERM || httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_TEMP || httpURLConnectionResponseCode == HttpURLConnection.HTTP_SEE_OTHER || httpURLConnectionResponseCode == HTTP_STATUS_TEMPORARY_REDIRECT || httpURLConnectionResponseCode == HTTP_STATUS_PERMANENT_REDIRECT)) { httpURLConnection.disconnect(); url = handleRedirect(url, location, dataSpecToUse); } else if (httpMethod == DataSpec.HTTP_METHOD_POST && (httpURLConnectionResponseCode == HttpURLConnection.HTTP_MULT_CHOICE || httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_PERM || httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_TEMP || httpURLConnectionResponseCode == HttpURLConnection.HTTP_SEE_OTHER)) { httpURLConnection.disconnect(); final boolean shouldKeepPost = keepPostFor302Redirects && responseCode == HttpURLConnection.HTTP_MOVED_TEMP; if (!shouldKeepPost) { // POST request follows the redirect and is transformed into a GET request. httpMethod = DataSpec.HTTP_METHOD_GET; httpBody = null; } url = handleRedirect(url, location, dataSpecToUse); } else { return httpURLConnection; } } // If we get here we've been redirected more times than are permitted. throw new HttpDataSourceException( new NoRouteToHostException("Too many redirects: " + redirectCount), dataSpecToUse, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, HttpDataSourceException.TYPE_OPEN); } /** * Configures a connection and opens it. * * @param url The url to connect to. * @param httpMethod The http method. * @param httpBody The body data, or {@code null} if not required. * @param position The byte offset of the requested data. * @param length The length of the requested data, or {@link C#LENGTH_UNSET}. * @param allowGzip Whether to allow the use of gzip. * @param followRedirects Whether to follow redirects. * @param requestParameters parameters (HTTP headers) to include in request. * @return the connection opened */ @SuppressWarnings("checkstyle:ParameterNumber") @NonNull private HttpURLConnection makeConnection( @NonNull final URL url, @HttpMethod final int httpMethod, @Nullable final byte[] httpBody, final long position, final long length, final boolean allowGzip, final boolean followRedirects, final Map requestParameters) throws IOException { // This is the method that contains breaking changes with respect to DefaultHttpDataSource! String requestUrl = url.toString(); // Don't add the request number parameter if it has been already added (for instance in // DASH manifests) or if that's not a videoplayback URL final boolean isVideoPlaybackUrl = url.getPath().startsWith("/videoplayback"); if (isVideoPlaybackUrl && rnParameterEnabled && !requestUrl.contains(RN_PARAMETER)) { requestUrl += RN_PARAMETER + requestNumber; ++requestNumber; } if (rangeParameterEnabled && isVideoPlaybackUrl) { final String rangeParameterBuilt = buildRangeParameter(position, length); if (rangeParameterBuilt != null) { requestUrl += rangeParameterBuilt; } } final HttpURLConnection httpURLConnection = openConnection(new URL(requestUrl)); httpURLConnection.setConnectTimeout(connectTimeoutMillis); httpURLConnection.setReadTimeout(readTimeoutMillis); final Map requestHeaders = new HashMap<>(); if (defaultRequestProperties != null) { requestHeaders.putAll(defaultRequestProperties.getSnapshot()); } requestHeaders.putAll(requestProperties.getSnapshot()); requestHeaders.putAll(requestParameters); for (final Map.Entry property : requestHeaders.entrySet()) { httpURLConnection.setRequestProperty(property.getKey(), property.getValue()); } if (!rangeParameterEnabled) { final String rangeHeader = buildRangeRequestHeader(position, length); if (rangeHeader != null) { httpURLConnection.setRequestProperty(HttpHeaders.RANGE, rangeHeader); } } if (isWebStreamingUrl(requestUrl) || isWebEmbeddedPlayerStreamingUrl(requestUrl)) { httpURLConnection.setRequestProperty(HttpHeaders.ORIGIN, YOUTUBE_BASE_URL); httpURLConnection.setRequestProperty(HttpHeaders.REFERER, YOUTUBE_BASE_URL); httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_DEST, "empty"); httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_MODE, "cors"); httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_SITE, "cross-site"); } httpURLConnection.setRequestProperty(HttpHeaders.TE, "trailers"); final boolean isAndroidStreamingUrl = isAndroidStreamingUrl(requestUrl); final boolean isIosStreamingUrl = isIosStreamingUrl(requestUrl); if (isAndroidStreamingUrl) { // Improvement which may be done: find the content country used to request YouTube // contents to add it in the user agent instead of using the default httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, getAndroidUserAgent(null)); } else if (isIosStreamingUrl) { httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, getIosUserAgent(null)); } else { // non-mobile user agent httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, DownloaderImpl.USER_AGENT); } httpURLConnection.setRequestProperty(HttpHeaders.ACCEPT_ENCODING, allowGzip ? "gzip" : "identity"); httpURLConnection.setInstanceFollowRedirects(followRedirects); // Most clients use POST requests to fetch contents httpURLConnection.setRequestMethod("POST"); httpURLConnection.setDoOutput(true); httpURLConnection.setFixedLengthStreamingMode(POST_BODY.length); httpURLConnection.connect(); final OutputStream os = httpURLConnection.getOutputStream(); os.write(POST_BODY); os.close(); return httpURLConnection; } /** * Creates an {@link HttpURLConnection} that is connected with the {@code url}. * * @param url the {@link URL} to create an {@link HttpURLConnection} * @return an {@link HttpURLConnection} created with the {@code url} */ private HttpURLConnection openConnection(@NonNull final URL url) throws IOException { return (HttpURLConnection) url.openConnection(); } /** * Handles a redirect. * * @param originalUrl The original URL. * @param location The Location header in the response. May be {@code null}. * @param dataSpecToHandleRedirect The {@link DataSpec}. * @return The next URL. * @throws HttpDataSourceException If redirection isn't possible. */ @NonNull private URL handleRedirect(final URL originalUrl, @Nullable final String location, final DataSpec dataSpecToHandleRedirect) throws HttpDataSourceException { if (location == null) { throw new HttpDataSourceException("Null location redirect", dataSpecToHandleRedirect, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, HttpDataSourceException.TYPE_OPEN); } // Form the new url. final URL url; try { url = new URL(originalUrl, location); } catch (final MalformedURLException e) { throw new HttpDataSourceException(e, dataSpecToHandleRedirect, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, HttpDataSourceException.TYPE_OPEN); } // Check that the protocol of the new url is supported. final String protocol = url.getProtocol(); if (!"https".equals(protocol) && !"http".equals(protocol)) { throw new HttpDataSourceException("Unsupported protocol redirect: " + protocol, dataSpecToHandleRedirect, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, HttpDataSourceException.TYPE_OPEN); } if (!allowCrossProtocolRedirects && !protocol.equals(originalUrl.getProtocol())) { throw new HttpDataSourceException( "Disallowed cross-protocol redirect (" + originalUrl.getProtocol() + " to " + protocol + ")", dataSpecToHandleRedirect, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, HttpDataSourceException.TYPE_OPEN); } return url; } /** * Attempts to skip the specified number of bytes in full. * * @param bytesToSkip The number of bytes to skip. * @param dataSpecToUse The {@link DataSpec}. * @throws IOException If the thread is interrupted during the operation, or if the data ended * before skipping the specified number of bytes. */ @SuppressWarnings("checkstyle:FinalParameters") private void skipFully(long bytesToSkip, final DataSpec dataSpecToUse) throws IOException { if (bytesToSkip == 0) { return; } final byte[] skipBuffer = new byte[4096]; while (bytesToSkip > 0) { final int readLength = (int) min(bytesToSkip, skipBuffer.length); final int read = castNonNull(inputStream).read(skipBuffer, 0, readLength); if (Thread.currentThread().isInterrupted()) { throw new HttpDataSourceException( new InterruptedIOException(), dataSpecToUse, PlaybackException.ERROR_CODE_IO_UNSPECIFIED, HttpDataSourceException.TYPE_OPEN); } if (read == -1) { throw new HttpDataSourceException( dataSpecToUse, PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, HttpDataSourceException.TYPE_OPEN); } bytesToSkip -= read; bytesTransferred(read); } } /** * Reads up to {@code length} bytes of data and stores them into {@code buffer}, starting at * index {@code offset}. * *

* This method blocks until at least one byte of data can be read, the end of the opened range * is detected, or an exception is thrown. *

* * @param buffer The buffer into which the read data should be stored. * @param offset The start offset into {@code buffer} at which data should be written. * @param readLength The maximum number of bytes to read. * @return The number of bytes read, or {@link C#RESULT_END_OF_INPUT} if the end of the opened * range is reached. * @throws IOException If an error occurs reading from the source. */ @SuppressWarnings("checkstyle:FinalParameters") private int readInternal(final byte[] buffer, final int offset, int readLength) throws IOException { if (readLength == 0) { return 0; } if (bytesToRead != C.LENGTH_UNSET) { final long bytesRemaining = bytesToRead - bytesRead; if (bytesRemaining == 0) { return C.RESULT_END_OF_INPUT; } readLength = (int) min(readLength, bytesRemaining); } final int read = castNonNull(inputStream).read(buffer, offset, readLength); if (read == -1) { return C.RESULT_END_OF_INPUT; } bytesRead += read; bytesTransferred(read); return read; } /** * On platform API levels 19 and 20, okhttp's implementation of {@link InputStream#close} can * block for a long time if the stream has a lot of data remaining. Call this method before * closing the input stream to make a best effort to cause the input stream to encounter an * unexpected end of input, working around this issue. On other platform API levels, the method * does nothing. * * @param connection The connection whose {@link InputStream} should be terminated. * @param bytesRemaining The number of bytes remaining to be read from the input stream if its * length is known. {@link C#LENGTH_UNSET} otherwise. */ private static void maybeTerminateInputStream(@Nullable final HttpURLConnection connection, final long bytesRemaining) { if (connection == null || Util.SDK_INT < 19 || Util.SDK_INT > 20) { return; } try { final InputStream inputStream = connection.getInputStream(); if (bytesRemaining == C.LENGTH_UNSET) { // If the input stream has already ended, do nothing. The socket may be re-used. if (inputStream.read() == -1) { return; } } else if (bytesRemaining <= MAX_BYTES_TO_DRAIN) { // There isn't much data left. Prefer to allow it to drain, which may allow the // socket to be re-used. return; } final String className = inputStream.getClass().getName(); if ("com.android.okhttp.internal.http.HttpTransport$ChunkedInputStream" .equals(className) || "com.android.okhttp.internal.http.HttpTransport$FixedLengthInputStream" .equals(className)) { final Class superclass = inputStream.getClass().getSuperclass(); final Method unexpectedEndOfInput = checkNotNull(superclass).getDeclaredMethod( "unexpectedEndOfInput"); unexpectedEndOfInput.setAccessible(true); unexpectedEndOfInput.invoke(inputStream); } } catch (final Exception e) { // If an IOException then the connection didn't ever have an input stream, or it was // closed already. If another type of exception then something went wrong, most likely // the device isn't using okhttp. } } /** * Closes the current connection quietly, if there is one. */ private void closeConnectionQuietly() { if (connection != null) { try { connection.disconnect(); } catch (final Exception e) { Log.e(TAG, "Unexpected error while disconnecting", e); } connection = null; } } private static boolean isCompressed(@NonNull final HttpURLConnection connection) { final String contentEncoding = connection.getHeaderField("Content-Encoding"); return "gzip".equalsIgnoreCase(contentEncoding); } /** * Builds a {@code range} parameter for the given position and length. * *

* To fetch its contents, YouTube use range requests which append a {@code range} parameter * to videoplayback URLs instead of the {@code Range} header (even if the server respond * correctly when requesting a range of a ressouce with it). *

* *

* The parameter works in the same way as the header. *

* * @param position The request position. * @param length The request length, or {@link C#LENGTH_UNSET} if the request is unbounded. * @return The corresponding {@code range} parameter, or {@code null} if this parameter is * unnecessary because the whole resource is being requested. */ @Nullable private static String buildRangeParameter(final long position, final long length) { if (position == 0 && length == C.LENGTH_UNSET) { return null; } final StringBuilder rangeParameter = new StringBuilder(); rangeParameter.append("&range="); rangeParameter.append(position); rangeParameter.append("-"); if (length != C.LENGTH_UNSET) { rangeParameter.append(position + length - 1); } return rangeParameter.toString(); } private static final class NullFilteringHeadersMap extends ForwardingMap> { private final Map> headers; NullFilteringHeadersMap(final Map> headers) { this.headers = headers; } @NonNull @Override protected Map> delegate() { return headers; } @Override public boolean containsKey(@Nullable final Object key) { return key != null && super.containsKey(key); } @Nullable @Override public List get(@Nullable final Object key) { return key == null ? null : super.get(key); } @NonNull @Override public Set keySet() { return Sets.filter(super.keySet(), Objects::nonNull); } @NonNull @Override public Set>> entrySet() { return Sets.filter(super.entrySet(), entry -> entry.getKey() != null); } @Override public int size() { return super.size() - (super.containsKey(null) ? 1 : 0); } @Override public boolean isEmpty() { return super.isEmpty() || (super.size() == 1 && super.containsKey(null)); } @Override public boolean containsValue(@Nullable final Object value) { return super.standardContainsValue(value); } @Override public boolean equals(@Nullable final Object object) { return object != null && super.standardEquals(object); } @Override public int hashCode() { return super.standardHashCode(); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/event/OnKeyDownListener.java ================================================ package org.schabi.newpipe.player.event; public interface OnKeyDownListener { boolean onKeyDown(int keyCode); } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java ================================================ package org.schabi.newpipe.player.event; import com.google.android.exoplayer2.PlaybackParameters; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.player.playqueue.PlayQueue; public interface PlayerEventListener { void onQueueUpdate(PlayQueue queue); void onPlaybackUpdate(int state, int repeatMode, boolean shuffled, PlaybackParameters parameters); void onProgressUpdate(int currentProgress, int duration, int bufferPercent); void onMetadataUpdate(StreamInfo info, PlayQueue queue); default void onAudioTrackUpdate() { } void onServiceStopped(); } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java ================================================ package org.schabi.newpipe.player.event; import com.google.android.exoplayer2.PlaybackException; public interface PlayerServiceEventListener extends PlayerEventListener { void onViewCreated(); void onFullscreenStateChanged(boolean fullscreen); void onScreenRotationButtonClicked(); void onMoreOptionsLongClicked(); void onPlayerError(PlaybackException error, boolean isCatchableException); void hideSystemUiIfNeeded(); } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java ================================================ package org.schabi.newpipe.player.event; import androidx.annotation.NonNull; import org.schabi.newpipe.player.PlayerService; import org.schabi.newpipe.player.Player; /** * In addition to {@link PlayerServiceEventListener}, provides callbacks for service and player * connections and disconnections. "Connected" here means that the service (resp. the * player) is running and is bound to {@link org.schabi.newpipe.player.helper.PlayerHolder}. * "Disconnected" means that either the service (resp. the player) was stopped completely, or that * {@link org.schabi.newpipe.player.helper.PlayerHolder} is not bound. */ public interface PlayerServiceExtendedEventListener extends PlayerServiceEventListener { /** * The player service just connected to {@link org.schabi.newpipe.player.helper.PlayerHolder}, * but the player may not be active at this moment, e.g. in case the service is running to * respond to Android Auto media browser queries without playing anything. * {@link #onPlayerConnected(Player, boolean)} will be called right after this function if there * is a player. * * @param playerService the newly connected player service */ void onServiceConnected(@NonNull PlayerService playerService); /** * The player service is already connected and the player was just started. * * @param player the newly connected or started player * @param playAfterConnect whether to open the video player in the video details fragment */ void onPlayerConnected(@NonNull Player player, boolean playAfterConnect); /** * The player got disconnected, for one of these reasons: the player is getting closed while * leaving the service open for future media browser queries, the service is stopping * completely, or {@link org.schabi.newpipe.player.helper.PlayerHolder} is unbinding. */ void onPlayerDisconnected(); /** * The service got disconnected from {@link org.schabi.newpipe.player.helper.PlayerHolder}, * either because {@link org.schabi.newpipe.player.helper.PlayerHolder} is unbinding or because * the service is stopping completely. */ void onServiceDisconnected(); } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/gesture/BasePlayerGestureListener.kt ================================================ package org.schabi.newpipe.player.gesture import android.os.Handler import android.os.Looper import android.util.Log import android.view.GestureDetector import android.view.MotionEvent import android.view.View import androidx.core.os.postDelayed import org.schabi.newpipe.databinding.PlayerBinding import org.schabi.newpipe.player.Player import org.schabi.newpipe.player.ui.VideoPlayerUi /** * Base gesture handling for [Player] * * This class contains the logic for the player gestures like View preparations * and provides some abstract methods to make it easier separating the logic from the UI. */ abstract class BasePlayerGestureListener( private val playerUi: VideoPlayerUi ) : GestureDetector.SimpleOnGestureListener(), View.OnTouchListener { protected val player: Player = playerUi.player protected val binding: PlayerBinding = playerUi.binding override fun onTouch(v: View, event: MotionEvent): Boolean { playerUi.gestureDetector.onTouchEvent(event) return false } private fun onDoubleTap( event: MotionEvent, portion: DisplayPortion ) { if (DEBUG) { Log.d( TAG, "onDoubleTap called with playerType = [" + player.playerType + "], portion = [" + portion + "]" ) } if (playerUi.isSomePopupMenuVisible) { playerUi.hideControls(0, 0) } if (portion === DisplayPortion.LEFT || portion === DisplayPortion.RIGHT) { startMultiDoubleTap(event) } else if (portion === DisplayPortion.MIDDLE) { player.playPause() if (player.isPlaying) { playerUi.hideControls(0, 0) } } } protected fun onSingleTap() { if (playerUi.isControlsVisible) { playerUi.hideControls(150, 0) return } // -- Controls are not visible -- // When player is completed show controls and don't hide them later if (player.currentState == Player.STATE_COMPLETED) { playerUi.showControls(0) } else { playerUi.showControlsThenHide() } } open fun onScrollEnd(event: MotionEvent) { if (DEBUG) { Log.d( TAG, "onScrollEnd called with playerType = [" + player.playerType + "]" ) } if (playerUi.isControlsVisible && player.currentState == Player.STATE_PLAYING) { playerUi.hideControls( VideoPlayerUi.DEFAULT_CONTROLS_DURATION, VideoPlayerUi.DEFAULT_CONTROLS_HIDE_TIME ) } } // /////////////////////////////////////////////////////////////////// // Simple gestures // /////////////////////////////////////////////////////////////////// override fun onDown(e: MotionEvent): Boolean { if (DEBUG) { Log.d(TAG, "onDown called with e = [$e]") } if (isDoubleTapping && isDoubleTapEnabled) { doubleTapControls?.onDoubleTapProgressDown(getDisplayPortion(e)) return true } if (onDownNotDoubleTapping(e)) { return super.onDown(e) } return true } /** * @return true if `super.onDown(e)` should be called, false otherwise */ open fun onDownNotDoubleTapping(e: MotionEvent): Boolean { return false // do not call super.onDown(e) by default, overridden for popup player } override fun onDoubleTap(e: MotionEvent): Boolean { if (DEBUG) { Log.d(TAG, "onDoubleTap called with e = [$e]") } onDoubleTap(e, getDisplayPortion(e)) return true } // /////////////////////////////////////////////////////////////////// // Multi double tapping // /////////////////////////////////////////////////////////////////// private var doubleTapControls: DoubleTapListener? = null private val isDoubleTapEnabled: Boolean get() = doubleTapDelay > 0 var isDoubleTapping = false private set fun doubleTapControls(listener: DoubleTapListener) = apply { doubleTapControls = listener } private var doubleTapDelay = DOUBLE_TAP_DELAY private val doubleTapHandler: Handler = Handler(Looper.getMainLooper()) private fun startMultiDoubleTap(e: MotionEvent) { if (!isDoubleTapping) { if (DEBUG) { Log.d(TAG, "startMultiDoubleTap called with e = [$e]") } keepInDoubleTapMode() doubleTapControls?.onDoubleTapStarted(getDisplayPortion(e)) } } fun keepInDoubleTapMode() { if (DEBUG) { Log.d(TAG, "keepInDoubleTapMode called") } isDoubleTapping = true doubleTapHandler.removeCallbacksAndMessages(DOUBLE_TAP) doubleTapHandler.postDelayed(DOUBLE_TAP_DELAY, DOUBLE_TAP) { if (DEBUG) { Log.d(TAG, "doubleTapRunnable called") } isDoubleTapping = false doubleTapControls?.onDoubleTapFinished() } } fun endMultiDoubleTap() { if (DEBUG) { Log.d(TAG, "endMultiDoubleTap called") } isDoubleTapping = false doubleTapHandler.removeCallbacksAndMessages(DOUBLE_TAP) doubleTapControls?.onDoubleTapFinished() } // /////////////////////////////////////////////////////////////////// // Utils // /////////////////////////////////////////////////////////////////// abstract fun getDisplayPortion(e: MotionEvent): DisplayPortion // Currently needed for scrolling since there is no action more the middle portion abstract fun getDisplayHalfPortion(e: MotionEvent): DisplayPortion companion object { private const val TAG = "BasePlayerGestListener" private val DEBUG = Player.DEBUG private const val DOUBLE_TAP = "doubleTap" private const val DOUBLE_TAP_DELAY = 550L } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/gesture/CustomBottomSheetBehavior.java ================================================ package org.schabi.newpipe.player.gesture; import android.content.Context; import android.graphics.Rect; import android.util.AttributeSet; import android.view.MotionEvent; import android.view.View; import android.widget.FrameLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.coordinatorlayout.widget.CoordinatorLayout; import com.google.android.material.bottomsheet.BottomSheetBehavior; import org.schabi.newpipe.R; import java.util.List; public class CustomBottomSheetBehavior extends BottomSheetBehavior { public CustomBottomSheetBehavior(@NonNull final Context context, @Nullable final AttributeSet attrs) { super(context, attrs); } Rect globalRect = new Rect(); private boolean skippingInterception = false; private final List skipInterceptionOfElements = List.of( R.id.detail_content_root_layout, R.id.relatedItemsLayout, R.id.itemsListPanel, R.id.view_pager, R.id.tab_layout, R.id.bottomControls, R.id.playPauseButton, R.id.playPreviousButton, R.id.playNextButton); @Override public boolean onInterceptTouchEvent(@NonNull final CoordinatorLayout parent, @NonNull final FrameLayout child, @NonNull final MotionEvent event) { // Drop following when action ends if (event.getAction() == MotionEvent.ACTION_CANCEL || event.getAction() == MotionEvent.ACTION_UP) { skippingInterception = false; } // Found that user still swiping, continue following if (skippingInterception || getState() == BottomSheetBehavior.STATE_SETTLING) { return false; } // The interception listens for the child view with the id "fragment_player_holder", // so the following two-finger gesture will be triggered only for the player view on // portrait and for the top controls (visible) on landscape. setSkipCollapsed(event.getPointerCount() == 2); if (event.getPointerCount() == 2) { return super.onInterceptTouchEvent(parent, child, event); } // Don't need to do anything if bottomSheet isn't expanded if (getState() == BottomSheetBehavior.STATE_EXPANDED && event.getAction() == MotionEvent.ACTION_DOWN) { // Without overriding scrolling will not work when user touches these elements for (final int element : skipInterceptionOfElements) { final View view = child.findViewById(element); if (view != null) { final boolean visible = view.getGlobalVisibleRect(globalRect); if (visible && globalRect.contains((int) event.getRawX(), (int) event.getRawY())) { // Makes bottom part of the player draggable in portrait when // playbackControlRoot is hidden if (element == R.id.bottomControls && child.findViewById(R.id.playbackControlRoot) .getVisibility() != View.VISIBLE) { return super.onInterceptTouchEvent(parent, child, event); } skippingInterception = true; return false; } } } } return super.onInterceptTouchEvent(parent, child, event); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/gesture/DisplayPortion.kt ================================================ package org.schabi.newpipe.player.gesture enum class DisplayPortion { LEFT, MIDDLE, RIGHT, LEFT_HALF, RIGHT_HALF } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/gesture/DoubleTapListener.kt ================================================ package org.schabi.newpipe.player.gesture interface DoubleTapListener { fun onDoubleTapStarted(portion: DisplayPortion) fun onDoubleTapProgressDown(portion: DisplayPortion) fun onDoubleTapFinished() } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/gesture/MainPlayerGestureListener.kt ================================================ package org.schabi.newpipe.player.gesture import android.util.Log import android.view.MotionEvent import android.view.View import android.view.View.OnTouchListener import android.widget.ProgressBar import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.content.res.AppCompatResources import androidx.core.view.isVisible import kotlin.math.abs import org.schabi.newpipe.MainActivity import org.schabi.newpipe.R import org.schabi.newpipe.ktx.AnimationType import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.player.Player import org.schabi.newpipe.player.helper.AudioReactor import org.schabi.newpipe.player.helper.PlayerHelper import org.schabi.newpipe.player.ui.MainPlayerUi import org.schabi.newpipe.util.ThemeHelper.getAndroidDimenPx /** * GestureListener for the player * * While [BasePlayerGestureListener] contains the logic behind the single gestures * this class focuses on the visual aspect like hiding and showing the controls or changing * volume/brightness during scrolling for specific events. */ class MainPlayerGestureListener( private val playerUi: MainPlayerUi ) : BasePlayerGestureListener(playerUi), OnTouchListener { private var isMoving = false override fun onTouch(v: View, event: MotionEvent): Boolean { super.onTouch(v, event) if (event.action == MotionEvent.ACTION_UP && isMoving) { isMoving = false onScrollEnd(event) } return when (event.action) { MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { v.parent?.requestDisallowInterceptTouchEvent(playerUi.isFullscreen) true } MotionEvent.ACTION_UP -> { v.parent?.requestDisallowInterceptTouchEvent(false) false } else -> true } } override fun onSingleTapConfirmed(e: MotionEvent): Boolean { if (DEBUG) { Log.d(TAG, "onSingleTapConfirmed() called with: e = [$e]") } if (isDoubleTapping) { return true } super.onSingleTapConfirmed(e) if (player.currentState != Player.STATE_BLOCKED) { onSingleTap() } return true } private fun onScrollVolume(distanceY: Float) { val bar: ProgressBar = binding.volumeProgressBar val audioReactor: AudioReactor = player.audioReactor // If we just started sliding, change the progress bar to match the system volume if (!binding.volumeRelativeLayout.isVisible) { val volumePercent: Float = audioReactor.volume / audioReactor.maxVolume.toFloat() bar.progress = (volumePercent * bar.max).toInt() } // Update progress bar binding.volumeProgressBar.incrementProgressBy(distanceY.toInt()) // Update volume val currentProgressPercent: Float = bar.progress / bar.max.toFloat() val currentVolume = (audioReactor.maxVolume * currentProgressPercent).toInt() audioReactor.volume = currentVolume if (DEBUG) { Log.d(TAG, "onScroll().volumeControl, currentVolume = $currentVolume") } // Update player center image binding.volumeImageView.setImageDrawable( AppCompatResources.getDrawable( player.context, when { currentProgressPercent <= 0 -> R.drawable.ic_volume_off currentProgressPercent < 0.25 -> R.drawable.ic_volume_mute currentProgressPercent < 0.75 -> R.drawable.ic_volume_down else -> R.drawable.ic_volume_up } ) ) // Make sure the correct layout is visible if (!binding.volumeRelativeLayout.isVisible) { binding.volumeRelativeLayout.animate(true, 200, AnimationType.SCALE_AND_ALPHA) } binding.brightnessRelativeLayout.isVisible = false } private fun onScrollBrightness(distanceY: Float) { val parent: AppCompatActivity = playerUi.parentActivity.orElse(null) ?: return val window = parent.window val layoutParams = window.attributes val bar: ProgressBar = binding.brightnessProgressBar // Update progress bar val oldBrightness = layoutParams.screenBrightness bar.progress = (bar.max * oldBrightness.coerceIn(0f, 1f)).toInt() bar.incrementProgressBy(distanceY.toInt()) // Update brightness val currentProgressPercent = bar.progress.toFloat() / bar.max layoutParams.screenBrightness = currentProgressPercent window.attributes = layoutParams // Save current brightness level PlayerHelper.setScreenBrightness(parent, currentProgressPercent) if (DEBUG) { Log.d( TAG, "onScroll().brightnessControl, " + "currentBrightness = " + currentProgressPercent ) } // Update player center image binding.brightnessImageView.setImageDrawable( AppCompatResources.getDrawable( player.context, when { currentProgressPercent < 0.25 -> R.drawable.ic_brightness_low currentProgressPercent < 0.75 -> R.drawable.ic_brightness_medium else -> R.drawable.ic_brightness_high } ) ) // Make sure the correct layout is visible if (!binding.brightnessRelativeLayout.isVisible) { binding.brightnessRelativeLayout.animate(true, 200, AnimationType.SCALE_AND_ALPHA) } binding.volumeRelativeLayout.isVisible = false } override fun onScrollEnd(event: MotionEvent) { super.onScrollEnd(event) if (binding.volumeRelativeLayout.isVisible) { binding.volumeRelativeLayout.animate(false, 200, AnimationType.SCALE_AND_ALPHA, 200) } if (binding.brightnessRelativeLayout.isVisible) { binding.brightnessRelativeLayout.animate(false, 200, AnimationType.SCALE_AND_ALPHA, 200) } } override fun onScroll( initialEvent: MotionEvent?, movingEvent: MotionEvent, distanceX: Float, distanceY: Float ): Boolean { if (initialEvent == null || !playerUi.isFullscreen) { return false } // Calculate heights of status and navigation bars val statusBarHeight = getAndroidDimenPx(player.context, "status_bar_height") val navigationBarHeight = getAndroidDimenPx(player.context, "navigation_bar_height") // Do not handle this event if initially it started from status or navigation bars val isTouchingStatusBar = initialEvent.y < statusBarHeight val isTouchingNavigationBar = initialEvent.y > (binding.root.height - navigationBarHeight) if (isTouchingStatusBar || isTouchingNavigationBar) { return false } val insideThreshold = abs(movingEvent.y - initialEvent.y) <= MOVEMENT_THRESHOLD if ( !isMoving && (insideThreshold || abs(distanceX) > abs(distanceY)) || player.currentState == Player.STATE_COMPLETED ) { return false } isMoving = true // -- Brightness and Volume control -- if (getDisplayHalfPortion(initialEvent) == DisplayPortion.RIGHT_HALF) { when (PlayerHelper.getActionForRightGestureSide(player.context)) { player.context.getString(R.string.volume_control_key) -> onScrollVolume(distanceY) player.context.getString(R.string.brightness_control_key) -> onScrollBrightness(distanceY) } } else { when (PlayerHelper.getActionForLeftGestureSide(player.context)) { player.context.getString(R.string.volume_control_key) -> onScrollVolume(distanceY) player.context.getString(R.string.brightness_control_key) -> onScrollBrightness(distanceY) } } return true } override fun getDisplayPortion(e: MotionEvent): DisplayPortion { return when { e.x < binding.root.width / 3.0 -> DisplayPortion.LEFT e.x > binding.root.width * 2.0 / 3.0 -> DisplayPortion.RIGHT else -> DisplayPortion.MIDDLE } } override fun getDisplayHalfPortion(e: MotionEvent): DisplayPortion { return when { e.x < binding.root.width / 2.0 -> DisplayPortion.LEFT_HALF else -> DisplayPortion.RIGHT_HALF } } companion object { private val TAG = MainPlayerGestureListener::class.java.simpleName private val DEBUG = MainActivity.DEBUG private const val MOVEMENT_THRESHOLD = 40 } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/gesture/PopupPlayerGestureListener.kt ================================================ package org.schabi.newpipe.player.gesture import android.util.Log import android.view.MotionEvent import android.view.View import android.view.ViewConfiguration import androidx.core.view.isVisible import kotlin.math.abs import kotlin.math.hypot import kotlin.math.max import kotlin.math.min import org.schabi.newpipe.MainActivity import org.schabi.newpipe.ktx.AnimationType import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.player.ui.PopupPlayerUi class PopupPlayerGestureListener( private val playerUi: PopupPlayerUi ) : BasePlayerGestureListener(playerUi) { private var isMoving = false private var initialPopupX: Int = -1 private var initialPopupY: Int = -1 private var isResizing = false // initial coordinates and distance between fingers private var initPointerDistance = -1.0 private var initFirstPointerX = -1f private var initFirstPointerY = -1f private var initSecPointerX = -1f private var initSecPointerY = -1f override fun onTouch(v: View, event: MotionEvent): Boolean { super.onTouch(v, event) if (event.pointerCount == 2 && !isMoving && !isResizing) { if (DEBUG) { Log.d(TAG, "onTouch() 2 finger pointer detected, enabling resizing.") } onPopupResizingStart() // record coordinates of fingers initFirstPointerX = event.getX(0) initFirstPointerY = event.getY(0) initSecPointerX = event.getX(1) initSecPointerY = event.getY(1) // record distance between fingers initPointerDistance = hypot( initFirstPointerX - initSecPointerX.toDouble(), initFirstPointerY - initSecPointerY.toDouble() ) isResizing = true } if (event.action == MotionEvent.ACTION_MOVE && !isMoving && isResizing) { if (DEBUG) { Log.d( TAG, "onTouch() ACTION_MOVE > v = [$v], e1.getRaw =" + "[${event.rawX}, ${event.rawY}]" ) } return handleMultiDrag(event) } if (event.action == MotionEvent.ACTION_UP) { if (DEBUG) { Log.d( TAG, "onTouch() ACTION_UP > v = [$v], e1.getRaw =" + " [${event.rawX}, ${event.rawY}]" ) } if (isMoving) { isMoving = false onScrollEnd(event) } if (isResizing) { isResizing = false initPointerDistance = (-1).toDouble() initFirstPointerX = (-1).toFloat() initFirstPointerY = (-1).toFloat() initSecPointerX = (-1).toFloat() initSecPointerY = (-1).toFloat() onPopupResizingEnd() player.changeState(player.currentState) } if (!playerUi.isPopupClosing) { playerUi.savePopupPositionAndSizeToPrefs() } } v.performClick() return true } override fun onScrollEnd(event: MotionEvent) { super.onScrollEnd(event) if (playerUi.isInsideClosingRadius(event)) { playerUi.closePopup() } else if (!playerUi.isPopupClosing) { playerUi.closeOverlayBinding.closeButton.animate(false, 200) binding.closingOverlay.animate(false, 200) } } private fun handleMultiDrag(event: MotionEvent): Boolean { if (initPointerDistance == -1.0 || event.pointerCount != 2) { return false } // get the movements of the fingers val firstPointerMove = hypot( event.getX(0) - initFirstPointerX.toDouble(), event.getY(0) - initFirstPointerY.toDouble() ) val secPointerMove = hypot( event.getX(1) - initSecPointerX.toDouble(), event.getY(1) - initSecPointerY.toDouble() ) // minimum threshold beyond which pinch gesture will work val minimumMove = ViewConfiguration.get(player.context).scaledTouchSlop if (max(firstPointerMove, secPointerMove) <= minimumMove) { return false } // calculate current distance between the pointers val currentPointerDistance = hypot( event.getX(0) - event.getX(1).toDouble(), event.getY(0) - event.getY(1).toDouble() ) val popupWidth = playerUi.popupLayoutParams.width.toDouble() // change co-ordinates of popup so the center stays at the same position val newWidth = popupWidth * currentPointerDistance / initPointerDistance initPointerDistance = currentPointerDistance playerUi.popupLayoutParams.x += ((popupWidth - newWidth) / 2.0).toInt() playerUi.checkPopupPositionBounds() playerUi.updateScreenSize() playerUi.changePopupSize(min(playerUi.screenWidth.toDouble(), newWidth).toInt()) return true } private fun onPopupResizingStart() { if (DEBUG) { Log.d(TAG, "onPopupResizingStart called") } binding.loadingPanel.visibility = View.GONE playerUi.hideControls(0, 0) binding.fastSeekOverlay.animate(false, 0) binding.currentDisplaySeek.animate(false, 0, AnimationType.ALPHA, 0) } private fun onPopupResizingEnd() { if (DEBUG) { Log.d(TAG, "onPopupResizingEnd called") } } override fun onLongPress(e: MotionEvent) { playerUi.updateScreenSize() playerUi.checkPopupPositionBounds() playerUi.changePopupSize(playerUi.screenWidth) } override fun onFling( e1: MotionEvent?, e2: MotionEvent, velocityX: Float, velocityY: Float ): Boolean { return if (player.popupPlayerSelected()) { val absVelocityX = abs(velocityX) val absVelocityY = abs(velocityY) if (absVelocityX.coerceAtLeast(absVelocityY) > TOSS_FLING_VELOCITY) { if (absVelocityX > TOSS_FLING_VELOCITY) { playerUi.popupLayoutParams.x = velocityX.toInt() } if (absVelocityY > TOSS_FLING_VELOCITY) { playerUi.popupLayoutParams.y = velocityY.toInt() } playerUi.checkPopupPositionBounds() playerUi.windowManager.updateViewLayout(binding.root, playerUi.popupLayoutParams) return true } return false } else { true } } override fun onDownNotDoubleTapping(e: MotionEvent): Boolean { // Fix popup position when the user touch it, it may have the wrong one // because the soft input is visible (the draggable area is currently resized). playerUi.updateScreenSize() playerUi.checkPopupPositionBounds() playerUi.popupLayoutParams.let { initialPopupX = it.x initialPopupY = it.y } return true // we want `super.onDown(e)` to be called } override fun onSingleTapConfirmed(e: MotionEvent): Boolean { if (DEBUG) { Log.d(TAG, "onSingleTapConfirmed() called with: e = [$e]") } if (isDoubleTapping) { return true } if (player.exoPlayerIsNull()) { return false } onSingleTap() return true } override fun onScroll( initialEvent: MotionEvent?, movingEvent: MotionEvent, distanceX: Float, distanceY: Float ): Boolean { if (initialEvent == null) { return false } if (isResizing) { return super.onScroll(initialEvent, movingEvent, distanceX, distanceY) } if (!isMoving) { playerUi.closeOverlayBinding.closeButton.animate(true, 200) } isMoving = true val diffX = (movingEvent.rawX - initialEvent.rawX) val posX = (initialPopupX + diffX).coerceIn( 0f, (playerUi.screenWidth - playerUi.popupLayoutParams.width).toFloat() .coerceAtLeast(0f) ) val diffY = (movingEvent.rawY - initialEvent.rawY) val posY = (initialPopupY + diffY).coerceIn( 0f, (playerUi.screenHeight - playerUi.popupLayoutParams.height).toFloat() .coerceAtLeast(0f) ) playerUi.popupLayoutParams.x = posX.toInt() playerUi.popupLayoutParams.y = posY.toInt() // -- Determine if the ClosingOverlayView (red X) has to be shown or hidden -- val showClosingOverlayView: Boolean = playerUi.isInsideClosingRadius(movingEvent) // Check if an view is in expected state and if not animate it into the correct state if (binding.closingOverlay.isVisible != showClosingOverlayView) { binding.closingOverlay.animate(showClosingOverlayView, 200) } playerUi.windowManager.updateViewLayout(binding.root, playerUi.popupLayoutParams) return true } override fun getDisplayPortion(e: MotionEvent): DisplayPortion { return when { e.x < playerUi.popupLayoutParams.width / 3.0 -> DisplayPortion.LEFT e.x > playerUi.popupLayoutParams.width * 2.0 / 3.0 -> DisplayPortion.RIGHT else -> DisplayPortion.MIDDLE } } override fun getDisplayHalfPortion(e: MotionEvent): DisplayPortion { return when { e.x < playerUi.popupLayoutParams.width / 2.0 -> DisplayPortion.LEFT_HALF else -> DisplayPortion.RIGHT_HALF } } companion object { private val TAG = PopupPlayerGestureListener::class.java.simpleName private val DEBUG = MainActivity.DEBUG private const val TOSS_FLING_VELOCITY = 2500 } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java ================================================ package org.schabi.newpipe.player.helper; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ValueAnimator; import android.content.Context; import android.content.Intent; import android.media.AudioManager; import android.media.audiofx.AudioEffect; import android.util.Log; import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; import androidx.media.AudioFocusRequestCompat; import androidx.media.AudioManagerCompat; import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.analytics.AnalyticsListener; public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, AnalyticsListener { private static final String TAG = "AudioFocusReactor"; private static final int DUCK_DURATION = 1500; private static final float DUCK_AUDIO_TO = .2f; private static final int FOCUS_GAIN_TYPE = AudioManagerCompat.AUDIOFOCUS_GAIN; private static final int STREAM_TYPE = AudioManager.STREAM_MUSIC; private final ExoPlayer player; private final Context context; private final AudioManager audioManager; private final AudioFocusRequestCompat request; public AudioReactor(@NonNull final Context context, @NonNull final ExoPlayer player) { this.player = player; this.context = context; this.audioManager = ContextCompat.getSystemService(context, AudioManager.class); player.addAnalyticsListener(this); request = new AudioFocusRequestCompat.Builder(FOCUS_GAIN_TYPE) //.setAcceptsDelayedFocusGain(true) .setWillPauseWhenDucked(true) .setOnAudioFocusChangeListener(this) .build(); } public void dispose() { abandonAudioFocus(); player.removeAnalyticsListener(this); notifyAudioSessionUpdate(false, player.getAudioSessionId()); } /*////////////////////////////////////////////////////////////////////////// // Audio Manager //////////////////////////////////////////////////////////////////////////*/ public void requestAudioFocus() { AudioManagerCompat.requestAudioFocus(audioManager, request); } public void abandonAudioFocus() { AudioManagerCompat.abandonAudioFocusRequest(audioManager, request); } public int getVolume() { return audioManager.getStreamVolume(STREAM_TYPE); } public void setVolume(final int volume) { audioManager.setStreamVolume(STREAM_TYPE, volume, 0); } public int getMaxVolume() { return AudioManagerCompat.getStreamMaxVolume(audioManager, STREAM_TYPE); } /*////////////////////////////////////////////////////////////////////////// // AudioFocus //////////////////////////////////////////////////////////////////////////*/ @Override public void onAudioFocusChange(final int focusChange) { Log.d(TAG, "onAudioFocusChange() called with: focusChange = [" + focusChange + "]"); switch (focusChange) { case AudioManager.AUDIOFOCUS_GAIN: onAudioFocusGain(); break; case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: onAudioFocusLossCanDuck(); break; case AudioManager.AUDIOFOCUS_LOSS: case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: onAudioFocusLoss(); break; } } private void onAudioFocusGain() { Log.d(TAG, "onAudioFocusGain() called"); player.setVolume(DUCK_AUDIO_TO); animateAudio(DUCK_AUDIO_TO, 1.0f); if (PlayerHelper.isResumeAfterAudioFocusGain(context)) { player.play(); } } private void onAudioFocusLoss() { Log.d(TAG, "onAudioFocusLoss() called"); player.pause(); } private void onAudioFocusLossCanDuck() { Log.d(TAG, "onAudioFocusLossCanDuck() called"); // Set the volume to 1/10 on ducking player.setVolume(DUCK_AUDIO_TO); } private void animateAudio(final float from, final float to) { final ValueAnimator valueAnimator = new ValueAnimator(); valueAnimator.setFloatValues(from, to); valueAnimator.setDuration(AudioReactor.DUCK_DURATION); valueAnimator.addListener(new AnimatorListenerAdapter() { @Override public void onAnimationStart(final Animator animation) { player.setVolume(from); } @Override public void onAnimationCancel(final Animator animation) { player.setVolume(to); } @Override public void onAnimationEnd(final Animator animation) { player.setVolume(to); } }); valueAnimator.addUpdateListener(animation -> player.setVolume(((float) animation.getAnimatedValue()))); valueAnimator.start(); } /*////////////////////////////////////////////////////////////////////////// // Audio Processing //////////////////////////////////////////////////////////////////////////*/ @Override public void onAudioSessionIdChanged(@NonNull final EventTime eventTime, final int audioSessionId) { notifyAudioSessionUpdate(true, audioSessionId); } private void notifyAudioSessionUpdate(final boolean active, final int audioSessionId) { final Intent intent = new Intent(active ? AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION : AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION); intent.putExtra(AudioEffect.EXTRA_AUDIO_SESSION, audioSessionId); intent.putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.getPackageName()); context.sendBroadcast(intent); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java ================================================ package org.schabi.newpipe.player.helper; import android.content.Context; import androidx.annotation.NonNull; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSource; import com.google.android.exoplayer2.upstream.FileDataSource; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.upstream.cache.CacheDataSink; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; import com.google.android.exoplayer2.upstream.cache.SimpleCache; final class CacheFactory implements DataSource.Factory { private static final int CACHE_FLAGS = CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR; private final Context context; private final TransferListener transferListener; private final DataSource.Factory upstreamDataSourceFactory; private final SimpleCache cache; CacheFactory(final Context context, final TransferListener transferListener, final SimpleCache cache, final DataSource.Factory upstreamDataSourceFactory) { this.context = context; this.transferListener = transferListener; this.cache = cache; this.upstreamDataSourceFactory = upstreamDataSourceFactory; } @NonNull @Override public DataSource createDataSource() { final DefaultDataSource dataSource = new DefaultDataSource.Factory(context, upstreamDataSourceFactory) .setTransferListener(transferListener) .createDataSource(); final FileDataSource fileSource = new FileDataSource(); final CacheDataSink dataSink = new CacheDataSink(cache, PlayerHelper.getPreferredFileSize()); return new CacheDataSource(cache, dataSource, fileSource, dataSink, CACHE_FLAGS, null); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/helper/CustomMediaCodecVideoRenderer.java ================================================ package org.schabi.newpipe.player.helper; import android.content.Context; import android.os.Handler; import androidx.annotation.Nullable; import com.google.android.exoplayer2.mediacodec.MediaCodecAdapter; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; import com.google.android.exoplayer2.video.MediaCodecVideoRenderer; import com.google.android.exoplayer2.video.VideoRendererEventListener; /** * A {@link MediaCodecVideoRenderer} which always enable the output surface workaround that * ExoPlayer enables on several devices which are known to implement * {@link android.media.MediaCodec#setOutputSurface(android.view.Surface) * MediaCodec.setOutputSurface(Surface)} incorrectly. * *

* See {@link MediaCodecVideoRenderer#codecNeedsSetOutputSurfaceWorkaround(String)} for more * details. *

* *

* This custom {@link MediaCodecVideoRenderer} may be useful in the case a device is affected by * this issue but is not present in ExoPlayer's list. *

* *

* This class has only effect on devices with Android 6 and higher, as the {@code setOutputSurface} * method is only implemented in these Android versions and the method used as a workaround is * always applied on older Android versions (releasing and re-instantiating video codec instances). *

*/ public final class CustomMediaCodecVideoRenderer extends MediaCodecVideoRenderer { @SuppressWarnings({"checkstyle:ParameterNumber", "squid:S107"}) public CustomMediaCodecVideoRenderer(final Context context, final MediaCodecAdapter.Factory codecAdapterFactory, final MediaCodecSelector mediaCodecSelector, final long allowedJoiningTimeMs, final boolean enableDecoderFallback, @Nullable final Handler eventHandler, @Nullable final VideoRendererEventListener eventListener, final int maxDroppedFramesToNotify) { super(context, codecAdapterFactory, mediaCodecSelector, allowedJoiningTimeMs, enableDecoderFallback, eventHandler, eventListener, maxDroppedFramesToNotify); } @Override protected boolean codecNeedsSetOutputSurfaceWorkaround(final String name) { return true; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/helper/CustomRenderersFactory.java ================================================ package org.schabi.newpipe.player.helper; import android.content.Context; import android.os.Handler; import com.google.android.exoplayer2.DefaultRenderersFactory; import com.google.android.exoplayer2.Renderer; import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; import com.google.android.exoplayer2.video.VideoRendererEventListener; import java.util.ArrayList; /** * A {@link DefaultRenderersFactory} which only uses {@link CustomMediaCodecVideoRenderer} as an * implementation of video codec renders. * *

* As no ExoPlayer extension is currently used, the reflection code used by ExoPlayer to try to * load video extension libraries is not needed in our case and has been removed. This should be * changed in the case an extension is shipped with the app, such as the AV1 one. *

*/ public final class CustomRenderersFactory extends DefaultRenderersFactory { public CustomRenderersFactory(final Context context) { super(context); } @SuppressWarnings("checkstyle:ParameterNumber") @Override protected void buildVideoRenderers(final Context context, @ExtensionRendererMode final int extensionRendererMode, final MediaCodecSelector mediaCodecSelector, final boolean enableDecoderFallback, final Handler eventHandler, final VideoRendererEventListener eventListener, final long allowedVideoJoiningTimeMs, final ArrayList out) { out.add(new CustomMediaCodecVideoRenderer(context, getCodecAdapterFactory(), mediaCodecSelector, allowedVideoJoiningTimeMs, enableDecoderFallback, eventHandler, eventListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY)); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java ================================================ package org.schabi.newpipe.player.helper; import com.google.android.exoplayer2.DefaultLoadControl; public class LoadController extends DefaultLoadControl { public static final String TAG = "LoadController"; private boolean preloadingEnabled = true; @Override public void onPrepared() { preloadingEnabled = true; super.onPrepared(); } @Override public void onStopped() { preloadingEnabled = true; super.onStopped(); } @Override public void onReleased() { preloadingEnabled = true; super.onReleased(); } @Override public boolean shouldContinueLoading(final long playbackPositionUs, final long bufferedDurationUs, final float playbackSpeed) { if (!preloadingEnabled) { return false; } return super.shouldContinueLoading( playbackPositionUs, bufferedDurationUs, playbackSpeed); } public void disablePreloadingOfCurrentTrack() { preloadingEnabled = false; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/helper/LockManager.java ================================================ package org.schabi.newpipe.player.helper; import android.content.Context; import android.net.wifi.WifiManager; import android.os.PowerManager; import android.util.Log; import androidx.core.content.ContextCompat; public class LockManager { private final String TAG = "LockManager@" + hashCode(); private final PowerManager powerManager; private final WifiManager wifiManager; private PowerManager.WakeLock wakeLock; private WifiManager.WifiLock wifiLock; public LockManager(final Context context) { powerManager = ContextCompat.getSystemService(context.getApplicationContext(), PowerManager.class); wifiManager = ContextCompat.getSystemService(context, WifiManager.class); } public void acquireWifiAndCpu() { Log.d(TAG, "acquireWifiAndCpu() called"); if (wakeLock != null && wakeLock.isHeld() && wifiLock != null && wifiLock.isHeld()) { return; } wakeLock = powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG); wifiLock = wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL, TAG); if (wakeLock != null) { wakeLock.acquire(); } if (wifiLock != null) { wifiLock.acquire(); } } public void releaseWifiAndCpu() { Log.d(TAG, "releaseWifiAndCpu() called"); if (wakeLock != null && wakeLock.isHeld()) { wakeLock.release(); } if (wifiLock != null && wifiLock.isHeld()) { wifiLock.release(); } wakeLock = null; wifiLock = null; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java ================================================ package org.schabi.newpipe.player.helper; import static org.schabi.newpipe.ktx.ViewUtils.animateRotation; import static org.schabi.newpipe.player.Player.DEBUG; import static org.schabi.newpipe.util.ThemeHelper.resolveDrawable; import android.app.Dialog; import android.content.Context; import android.graphics.drawable.Drawable; import android.graphics.drawable.LayerDrawable; import android.os.Bundle; import android.util.Log; import android.view.View; import android.widget.CheckBox; import android.widget.SeekBar; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; import androidx.core.math.MathUtils; import androidx.fragment.app.DialogFragment; import androidx.preference.PreferenceManager; import com.evernote.android.state.State; import com.livefront.bridge.Bridge; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.DialogPlaybackParameterBinding; import org.schabi.newpipe.player.ui.VideoPlayerUi; import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener; import org.schabi.newpipe.util.SliderStrategy; import java.util.Map; import java.util.Objects; import java.util.function.Consumer; import java.util.function.DoubleConsumer; import java.util.function.DoubleFunction; import java.util.function.DoubleSupplier; public class PlaybackParameterDialog extends DialogFragment { private static final String TAG = "PlaybackParameterDialog"; // Minimum allowable range in ExoPlayer private static final double MIN_PITCH_OR_SPEED = 0.10f; private static final double MAX_PITCH_OR_SPEED = 3.00f; private static final boolean PITCH_CTRL_MODE_PERCENT = false; private static final boolean PITCH_CTRL_MODE_SEMITONE = true; private static final double STEP_1_PERCENT_VALUE = 0.01f; private static final double STEP_5_PERCENT_VALUE = 0.05f; private static final double STEP_10_PERCENT_VALUE = 0.10f; private static final double STEP_25_PERCENT_VALUE = 0.25f; private static final double STEP_100_PERCENT_VALUE = 1.00f; private static final double DEFAULT_TEMPO = 1.00f; private static final double DEFAULT_PITCH_PERCENT = 1.00f; private static final double DEFAULT_STEP = STEP_25_PERCENT_VALUE; private static final boolean DEFAULT_SKIP_SILENCE = false; private static final SliderStrategy QUADRATIC_STRATEGY = new SliderStrategy.Quadratic( MIN_PITCH_OR_SPEED, MAX_PITCH_OR_SPEED, 1.00f, 10_000); private static final SliderStrategy SEMITONE_STRATEGY = new SliderStrategy() { @Override public int progressOf(final double value) { return PlayerSemitoneHelper.percentToSemitones(value) + 12; } @Override public double valueOf(final int progress) { return PlayerSemitoneHelper.semitonesToPercent(progress - 12); } }; @Nullable private Callback callback; @State double initialTempo = DEFAULT_TEMPO; @State double initialPitchPercent = DEFAULT_PITCH_PERCENT; @State boolean initialSkipSilence = DEFAULT_SKIP_SILENCE; @State double tempo = DEFAULT_TEMPO; @State double pitchPercent = DEFAULT_PITCH_PERCENT; @State boolean skipSilence = DEFAULT_SKIP_SILENCE; private DialogPlaybackParameterBinding binding; public static PlaybackParameterDialog newInstance( final double playbackTempo, final double playbackPitch, final boolean playbackSkipSilence, final Callback callback ) { final PlaybackParameterDialog dialog = new PlaybackParameterDialog(); dialog.callback = callback; dialog.initialTempo = playbackTempo; dialog.initialPitchPercent = playbackPitch; dialog.initialSkipSilence = playbackSkipSilence; dialog.tempo = dialog.initialTempo; dialog.pitchPercent = dialog.initialPitchPercent; dialog.skipSilence = dialog.initialSkipSilence; return dialog; } /*////////////////////////////////////////////////////////////////////////// // Lifecycle //////////////////////////////////////////////////////////////////////////*/ @Override public void onAttach(@NonNull final Context context) { super.onAttach(context); if (context instanceof Callback) { callback = (Callback) context; } else if (callback == null) { dismiss(); } } @Override public void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); Bridge.saveInstanceState(this, outState); } /*////////////////////////////////////////////////////////////////////////// // Dialog //////////////////////////////////////////////////////////////////////////*/ @NonNull @Override public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { Bridge.restoreInstanceState(this, savedInstanceState); binding = DialogPlaybackParameterBinding.inflate(getLayoutInflater()); initUI(); final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(requireActivity()) .setView(binding.getRoot()) .setCancelable(true) .setNegativeButton(R.string.cancel, (dialogInterface, i) -> { setAndUpdateTempo(initialTempo); setAndUpdatePitch(initialPitchPercent); setAndUpdateSkipSilence(initialSkipSilence); updateCallback(); }) .setNeutralButton(R.string.playback_reset, (dialogInterface, i) -> { setAndUpdateTempo(DEFAULT_TEMPO); setAndUpdatePitch(DEFAULT_PITCH_PERCENT); setAndUpdateSkipSilence(DEFAULT_SKIP_SILENCE); updateCallback(); }) .setPositiveButton(R.string.ok, (dialogInterface, i) -> updateCallback()); return dialogBuilder.create(); } /*////////////////////////////////////////////////////////////////////////// // UI Initialization and Control //////////////////////////////////////////////////////////////////////////*/ private void initUI() { // Tempo setText(binding.tempoMinimumText, PlayerHelper::formatSpeed, MIN_PITCH_OR_SPEED); setText(binding.tempoMaximumText, PlayerHelper::formatSpeed, MAX_PITCH_OR_SPEED); binding.tempoSeekbar.setMax(QUADRATIC_STRATEGY.progressOf(MAX_PITCH_OR_SPEED)); setAndUpdateTempo(tempo); binding.tempoSeekbar.setOnSeekBarChangeListener( getTempoOrPitchSeekbarChangeListener( QUADRATIC_STRATEGY, this::onTempoSliderUpdated)); registerOnStepClickListener( binding.tempoStepDown, () -> tempo, -1, this::onTempoSliderUpdated); registerOnStepClickListener( binding.tempoStepUp, () -> tempo, 1, this::onTempoSliderUpdated); // Pitch binding.pitchToogleControlModes.setOnClickListener(v -> { final boolean isCurrentlyVisible = binding.pitchControlModeTabs.getVisibility() == View.GONE; binding.pitchControlModeTabs.setVisibility(isCurrentlyVisible ? View.VISIBLE : View.GONE); animateRotation(binding.pitchToogleControlModes, VideoPlayerUi.DEFAULT_CONTROLS_DURATION, isCurrentlyVisible ? 180 : 0); }); getPitchControlModeComponentMappings() .forEach(this::setupPitchControlModeTextView); // Initialization is done at the end // Pitch - Percent setText(binding.pitchPercentMinimumText, PlayerHelper::formatPitch, MIN_PITCH_OR_SPEED); setText(binding.pitchPercentMaximumText, PlayerHelper::formatPitch, MAX_PITCH_OR_SPEED); binding.pitchPercentSeekbar.setMax(QUADRATIC_STRATEGY.progressOf(MAX_PITCH_OR_SPEED)); setAndUpdatePitch(pitchPercent); binding.pitchPercentSeekbar.setOnSeekBarChangeListener( getTempoOrPitchSeekbarChangeListener( QUADRATIC_STRATEGY, this::onPitchPercentSliderUpdated)); registerOnStepClickListener( binding.pitchPercentStepDown, () -> pitchPercent, -1, this::onPitchPercentSliderUpdated); registerOnStepClickListener( binding.pitchPercentStepUp, () -> pitchPercent, 1, this::onPitchPercentSliderUpdated); // Pitch - Semitone binding.pitchSemitoneSeekbar.setOnSeekBarChangeListener( getTempoOrPitchSeekbarChangeListener( SEMITONE_STRATEGY, this::onPitchPercentSliderUpdated)); registerOnSemitoneStepClickListener( binding.pitchSemitoneStepDown, -1, this::onPitchPercentSliderUpdated); registerOnSemitoneStepClickListener( binding.pitchSemitoneStepUp, 1, this::onPitchPercentSliderUpdated); // Steps getStepSizeComponentMappings() .forEach(this::setupStepTextView); // Initialize UI setStepSizeToUI(getCurrentStepSize()); // Bottom controls bindCheckboxWithBoolPref( binding.unhookCheckbox, R.string.playback_unhook_key, true, isChecked -> { if (!isChecked) { // when unchecked, slide back to the minimum of current tempo or pitch ensureHookIsValidAndUpdateCallBack(); } }); setAndUpdateSkipSilence(skipSilence); binding.skipSilenceCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> { skipSilence = isChecked; updateCallback(); }); // PitchControlMode has to be initialized at the end because it requires the unhookCheckbox changePitchControlMode(isCurrentPitchControlModeSemitone()); } // -- General formatting -- private void setText( final TextView textView, final DoubleFunction formatter, final double value ) { Objects.requireNonNull(textView).setText(formatter.apply(value)); } // -- Steps -- private void registerOnStepClickListener( final TextView stepTextView, final DoubleSupplier currentValueSupplier, final double direction, // -1 for step down, +1 for step up final DoubleConsumer newValueConsumer ) { stepTextView.setOnClickListener(view -> { newValueConsumer.accept( currentValueSupplier.getAsDouble() + 1 * getCurrentStepSize() * direction); updateCallback(); }); } private void registerOnSemitoneStepClickListener( final TextView stepTextView, final int direction, // -1 for step down, +1 for step up final DoubleConsumer newValueConsumer ) { stepTextView.setOnClickListener(view -> { newValueConsumer.accept(PlayerSemitoneHelper.semitonesToPercent( PlayerSemitoneHelper.percentToSemitones(this.pitchPercent) + direction)); updateCallback(); }); } // -- Pitch -- private void setupPitchControlModeTextView( final boolean semitones, final TextView textView ) { textView.setOnClickListener(view -> { PreferenceManager.getDefaultSharedPreferences(requireContext()) .edit() .putBoolean(getString(R.string.playback_adjust_by_semitones_key), semitones) .apply(); changePitchControlMode(semitones); }); } private Map getPitchControlModeComponentMappings() { return Map.of(PITCH_CTRL_MODE_PERCENT, binding.pitchControlModePercent, PITCH_CTRL_MODE_SEMITONE, binding.pitchControlModeSemitone); } private void changePitchControlMode(final boolean semitones) { // Bring all textviews into a normal state final Map pitchCtrlModeComponentMapping = getPitchControlModeComponentMappings(); pitchCtrlModeComponentMapping.forEach((v, textView) -> textView.setBackground( resolveDrawable(requireContext(), android.R.attr.selectableItemBackground))); // Mark the selected textview final TextView textView = pitchCtrlModeComponentMapping.get(semitones); if (textView != null) { textView.setBackground(new LayerDrawable(new Drawable[]{ resolveDrawable(requireContext(), R.attr.dashed_border), resolveDrawable(requireContext(), android.R.attr.selectableItemBackground) })); } // Show or hide component binding.pitchPercentControl.setVisibility(semitones ? View.GONE : View.VISIBLE); binding.pitchSemitoneControl.setVisibility(semitones ? View.VISIBLE : View.GONE); if (semitones) { // Recalculate pitch percent when changing to semitone // (as it could be an invalid semitone value) final double newPitchPercent = calcValidPitch(pitchPercent); // If the values differ set the new pitch if (this.pitchPercent != newPitchPercent) { if (DEBUG) { Log.d(TAG, "Bringing pitchPercent to correct corresponding semitone: " + "currentPitchPercent = " + pitchPercent + ", " + "newPitchPercent = " + newPitchPercent ); } this.onPitchPercentSliderUpdated(newPitchPercent); updateCallback(); } } else if (!binding.unhookCheckbox.isChecked()) { // When changing to percent it's possible that tempo is != pitch ensureHookIsValidAndUpdateCallBack(); } } private boolean isCurrentPitchControlModeSemitone() { return PreferenceManager.getDefaultSharedPreferences(requireContext()) .getBoolean( getString(R.string.playback_adjust_by_semitones_key), PITCH_CTRL_MODE_PERCENT); } // -- Steps (Set) -- private void setupStepTextView( final double stepSizeValue, final TextView textView ) { setText(textView, PlaybackParameterDialog::getPercentString, stepSizeValue); textView.setOnClickListener(view -> { PreferenceManager.getDefaultSharedPreferences(requireContext()) .edit() .putFloat(getString(R.string.adjustment_step_key), (float) stepSizeValue) .apply(); setStepSizeToUI(stepSizeValue); }); } private Map getStepSizeComponentMappings() { return Map.of(STEP_1_PERCENT_VALUE, binding.stepSizeOnePercent, STEP_5_PERCENT_VALUE, binding.stepSizeFivePercent, STEP_10_PERCENT_VALUE, binding.stepSizeTenPercent, STEP_25_PERCENT_VALUE, binding.stepSizeTwentyFivePercent, STEP_100_PERCENT_VALUE, binding.stepSizeOneHundredPercent); } private void setStepSizeToUI(final double newStepSize) { // Bring all textviews into a normal state final Map stepSiteComponentMapping = getStepSizeComponentMappings(); stepSiteComponentMapping.forEach((v, textView) -> textView.setBackground( resolveDrawable(requireContext(), android.R.attr.selectableItemBackground))); // Mark the selected textview final TextView textView = stepSiteComponentMapping.get(newStepSize); if (textView != null) { textView.setBackground(new LayerDrawable(new Drawable[]{ resolveDrawable(requireContext(), R.attr.dashed_border), resolveDrawable(requireContext(), android.R.attr.selectableItemBackground) })); } // Bind to the corresponding control components binding.tempoStepUp.setText(getStepUpPercentString(newStepSize)); binding.tempoStepDown.setText(getStepDownPercentString(newStepSize)); binding.pitchPercentStepUp.setText(getStepUpPercentString(newStepSize)); binding.pitchPercentStepDown.setText(getStepDownPercentString(newStepSize)); } private double getCurrentStepSize() { return PreferenceManager.getDefaultSharedPreferences(requireContext()) .getFloat(getString(R.string.adjustment_step_key), (float) DEFAULT_STEP); } // -- Additional options -- private void setAndUpdateSkipSilence(final boolean newSkipSilence) { this.skipSilence = newSkipSilence; binding.skipSilenceCheckbox.setChecked(newSkipSilence); } @SuppressWarnings("SameParameterValue") // this method was written to be reusable private void bindCheckboxWithBoolPref( @NonNull final CheckBox checkBox, @StringRes final int resId, final boolean defaultValue, @NonNull final Consumer onInitialValueOrValueChange ) { final boolean prefValue = PreferenceManager .getDefaultSharedPreferences(requireContext()) .getBoolean(getString(resId), defaultValue); checkBox.setChecked(prefValue); onInitialValueOrValueChange.accept(prefValue); checkBox.setOnCheckedChangeListener((compoundButton, isChecked) -> { // save whether pitch and tempo are unhooked or not PreferenceManager.getDefaultSharedPreferences(requireContext()) .edit() .putBoolean(getString(resId), isChecked) .apply(); onInitialValueOrValueChange.accept(isChecked); }); } /** * Ensures that the slider hook is valid and if not sets and updates the sliders accordingly. *
* You have to ensure by yourself that the hooking is active. */ private void ensureHookIsValidAndUpdateCallBack() { if (tempo != pitchPercent) { setSliders(Math.min(tempo, pitchPercent)); updateCallback(); } } /*////////////////////////////////////////////////////////////////////////// // Sliders //////////////////////////////////////////////////////////////////////////*/ private SeekBar.OnSeekBarChangeListener getTempoOrPitchSeekbarChangeListener( final SliderStrategy sliderStrategy, final DoubleConsumer newValueConsumer ) { return new SimpleOnSeekBarChangeListener() { @Override public void onProgressChanged(@NonNull final SeekBar seekBar, final int progress, final boolean fromUser) { if (fromUser) { // ensure that the user triggered the change newValueConsumer.accept(sliderStrategy.valueOf(progress)); updateCallback(); } } }; } private void onTempoSliderUpdated(final double newTempo) { if (!binding.unhookCheckbox.isChecked()) { setSliders(newTempo); } else { setAndUpdateTempo(newTempo); } } private void onPitchPercentSliderUpdated(final double newPitch) { if (!binding.unhookCheckbox.isChecked()) { setSliders(newPitch); } else { setAndUpdatePitch(newPitch); } } private void setSliders(final double newValue) { setAndUpdateTempo(newValue); setAndUpdatePitch(newValue); } private void setAndUpdateTempo(final double newTempo) { this.tempo = MathUtils.clamp(newTempo, MIN_PITCH_OR_SPEED, MAX_PITCH_OR_SPEED); binding.tempoSeekbar.setProgress(QUADRATIC_STRATEGY.progressOf(tempo)); setText(binding.tempoCurrentText, PlayerHelper::formatSpeed, tempo); } private void setAndUpdatePitch(final double newPitch) { this.pitchPercent = calcValidPitch(newPitch); binding.pitchPercentSeekbar.setProgress(QUADRATIC_STRATEGY.progressOf(pitchPercent)); binding.pitchSemitoneSeekbar.setProgress(SEMITONE_STRATEGY.progressOf(pitchPercent)); setText(binding.pitchPercentCurrentText, PlayerHelper::formatPitch, pitchPercent); setText(binding.pitchSemitoneCurrentText, PlayerSemitoneHelper::formatPitchSemitones, pitchPercent); } private double calcValidPitch(final double newPitch) { final double calcPitch = MathUtils.clamp(newPitch, MIN_PITCH_OR_SPEED, MAX_PITCH_OR_SPEED); if (!isCurrentPitchControlModeSemitone()) { return calcPitch; } return PlayerSemitoneHelper.semitonesToPercent( PlayerSemitoneHelper.percentToSemitones(calcPitch)); } /*////////////////////////////////////////////////////////////////////////// // Helper //////////////////////////////////////////////////////////////////////////*/ private void updateCallback() { if (callback == null) { return; } if (DEBUG) { Log.d(TAG, "Updating callback: " + "tempo = " + tempo + ", " + "pitchPercent = " + pitchPercent + ", " + "skipSilence = " + skipSilence ); } callback.onPlaybackParameterChanged((float) tempo, (float) pitchPercent, skipSilence); } @NonNull private static String getStepUpPercentString(final double percent) { return '+' + getPercentString(percent); } @NonNull private static String getStepDownPercentString(final double percent) { return '-' + getPercentString(percent); } @NonNull private static String getPercentString(final double percent) { return PlayerHelper.formatPitch(percent); } public interface Callback { void onPlaybackParameterChanged(float playbackTempo, float playbackPitch, boolean playbackSkipSilence); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java ================================================ package org.schabi.newpipe.player.helper; import static org.schabi.newpipe.MainActivity.DEBUG; import android.content.Context; import android.util.Log; import androidx.annotation.Nullable; import com.google.android.exoplayer2.database.StandaloneDatabaseProvider; import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.source.SingleSampleMediaSource; import com.google.android.exoplayer2.source.dash.DashMediaSource; import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource; import com.google.android.exoplayer2.source.hls.HlsMediaSource; import com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistTracker; import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource; import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSource; import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeOtfDashManifestCreator; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubePostLiveStreamDvrDashManifestCreator; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeProgressiveDashManifestCreator; import org.schabi.newpipe.player.datasource.NonUriHlsDataSourceFactory; import org.schabi.newpipe.player.datasource.YoutubeHttpDataSource; import java.io.File; public class PlayerDataSource { public static final String TAG = PlayerDataSource.class.getSimpleName(); public static final int LIVE_STREAM_EDGE_GAP_MILLIS = 10000; /** * An approximately 4.3 times greater value than the * {@link DefaultHlsPlaylistTracker#DEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT default} * to ensure that (very) low latency livestreams which got stuck for a moment don't crash too * early. */ private static final double PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT = 15; /** * The maximum number of generated manifests per cache, in * {@link YoutubeProgressiveDashManifestCreator}, {@link YoutubeOtfDashManifestCreator} and * {@link YoutubePostLiveStreamDvrDashManifestCreator}. */ private static final int MAX_MANIFEST_CACHE_SIZE = 500; /** * The folder name in which the ExoPlayer cache will be written. */ private static final String CACHE_FOLDER_NAME = "exoplayer"; /** * The {@link SimpleCache} instance which will be used to build * {@link com.google.android.exoplayer2.upstream.cache.CacheDataSource}s instances (with * {@link CacheFactory}). */ private static SimpleCache cache; private final int progressiveLoadIntervalBytes; // Generic Data Source Factories (without or with cache) private final DataSource.Factory cachelessDataSourceFactory; private final CacheFactory cacheDataSourceFactory; // YouTube-specific Data Source Factories (with cache) // They use YoutubeHttpDataSource.Factory, with different parameters each private final CacheFactory ytHlsCacheDataSourceFactory; private final CacheFactory ytDashCacheDataSourceFactory; private final CacheFactory ytProgressiveDashCacheDataSourceFactory; public PlayerDataSource(final Context context, final TransferListener transferListener) { progressiveLoadIntervalBytes = PlayerHelper.getProgressiveLoadIntervalBytes(context); // make sure the static cache was created: needed by CacheFactories below instantiateCacheIfNeeded(context); // generic data source factories use DefaultHttpDataSource.Factory cachelessDataSourceFactory = new DefaultDataSource.Factory(context, new DefaultHttpDataSource.Factory().setUserAgent(DownloaderImpl.USER_AGENT)) .setTransferListener(transferListener); cacheDataSourceFactory = new CacheFactory(context, transferListener, cache, new DefaultHttpDataSource.Factory().setUserAgent(DownloaderImpl.USER_AGENT)); // YouTube-specific data source factories use getYoutubeHttpDataSourceFactory() ytHlsCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, getYoutubeHttpDataSourceFactory(false, false)); ytDashCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, getYoutubeHttpDataSourceFactory(true, true)); ytProgressiveDashCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, getYoutubeHttpDataSourceFactory(false, true)); // set the maximum size to manifest creators YoutubeProgressiveDashManifestCreator.getCache().setMaximumSize(MAX_MANIFEST_CACHE_SIZE); YoutubeOtfDashManifestCreator.getCache().setMaximumSize(MAX_MANIFEST_CACHE_SIZE); YoutubePostLiveStreamDvrDashManifestCreator.getCache().setMaximumSize( MAX_MANIFEST_CACHE_SIZE); } //region Live media source factories public SsMediaSource.Factory getLiveSsMediaSourceFactory() { return getSSMediaSourceFactory().setLivePresentationDelayMs(LIVE_STREAM_EDGE_GAP_MILLIS); } public HlsMediaSource.Factory getLiveHlsMediaSourceFactory() { return new HlsMediaSource.Factory(cachelessDataSourceFactory) .setAllowChunklessPreparation(true) .setPlaylistTrackerFactory((dataSourceFactory, loadErrorHandlingPolicy, playlistParserFactory) -> new DefaultHlsPlaylistTracker(dataSourceFactory, loadErrorHandlingPolicy, playlistParserFactory, PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT)); } public DashMediaSource.Factory getLiveDashMediaSourceFactory() { return new DashMediaSource.Factory( getDefaultDashChunkSourceFactory(cachelessDataSourceFactory), cachelessDataSourceFactory); } public DashMediaSource.Factory getLiveYoutubeDashMediaSourceFactory() { return new DashMediaSource.Factory( getDefaultDashChunkSourceFactory(cachelessDataSourceFactory), cachelessDataSourceFactory) .setManifestParser(new YoutubeDashLiveManifestParser()); } //endregion //region Generic media source factories public HlsMediaSource.Factory getHlsMediaSourceFactory( @Nullable final NonUriHlsDataSourceFactory.Builder hlsDataSourceFactoryBuilder) { if (hlsDataSourceFactoryBuilder != null) { hlsDataSourceFactoryBuilder.setDataSourceFactory(cacheDataSourceFactory); return new HlsMediaSource.Factory(hlsDataSourceFactoryBuilder.build()); } return new HlsMediaSource.Factory(cacheDataSourceFactory); } public DashMediaSource.Factory getDashMediaSourceFactory() { return new DashMediaSource.Factory( getDefaultDashChunkSourceFactory(cacheDataSourceFactory), cacheDataSourceFactory); } public ProgressiveMediaSource.Factory getProgressiveMediaSourceFactory() { return new ProgressiveMediaSource.Factory(cacheDataSourceFactory) .setContinueLoadingCheckIntervalBytes(progressiveLoadIntervalBytes); } public SsMediaSource.Factory getSSMediaSourceFactory() { return new SsMediaSource.Factory( new DefaultSsChunkSource.Factory(cachelessDataSourceFactory), cachelessDataSourceFactory); } public SingleSampleMediaSource.Factory getSingleSampleMediaSourceFactory() { return new SingleSampleMediaSource.Factory(cacheDataSourceFactory); } //endregion //region YouTube media source factories public HlsMediaSource.Factory getYoutubeHlsMediaSourceFactory() { return new HlsMediaSource.Factory(ytHlsCacheDataSourceFactory); } public DashMediaSource.Factory getYoutubeDashMediaSourceFactory() { return new DashMediaSource.Factory( getDefaultDashChunkSourceFactory(ytDashCacheDataSourceFactory), ytDashCacheDataSourceFactory); } public ProgressiveMediaSource.Factory getYoutubeProgressiveMediaSourceFactory() { return new ProgressiveMediaSource.Factory(ytProgressiveDashCacheDataSourceFactory) .setContinueLoadingCheckIntervalBytes(progressiveLoadIntervalBytes); } //endregion //region Static methods private static DefaultDashChunkSource.Factory getDefaultDashChunkSourceFactory( final DataSource.Factory dataSourceFactory) { return new DefaultDashChunkSource.Factory(dataSourceFactory); } private static YoutubeHttpDataSource.Factory getYoutubeHttpDataSourceFactory( final boolean rangeParameterEnabled, final boolean rnParameterEnabled) { return new YoutubeHttpDataSource.Factory() .setRangeParameterEnabled(rangeParameterEnabled) .setRnParameterEnabled(rnParameterEnabled); } private static void instantiateCacheIfNeeded(final Context context) { if (cache == null) { final File cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); if (DEBUG) { Log.d(TAG, "instantiateCacheIfNeeded: cacheDir = " + cacheDir.getAbsolutePath()); } if (!cacheDir.exists() && !cacheDir.mkdir()) { Log.w(TAG, "instantiateCacheIfNeeded: could not create cache dir"); } final LeastRecentlyUsedCacheEvictor evictor = new LeastRecentlyUsedCacheEvictor(PlayerHelper.getPreferredCacheSize()); cache = new SimpleCache(cacheDir, evictor, new StandaloneDatabaseProvider(context)); } } //endregion } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java ================================================ package org.schabi.newpipe.player.helper; import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_ALWAYS; import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_NEVER; import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_WIFI; import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_BACKGROUND; import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_NONE; import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_POPUP; import static java.lang.annotation.RetentionPolicy.SOURCE; import android.annotation.SuppressLint; import android.content.Context; import android.content.SharedPreferences; import android.content.pm.PackageManager; import android.provider.Settings; import android.view.accessibility.CaptioningManager; import androidx.annotation.IntDef; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; import androidx.preference.PreferenceManager; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.SeekParameters; import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection; import com.google.android.exoplayer2.trackselection.ExoTrackSelection; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.ResizeMode; import com.google.android.exoplayer2.ui.CaptionStyleCompat; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.utils.Utils; import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.Localization; import java.lang.annotation.Retention; import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; import java.text.NumberFormat; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; public final class PlayerHelper { private static final FormattersProvider FORMATTERS_PROVIDER = new FormattersProvider(); @Retention(SOURCE) @IntDef({AUTOPLAY_TYPE_ALWAYS, AUTOPLAY_TYPE_WIFI, AUTOPLAY_TYPE_NEVER}) public @interface AutoplayType { int AUTOPLAY_TYPE_ALWAYS = 0; int AUTOPLAY_TYPE_WIFI = 1; int AUTOPLAY_TYPE_NEVER = 2; } @Retention(SOURCE) @IntDef({MINIMIZE_ON_EXIT_MODE_NONE, MINIMIZE_ON_EXIT_MODE_BACKGROUND, MINIMIZE_ON_EXIT_MODE_POPUP}) public @interface MinimizeMode { int MINIMIZE_ON_EXIT_MODE_NONE = 0; int MINIMIZE_ON_EXIT_MODE_BACKGROUND = 1; int MINIMIZE_ON_EXIT_MODE_POPUP = 2; } private PlayerHelper() { } // region Exposed helpers public static void resetFormat() { FORMATTERS_PROVIDER.reset(); } @NonNull public static String getTimeString(final long milliSeconds) { final long seconds = (milliSeconds % 60000) / 1000; final long minutes = (milliSeconds % 3600000) / 60000; final long hours = (milliSeconds % 86400000) / 3600000; final long days = (milliSeconds % (86400000 * 7)) / 86400000; final Formatters formatters = FORMATTERS_PROVIDER.formatters(); if (days > 0) { return formatters.stringFormat("%d:%02d:%02d:%02d", days, hours, minutes, seconds); } return hours > 0 ? formatters.stringFormat("%d:%02d:%02d", hours, minutes, seconds) : formatters.stringFormat("%02d:%02d", minutes, seconds); } @NonNull public static String formatSpeed(final double speed) { return FORMATTERS_PROVIDER.formatters().speed().format(speed); } @NonNull public static String formatPitch(final double pitch) { return FORMATTERS_PROVIDER.formatters().pitch().format(pitch); } @NonNull public static String captionLanguageOf(@NonNull final Context context, @NonNull final SubtitlesStream subtitles) { final String displayName = subtitles.getDisplayLanguageName(); return displayName + (subtitles.isAutoGenerated() ? " (" + context.getString(R.string.caption_auto_generated) + ")" : ""); } @NonNull public static String captionLanguageStemOf(@NonNull final String language) { if (!language.contains("(") || !language.contains(")")) { return language; } if (language.startsWith("(")) { // language text is right-to-left final String[] parts = language.split("\\)"); return parts[parts.length - 1].trim(); } return language.split("\\(")[0].trim(); } @NonNull public static String resizeTypeOf(@NonNull final Context context, @ResizeMode final int resizeMode) { switch (resizeMode) { case AspectRatioFrameLayout.RESIZE_MODE_FIT: return context.getString(R.string.resize_fit); case AspectRatioFrameLayout.RESIZE_MODE_FILL: return context.getString(R.string.resize_fill); case AspectRatioFrameLayout.RESIZE_MODE_ZOOM: return context.getString(R.string.resize_zoom); case AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT: case AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH: default: throw new IllegalArgumentException("Unrecognized resize mode: " + resizeMode); } } /** * Given a {@link StreamInfo} and the existing queue items, * provide the {@link SinglePlayQueue} consisting of the next video for auto queueing. *

* This method detects and prevents cycles by naively checking * if a candidate next video's url already exists in the existing items. *

*

* The first item in {@link StreamInfo#getRelatedItems()} is checked first. * If it is non-null and is not part of the existing items, it will be used as the next stream. * Otherwise, a random stream with non-repeating url will be selected * from the {@link StreamInfo#getRelatedItems()}. Non-stream items are ignored. *

* * @param info currently playing stream * @param existingItems existing items in the queue * @return {@link SinglePlayQueue} with the next stream to queue */ @Nullable public static PlayQueue autoQueueOf(@NonNull final StreamInfo info, @NonNull final List existingItems) { final Set urls = existingItems.stream() .map(PlayQueueItem::getUrl) .collect(Collectors.toUnmodifiableSet()); final List relatedItems = info.getRelatedItems(); if (Utils.isNullOrEmpty(relatedItems)) { return null; } if (relatedItems.get(0) instanceof StreamInfoItem && !urls.contains(relatedItems.get(0).getUrl())) { return getAutoQueuedSinglePlayQueue((StreamInfoItem) relatedItems.get(0)); } final List autoQueueItems = new ArrayList<>(); for (final InfoItem item : relatedItems) { if (item instanceof StreamInfoItem && !urls.contains(item.getUrl())) { autoQueueItems.add((StreamInfoItem) item); } } Collections.shuffle(autoQueueItems); return autoQueueItems.isEmpty() ? null : getAutoQueuedSinglePlayQueue(autoQueueItems.get(0)); } // endregion // region Resolution public static boolean isResumeAfterAudioFocusGain(@NonNull final Context context) { return getPreferences(context) .getBoolean(context.getString(R.string.resume_on_audio_focus_gain_key), false); } public static String getActionForRightGestureSide(@NonNull final Context context) { return getPreferences(context) .getString(context.getString(R.string.right_gesture_control_key), context.getString(R.string.default_right_gesture_control_value)); } public static String getActionForLeftGestureSide(@NonNull final Context context) { return getPreferences(context) .getString(context.getString(R.string.left_gesture_control_key), context.getString(R.string.default_left_gesture_control_value)); } public static boolean isStartMainPlayerFullscreenEnabled(@NonNull final Context context) { return getPreferences(context) .getBoolean(context.getString(R.string.start_main_player_fullscreen_key), false); } public static boolean isAutoQueueEnabled(@NonNull final Context context) { return getPreferences(context) .getBoolean(context.getString(R.string.auto_queue_key), false); } public static boolean isClearingQueueConfirmationRequired(@NonNull final Context context) { return getPreferences(context) .getBoolean(context.getString(R.string.clear_queue_confirmation_key), false); } @MinimizeMode public static int getMinimizeOnExitAction(@NonNull final Context context) { final String action = getPreferences(context) .getString(context.getString(R.string.minimize_on_exit_key), ""); if (action.equals(context.getString(R.string.minimize_on_exit_popup_key))) { return MINIMIZE_ON_EXIT_MODE_POPUP; } else if (action.equals(context.getString(R.string.minimize_on_exit_none_key))) { return MINIMIZE_ON_EXIT_MODE_NONE; } else { return MINIMIZE_ON_EXIT_MODE_BACKGROUND; // default } } @AutoplayType public static int getAutoplayType(@NonNull final Context context) { final String type = getPreferences(context).getString( context.getString(R.string.autoplay_key), ""); if (type.equals(context.getString(R.string.autoplay_always_key))) { return AUTOPLAY_TYPE_ALWAYS; } else if (type.equals(context.getString(R.string.autoplay_never_key))) { return AUTOPLAY_TYPE_NEVER; } else { return AUTOPLAY_TYPE_WIFI; // default } } public static boolean isAutoplayAllowedByUser(@NonNull final Context context) { switch (PlayerHelper.getAutoplayType(context)) { case PlayerHelper.AutoplayType.AUTOPLAY_TYPE_NEVER: return false; case PlayerHelper.AutoplayType.AUTOPLAY_TYPE_WIFI: return !ListHelper.isMeteredNetwork(context); case PlayerHelper.AutoplayType.AUTOPLAY_TYPE_ALWAYS: default: return true; } } @NonNull public static SeekParameters getSeekParameters(@NonNull final Context context) { return isUsingInexactSeek(context) ? SeekParameters.CLOSEST_SYNC : SeekParameters.EXACT; } public static long getPreferredCacheSize() { return 64 * 1024 * 1024L; } public static long getPreferredFileSize() { return 2 * 1024 * 1024L; // ExoPlayer CacheDataSink.MIN_RECOMMENDED_FRAGMENT_SIZE } @NonNull public static ExoTrackSelection.Factory getQualitySelector() { return new AdaptiveTrackSelection.Factory( 1000, AdaptiveTrackSelection.DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, AdaptiveTrackSelection.DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, AdaptiveTrackSelection.DEFAULT_BANDWIDTH_FRACTION); } @NonNull public static CaptionStyleCompat getCaptionStyle(@NonNull final Context context) { final CaptioningManager captioningManager = ContextCompat.getSystemService(context, CaptioningManager.class); if (captioningManager == null || !captioningManager.isEnabled()) { return CaptionStyleCompat.DEFAULT; } return CaptionStyleCompat.createFromCaptionStyle(captioningManager.getUserStyle()); } /** * Get scaling for captions based on system font scaling. *

Options:

*
    *
  • Very small: 0.25f
  • *
  • Small: 0.5f
  • *
  • Normal: 1.0f
  • *
  • Large: 1.5f
  • *
  • Very large: 2.0f
  • *
* * @param context Android app context * @return caption scaling */ public static float getCaptionScale(@NonNull final Context context) { final CaptioningManager captioningManager = ContextCompat.getSystemService(context, CaptioningManager.class); if (captioningManager == null || !captioningManager.isEnabled()) { return 1.0f; } return captioningManager.getFontScale(); } /** * @param context the Android context * @return the screen brightness to use. A value less than 0 (the default) means to use the * preferred screen brightness */ public static float getScreenBrightness(@NonNull final Context context) { final SharedPreferences sp = getPreferences(context); final long timestamp = sp.getLong(context.getString(R.string.screen_brightness_timestamp_key), 0); // Hypothesis: 4h covers a viewing block, e.g. evening. // External lightning conditions will change in the next // viewing block so we fall back to the default brightness if ((System.currentTimeMillis() - timestamp) > TimeUnit.HOURS.toMillis(4)) { return -1; } else { return sp.getFloat(context.getString(R.string.screen_brightness_key), -1); } } public static void setScreenBrightness(@NonNull final Context context, final float screenBrightness) { getPreferences(context).edit() .putFloat(context.getString(R.string.screen_brightness_key), screenBrightness) .putLong(context.getString(R.string.screen_brightness_timestamp_key), System.currentTimeMillis()) .apply(); } public static boolean globalScreenOrientationLocked(final Context context) { // 1: Screen orientation changes using accelerometer // 0: Screen orientation is locked // if the accelerometer sensor is missing completely, assume locked orientation return android.provider.Settings.System.getInt( context.getContentResolver(), Settings.System.ACCELEROMETER_ROTATION, 0) == 0 || !context.getPackageManager() .hasSystemFeature(PackageManager.FEATURE_SENSOR_ACCELEROMETER); } public static int getProgressiveLoadIntervalBytes(@NonNull final Context context) { final String preferredIntervalBytes = getPreferences(context).getString( context.getString(R.string.progressive_load_interval_key), context.getString(R.string.progressive_load_interval_default_value)); if (context.getString(R.string.progressive_load_interval_exoplayer_default_value) .equals(preferredIntervalBytes)) { return ProgressiveMediaSource.DEFAULT_LOADING_CHECK_INTERVAL_BYTES; } // Keeping the same KiB unit used by ProgressiveMediaSource return Integer.parseInt(preferredIntervalBytes) * 1024; } // endregion // region Private helpers @NonNull private static SharedPreferences getPreferences(@NonNull final Context context) { return PreferenceManager.getDefaultSharedPreferences(context); } private static boolean isUsingInexactSeek(@NonNull final Context context) { return getPreferences(context) .getBoolean(context.getString(R.string.use_inexact_seek_key), false); } private static SinglePlayQueue getAutoQueuedSinglePlayQueue( final StreamInfoItem streamInfoItem) { final SinglePlayQueue singlePlayQueue = new SinglePlayQueue(streamInfoItem); Objects.requireNonNull(singlePlayQueue.getItem()).setAutoQueued(true); return singlePlayQueue; } // endregion // region Utils used by player @ResizeMode public static int retrieveResizeModeFromPrefs(final Player player) { return player.getPrefs().getInt(player.getContext().getString(R.string.last_resize_mode), AspectRatioFrameLayout.RESIZE_MODE_FIT); } @SuppressLint("SwitchIntDef") // only fit, fill and zoom are supported by NewPipe @ResizeMode public static int nextResizeModeAndSaveToPrefs(final Player player, @ResizeMode final int resizeMode) { final int newResizeMode; switch (resizeMode) { case AspectRatioFrameLayout.RESIZE_MODE_FIT: newResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL; break; case AspectRatioFrameLayout.RESIZE_MODE_FILL: newResizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM; break; case AspectRatioFrameLayout.RESIZE_MODE_ZOOM: default: newResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT; break; } // save the new resize mode so it can be restored in a future session player.getPrefs().edit().putInt( player.getContext().getString(R.string.last_resize_mode), newResizeMode).apply(); return newResizeMode; } public static PlaybackParameters retrievePlaybackParametersFromPrefs(final Player player) { final float speed = player.getPrefs().getFloat(player.getContext().getString( R.string.playback_speed_key), player.getPlaybackSpeed()); final float pitch = player.getPrefs().getFloat(player.getContext().getString( R.string.playback_pitch_key), player.getPlaybackPitch()); return new PlaybackParameters(speed, pitch); } public static void savePlaybackParametersToPrefs(final Player player, final float speed, final float pitch, final boolean skipSilence) { player.getPrefs().edit() .putFloat(player.getContext().getString(R.string.playback_speed_key), speed) .putFloat(player.getContext().getString(R.string.playback_pitch_key), pitch) .putBoolean(player.getContext().getString(R.string.playback_skip_silence_key), skipSilence) .apply(); } public static float getMinimumVideoHeight(final float width) { return width / (16.0f / 9.0f); // Respect the 16:9 ratio that most videos have } public static int retrieveSeekDurationFromPreferences(final Player player) { return Integer.parseInt(Objects.requireNonNull(player.getPrefs().getString( player.getContext().getString(R.string.seek_duration_key), player.getContext().getString(R.string.seek_duration_default_value)))); } // endregion // region Format static class FormattersProvider { private Formatters formatters; public Formatters formatters() { if (formatters == null) { formatters = Formatters.create(); } return formatters; } public void reset() { formatters = null; } } record Formatters( Locale locale, NumberFormat speed, NumberFormat pitch) { static Formatters create() { final Locale locale = Localization.getAppLocale(); final DecimalFormatSymbols dfs = DecimalFormatSymbols.getInstance(locale); return new Formatters( locale, new DecimalFormat("0.##x", dfs), new DecimalFormat("##%", dfs)); } String stringFormat(final String format, final Object... args) { return String.format(locale, format, args); } } // endregion } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java ================================================ package org.schabi.newpipe.player.helper; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; import android.os.IBinder; import android.util.Log; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; import com.google.android.exoplayer2.PlaybackException; import com.google.android.exoplayer2.PlaybackParameters; import org.schabi.newpipe.App; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.player.PlayerService; import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.PlayerType; import org.schabi.newpipe.player.event.PlayerServiceEventListener; import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.util.NavigationHelper; import java.util.Optional; import java.util.function.Consumer; public final class PlayerHolder { private PlayerHolder() { } private static PlayerHolder instance; public static synchronized PlayerHolder getInstance() { if (PlayerHolder.instance == null) { PlayerHolder.instance = new PlayerHolder(); } return PlayerHolder.instance; } private static final boolean DEBUG = MainActivity.DEBUG; private static final String TAG = PlayerHolder.class.getSimpleName(); @Nullable private PlayerServiceExtendedEventListener listener; private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection(); private boolean bound; @Nullable private PlayerService playerService; private Optional getPlayer() { return Optional.ofNullable(playerService) .flatMap(s -> Optional.ofNullable(s.getPlayer())); } private Optional getPlayQueue() { // player play queue might be null e.g. while player is starting return getPlayer().flatMap(p -> Optional.ofNullable(p.getPlayQueue())); } /** * Returns the current {@link PlayerType} of the {@link PlayerService} service, * otherwise `null` if no service is running. * * @return Current PlayerType */ @Nullable public PlayerType getType() { return getPlayer().map(Player::getPlayerType).orElse(null); } public boolean isPlaying() { return getPlayer().map(Player::isPlaying).orElse(false); } public boolean isPlayerOpen() { return getPlayer().isPresent(); } /** * Use this method to only allow the user to manipulate the play queue (e.g. by enqueueing via * the stream long press menu) when there actually is a play queue to manipulate. * @return true only if the player is open and its play queue is ready (i.e. it is not null) */ public boolean isPlayQueueReady() { return getPlayQueue().isPresent(); } public boolean isBound() { return bound; } public int getQueueSize() { return getPlayQueue().map(PlayQueue::size).orElse(0); } public int getQueuePosition() { return getPlayQueue().map(PlayQueue::getIndex).orElse(0); } public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) { listener = newListener; if (listener == null) { return; } // Force reload data from service if (playerService != null) { listener.onServiceConnected(playerService); startPlayerListener(); // ^ will call listener.onPlayerConnected() down the line if there is an active player } } // helper to handle context in common place as using the same // context to bind/unbind a service is crucial private Context getCommonContext() { return App.getInstance(); } public void startService(final boolean playAfterConnect, final PlayerServiceExtendedEventListener newListener) { if (DEBUG) { Log.d(TAG, "startService() called with playAfterConnect=" + playAfterConnect); } final Context context = getCommonContext(); setListener(newListener); if (bound) { return; } // startService() can be called concurrently and it will give a random crashes // and NullPointerExceptions inside the service because the service will be // bound twice. Prevent it with unbinding first unbind(context); final Intent intent = new Intent(context, PlayerService.class); intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true); ContextCompat.startForegroundService(context, intent); serviceConnection.doPlayAfterConnect(playAfterConnect); bind(context); } public void stopService() { if (DEBUG) { Log.d(TAG, "stopService() called"); } if (playerService != null) { playerService.destroyPlayerAndStopService(); } final Context context = getCommonContext(); unbind(context); // destroyPlayerAndStopService() already runs the next line of code, but run it again just // to make sure to stop the service even if playerService is null by any chance. context.stopService(new Intent(context, PlayerService.class)); } class PlayerServiceConnection implements ServiceConnection { private boolean playAfterConnect = false; /** * @param playAfterConnection Sets the value of `playAfterConnect` to pass to the {@link * PlayerServiceExtendedEventListener#onPlayerConnected(Player, boolean)} the next time it * is called. The value of `playAfterConnect` will be reset to false after that. */ public void doPlayAfterConnect(final boolean playAfterConnection) { this.playAfterConnect = playAfterConnection; } @Override public void onServiceDisconnected(final ComponentName compName) { if (DEBUG) { Log.d(TAG, "Player service is disconnected"); } final Context context = getCommonContext(); unbind(context); } @Override public void onServiceConnected(final ComponentName compName, final IBinder service) { if (DEBUG) { Log.d(TAG, "Player service is connected"); } final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service; playerService = localBinder.getService(); if (listener != null) { listener.onServiceConnected(playerService); } startPlayerListener(); // ^ will call listener.onPlayerConnected() down the line if there is an active player if (playerService != null && playerService.getPlayer() != null) { // notify the main activity that binding the service has completed and that there is // a player, so that it can open the bottom mini-player NavigationHelper.sendPlayerStartedEvent(localBinder.getService()); } } } private void bind(final Context context) { if (DEBUG) { Log.d(TAG, "bind() called"); } // BIND_AUTO_CREATE starts the service if it's not already running bound = bind(context, Context.BIND_AUTO_CREATE); if (!bound) { context.unbindService(serviceConnection); } } public void tryBindIfNeeded(final Context context) { if (!bound) { // flags=0 means the service will not be started if it does not already exist. In this // case the return value is not useful, as a value of "true" does not really indicate // that the service is going to be bound. bind(context, 0); } } private boolean bind(final Context context, final int flags) { final Intent serviceIntent = new Intent(context, PlayerService.class); serviceIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION); return context.bindService(serviceIntent, serviceConnection, flags); } private void unbind(final Context context) { if (DEBUG) { Log.d(TAG, "unbind() called"); } if (bound) { context.unbindService(serviceConnection); bound = false; stopPlayerListener(); playerService = null; if (listener != null) { listener.onPlayerDisconnected(); listener.onServiceDisconnected(); } } } private void startPlayerListener() { if (playerService != null) { // setting the player listener will take care of calling relevant callbacks if the // player in the service is (not) already active, also see playerStateListener below playerService.setPlayerListener(playerStateListener); } getPlayer().ifPresent(p -> p.setFragmentListener(internalListener)); } private void stopPlayerListener() { if (playerService != null) { playerService.setPlayerListener(null); } getPlayer().ifPresent(p -> p.removeFragmentListener(internalListener)); } /** * This listener will be held by the players created by {@link PlayerService}. */ private final PlayerServiceEventListener internalListener = new PlayerServiceEventListener() { @Override public void onViewCreated() { if (listener != null) { listener.onViewCreated(); } } @Override public void onFullscreenStateChanged(final boolean fullscreen) { if (listener != null) { listener.onFullscreenStateChanged(fullscreen); } } @Override public void onScreenRotationButtonClicked() { if (listener != null) { listener.onScreenRotationButtonClicked(); } } @Override public void onMoreOptionsLongClicked() { if (listener != null) { listener.onMoreOptionsLongClicked(); } } @Override public void onPlayerError(final PlaybackException error, final boolean isCatchableException) { if (listener != null) { listener.onPlayerError(error, isCatchableException); } } @Override public void hideSystemUiIfNeeded() { if (listener != null) { listener.hideSystemUiIfNeeded(); } } @Override public void onQueueUpdate(final PlayQueue queue) { if (listener != null) { listener.onQueueUpdate(queue); } } @Override public void onPlaybackUpdate(final int state, final int repeatMode, final boolean shuffled, final PlaybackParameters parameters) { if (listener != null) { listener.onPlaybackUpdate(state, repeatMode, shuffled, parameters); } } @Override public void onProgressUpdate(final int currentProgress, final int duration, final int bufferPercent) { if (listener != null) { listener.onProgressUpdate(currentProgress, duration, bufferPercent); } } @Override public void onMetadataUpdate(final StreamInfo info, final PlayQueue queue) { if (listener != null) { listener.onMetadataUpdate(info, queue); } } @Override public void onServiceStopped() { if (listener != null) { listener.onServiceStopped(); } unbind(getCommonContext()); } }; /** * This listener will be held by bound {@link PlayerService}s to notify of the player starting * or stopping. This is necessary since the service outlives the player e.g. to answer Android * Auto media browser queries. */ private final Consumer playerStateListener = (@Nullable final Player player) -> { if (listener != null) { if (player == null) { // player.fragmentListener=null is already done by player.stopActivityBinding(), // which is called by player.destroy(), which is in turn called by PlayerService // before setting its player to null listener.onPlayerDisconnected(); } else { listener.onPlayerConnected(player, serviceConnection.playAfterConnect); // reset the value of playAfterConnect: if it was true before, it is now "consumed" serviceConnection.playAfterConnect = false; player.setFragmentListener(internalListener); } } }; } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/helper/PlayerSemitoneHelper.java ================================================ package org.schabi.newpipe.player.helper; import androidx.core.math.MathUtils; /** * Converts between percent and 12-tone equal temperament semitones. *
* @see * * Wikipedia: Equal temperament#Twelve-tone equal temperament * */ public final class PlayerSemitoneHelper { public static final int SEMITONE_COUNT = 12; private PlayerSemitoneHelper() { // No impl } public static String formatPitchSemitones(final double percent) { return formatPitchSemitones(percentToSemitones(percent)); } public static String formatPitchSemitones(final int semitones) { return semitones > 0 ? "+" + semitones : "" + semitones; } public static double semitonesToPercent(final int semitones) { return Math.pow(2, ensureSemitonesInRange(semitones) / (double) SEMITONE_COUNT); } public static int percentToSemitones(final double percent) { return ensureSemitonesInRange( (int) Math.round(SEMITONE_COUNT * Math.log(percent) / Math.log(2))); } private static int ensureSemitonesInRange(final int semitones) { return MathUtils.clamp(semitones, -SEMITONE_COUNT, SEMITONE_COUNT); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/helper/YoutubeDashLiveManifestParser.java ================================================ package org.schabi.newpipe.player.helper; import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.android.exoplayer2.source.dash.manifest.DashManifest; import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; import com.google.android.exoplayer2.source.dash.manifest.Period; import com.google.android.exoplayer2.source.dash.manifest.ProgramInformation; import com.google.android.exoplayer2.source.dash.manifest.ServiceDescriptionElement; import com.google.android.exoplayer2.source.dash.manifest.UtcTimingElement; import java.util.List; /** * A {@link DashManifestParser} fixing YouTube DASH manifests to allow starting playback from the * newest period available instead of the earliest one in some cases. * *

* It changes the {@code availabilityStartTime} passed to a custom value doing the workaround. * A better approach to fix the issue should be investigated and used in the future. *

*/ public class YoutubeDashLiveManifestParser extends DashManifestParser { // Result of Util.parseXsDateTime("1970-01-01T00:00:00Z") private static final long AVAILABILITY_START_TIME_TO_USE = 0; // There is no computation made with the availabilityStartTime value in the // parseMediaPresentationDescription method itself, so we can just override methods called in // this method using the workaround value // Overriding parsePeriod does not seem to be needed @SuppressWarnings("checkstyle:ParameterNumber") @NonNull @Override protected DashManifest buildMediaPresentationDescription( final long availabilityStartTime, final long durationMs, final long minBufferTimeMs, final boolean dynamic, final long minUpdateTimeMs, final long timeShiftBufferDepthMs, final long suggestedPresentationDelayMs, final long publishTimeMs, @Nullable final ProgramInformation programInformation, @Nullable final UtcTimingElement utcTiming, @Nullable final ServiceDescriptionElement serviceDescription, @Nullable final Uri location, @NonNull final List periods) { return super.buildMediaPresentationDescription( AVAILABILITY_START_TIME_TO_USE, durationMs, minBufferTimeMs, dynamic, minUpdateTimeMs, timeShiftBufferDepthMs, suggestedPresentationDelayMs, publishTimeMs, programInformation, utcTiming, serviceDescription, location, periods); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserCommon.kt ================================================ package org.schabi.newpipe.player.mediabrowser import org.schabi.newpipe.BuildConfig import org.schabi.newpipe.extractor.InfoItem.InfoType import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException internal const val ID_AUTHORITY = BuildConfig.APPLICATION_ID internal const val ID_ROOT = "//$ID_AUTHORITY" internal const val ID_BOOKMARKS = "playlists" internal const val ID_HISTORY = "history" internal const val ID_INFO_ITEM = "item" internal const val ID_LOCAL = "local" internal const val ID_REMOTE = "remote" internal const val ID_URL = "url" internal const val ID_STREAM = "stream" internal const val ID_PLAYLIST = "playlist" internal const val ID_CHANNEL = "channel" internal fun infoItemTypeToString(type: InfoType): String { return when (type) { InfoType.STREAM -> ID_STREAM InfoType.PLAYLIST -> ID_PLAYLIST InfoType.CHANNEL -> ID_CHANNEL else -> error("Unexpected value: $type") } } internal fun infoItemTypeFromString(type: String): InfoType { return when (type) { ID_STREAM -> InfoType.STREAM ID_PLAYLIST -> InfoType.PLAYLIST ID_CHANNEL -> InfoType.CHANNEL else -> error("Unexpected value: $type") } } internal fun parseError(mediaId: String): ContentNotAvailableException { return ContentNotAvailableException("Failed to parse media ID $mediaId") } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt ================================================ package org.schabi.newpipe.player.mediabrowser import android.content.ContentResolver import android.content.Context import android.net.Uri import android.os.Bundle import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaDescriptionCompat import android.util.Log import androidx.annotation.DrawableRes import androidx.core.net.toUri import androidx.media.MediaBrowserServiceCompat import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT import androidx.media.MediaBrowserServiceCompat.Result import androidx.media.utils.MediaConstants import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.schedulers.Schedulers import java.util.function.Consumer import org.schabi.newpipe.MainActivity.DEBUG import org.schabi.newpipe.NewPipeDatabase import org.schabi.newpipe.R import org.schabi.newpipe.database.history.model.StreamHistoryEntry import org.schabi.newpipe.database.playlist.PlaylistLocalItem import org.schabi.newpipe.database.playlist.PlaylistStreamEntry import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity import org.schabi.newpipe.extractor.InfoItem import org.schabi.newpipe.extractor.InfoItem.InfoType import org.schabi.newpipe.extractor.channel.ChannelInfoItem import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem import org.schabi.newpipe.extractor.search.SearchInfo import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.local.bookmark.MergedPlaylistManager import org.schabi.newpipe.local.playlist.LocalPlaylistManager import org.schabi.newpipe.local.playlist.RemotePlaylistManager import org.schabi.newpipe.util.ExtractorHelper import org.schabi.newpipe.util.ServiceHelper import org.schabi.newpipe.util.image.ImageStrategy /** * This class is used to cleanly separate the Service implementation (in * [org.schabi.newpipe.player.PlayerService]) and the media browser implementation (in this file). * * @param notifyChildrenChanged takes the parent id of the children that changed */ class MediaBrowserImpl( private val context: Context, // parentId notifyChildrenChanged: Consumer ) { private val packageValidator = PackageValidator(context) private val database = NewPipeDatabase.getInstance(context) private var disposables = CompositeDisposable() init { // this will listen to changes in the bookmarks until this MediaBrowserImpl is dispose()d disposables.add( getMergedPlaylists().subscribe { notifyChildrenChanged.accept(ID_BOOKMARKS) } ) } //region Cleanup fun dispose() { disposables.dispose() } //endregion //region onGetRoot fun onGetRoot( clientPackageName: String, clientUid: Int, rootHints: Bundle? ): MediaBrowserServiceCompat.BrowserRoot? { if (DEBUG) { Log.d(TAG, "onGetRoot($clientPackageName, $clientUid, $rootHints)") } if (!packageValidator.isKnownCaller(clientPackageName, clientUid)) { // this is a caller we can't trust (see PackageValidator's rules taken from uamp) return null } if (rootHints?.getBoolean(EXTRA_RECENT, false) == true) { // the system is asking for a root to do media resumption, but we can't handle that yet, // see https://developer.android.com/media/implement/surfaces/mobile#mediabrowserservice_implementation return null } val extras = Bundle() extras.putBoolean( MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true ) return MediaBrowserServiceCompat.BrowserRoot(ID_ROOT, extras) } //endregion //region onLoadChildren fun onLoadChildren(parentId: String, result: Result>) { if (DEBUG) { Log.d(TAG, "onLoadChildren($parentId)") } result.detach() // allows sendResult() to happen later disposables.add( onLoadChildren(parentId) .subscribe( { result.sendResult(it) }, { throwable -> // null indicates an error, see the docs of MediaSessionCompat.onSearch() result.sendResult(null) Log.e(TAG, "onLoadChildren error for parentId=$parentId: $throwable") } ) ) } private fun onLoadChildren(parentId: String): Single> { try { val parentIdUri = parentId.toUri() val path = ArrayList(parentIdUri.pathSegments) if (path.isEmpty()) { return Single.just( listOf( createRootMediaItem( ID_BOOKMARKS, context.resources.getString(R.string.tab_bookmarks_short), R.drawable.ic_bookmark_white ), createRootMediaItem( ID_HISTORY, context.resources.getString(R.string.action_history), R.drawable.ic_history_white ) ) ) } when (path.removeAt(0)) { ID_BOOKMARKS -> { if (path.isEmpty()) { return populateBookmarks() } if (path.size == 2) { val localOrRemote = path[0] val playlistId = path[1].toLong() if (localOrRemote == ID_LOCAL) { return populateLocalPlaylist(playlistId) } else if (localOrRemote == ID_REMOTE) { return populateRemotePlaylist(playlistId) } } Log.w(TAG, "Unknown playlist URI: $parentId") throw parseError(parentId) } ID_HISTORY -> return populateHistory() else -> throw parseError(parentId) } } catch (e: ContentNotAvailableException) { return Single.error(e) } } private fun createRootMediaItem( mediaId: String?, folderName: String?, @DrawableRes iconResId: Int ): MediaBrowserCompat.MediaItem { val builder = MediaDescriptionCompat.Builder() builder.setMediaId(mediaId) builder.setTitle(folderName) val resources = context.resources builder.setIconUri( Uri.Builder() .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE) .authority(resources.getResourcePackageName(iconResId)) .appendPath(resources.getResourceTypeName(iconResId)) .appendPath(resources.getResourceEntryName(iconResId)) .build() ) val extras = Bundle() extras.putString( MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, context.getString(R.string.app_name) ) builder.setExtras(extras) return MediaBrowserCompat.MediaItem( builder.build(), MediaBrowserCompat.MediaItem.FLAG_BROWSABLE ) } private fun createPlaylistMediaItem(playlist: PlaylistLocalItem): MediaBrowserCompat.MediaItem { val builder = MediaDescriptionCompat.Builder() builder .setMediaId(createMediaIdForInfoItem(playlist is PlaylistRemoteEntity, playlist.uid)) .setTitle(playlist.orderingName) .setIconUri(imageUriOrNullIfDisabled(playlist.thumbnailUrl)) val extras = Bundle() extras.putString( MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, context.resources.getString(R.string.tab_bookmarks) ) builder.setExtras(extras) return MediaBrowserCompat.MediaItem( builder.build(), MediaBrowserCompat.MediaItem.FLAG_BROWSABLE ) } private fun createInfoItemMediaItem(item: InfoItem): MediaBrowserCompat.MediaItem? { val builder = MediaDescriptionCompat.Builder() builder.setMediaId(createMediaIdForInfoItem(item)) .setTitle(item.name) when (item.infoType) { InfoType.STREAM -> builder.setSubtitle((item as StreamInfoItem).uploaderName) InfoType.PLAYLIST -> builder.setSubtitle((item as PlaylistInfoItem).uploaderName) InfoType.CHANNEL -> builder.setSubtitle((item as ChannelInfoItem).description) else -> return null } ImageStrategy.choosePreferredImage(item.thumbnails)?.let { builder.setIconUri(imageUriOrNullIfDisabled(it)) } return MediaBrowserCompat.MediaItem( builder.build(), MediaBrowserCompat.MediaItem.FLAG_PLAYABLE ) } private fun buildMediaId(): Uri.Builder { return Uri.Builder().authority(ID_AUTHORITY) } private fun buildPlaylistMediaId(playlistType: String?): Uri.Builder { return buildMediaId() .appendPath(ID_BOOKMARKS) .appendPath(playlistType) } private fun buildLocalPlaylistItemMediaId(isRemote: Boolean, playlistId: Long): Uri.Builder { return buildPlaylistMediaId(if (isRemote) ID_REMOTE else ID_LOCAL) .appendPath(playlistId.toString()) } private fun buildInfoItemMediaId(item: InfoItem): Uri.Builder { return buildMediaId() .appendPath(ID_INFO_ITEM) .appendPath(infoItemTypeToString(item.infoType)) .appendPath(item.serviceId.toString()) .appendQueryParameter(ID_URL, item.url) } private fun createMediaIdForInfoItem(isRemote: Boolean, playlistId: Long): String { return buildLocalPlaylistItemMediaId(isRemote, playlistId) .build().toString() } private fun createLocalPlaylistStreamMediaItem( playlistId: Long, item: PlaylistStreamEntry, index: Int ): MediaBrowserCompat.MediaItem { val builder = MediaDescriptionCompat.Builder() builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index)) .setTitle(item.streamEntity.title) .setSubtitle(item.streamEntity.uploader) .setIconUri(imageUriOrNullIfDisabled(item.streamEntity.thumbnailUrl)) return MediaBrowserCompat.MediaItem( builder.build(), MediaBrowserCompat.MediaItem.FLAG_PLAYABLE ) } private fun createRemotePlaylistStreamMediaItem( playlistId: Long, item: StreamInfoItem, index: Int ): MediaBrowserCompat.MediaItem { val builder = MediaDescriptionCompat.Builder() builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index)) .setTitle(item.name) .setSubtitle(item.uploaderName) ImageStrategy.choosePreferredImage(item.thumbnails)?.let { builder.setIconUri(imageUriOrNullIfDisabled(it)) } return MediaBrowserCompat.MediaItem( builder.build(), MediaBrowserCompat.MediaItem.FLAG_PLAYABLE ) } private fun createMediaIdForPlaylistIndex( isRemote: Boolean, playlistId: Long, index: Int ): String { return buildLocalPlaylistItemMediaId(isRemote, playlistId) .appendPath(index.toString()) .build().toString() } private fun createMediaIdForInfoItem(item: InfoItem): String { return buildInfoItemMediaId(item).build().toString() } private fun populateHistory(): Single> { val history = database.streamHistoryDAO().history.firstOrError() return history.map { items -> items.map { this.createHistoryMediaItem(it) } } } private fun createHistoryMediaItem(streamHistoryEntry: StreamHistoryEntry): MediaBrowserCompat.MediaItem { val builder = MediaDescriptionCompat.Builder() val mediaId = buildMediaId() .appendPath(ID_HISTORY) .appendPath(streamHistoryEntry.streamId.toString()) .build().toString() builder.setMediaId(mediaId) .setTitle(streamHistoryEntry.streamEntity.title) .setSubtitle(streamHistoryEntry.streamEntity.uploader) .setIconUri(imageUriOrNullIfDisabled(streamHistoryEntry.streamEntity.thumbnailUrl)) return MediaBrowserCompat.MediaItem( builder.build(), MediaBrowserCompat.MediaItem.FLAG_PLAYABLE ) } private fun getMergedPlaylists(): Flowable> { return MergedPlaylistManager.getMergedOrderedPlaylists( LocalPlaylistManager(database), RemotePlaylistManager(database) ) } private fun populateBookmarks(): Single> { val playlists = getMergedPlaylists().firstOrError() return playlists.map { playlist -> playlist.map { this.createPlaylistMediaItem(it) } } } private fun populateLocalPlaylist(playlistId: Long): Single> { val playlist = LocalPlaylistManager(database).getPlaylistStreams(playlistId).firstOrError() return playlist.map { items -> items.mapIndexed { index, item -> createLocalPlaylistStreamMediaItem(playlistId, item, index) } } } private fun populateRemotePlaylist(playlistId: Long): Single> { return RemotePlaylistManager(database).getPlaylist(playlistId).firstOrError() .flatMap { ExtractorHelper.getPlaylistInfo(it.serviceId, it.url, false) } .map { // ignore it.errors, i.e. ignore errors about specific items, since there would // be no way to show the error properly in Android Auto anyway it.relatedItems.mapIndexed { index, item -> createRemotePlaylistStreamMediaItem(playlistId, item, index) } } } //endregion //region Search fun onSearch( query: String, result: Result> ) { if (DEBUG) { Log.d(TAG, "onSearch($query)") } result.detach() // allows sendResult() to happen later disposables.add( searchMusicBySongTitle(query) // ignore it.errors, i.e. ignore errors about specific items, since there would // be no way to show the error properly in Android Auto anyway .map { it.relatedItems.mapNotNull(this::createInfoItemMediaItem) } .subscribeOn(Schedulers.io()) .subscribe( { result.sendResult(it) }, { throwable -> // null indicates an error, see the docs of MediaSessionCompat.onSearch() result.sendResult(null) Log.e(TAG, "Search error for query=\"$query\": $throwable") } ) ) } private fun searchMusicBySongTitle(query: String?): Single { val serviceId = ServiceHelper.getSelectedServiceId(context) return ExtractorHelper.searchFor(serviceId, query, listOf(), "") } //endregion companion object { private val TAG: String = MediaBrowserImpl::class.java.getSimpleName() fun imageUriOrNullIfDisabled(url: String?): Uri? { return if (ImageStrategy.shouldLoadImages()) { url?.toUri() } else { null } } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt ================================================ package org.schabi.newpipe.player.mediabrowser import android.content.Context import android.net.Uri import android.os.Bundle import android.os.ResultReceiver import android.support.v4.media.session.PlaybackStateCompat import android.util.Log import androidx.core.content.ContextCompat import androidx.core.net.toUri import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.PlaybackPreparer import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.schedulers.Schedulers import java.util.function.BiConsumer import java.util.function.Consumer import org.schabi.newpipe.MainActivity import org.schabi.newpipe.NewPipeDatabase import org.schabi.newpipe.R import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.extractor.InfoItem.InfoType import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler import org.schabi.newpipe.local.playlist.LocalPlaylistManager import org.schabi.newpipe.local.playlist.RemotePlaylistManager import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue import org.schabi.newpipe.player.playqueue.PlayQueue import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue import org.schabi.newpipe.player.playqueue.SinglePlayQueue import org.schabi.newpipe.util.ChannelTabHelper import org.schabi.newpipe.util.ExtractorHelper import org.schabi.newpipe.util.NavigationHelper /** * This class is used to cleanly separate the Service implementation (in * [org.schabi.newpipe.player.PlayerService]) and the playback preparer implementation (in this * file). We currently use the playback preparer only in conjunction with the media browser: the * playback preparer will receive the media URLs generated by [MediaBrowserImpl] and will start * playback of the corresponding streams or playlists. * * @param setMediaSessionError takes an error String and an error code from [PlaybackStateCompat], * calls `sessionConnector.setCustomErrorMessage(errorString, errorCode)` * @param clearMediaSessionError calls `sessionConnector.setCustomErrorMessage(null)` * @param onPrepare takes playWhenReady, calls `player.prepare()`; this is needed because * `MediaSessionConnector`'s `onPlay()` method calls this class' [onPrepare] instead of * `player.prepare()` if the playback preparer is not null, but we want the original behavior */ class MediaBrowserPlaybackPreparer( private val context: Context, private val setMediaSessionError: BiConsumer, // error string, error code private val clearMediaSessionError: Runnable, private val onPrepare: Consumer ) : PlaybackPreparer { private val database = NewPipeDatabase.getInstance(context) private var disposable: Disposable? = null fun dispose() { disposable?.dispose() } //region Overrides override fun getSupportedPrepareActions(): Long { return PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID } override fun onPrepare(playWhenReady: Boolean) { onPrepare.accept(playWhenReady) } override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { if (MainActivity.DEBUG) { Log.d(TAG, "onPrepareFromMediaId($mediaId, $playWhenReady, $extras)") } disposable?.dispose() disposable = extractPlayQueueFromMediaId(mediaId) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( { playQueue -> clearMediaSessionError.run() NavigationHelper.playOnBackgroundPlayer(context, playQueue, playWhenReady) }, { throwable -> Log.e(TAG, "Failed to start playback of media ID [$mediaId]", throwable) onPrepareError(throwable) } ) } override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) { onUnsupportedError() } override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) { onUnsupportedError() } override fun onCommand( player: Player, command: String, extras: Bundle?, cb: ResultReceiver? ): Boolean { return false } //endregion //region Errors private fun onUnsupportedError() { setMediaSessionError.accept( ContextCompat.getString(context, R.string.content_not_supported), PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED ) } private fun onPrepareError(throwable: Throwable) { setMediaSessionError.accept( ErrorInfo.getMessage(throwable, null, null).getText(context), PlaybackStateCompat.ERROR_CODE_APP_ERROR ) } //endregion //region Building play queues from playlists and history private fun extractLocalPlayQueue(playlistId: Long, index: Int): Single { return LocalPlaylistManager(database).getPlaylistStreams(playlistId).firstOrError() .map { items -> SinglePlayQueue(items.map { it.toStreamInfoItem() }, index) } } private fun extractRemotePlayQueue(playlistId: Long, index: Int): Single { return RemotePlaylistManager(database).getPlaylist(playlistId).firstOrError() .flatMap { ExtractorHelper.getPlaylistInfo(it.serviceId, it.url, false) } // ignore info.errors, i.e. ignore errors about specific items, since there would // be no way to show the error properly in Android Auto anyway .map { info -> PlaylistPlayQueue(info, index) } } private fun extractPlayQueueFromMediaId(mediaId: String): Single { try { val mediaIdUri = mediaId.toUri() val path = ArrayList(mediaIdUri.pathSegments) if (path.isEmpty()) { throw parseError(mediaId) } return when (path.removeAt(0)) { ID_BOOKMARKS -> extractPlayQueueFromPlaylistMediaId( mediaId, path, mediaIdUri.getQueryParameter(ID_URL) ) ID_HISTORY -> extractPlayQueueFromHistoryMediaId(mediaId, path) ID_INFO_ITEM -> extractPlayQueueFromInfoItemMediaId( mediaId, path, mediaIdUri.getQueryParameter(ID_URL) ?: throw parseError(mediaId) ) else -> throw parseError(mediaId) } } catch (e: ContentNotAvailableException) { return Single.error(e) } } @Throws(ContentNotAvailableException::class) private fun extractPlayQueueFromPlaylistMediaId( mediaId: String, path: MutableList, url: String? ): Single { if (path.isEmpty()) { throw parseError(mediaId) } when (val playlistType = path.removeAt(0)) { ID_LOCAL, ID_REMOTE -> { if (path.size != 2) { throw parseError(mediaId) } val playlistId = path[0].toLong() val index = path[1].toInt() return if (playlistType == ID_LOCAL) { extractLocalPlayQueue(playlistId, index) } else { extractRemotePlayQueue(playlistId, index) } } ID_URL -> { if (path.size != 1 || url == null) { throw parseError(mediaId) } val serviceId = path[0].toInt() return ExtractorHelper.getPlaylistInfo(serviceId, url, false) .map { PlaylistPlayQueue(it) } } else -> throw parseError(mediaId) } } @Throws(ContentNotAvailableException::class) private fun extractPlayQueueFromHistoryMediaId( mediaId: String, path: List ): Single { if (path.size != 1) { throw parseError(mediaId) } val streamId = path[0].toLong() return database.streamHistoryDAO().history .firstOrError() .map { items -> val infoItems = items .filter { it.streamId == streamId } .map { it.toStreamInfoItem() } SinglePlayQueue(infoItems, 0) } } @Throws(ContentNotAvailableException::class) private fun extractPlayQueueFromInfoItemMediaId( mediaId: String, path: List, url: String ): Single { if (path.size != 2) { throw parseError(mediaId) } val serviceId = path[1].toInt() return when (infoItemTypeFromString(path[0])) { InfoType.STREAM -> ExtractorHelper.getStreamInfo(serviceId, url, false) .map { SinglePlayQueue(it) } InfoType.PLAYLIST -> ExtractorHelper.getPlaylistInfo(serviceId, url, false) .map { PlaylistPlayQueue(it) } InfoType.CHANNEL -> ExtractorHelper.getChannelInfo(serviceId, url, false) .map { info -> val playableTab = info.tabs .firstOrNull { ChannelTabHelper.isStreamsTab(it) } ?: throw ContentNotAvailableException("No streams tab found") return@map ChannelTabPlayQueue(serviceId, ListLinkHandler(playableTab)) } else -> throw parseError(mediaId) } } //endregion companion object { private val TAG = MediaBrowserPlaybackPreparer::class.simpleName } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/mediabrowser/PackageValidator.kt ================================================ /* * Copyright 2018 Google Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // THIS FILE WAS TAKEN FROM UAMP, EXCEPT FOR THINGS RELATED TO THE WHITELIST. UPDATE IT WHEN NEEDED. // https://github.com/android/uamp/blob/329a21b63c247e9bd35f6858d4fc0e448fa38603/common/src/main/java/com/example/android/uamp/media/PackageValidator.kt package org.schabi.newpipe.player.mediabrowser import android.Manifest.permission.MEDIA_CONTENT_CONTROL import android.annotation.SuppressLint import android.content.Context import android.content.pm.PackageInfo import android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED import android.content.pm.PackageManager import android.os.Process import android.support.v4.media.session.MediaSessionCompat import android.util.Log import androidx.core.app.NotificationManagerCompat import androidx.media.MediaBrowserServiceCompat import java.security.MessageDigest import java.security.NoSuchAlgorithmException import org.schabi.newpipe.BuildConfig /** * Validates that the calling package is authorized to browse a [MediaBrowserServiceCompat]. * * The list of allowed signing certificates and their corresponding package names is defined in * res/xml/allowed_media_browser_callers.xml. * * If you want to add a new caller to allowed_media_browser_callers.xml and you don't know * its signature, this class will print to logcat (INFO level) a message with the proper * xml tags to add to allow the caller. * * For more information, see res/xml/allowed_media_browser_callers.xml. */ internal class PackageValidator(context: Context) { private val context: Context = context.applicationContext private val packageManager: PackageManager = this.context.packageManager private val platformSignature: String = getSystemSignature() private val callerChecked = mutableMapOf>() /** * Checks whether the caller attempting to connect to a [MediaBrowserServiceCompat] is known. * See [MusicService.onGetRoot] for where this is utilized. * * @param callingPackage The package name of the caller. * @param callingUid The user id of the caller. * @return `true` if the caller is known, `false` otherwise. */ fun isKnownCaller(callingPackage: String, callingUid: Int): Boolean { // If the caller has already been checked, return the previous result here. val (checkedUid, checkResult) = callerChecked[callingPackage] ?: Pair(0, false) if (checkedUid == callingUid) { return checkResult } /** * Because some of these checks can be slow, we save the results in [callerChecked] after * this code is run. * * In particular, there's little reason to recompute the calling package's certificate * signature (SHA-256) each call. * * This is safe to do as we know the UID matches the package's UID (from the check above), * and app UIDs are set at install time. Additionally, a package name + UID is guaranteed to * be constant until a reboot. (After a reboot then a previously assigned UID could be * reassigned.) */ // Build the caller info for the rest of the checks here. val callerPackageInfo = buildCallerInfo(callingPackage) ?: error("Caller wasn't found in the system?") // Verify that things aren't ... broken. (This test should always pass.) check(callerPackageInfo.uid == callingUid) { "Caller's package UID doesn't match caller's actual UID?" } val callerSignature = callerPackageInfo.signature val isCallerKnown = when { // If it's our own app making the call, allow it. callingUid == Process.myUid() -> true // If the system is making the call, allow it. callingUid == Process.SYSTEM_UID -> true // If the app was signed by the same certificate as the platform itself, also allow it. callerSignature == platformSignature -> true /* * [MEDIA_CONTENT_CONTROL] permission is only available to system applications, and * while it isn't required to allow these apps to connect to a * [MediaBrowserServiceCompat], allowing this ensures optimal compatability with apps * such as Android TV and the Google Assistant. */ callerPackageInfo.permissions.contains(MEDIA_CONTENT_CONTROL) -> true /* * If the calling app has a notification listener it is able to retrieve notifications * and can connect to an active [MediaSessionCompat]. * * It's not required to allow apps with a notification listener to * connect to your [MediaBrowserServiceCompat], but it does allow easy compatibility * with apps such as Wear OS. */ NotificationManagerCompat.getEnabledListenerPackages(this.context) .contains(callerPackageInfo.packageName) -> true // If none of the previous checks succeeded, then the caller is unrecognized. else -> false } if (!isCallerKnown) { logUnknownCaller(callerPackageInfo) } // Save our work for next time. callerChecked[callingPackage] = Pair(callingUid, isCallerKnown) return isCallerKnown } /** * Logs an info level message with details of how to add a caller to the allowed callers list * when the app is debuggable. */ private fun logUnknownCaller(callerPackageInfo: CallerPackageInfo) { if (BuildConfig.DEBUG) { Log.w(TAG, "Unknown caller $callerPackageInfo") } } /** * Builds a [CallerPackageInfo] for a given package that can be used for all the * various checks that are performed before allowing an app to connect to a * [MediaBrowserServiceCompat]. */ private fun buildCallerInfo(callingPackage: String): CallerPackageInfo? { val packageInfo = getPackageInfo(callingPackage) ?: return null val appName = packageInfo.applicationInfo?.loadLabel(packageManager).toString() val uid = packageInfo.applicationInfo?.uid ?: -1 val signature = getSignature(packageInfo) val requestedPermissions = packageInfo.requestedPermissions?.asSequence().orEmpty() val permissionFlags = packageInfo.requestedPermissionsFlags?.asSequence().orEmpty() val activePermissions = (requestedPermissions zip permissionFlags) .filter { (permission, flag) -> flag and REQUESTED_PERMISSION_GRANTED != 0 } .mapTo(mutableSetOf()) { (permission, flag) -> permission } return CallerPackageInfo(appName, callingPackage, uid, signature, activePermissions.toSet()) } /** * Looks up the [PackageInfo] for a package name. * This requests both the signatures (for checking if an app is on the allow list) and * the app's permissions, which allow for more flexibility in the allow list. * * @return [PackageInfo] for the package name or null if it's not found. */ @Suppress("deprecation") @SuppressLint("PackageManagerGetSignatures") private fun getPackageInfo(callingPackage: String): PackageInfo? = packageManager.getPackageInfo( callingPackage, PackageManager.GET_SIGNATURES or PackageManager.GET_PERMISSIONS ) /** * Gets the signature of a given package's [PackageInfo]. * * The "signature" is a SHA-256 hash of the public key of the signing certificate used by * the app. * * If the app is not found, or if the app does not have exactly one signature, this method * returns `null` as the signature. */ @Suppress("deprecation") private fun getSignature(packageInfo: PackageInfo): String? = if (packageInfo.signatures == null || packageInfo.signatures!!.size != 1) { // Security best practices dictate that an app should be signed with exactly one (1) // signature. Because of this, if there are multiple signatures, reject it. null } else { val certificate = packageInfo.signatures!![0].toByteArray() getSignatureSha256(certificate) } /** * Finds the Android platform signing key signature. This key is never null. */ private fun getSystemSignature(): String = getPackageInfo(ANDROID_PLATFORM)?.let { platformInfo -> getSignature(platformInfo) } ?: error("Platform signature not found") /** * Creates a SHA-256 signature given a certificate byte array. */ private fun getSignatureSha256(certificate: ByteArray): String { val md: MessageDigest try { md = MessageDigest.getInstance("SHA256") } catch (noSuchAlgorithmException: NoSuchAlgorithmException) { Log.e(TAG, "No such algorithm: $noSuchAlgorithmException") throw RuntimeException("Could not find SHA256 hash algorithm", noSuchAlgorithmException) } md.update(certificate) // This code takes the byte array generated by `md.digest()` and joins each of the bytes // to a string, applying the string format `%02x` on each digit before it's appended, with // a colon (':') between each of the items. // For example: input=[0,2,4,6,8,10,12], output="00:02:04:06:08:0a:0c" return md.digest().joinToString(":") { String.format("%02x", it) } } /** * Convenience class to hold all of the information about an app that's being checked * to see if it's a known caller. */ private data class CallerPackageInfo( val name: String, val packageName: String, val uid: Int, val signature: String?, val permissions: Set ) } private const val TAG = "PackageValidator" private const val ANDROID_PLATFORM = "android" ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.java ================================================ package org.schabi.newpipe.player.mediaitem; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.util.image.ImageStrategy; import java.util.List; import java.util.Optional; import androidx.annotation.NonNull; import androidx.annotation.Nullable; /** * This {@link MediaItemTag} object is designed to contain metadata for a stream * that has failed to load. It supplies metadata from an underlying * {@link PlayQueueItem}, which is used by the internal players to resolve actual * playback info. * * This {@link MediaItemTag} does not contain any {@link StreamInfo} that can be * used to start playback and can be detected by checking {@link ExceptionTag#getErrors()} * when in generic form. **/ public final class ExceptionTag implements MediaItemTag { @NonNull private final PlayQueueItem item; @NonNull private final List errors; @Nullable private final Object extras; private ExceptionTag(@NonNull final PlayQueueItem item, @NonNull final List errors, @Nullable final Object extras) { this.item = item; this.errors = errors; this.extras = extras; } public static ExceptionTag of(@NonNull final PlayQueueItem playQueueItem, @NonNull final List errors) { return new ExceptionTag(playQueueItem, errors, null); } @NonNull @Override public List getErrors() { return errors; } @Override public int getServiceId() { return item.getServiceId(); } @Override public String getTitle() { return item.getTitle(); } @Override public String getUploaderName() { return item.getUploader(); } @Override public long getDurationSeconds() { return item.getDuration(); } @Override public String getStreamUrl() { return item.getUrl(); } @Override public String getThumbnailUrl() { return ImageStrategy.choosePreferredImage(item.getThumbnails()); } @Override public String getUploaderUrl() { return item.getUploaderUrl(); } @Override public StreamType getStreamType() { return item.getStreamType(); } @Override public Optional getMaybeExtras(@NonNull final Class type) { return Optional.ofNullable(extras).map(type::cast); } @Override public MediaItemTag withExtras(@NonNull final T extra) { return new ExceptionTag(item, errors, extra); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java ================================================ package org.schabi.newpipe.player.mediaitem; import android.net.Uri; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.MediaItem.RequestMetadata; import com.google.android.exoplayer2.MediaMetadata; import com.google.android.exoplayer2.Player; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.VideoStream; import java.util.List; import java.util.Optional; import java.util.UUID; import androidx.annotation.NonNull; import androidx.annotation.Nullable; /** * Metadata container and accessor used by player internals. * * This interface ensures consistency of fetching metadata on each stream, * which is encapsulated in a {@link MediaItem} and delivered via ExoPlayer's * {@link Player.Listener} on event triggers to the downstream users. **/ public interface MediaItemTag { List getErrors(); int getServiceId(); String getTitle(); String getUploaderName(); long getDurationSeconds(); String getStreamUrl(); String getThumbnailUrl(); String getUploaderUrl(); StreamType getStreamType(); @NonNull default Optional getMaybeStreamInfo() { return Optional.empty(); } @NonNull default Optional getMaybeQuality() { return Optional.empty(); } @NonNull default Optional getMaybeAudioTrack() { return Optional.empty(); } Optional getMaybeExtras(@NonNull Class type); MediaItemTag withExtras(@NonNull T extra); @NonNull static Optional from(@Nullable final MediaItem mediaItem) { return Optional.ofNullable(mediaItem) .map(item -> item.localConfiguration) .map(localConfiguration -> localConfiguration.tag) .filter(MediaItemTag.class::isInstance) .map(MediaItemTag.class::cast); } @NonNull default String makeMediaId() { return UUID.randomUUID().toString() + "[" + getTitle() + "]"; } @NonNull default MediaItem asMediaItem() { final String thumbnailUrl = getThumbnailUrl(); final MediaMetadata mediaMetadata = new MediaMetadata.Builder() .setArtworkUri(thumbnailUrl == null ? null : Uri.parse(thumbnailUrl)) .setArtist(getUploaderName()) .setDescription(getTitle()) .setDisplayTitle(getTitle()) .setTitle(getTitle()) .build(); final RequestMetadata requestMetaData = new RequestMetadata.Builder() .setMediaUri(Uri.parse(getStreamUrl())) .build(); return MediaItem.fromUri(getStreamUrl()) .buildUpon() .setMediaId(makeMediaId()) .setMediaMetadata(mediaMetadata) .setRequestMetadata(requestMetaData) .setTag(this) .build(); } final class Quality { @NonNull private final List sortedVideoStreams; private final int selectedVideoStreamIndex; private Quality(@NonNull final List sortedVideoStreams, final int selectedVideoStreamIndex) { this.sortedVideoStreams = sortedVideoStreams; this.selectedVideoStreamIndex = selectedVideoStreamIndex; } static Quality of(@NonNull final List sortedVideoStreams, final int selectedVideoStreamIndex) { return new Quality(sortedVideoStreams, selectedVideoStreamIndex); } @NonNull public List getSortedVideoStreams() { return sortedVideoStreams; } public int getSelectedVideoStreamIndex() { return selectedVideoStreamIndex; } @Nullable public VideoStream getSelectedVideoStream() { return selectedVideoStreamIndex < 0 || selectedVideoStreamIndex >= sortedVideoStreams.size() ? null : sortedVideoStreams.get(selectedVideoStreamIndex); } } final class AudioTrack { @NonNull private final List audioStreams; private final int selectedAudioStreamIndex; private AudioTrack(@NonNull final List audioStreams, final int selectedAudioStreamIndex) { this.audioStreams = audioStreams; this.selectedAudioStreamIndex = selectedAudioStreamIndex; } static AudioTrack of(@NonNull final List audioStreams, final int selectedAudioStreamIndex) { return new AudioTrack(audioStreams, selectedAudioStreamIndex); } @NonNull public List getAudioStreams() { return audioStreams; } public int getSelectedAudioStreamIndex() { return selectedAudioStreamIndex; } @Nullable public AudioStream getSelectedAudioStream() { return selectedAudioStreamIndex < 0 || selectedAudioStreamIndex >= audioStreams.size() ? null : audioStreams.get(selectedAudioStreamIndex); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/mediaitem/PlaceholderTag.java ================================================ package org.schabi.newpipe.player.mediaitem; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.util.Constants; import java.util.Collections; import java.util.List; import java.util.Optional; import androidx.annotation.NonNull; import androidx.annotation.Nullable; /** * This is a Placeholding {@link MediaItemTag}, designed as a dummy metadata object for * any stream that has not been resolved. * * This object cannot be instantiated and does not hold real metadata of any form. * */ public final class PlaceholderTag implements MediaItemTag { public static final PlaceholderTag EMPTY = new PlaceholderTag(null); private static final String UNKNOWN_VALUE_INTERNAL = "Placeholder"; @Nullable private final Object extras; private PlaceholderTag(@Nullable final Object extras) { this.extras = extras; } @NonNull @Override public List getErrors() { return Collections.emptyList(); } @Override public int getServiceId() { return Constants.NO_SERVICE_ID; } @Override public String getTitle() { return UNKNOWN_VALUE_INTERNAL; } @Override public String getUploaderName() { return UNKNOWN_VALUE_INTERNAL; } @Override public long getDurationSeconds() { return 0; } @Override public String getStreamUrl() { return UNKNOWN_VALUE_INTERNAL; } @Override public String getThumbnailUrl() { return UNKNOWN_VALUE_INTERNAL; } @Override public String getUploaderUrl() { return UNKNOWN_VALUE_INTERNAL; } @Override public StreamType getStreamType() { return StreamType.NONE; } @Override public Optional getMaybeExtras(@NonNull final Class type) { return Optional.ofNullable(extras).map(type::cast); } @Override public MediaItemTag withExtras(@NonNull final T extra) { return new PlaceholderTag(extra); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java ================================================ package org.schabi.newpipe.player.mediaitem; import com.google.android.exoplayer2.MediaItem; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.util.image.ImageStrategy; import java.util.Collections; import java.util.List; import java.util.Optional; import androidx.annotation.NonNull; import androidx.annotation.Nullable; /** * This {@link MediaItemTag} object contains metadata for a resolved stream * that is ready for playback. This object guarantees the {@link StreamInfo} * is available and may provide the {@link Quality} of video stream used in * the {@link MediaItem}. **/ public final class StreamInfoTag implements MediaItemTag { @NonNull private final StreamInfo streamInfo; @Nullable private final MediaItemTag.Quality quality; @Nullable private final MediaItemTag.AudioTrack audioTrack; @Nullable private final Object extras; private StreamInfoTag(@NonNull final StreamInfo streamInfo, @Nullable final MediaItemTag.Quality quality, @Nullable final MediaItemTag.AudioTrack audioTrack, @Nullable final Object extras) { this.streamInfo = streamInfo; this.quality = quality; this.audioTrack = audioTrack; this.extras = extras; } public static StreamInfoTag of(@NonNull final StreamInfo streamInfo, @NonNull final List sortedVideoStreams, final int selectedVideoStreamIndex, @NonNull final List audioStreams, final int selectedAudioStreamIndex) { final Quality quality = Quality.of(sortedVideoStreams, selectedVideoStreamIndex); final AudioTrack audioTrack = AudioTrack.of(audioStreams, selectedAudioStreamIndex); return new StreamInfoTag(streamInfo, quality, audioTrack, null); } public static StreamInfoTag of(@NonNull final StreamInfo streamInfo, @NonNull final List audioStreams, final int selectedAudioStreamIndex) { final AudioTrack audioTrack = AudioTrack.of(audioStreams, selectedAudioStreamIndex); return new StreamInfoTag(streamInfo, null, audioTrack, null); } public static StreamInfoTag of(@NonNull final StreamInfo streamInfo) { return new StreamInfoTag(streamInfo, null, null, null); } @Override public List getErrors() { return Collections.emptyList(); } @Override public int getServiceId() { return streamInfo.getServiceId(); } @Override public String getTitle() { return streamInfo.getName(); } @Override public String getUploaderName() { return streamInfo.getUploaderName(); } @Override public long getDurationSeconds() { return streamInfo.getDuration(); } @Override public String getStreamUrl() { return streamInfo.getUrl(); } @Override public String getThumbnailUrl() { return ImageStrategy.choosePreferredImage(streamInfo.getThumbnails()); } @Override public String getUploaderUrl() { return streamInfo.getUploaderUrl(); } @Override public StreamType getStreamType() { return streamInfo.getStreamType(); } @NonNull @Override public Optional getMaybeStreamInfo() { return Optional.of(streamInfo); } @NonNull @Override public Optional getMaybeQuality() { return Optional.ofNullable(quality); } @NonNull @Override public Optional getMaybeAudioTrack() { return Optional.ofNullable(audioTrack); } @Override public Optional getMaybeExtras(@NonNull final Class type) { return Optional.ofNullable(extras).map(type::cast); } @Override public StreamInfoTag withExtras(@NonNull final Object extra) { return new StreamInfoTag(streamInfo, quality, audioTrack, extra); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java ================================================ package org.schabi.newpipe.player.mediasession; import static org.schabi.newpipe.MainActivity.DEBUG; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_RECREATE_NOTIFICATION; import android.content.Intent; import android.content.SharedPreferences; import android.graphics.Bitmap; import android.os.Build; import android.support.v4.media.MediaMetadataCompat; import android.support.v4.media.session.MediaSessionCompat; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.media.session.MediaButtonReceiver; import com.google.android.exoplayer2.ForwardingPlayer; import com.google.android.exoplayer2.Player.RepeatMode; import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.notification.NotificationActionData; import org.schabi.newpipe.player.notification.NotificationConstants; import org.schabi.newpipe.player.ui.PlayerUi; import org.schabi.newpipe.player.ui.VideoPlayerUi; import org.schabi.newpipe.util.StreamTypeUtil; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; import java.util.stream.IntStream; public class MediaSessionPlayerUi extends PlayerUi implements SharedPreferences.OnSharedPreferenceChangeListener { private static final String TAG = "MediaSessUi"; @NonNull private final MediaSessionCompat mediaSession; @NonNull private final MediaSessionConnector sessionConnector; private final String ignoreHardwareMediaButtonsKey; private boolean shouldIgnoreHardwareMediaButtons = false; // used to check whether any notification action changed, before sending costly updates private List prevNotificationActions = List.of(); public MediaSessionPlayerUi(@NonNull final Player player, @NonNull final MediaSessionCompat mediaSession, @NonNull final MediaSessionConnector sessionConnector) { super(player); this.mediaSession = mediaSession; this.sessionConnector = sessionConnector; this.ignoreHardwareMediaButtonsKey = context.getString(R.string.ignore_hardware_media_buttons_key); } @Override public void initPlayer() { super.initPlayer(); destroyPlayer(); // release previously used resources mediaSession.setActive(true); sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, player)); sessionConnector.setPlayer(getForwardingPlayer()); // It seems like events from the Media Control UI in the notification area don't go through // this function, so it's safe to just ignore all events in case we want to ignore the // hardware media buttons. Returning true stops all further event processing of the system. sessionConnector.setMediaButtonEventHandler((p, i) -> shouldIgnoreHardwareMediaButtons); // listen to changes to ignore_hardware_media_buttons_key updateShouldIgnoreHardwareMediaButtons(player.getPrefs()); player.getPrefs().registerOnSharedPreferenceChangeListener(this); sessionConnector.setMetadataDeduplicationEnabled(true); sessionConnector.setMediaMetadataProvider(exoPlayer -> buildMediaMetadata()); // force updating media session actions by resetting the previous ones prevNotificationActions = List.of(); updateMediaSessionActions(); } @Override public void destroyPlayer() { super.destroyPlayer(); player.getPrefs().unregisterOnSharedPreferenceChangeListener(this); sessionConnector.setMediaButtonEventHandler(null); sessionConnector.setPlayer(null); sessionConnector.setQueueNavigator(null); mediaSession.setActive(false); prevNotificationActions = List.of(); } @Override public void onThumbnailLoaded(@Nullable final Bitmap bitmap) { super.onThumbnailLoaded(bitmap); // the thumbnail is now loaded: invalidate the metadata to trigger a metadata update sessionConnector.invalidateMediaSessionMetadata(); } @Override public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, final String key) { if (key == null || key.equals(ignoreHardwareMediaButtonsKey)) { updateShouldIgnoreHardwareMediaButtons(sharedPreferences); } } public void updateShouldIgnoreHardwareMediaButtons(final SharedPreferences sharedPreferences) { shouldIgnoreHardwareMediaButtons = sharedPreferences.getBoolean(ignoreHardwareMediaButtonsKey, false); } public void handleMediaButtonIntent(final Intent intent) { MediaButtonReceiver.handleIntent(mediaSession, intent); } public Optional getSessionToken() { return Optional.ofNullable(mediaSession).map(MediaSessionCompat::getSessionToken); } private ForwardingPlayer getForwardingPlayer() { // ForwardingPlayer means that all media session actions called on this player are // forwarded directly to the connected exoplayer, except for the overridden methods. So // override play and pause since our player adds more functionality to them over exoplayer. return new ForwardingPlayer(player.getExoPlayer()) { @Override public void play() { player.play(); // hide the player controls even if the play command came from the media session player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); } @Override public void pause() { player.pause(); } }; } private MediaMetadataCompat buildMediaMetadata() { if (DEBUG) { Log.d(TAG, "buildMediaMetadata called"); } // set title and artist final MediaMetadataCompat.Builder builder = new MediaMetadataCompat.Builder() .putString(MediaMetadataCompat.METADATA_KEY_TITLE, player.getVideoTitle()) .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, player.getUploaderName()); // set duration (-1 for livestreams or if unknown, see the METADATA_KEY_DURATION docs) final long duration = player.getCurrentStreamInfo() .filter(info -> !StreamTypeUtil.isLiveStream(info.getStreamType())) .map(info -> info.getDuration() * 1000L) .orElse(-1L); builder.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration); // set album art, unless the user asked not to, or there is no thumbnail available final boolean showThumbnail = player.getPrefs().getBoolean( context.getString(R.string.show_thumbnail_key), true); Optional.ofNullable(player.getThumbnail()) .filter(bitmap -> showThumbnail) .ifPresent(bitmap -> { builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap); builder.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, bitmap); }); return builder.build(); } private void updateMediaSessionActions() { // On Android 13+ (or Android T or API 33+) the actions in the player notification can't be // controlled directly anymore, but are instead derived from custom media session actions. // However the system allows customizing only two of these actions, since the other three // are fixed to play-pause-buffering, previous, next. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { // Although setting media session actions on older android versions doesn't seem to // cause any trouble, it also doesn't seem to do anything, so we don't do anything to // save battery. Check out NotificationUtil.updateActions() to see what happens on // older android versions. return; } if (!mediaSession.isActive()) { // mediaSession will be inactive after destroyPlayer is called return; } // only use the fourth and fifth actions (the settings page also shows only the last 2 on // Android 13+) final List newNotificationActions = IntStream.of(3, 4) .map(i -> player.getPrefs().getInt( player.getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]), NotificationConstants.SLOT_DEFAULTS[i])) .mapToObj(action -> NotificationActionData .fromNotificationActionEnum(player, action)) .filter(Objects::nonNull) .collect(Collectors.toList()); // avoid costly notification actions update, if nothing changed from last time if (!newNotificationActions.equals(prevNotificationActions)) { prevNotificationActions = newNotificationActions; sessionConnector.setCustomActionProviders( newNotificationActions.stream() .map(data -> new SessionConnectorActionProvider(data, context)) .toArray(SessionConnectorActionProvider[]::new)); } } @Override public void onBlocked() { super.onBlocked(); updateMediaSessionActions(); } @Override public void onPlaying() { super.onPlaying(); updateMediaSessionActions(); } @Override public void onBuffering() { super.onBuffering(); updateMediaSessionActions(); } @Override public void onPaused() { super.onPaused(); updateMediaSessionActions(); } @Override public void onPausedSeek() { super.onPausedSeek(); updateMediaSessionActions(); } @Override public void onCompleted() { super.onCompleted(); updateMediaSessionActions(); } @Override public void onRepeatModeChanged(@RepeatMode final int repeatMode) { super.onRepeatModeChanged(repeatMode); updateMediaSessionActions(); } @Override public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) { super.onShuffleModeEnabledChanged(shuffleModeEnabled); updateMediaSessionActions(); } @Override public void onBroadcastReceived(final Intent intent) { super.onBroadcastReceived(intent); if (ACTION_RECREATE_NOTIFICATION.equals(intent.getAction())) { // the notification actions changed updateMediaSessionActions(); } } @Override public void onMetadataChanged(@NonNull final StreamInfo info) { super.onMetadataChanged(info); updateMediaSessionActions(); } @Override public void onPlayQueueEdited() { super.onPlayQueueEdited(); updateMediaSessionActions(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java ================================================ package org.schabi.newpipe.player.mediasession; import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_NEXT; import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS; import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM; import android.net.Uri; import android.os.Bundle; import android.os.ResultReceiver; import android.support.v4.media.MediaDescriptionCompat; import android.support.v4.media.MediaMetadataCompat; import android.support.v4.media.session.MediaSessionCompat; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; import com.google.android.exoplayer2.util.Util; import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.util.image.ImageStrategy; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Optional; public class PlayQueueNavigator implements MediaSessionConnector.QueueNavigator { private static final int MAX_QUEUE_SIZE = 10; private final MediaSessionCompat mediaSession; private final Player player; private long activeQueueItemId; public PlayQueueNavigator(@NonNull final MediaSessionCompat mediaSession, @NonNull final Player player) { this.mediaSession = mediaSession; this.player = player; this.activeQueueItemId = MediaSessionCompat.QueueItem.UNKNOWN_ID; } @Override public long getSupportedQueueNavigatorActions( @Nullable final com.google.android.exoplayer2.Player exoPlayer) { return ACTION_SKIP_TO_NEXT | ACTION_SKIP_TO_PREVIOUS | ACTION_SKIP_TO_QUEUE_ITEM; } @Override public void onTimelineChanged(@NonNull final com.google.android.exoplayer2.Player exoPlayer) { publishFloatingQueueWindow(); } @Override public void onCurrentMediaItemIndexChanged( @NonNull final com.google.android.exoplayer2.Player exoPlayer) { if (activeQueueItemId == MediaSessionCompat.QueueItem.UNKNOWN_ID || exoPlayer.getCurrentTimeline().getWindowCount() > MAX_QUEUE_SIZE) { publishFloatingQueueWindow(); } else if (!exoPlayer.getCurrentTimeline().isEmpty()) { activeQueueItemId = exoPlayer.getCurrentMediaItemIndex(); } } @Override public long getActiveQueueItemId( @Nullable final com.google.android.exoplayer2.Player exoPlayer) { return Optional.ofNullable(player.getPlayQueue()).map(PlayQueue::getIndex).orElse(-1); } @Override public void onSkipToPrevious(@NonNull final com.google.android.exoplayer2.Player exoPlayer) { player.playPrevious(); } @Override public void onSkipToQueueItem(@NonNull final com.google.android.exoplayer2.Player exoPlayer, final long id) { if (player.getPlayQueue() != null) { player.selectQueueItem(player.getPlayQueue().getItem((int) id)); } } @Override public void onSkipToNext(@NonNull final com.google.android.exoplayer2.Player exoPlayer) { player.playNext(); } private void publishFloatingQueueWindow() { final int windowCount = Optional.ofNullable(player.getPlayQueue()) .map(PlayQueue::size) .orElse(0); if (windowCount == 0) { mediaSession.setQueue(Collections.emptyList()); activeQueueItemId = MediaSessionCompat.QueueItem.UNKNOWN_ID; return; } // Yes this is almost a copypasta, got a problem with that? =\ final int currentWindowIndex = player.getPlayQueue().getIndex(); final int queueSize = Math.min(MAX_QUEUE_SIZE, windowCount); final int startIndex = Util.constrainValue(currentWindowIndex - ((queueSize - 1) / 2), 0, windowCount - queueSize); final List queue = new ArrayList<>(); for (int i = startIndex; i < startIndex + queueSize; i++) { queue.add(new MediaSessionCompat.QueueItem(getQueueMetadata(i), i)); } mediaSession.setQueue(queue); activeQueueItemId = currentWindowIndex; } public MediaDescriptionCompat getQueueMetadata(final int index) { if (player.getPlayQueue() == null) { return null; } final PlayQueueItem item = player.getPlayQueue().getItem(index); if (item == null) { return null; } final MediaDescriptionCompat.Builder descBuilder = new MediaDescriptionCompat.Builder() .setMediaId(String.valueOf(index)) .setTitle(item.getTitle()) .setSubtitle(item.getUploader()); // set additional metadata for A2DP/AVRCP (Audio/Video Bluetooth profiles) final Bundle additionalMetadata = new Bundle(); additionalMetadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, item.getTitle()); additionalMetadata.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, item.getUploader()); additionalMetadata .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, item.getDuration() * 1000); additionalMetadata.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, index + 1L); additionalMetadata .putLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS, player.getPlayQueue().size()); descBuilder.setExtras(additionalMetadata); try { descBuilder.setIconUri(Uri.parse( ImageStrategy.choosePreferredImage(item.getThumbnails()))); } catch (final Throwable e) { // no thumbnail available at all, or the user disabled image loading, // or the obtained url is not a valid `Uri` } return descBuilder.build(); } @Override public boolean onCommand(@NonNull final com.google.android.exoplayer2.Player exoPlayer, @NonNull final String command, @Nullable final Bundle extras, @Nullable final ResultReceiver cb) { return false; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.java ================================================ package org.schabi.newpipe.player.mediasession; import android.content.Context; import android.content.Intent; import android.os.Bundle; import android.support.v4.media.session.PlaybackStateCompat; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; import org.schabi.newpipe.player.notification.NotificationActionData; import java.lang.ref.WeakReference; public class SessionConnectorActionProvider implements MediaSessionConnector.CustomActionProvider { private final NotificationActionData data; @NonNull private final WeakReference context; public SessionConnectorActionProvider(final NotificationActionData notificationActionData, @NonNull final Context context) { this.data = notificationActionData; this.context = new WeakReference<>(context); } @Override public void onCustomAction(@NonNull final Player player, @NonNull final String action, @Nullable final Bundle extras) { final Context actualContext = context.get(); if (actualContext != null) { actualContext.sendBroadcast(new Intent(action)); } } @Nullable @Override public PlaybackStateCompat.CustomAction getCustomAction(@NonNull final Player player) { return new PlaybackStateCompat.CustomAction.Builder( data.action(), data.name(), data.icon() ).build(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java ================================================ package org.schabi.newpipe.player.mediasource; import android.util.Log; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.PlaybackException; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.BaseMediaSource; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.SilenceMediaSource; import com.google.android.exoplayer2.source.SinglePeriodTimeline; import com.google.android.exoplayer2.upstream.Allocator; import com.google.android.exoplayer2.upstream.TransferListener; import org.schabi.newpipe.player.mediaitem.ExceptionTag; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import java.io.IOException; import java.util.List; import java.util.concurrent.TimeUnit; import androidx.annotation.NonNull; import androidx.annotation.Nullable; public class FailedMediaSource extends BaseMediaSource implements ManagedMediaSource { /** * Play 2 seconds of silenced audio when a stream fails to resolve due to a known issue, * such as {@link org.schabi.newpipe.extractor.exceptions.ExtractionException}. * * This silence duration allows user to react and have time to jump to a previous stream, * while still provide a smooth playback experience. A duration lower than 1 second is * not recommended, it may cause ExoPlayer to buffer for a while. * */ public static final long SILENCE_DURATION_US = TimeUnit.SECONDS.toMicros(2); public static final MediaPeriod SILENT_MEDIA = makeSilentMediaPeriod(SILENCE_DURATION_US); private final String TAG = "FailedMediaSource@" + Integer.toHexString(hashCode()); private final PlayQueueItem playQueueItem; private final Exception error; private final long retryTimestamp; private final MediaItem mediaItem; /** * Fail the play queue item associated with this source, with potential future retries. * * The error will be propagated if the cause for load exception is unspecified. * This means the error might be caused by reasons outside of extraction (e.g. no network). * Otherwise, a silenced stream will play instead. * * @param playQueueItem play queue item * @param error exception that was the reason to fail * @param retryTimestamp epoch timestamp when this MediaSource can be refreshed */ public FailedMediaSource(@NonNull final PlayQueueItem playQueueItem, @NonNull final Exception error, final long retryTimestamp) { this.playQueueItem = playQueueItem; this.error = error; this.retryTimestamp = retryTimestamp; this.mediaItem = ExceptionTag.of(playQueueItem, List.of(error)).withExtras(this) .asMediaItem(); } public static FailedMediaSource of(@NonNull final PlayQueueItem playQueueItem, @NonNull final FailedMediaSourceException error) { return new FailedMediaSource(playQueueItem, error, Long.MAX_VALUE); } public static FailedMediaSource of(@NonNull final PlayQueueItem playQueueItem, @NonNull final Exception error, final long retryWaitMillis) { return new FailedMediaSource(playQueueItem, error, System.currentTimeMillis() + retryWaitMillis); } public PlayQueueItem getStream() { return playQueueItem; } public Exception getError() { return error; } private boolean canRetry() { return System.currentTimeMillis() >= retryTimestamp; } @Override public MediaItem getMediaItem() { return mediaItem; } /** * Prepares the source with {@link Timeline} info on the silence playback when the error * is classed as {@link FailedMediaSourceException}, for example, when the error is * {@link org.schabi.newpipe.extractor.exceptions.ExtractionException ExtractionException}. * These types of error are swallowed by {@link FailedMediaSource}, and the underlying * exception is carried to the {@link MediaItem} metadata during playback. *

* If the exception is not known, e.g. {@link java.net.UnknownHostException} or some * other network issue, then no source info is refreshed and * {@link #maybeThrowSourceInfoRefreshError()} be will triggered. *

* Note that this method is called only once until {@link #releaseSourceInternal()} is called, * so if no action is done in here, playback will stall unless * {@link #maybeThrowSourceInfoRefreshError()} is called. * * @param mediaTransferListener No data transfer listener needed, ignored here. */ @Override protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) { Log.e(TAG, "Loading failed source: ", error); if (error instanceof FailedMediaSourceException) { refreshSourceInfo(makeSilentMediaTimeline(SILENCE_DURATION_US, mediaItem)); } } /** * If the error is not known, e.g. network issue, then the exception is not swallowed here in * {@link FailedMediaSource}. The exception is then propagated to the player, which * {@link org.schabi.newpipe.player.Player Player} can react to inside * {@link com.google.android.exoplayer2.Player.Listener#onPlayerError(PlaybackException)}. * * @throws IOException An error which will always result in * {@link com.google.android.exoplayer2.PlaybackException#ERROR_CODE_IO_UNSPECIFIED}. */ @Override public void maybeThrowSourceInfoRefreshError() throws IOException { if (!(error instanceof FailedMediaSourceException)) { throw new IOException(error); } } /** * This method is only called if {@link #prepareSourceInternal(TransferListener)} * refreshes the source info with no exception. All parameters are ignored as this * returns a static and reused piece of silent audio. * * @param id The identifier of the period. * @param allocator An {@link Allocator} from which to obtain media buffer allocations. * @param startPositionUs The expected start position, in microseconds. * @return The common {@link MediaPeriod} holding the silence. */ @Override public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator, final long startPositionUs) { return SILENT_MEDIA; } @Override public void releasePeriod(final MediaPeriod mediaPeriod) { /* Do Nothing (we want to keep re-using the Silent MediaPeriod) */ } @Override protected void releaseSourceInternal() { /* Do Nothing, no clean-up for processing/extra thread is needed by this MediaSource */ } @Override public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity, final boolean isInterruptable) { return newIdentity != playQueueItem || canRetry(); } @Override public boolean isStreamEqual(@NonNull final PlayQueueItem stream) { return playQueueItem == stream; } public static class FailedMediaSourceException extends Exception { FailedMediaSourceException(final String message) { super(message); } FailedMediaSourceException(final Throwable cause) { super(cause); } } public static final class MediaSourceResolutionException extends FailedMediaSourceException { public MediaSourceResolutionException(final String message) { super(message); } } public static final class StreamInfoLoadException extends FailedMediaSourceException { public StreamInfoLoadException(final Throwable cause) { super(cause); } } private static Timeline makeSilentMediaTimeline(final long durationUs, @NonNull final MediaItem mediaItem) { return new SinglePeriodTimeline( durationUs, /* isSeekable= */ true, /* isDynamic= */ false, /* useLiveConfiguration= */ false, /* manifest= */ null, mediaItem); } private static MediaPeriod makeSilentMediaPeriod(final long durationUs) { return new SilenceMediaSource.Factory() .setDurationUs(durationUs) .createMediaSource() .createPeriod(null, null, 0); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java ================================================ package org.schabi.newpipe.player.mediasource; import androidx.annotation.NonNull; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.WrappingMediaSource; import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.playqueue.PlayQueueItem; public class LoadedMediaSource extends WrappingMediaSource implements ManagedMediaSource { private final PlayQueueItem stream; private final MediaItem mediaItem; private final long expireTimestamp; /** * Uses a {@link WrappingMediaSource} to wrap one child {@link MediaSource}s * containing actual media. This wrapper {@link LoadedMediaSource} holds the expiration * timestamp as a {@link ManagedMediaSource} to allow explicit playlist management under * {@link ManagedMediaSourcePlaylist}. * * @param source The child media source with actual media. * @param tag Metadata for the child media source. * @param stream The queue item associated with the media source. * @param expireTimestamp The timestamp when the media source expires and might not be * available for playback. */ public LoadedMediaSource(@NonNull final MediaSource source, @NonNull final MediaItemTag tag, @NonNull final PlayQueueItem stream, final long expireTimestamp) { super(source); this.stream = stream; this.expireTimestamp = expireTimestamp; this.mediaItem = tag.withExtras(this).asMediaItem(); } public PlayQueueItem getStream() { return stream; } private boolean isExpired() { return System.currentTimeMillis() >= expireTimestamp; } @NonNull @Override public MediaItem getMediaItem() { return mediaItem; } @Override public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity, final boolean isInterruptable) { return newIdentity != stream || (isInterruptable && isExpired()); } @Override public boolean isStreamEqual(@NonNull final PlayQueueItem otherStream) { return this.stream == otherStream; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java ================================================ package org.schabi.newpipe.player.mediasource; import androidx.annotation.NonNull; import com.google.android.exoplayer2.source.MediaSource; import org.schabi.newpipe.player.playqueue.PlayQueueItem; public interface ManagedMediaSource extends MediaSource { /** * Determines whether or not this {@link ManagedMediaSource} can be replaced. * * @param newIdentity a stream the {@link ManagedMediaSource} should encapsulate over, if * it is different from the existing stream in the * {@link ManagedMediaSource}, then it should be replaced. * @param isInterruptable specifies if this {@link ManagedMediaSource} potentially * being played. * @return whether this could be replaces */ boolean shouldBeReplacedWith(@NonNull PlayQueueItem newIdentity, boolean isInterruptable); /** * Determines if the {@link PlayQueueItem} is the one the * {@link ManagedMediaSource} encapsulates over. * * @param stream play queue item to check * @return whether this source is for the specified stream */ boolean isStreamEqual(@NonNull PlayQueueItem stream); } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java ================================================ package org.schabi.newpipe.player.mediasource; import android.os.Handler; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.android.exoplayer2.source.ConcatenatingMediaSource; import com.google.android.exoplayer2.source.ShuffleOrder; import org.schabi.newpipe.player.mediaitem.MediaItemTag; public class ManagedMediaSourcePlaylist { @NonNull private final ConcatenatingMediaSource internalSource; public ManagedMediaSourcePlaylist() { internalSource = new ConcatenatingMediaSource(/*isPlaylistAtomic=*/false, new ShuffleOrder.UnshuffledShuffleOrder(0)); } /*////////////////////////////////////////////////////////////////////////// // MediaSource Delegations //////////////////////////////////////////////////////////////////////////*/ public int size() { return internalSource.getSize(); } /** * Returns the {@link ManagedMediaSource} at the given index of the playlist. * If the index is invalid, then null is returned. * * @param index index of {@link ManagedMediaSource} to get from the playlist * @return the {@link ManagedMediaSource} at the given index of the playlist */ @Nullable public ManagedMediaSource get(final int index) { if (index < 0 || index >= size()) { return null; } return MediaItemTag .from(internalSource.getMediaSource(index).getMediaItem()) .flatMap(tag -> tag.getMaybeExtras(ManagedMediaSource.class)) .orElse(null); } @NonNull public ConcatenatingMediaSource getParentMediaSource() { return internalSource; } /*////////////////////////////////////////////////////////////////////////// // Playlist Manipulation //////////////////////////////////////////////////////////////////////////*/ /** * Expands the {@link ConcatenatingMediaSource} by appending it with a * {@link PlaceholderMediaSource}. * * @see #append(ManagedMediaSource) */ public synchronized void expand() { append(PlaceholderMediaSource.COPY); } /** * Appends a {@link ManagedMediaSource} to the end of {@link ConcatenatingMediaSource}. * * @see ConcatenatingMediaSource#addMediaSource * @param source {@link ManagedMediaSource} to append */ public synchronized void append(@NonNull final ManagedMediaSource source) { internalSource.addMediaSource(source); } /** * Removes a {@link ManagedMediaSource} from {@link ConcatenatingMediaSource} * at the given index. If this index is out of bound, then the removal is ignored. * * @see ConcatenatingMediaSource#removeMediaSource(int) * @param index of {@link ManagedMediaSource} to be removed */ public synchronized void remove(final int index) { if (index < 0 || index > internalSource.getSize()) { return; } internalSource.removeMediaSource(index); } /** * Moves a {@link ManagedMediaSource} in {@link ConcatenatingMediaSource} * from the given source index to the target index. If either index is out of bound, * then the call is ignored. * * @see ConcatenatingMediaSource#moveMediaSource(int, int) * @param source original index of {@link ManagedMediaSource} * @param target new index of {@link ManagedMediaSource} */ public synchronized void move(final int source, final int target) { if (source < 0 || target < 0) { return; } if (source >= internalSource.getSize() || target >= internalSource.getSize()) { return; } internalSource.moveMediaSource(source, target); } /** * Invalidates the {@link ManagedMediaSource} at the given index by replacing it * with a {@link PlaceholderMediaSource}. * * @see #update(int, ManagedMediaSource, Handler, Runnable) * @param index index of {@link ManagedMediaSource} to invalidate * @param handler the {@link Handler} to run {@code finalizingAction} * @param finalizingAction a {@link Runnable} which is executed immediately * after the media source has been removed from the playlist */ public synchronized void invalidate(final int index, @Nullable final Handler handler, @Nullable final Runnable finalizingAction) { if (get(index) == PlaceholderMediaSource.COPY) { return; } update(index, PlaceholderMediaSource.COPY, handler, finalizingAction); } /** * Updates the {@link ManagedMediaSource} in {@link ConcatenatingMediaSource} * at the given index with a given {@link ManagedMediaSource}. * * @see #update(int, ManagedMediaSource, Handler, Runnable) * @param index index of {@link ManagedMediaSource} to update * @param source new {@link ManagedMediaSource} to use */ public synchronized void update(final int index, @NonNull final ManagedMediaSource source) { update(index, source, null, /*doNothing=*/null); } /** * Updates the {@link ManagedMediaSource} in {@link ConcatenatingMediaSource} * at the given index with a given {@link ManagedMediaSource}. If the index is out of bound, * then the replacement is ignored. * * @see ConcatenatingMediaSource#addMediaSource * @see ConcatenatingMediaSource#removeMediaSource(int, Handler, Runnable) * @param index index of {@link ManagedMediaSource} to update * @param source new {@link ManagedMediaSource} to use * @param handler the {@link Handler} to run {@code finalizingAction} * @param finalizingAction a {@link Runnable} which is executed immediately * after the media source has been removed from the playlist */ public synchronized void update(final int index, @NonNull final ManagedMediaSource source, @Nullable final Handler handler, @Nullable final Runnable finalizingAction) { if (index < 0 || index >= internalSource.getSize()) { return; } // Add and remove are sequential on the same thread, therefore here, the exoplayer // message queue must receive and process add before remove, effectively treating them // as atomic. // Since the finalizing action occurs strictly after the timeline has completed // all its changes on the playback thread, thus, it is possible, in the meantime, // other calls that modifies the playlist media source occur in between. This makes // it unsafe to call remove as the finalizing action of add. internalSource.addMediaSource(index + 1, source); // Because of the above race condition, it is thus only safe to synchronize the player // in the finalizing action AFTER the removal is complete and the timeline has changed. internalSource.removeMediaSource(index, handler, finalizingAction); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java ================================================ package org.schabi.newpipe.player.mediasource; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.source.CompositeMediaSource; import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.upstream.Allocator; import org.schabi.newpipe.player.mediaitem.PlaceholderTag; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import androidx.annotation.NonNull; final class PlaceholderMediaSource extends CompositeMediaSource implements ManagedMediaSource { public static final PlaceholderMediaSource COPY = new PlaceholderMediaSource(); private static final MediaItem MEDIA_ITEM = PlaceholderTag.EMPTY.withExtras(COPY).asMediaItem(); private PlaceholderMediaSource() { } @Override public MediaItem getMediaItem() { return MEDIA_ITEM; } @Override protected void onChildSourceInfoRefreshed(final Void id, final MediaSource mediaSource, final Timeline timeline) { /* Do nothing, no timeline updates or error will stall playback */ } @Override public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator, final long startPositionUs) { return null; } @Override public void releasePeriod(final MediaPeriod mediaPeriod) { } @Override public boolean shouldBeReplacedWith(@NonNull final PlayQueueItem newIdentity, final boolean isInterruptable) { return true; } @Override public boolean isStreamEqual(@NonNull final PlayQueueItem stream) { return false; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.java ================================================ package org.schabi.newpipe.player.notification; import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_FORWARD; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_REWIND; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_NEXT; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PREVIOUS; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_REPEAT; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_SHUFFLE; import android.annotation.SuppressLint; import android.content.Context; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.schabi.newpipe.R; import org.schabi.newpipe.player.Player; import java.util.Objects; public final class NotificationActionData { @NonNull private final String action; @NonNull private final String name; @DrawableRes private final int icon; public NotificationActionData(@NonNull final String action, @NonNull final String name, @DrawableRes final int icon) { this.action = action; this.name = name; this.icon = icon; } @NonNull public String action() { return action; } @NonNull public String name() { return name; } @DrawableRes public int icon() { return icon; } @SuppressLint("PrivateResource") // we currently use Exoplayer's internal strings and icons @Nullable public static NotificationActionData fromNotificationActionEnum( @NonNull final Player player, @NotificationConstants.Action final int selectedAction ) { final int baseActionIcon = NotificationConstants.ACTION_ICONS[selectedAction]; final Context ctx = player.getContext(); switch (selectedAction) { case NotificationConstants.PREVIOUS: return new NotificationActionData(ACTION_PLAY_PREVIOUS, ctx.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_previous_description), baseActionIcon); case NotificationConstants.NEXT: return new NotificationActionData(ACTION_PLAY_NEXT, ctx.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_next_description), baseActionIcon); case NotificationConstants.REWIND: return new NotificationActionData(ACTION_FAST_REWIND, ctx.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_rewind_description), baseActionIcon); case NotificationConstants.FORWARD: return new NotificationActionData(ACTION_FAST_FORWARD, ctx.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_fastforward_description), baseActionIcon); case NotificationConstants.SMART_REWIND_PREVIOUS: if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) { return new NotificationActionData(ACTION_PLAY_PREVIOUS, ctx.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_previous_description), com.google.android.exoplayer2.ui.R.drawable.exo_notification_previous); } else { return new NotificationActionData(ACTION_FAST_REWIND, ctx.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_rewind_description), com.google.android.exoplayer2.ui.R.drawable.exo_controls_rewind); } case NotificationConstants.SMART_FORWARD_NEXT: if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) { return new NotificationActionData(ACTION_PLAY_NEXT, ctx.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_next_description), com.google.android.exoplayer2.ui.R.drawable.exo_notification_next); } else { return new NotificationActionData(ACTION_FAST_FORWARD, ctx.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_fastforward_description), com.google.android.exoplayer2.ui.R.drawable.exo_controls_fastforward); } case NotificationConstants.PLAY_PAUSE_BUFFERING: if (player.getCurrentState() == Player.STATE_PREFLIGHT || player.getCurrentState() == Player.STATE_BLOCKED || player.getCurrentState() == Player.STATE_BUFFERING) { return new NotificationActionData(ACTION_PLAY_PAUSE, ctx.getString(R.string.notification_action_buffering), R.drawable.ic_hourglass_top); } // fallthrough case NotificationConstants.PLAY_PAUSE: if (player.getCurrentState() == Player.STATE_COMPLETED) { return new NotificationActionData(ACTION_PLAY_PAUSE, ctx.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_pause_description), R.drawable.ic_replay); } else if (player.isPlaying() || player.getCurrentState() == Player.STATE_PREFLIGHT || player.getCurrentState() == Player.STATE_BLOCKED || player.getCurrentState() == Player.STATE_BUFFERING) { return new NotificationActionData(ACTION_PLAY_PAUSE, ctx.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_pause_description), com.google.android.exoplayer2.ui.R.drawable.exo_notification_pause); } else { return new NotificationActionData(ACTION_PLAY_PAUSE, ctx.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_play_description), com.google.android.exoplayer2.ui.R.drawable.exo_notification_play); } case NotificationConstants.REPEAT: if (player.getRepeatMode() == REPEAT_MODE_ALL) { return new NotificationActionData(ACTION_REPEAT, ctx.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_repeat_all_description), com.google.android.exoplayer2.ext.mediasession.R.drawable .exo_media_action_repeat_all); } else if (player.getRepeatMode() == REPEAT_MODE_ONE) { return new NotificationActionData(ACTION_REPEAT, ctx.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_repeat_one_description), com.google.android.exoplayer2.ext.mediasession.R.drawable .exo_media_action_repeat_one); } else /* player.getRepeatMode() == REPEAT_MODE_OFF */ { return new NotificationActionData(ACTION_REPEAT, ctx.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_repeat_off_description), com.google.android.exoplayer2.ext.mediasession.R.drawable .exo_media_action_repeat_off); } case NotificationConstants.SHUFFLE: if (player.getPlayQueue() != null && player.getPlayQueue().isShuffled()) { return new NotificationActionData(ACTION_SHUFFLE, ctx.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_shuffle_on_description), com.google.android.exoplayer2.ui.R.drawable.exo_controls_shuffle_on); } else { return new NotificationActionData(ACTION_SHUFFLE, ctx.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_shuffle_off_description), com.google.android.exoplayer2.ui.R.drawable.exo_controls_shuffle_off); } case NotificationConstants.CLOSE: return new NotificationActionData(ACTION_CLOSE, ctx.getString(R.string.close), R.drawable.ic_close); case NotificationConstants.NOTHING: default: // do nothing return null; } } @Override public boolean equals(@Nullable final Object obj) { return (obj instanceof NotificationActionData other) && this.action.equals(other.action) && this.name.equals(other.name) && this.icon == other.icon; } @Override public int hashCode() { return Objects.hash(action, name, icon); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java ================================================ package org.schabi.newpipe.player.notification; import android.content.Context; import android.content.SharedPreferences; import androidx.annotation.DrawableRes; import androidx.annotation.IntDef; import androidx.annotation.NonNull; import org.schabi.newpipe.App; import org.schabi.newpipe.R; import org.schabi.newpipe.util.Localization; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.Collection; import java.util.List; import java.util.SortedSet; import java.util.TreeSet; public final class NotificationConstants { private NotificationConstants() { } /*////////////////////////////////////////////////////////////////////////// // Intent actions //////////////////////////////////////////////////////////////////////////*/ private static final String BASE_ACTION = App.PACKAGE_NAME + ".player.MainPlayer."; public static final String ACTION_CLOSE = BASE_ACTION + "CLOSE"; public static final String ACTION_PLAY_PAUSE = BASE_ACTION + ".player.MainPlayer.PLAY_PAUSE"; public static final String ACTION_REPEAT = BASE_ACTION + ".player.MainPlayer.REPEAT"; public static final String ACTION_PLAY_NEXT = BASE_ACTION + ".player.MainPlayer.ACTION_PLAY_NEXT"; public static final String ACTION_PLAY_PREVIOUS = BASE_ACTION + ".player.MainPlayer.ACTION_PLAY_PREVIOUS"; public static final String ACTION_FAST_REWIND = BASE_ACTION + ".player.MainPlayer.ACTION_FAST_REWIND"; public static final String ACTION_FAST_FORWARD = BASE_ACTION + ".player.MainPlayer.ACTION_FAST_FORWARD"; public static final String ACTION_SHUFFLE = BASE_ACTION + ".player.MainPlayer.ACTION_SHUFFLE"; public static final String ACTION_RECREATE_NOTIFICATION = BASE_ACTION + ".player.MainPlayer.ACTION_RECREATE_NOTIFICATION"; public static final int NOTHING = 0; public static final int PREVIOUS = 1; public static final int NEXT = 2; public static final int REWIND = 3; public static final int FORWARD = 4; public static final int SMART_REWIND_PREVIOUS = 5; public static final int SMART_FORWARD_NEXT = 6; public static final int PLAY_PAUSE = 7; public static final int PLAY_PAUSE_BUFFERING = 8; public static final int REPEAT = 9; public static final int SHUFFLE = 10; public static final int CLOSE = 11; @Retention(RetentionPolicy.SOURCE) @IntDef({NOTHING, PREVIOUS, NEXT, REWIND, FORWARD, SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT, SHUFFLE, CLOSE}) public @interface Action { } @Action public static final int[] ALL_ACTIONS = {NOTHING, PREVIOUS, NEXT, REWIND, FORWARD, SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT, SHUFFLE, CLOSE}; @DrawableRes public static final int[] ACTION_ICONS = { 0, com.google.android.exoplayer2.ui.R.drawable.exo_icon_previous, com.google.android.exoplayer2.ui.R.drawable.exo_icon_next, com.google.android.exoplayer2.ui.R.drawable.exo_icon_rewind, com.google.android.exoplayer2.ui.R.drawable.exo_icon_fastforward, com.google.android.exoplayer2.ui.R.drawable.exo_icon_previous, com.google.android.exoplayer2.ui.R.drawable.exo_icon_next, R.drawable.ic_pause, R.drawable.ic_hourglass_top, com.google.android.exoplayer2.ui.R.drawable.exo_icon_repeat_all, com.google.android.exoplayer2.ui.R.drawable.exo_icon_shuffle_on, R.drawable.ic_close, }; @Action public static final int[] SLOT_DEFAULTS = { SMART_REWIND_PREVIOUS, PLAY_PAUSE_BUFFERING, SMART_FORWARD_NEXT, REPEAT, CLOSE, }; public static final int[] SLOT_PREF_KEYS = { R.string.notification_slot_0_key, R.string.notification_slot_1_key, R.string.notification_slot_2_key, R.string.notification_slot_3_key, R.string.notification_slot_4_key, }; public static final List SLOT_COMPACT_DEFAULTS = List.of(0, 1, 2); public static final int[] SLOT_COMPACT_PREF_KEYS = { R.string.notification_slot_compact_0_key, R.string.notification_slot_compact_1_key, R.string.notification_slot_compact_2_key, }; public static String getActionName(@NonNull final Context context, @Action final int action) { switch (action) { case PREVIOUS: return context.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_previous_description); case NEXT: return context.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_next_description); case REWIND: return context.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_rewind_description); case FORWARD: return context.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_fastforward_description); case SMART_REWIND_PREVIOUS: return Localization.concatenateStrings( context.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_rewind_description), context.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_previous_description)); case SMART_FORWARD_NEXT: return Localization.concatenateStrings( context.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_fastforward_description), context.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_next_description)); case PLAY_PAUSE: return Localization.concatenateStrings( context.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_play_description), context.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_pause_description)); case PLAY_PAUSE_BUFFERING: return Localization.concatenateStrings( context.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_play_description), context.getString(com.google.android.exoplayer2.ui.R.string .exo_controls_pause_description), context.getString(R.string.notification_action_buffering)); case REPEAT: return context.getString(R.string.notification_action_repeat); case SHUFFLE: return context.getString(R.string.notification_action_shuffle); case CLOSE: return context.getString(R.string.close); case NOTHING: default: return context.getString(R.string.notification_action_nothing); } } /** * @param context the context to use * @param sharedPreferences the shared preferences to query values from * @return a sorted list of the indices of the slots to use as compact slots */ public static Collection getCompactSlotsFromPreferences( @NonNull final Context context, final SharedPreferences sharedPreferences) { final SortedSet compactSlots = new TreeSet<>(); for (int i = 0; i < 3; i++) { final int compactSlot = sharedPreferences.getInt( context.getString(SLOT_COMPACT_PREF_KEYS[i]), Integer.MAX_VALUE); if (compactSlot == Integer.MAX_VALUE) { // settings not yet populated, return default values return SLOT_COMPACT_DEFAULTS; } if (compactSlot >= 0) { // compact slot is < 0 if there are less than 3 checked checkboxes compactSlots.add(compactSlot); } } return compactSlots; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/notification/NotificationPlayerUi.java ================================================ package org.schabi.newpipe.player.notification; import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_NONE; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_RECREATE_NOTIFICATION; import android.content.Intent; import android.graphics.Bitmap; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.android.exoplayer2.Player.RepeatMode; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.ui.PlayerUi; public final class NotificationPlayerUi extends PlayerUi { private final NotificationUtil notificationUtil; public NotificationPlayerUi(@NonNull final Player player) { super(player); notificationUtil = new NotificationUtil(player); } @Override public void destroy() { super.destroy(); notificationUtil.cancelNotificationAndStopForeground(); } @Override public void onThumbnailLoaded(@Nullable final Bitmap bitmap) { super.onThumbnailLoaded(bitmap); notificationUtil.updateThumbnail(); } @Override public void onBlocked() { super.onBlocked(); notificationUtil.createNotificationIfNeededAndUpdate(false); } @Override public void onPlaying() { super.onPlaying(); notificationUtil.createNotificationIfNeededAndUpdate(false); } @Override public void onBuffering() { super.onBuffering(); if (notificationUtil.shouldUpdateBufferingSlot()) { notificationUtil.createNotificationIfNeededAndUpdate(false); } } @Override public void onPaused() { super.onPaused(); // Remove running notification when user does not want minimization to background or popup if (PlayerHelper.getMinimizeOnExitAction(context) == MINIMIZE_ON_EXIT_MODE_NONE && player.videoPlayerSelected()) { notificationUtil.cancelNotificationAndStopForeground(); } else { notificationUtil.createNotificationIfNeededAndUpdate(false); } } @Override public void onPausedSeek() { super.onPausedSeek(); notificationUtil.createNotificationIfNeededAndUpdate(false); } @Override public void onCompleted() { super.onCompleted(); notificationUtil.createNotificationIfNeededAndUpdate(false); } @Override public void onRepeatModeChanged(@RepeatMode final int repeatMode) { super.onRepeatModeChanged(repeatMode); notificationUtil.createNotificationIfNeededAndUpdate(false); } @Override public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) { super.onShuffleModeEnabledChanged(shuffleModeEnabled); notificationUtil.createNotificationIfNeededAndUpdate(false); } @Override public void onBroadcastReceived(final Intent intent) { super.onBroadcastReceived(intent); if (ACTION_RECREATE_NOTIFICATION.equals(intent.getAction())) { notificationUtil.createNotificationIfNeededAndUpdate(true); } } @Override public void onMetadataChanged(@NonNull final StreamInfo info) { super.onMetadataChanged(info); notificationUtil.createNotificationIfNeededAndUpdate(true); } @Override public void onPlayQueueEdited() { super.onPlayQueueEdited(); notificationUtil.createNotificationIfNeededAndUpdate(false); } public void createNotificationAndStartForeground() { notificationUtil.createNotificationAndStartForeground(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java ================================================ package org.schabi.newpipe.player.notification; import static android.app.PendingIntent.FLAG_UPDATE_CURRENT; import static androidx.media.app.NotificationCompat.MediaStyle; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE; import android.annotation.SuppressLint; import android.app.Notification; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.content.pm.ServiceInfo; import android.graphics.Bitmap; import android.os.Build; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationManagerCompat; import androidx.core.app.PendingIntentCompat; import androidx.core.app.ServiceCompat; import androidx.core.content.ContextCompat; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.PlayerIntentType; import org.schabi.newpipe.player.PlayerService; import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi; import org.schabi.newpipe.util.NavigationHelper; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Objects; import java.util.Optional; /** * This is a utility class for player notifications. */ public final class NotificationUtil { private static final String TAG = NotificationUtil.class.getSimpleName(); private static final boolean DEBUG = Player.DEBUG; private static final int NOTIFICATION_ID = 123789; @NotificationConstants.Action private final int[] notificationSlots = NotificationConstants.SLOT_DEFAULTS.clone(); private NotificationManagerCompat notificationManager; private NotificationCompat.Builder notificationBuilder; private final Player player; public NotificationUtil(final Player player) { this.player = player; } ///////////////////////////////////////////////////// // NOTIFICATION ///////////////////////////////////////////////////// /** * Creates the notification if it does not exist already and recreates it if forceRecreate is * true. Updates the notification with the data in the player. * @param forceRecreate whether to force the recreation of the notification even if it already * exists */ public synchronized void createNotificationIfNeededAndUpdate(final boolean forceRecreate) { if (forceRecreate || notificationBuilder == null) { notificationBuilder = createNotification(); } updateNotification(); if (notificationManager.areNotificationsEnabled()) { notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()); } } public synchronized void updateThumbnail() { if (notificationBuilder != null) { if (DEBUG) { Log.d(TAG, "updateThumbnail() called with thumbnail = [" + Integer.toHexString( Optional.ofNullable(player.getThumbnail()).map(Objects::hashCode).orElse(0)) + "], title = [" + player.getVideoTitle() + "]"); } setLargeIcon(notificationBuilder); if (notificationManager.areNotificationsEnabled()) { notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()); } } } private synchronized NotificationCompat.Builder createNotification() { if (DEBUG) { Log.d(TAG, "createNotification()"); } notificationManager = NotificationManagerCompat.from(player.getContext()); // setup media style (compact notification slots and media session) final MediaStyle mediaStyle = new MediaStyle(); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { // notification actions are ignored on Android 13+, and are replaced by code in // MediaSessionPlayerUi final int[] compactSlots = initializeNotificationSlots(); mediaStyle.setShowActionsInCompactView(compactSlots); } player.UIs() .get(MediaSessionPlayerUi.class) .flatMap(MediaSessionPlayerUi::getSessionToken) .ifPresent(mediaStyle::setMediaSession); // setup notification builder final var builder = setupNotificationBuilder(player.getContext(), mediaStyle) .setColorized(player.getPrefs().getBoolean( player.getContext().getString(R.string.notification_colorize_key), true)); // set the initial value for the video thumbnail, updatable with updateNotificationThumbnail setLargeIcon(builder); return builder; } /** * Updates the notification builder and the button icons depending on the playback state. */ private synchronized void updateNotification() { if (DEBUG) { Log.d(TAG, "updateNotification()"); } // also update content intent, in case the user switched players notificationBuilder.setContentIntent(PendingIntentCompat.getActivity(player.getContext(), NOTIFICATION_ID, getIntentForNotification(), FLAG_UPDATE_CURRENT, false)); notificationBuilder.setContentTitle(player.getVideoTitle()); notificationBuilder.setContentText(player.getUploaderName()); notificationBuilder.setTicker(player.getVideoTitle()); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { // notification actions are ignored on Android 13+, and are replaced by code in // MediaSessionPlayerUi updateActions(notificationBuilder); } } @SuppressLint("RestrictedApi") public boolean shouldUpdateBufferingSlot() { if (notificationBuilder == null) { // if there is no notification active, there is no point in updating it return false; } else if (notificationBuilder.mActions.size() < 3) { // this should never happen, but let's make sure notification actions are populated return true; } // only second and third slot could contain PLAY_PAUSE_BUFFERING, update them only if they // are not already in the buffering state (the only one with a null action intent) return (notificationSlots[1] == NotificationConstants.PLAY_PAUSE_BUFFERING && notificationBuilder.mActions.get(1).actionIntent != null) || (notificationSlots[2] == NotificationConstants.PLAY_PAUSE_BUFFERING && notificationBuilder.mActions.get(2).actionIntent != null); } public static void startForegroundWithDummyNotification(final PlayerService service) { final var builder = setupNotificationBuilder(service, new MediaStyle()); startForeground(service, builder.build()); } public void createNotificationAndStartForeground() { if (notificationBuilder == null) { notificationBuilder = createNotification(); } updateNotification(); startForeground(player.getService(), notificationBuilder.build()); } public void cancelNotificationAndStopForeground() { ServiceCompat.stopForeground(player.getService(), ServiceCompat.STOP_FOREGROUND_REMOVE); if (notificationManager != null) { notificationManager.cancel(NOTIFICATION_ID); } notificationManager = null; notificationBuilder = null; } ///////////////////////////////////////////////////// // STATIC FUNCTIONS IN COMMON BETWEEN DUMMY AND REAL NOTIFICATION ///////////////////////////////////////////////////// private static NotificationCompat.Builder setupNotificationBuilder(final Context context, final MediaStyle style) { return new NotificationCompat.Builder(context, context.getString(R.string.notification_channel_id)) .setStyle(style) .setPriority(NotificationCompat.PRIORITY_HIGH) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setCategory(NotificationCompat.CATEGORY_TRANSPORT) .setShowWhen(false) .setSmallIcon(R.drawable.ic_newpipe_triangle_white) .setColor(ContextCompat.getColor(context, R.color.dark_background_color)) .setDeleteIntent(PendingIntentCompat.getBroadcast(context, NOTIFICATION_ID, new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT, false)); } private static void startForeground(final PlayerService service, final Notification notification) { // ServiceInfo constants are not used below Android Q, so 0 is set here final int serviceType = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ? ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK : 0; ServiceCompat.startForeground(service, NOTIFICATION_ID, notification, serviceType); } ///////////////////////////////////////////////////// // ACTIONS ///////////////////////////////////////////////////// /** * The compact slots array from settings contains indices from 0 to 4, each referring to one of * the five actions configurable by the user. However, if the user sets an action to "Nothing", * then all of the actions coming after will have a "settings index" different than the index * of the corresponding action when sent to the system. * * @return the indices of compact slots referred to the list of non-nothing actions that will be * sent to the system */ private int[] initializeNotificationSlots() { final Collection settingsCompactSlots = NotificationConstants .getCompactSlotsFromPreferences(player.getContext(), player.getPrefs()); final List adjustedCompactSlots = new ArrayList<>(); int nonNothingIndex = 0; for (int i = 0; i < 5; ++i) { notificationSlots[i] = player.getPrefs().getInt( player.getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]), NotificationConstants.SLOT_DEFAULTS[i]); if (notificationSlots[i] != NotificationConstants.NOTHING) { if (settingsCompactSlots.contains(i)) { adjustedCompactSlots.add(nonNothingIndex); } nonNothingIndex += 1; } } return adjustedCompactSlots.stream().mapToInt(Integer::intValue).toArray(); } @SuppressLint("RestrictedApi") private void updateActions(final NotificationCompat.Builder builder) { builder.mActions.clear(); for (int i = 0; i < 5; ++i) { addAction(builder, notificationSlots[i]); } } private void addAction(final NotificationCompat.Builder builder, @NotificationConstants.Action final int slot) { @Nullable final NotificationActionData data = NotificationActionData.fromNotificationActionEnum(player, slot); if (data == null) { return; } final PendingIntent intent = PendingIntentCompat.getBroadcast(player.getContext(), NOTIFICATION_ID, new Intent(data.action()), FLAG_UPDATE_CURRENT, false); builder.addAction(new NotificationCompat.Action(data.icon(), data.name(), intent)); } private Intent getIntentForNotification() { if (player.audioPlayerSelected() || player.popupPlayerSelected()) { // Means we play in popup or audio only. Let's show the play queue return NavigationHelper.getPlayQueueActivityIntent(player.getContext()); } else { // We are playing in fragment. Don't open another activity just show fragment. That's it final Intent intent = NavigationHelper.getPlayerIntent( player.getContext(), MainActivity.class, null, PlayerIntentType.AllOthers); intent.putExtra(Player.RESUME_PLAYBACK, true); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.setAction(Intent.ACTION_MAIN); intent.addCategory(Intent.CATEGORY_LAUNCHER); return intent; } } ///////////////////////////////////////////////////// // BITMAP ///////////////////////////////////////////////////// private void setLargeIcon(final NotificationCompat.Builder builder) { final boolean showThumbnail = player.getPrefs().getBoolean( player.getContext().getString(R.string.show_thumbnail_key), true); final Bitmap thumbnail = player.getThumbnail(); if (thumbnail == null || !showThumbnail) { // since the builder is reused, make sure the thumbnail is unset if there is not one builder.setLargeIcon((Bitmap) null); return; } final boolean scaleImageToSquareAspectRatio = player.getPrefs().getBoolean( player.getContext().getString(R.string.scale_to_square_image_in_notifications_key), false); if (scaleImageToSquareAspectRatio) { builder.setLargeIcon(getBitmapWithSquareAspectRatio(thumbnail)); } else { builder.setLargeIcon(thumbnail); } } private Bitmap getBitmapWithSquareAspectRatio(@NonNull final Bitmap bitmap) { // Find the smaller dimension and then take a center portion of the image that // has that size. final int w = bitmap.getWidth(); final int h = bitmap.getHeight(); final int dstSize = Math.min(w, h); final int x = (w - dstSize) / 2; final int y = (h - dstSize) / 2; return Bitmap.createBitmap(bitmap, x, y, dstSize, dstSize); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java ================================================ package org.schabi.newpipe.player.playback; import android.os.Handler; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.collection.ArraySet; import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.mediasource.FailedMediaSource; import org.schabi.newpipe.player.mediasource.LoadedMediaSource; import org.schabi.newpipe.player.mediasource.ManagedMediaSource; import org.schabi.newpipe.player.mediasource.ManagedMediaSourcePlaylist; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.player.playqueue.PlayQueueEvent.MoveEvent; import org.schabi.newpipe.player.playqueue.PlayQueueEvent; import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RemoveEvent; import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ReorderEvent; import java.util.Collection; import java.util.Collections; import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; 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(); /** * Determines how many streams before and after the current stream should be loaded. * The default value (1) ensures seamless playback under typical network settings. *

* The streams after the current will be loaded into the playlist timeline while the * streams before will only be cached for future usage. *

* * @see #onMediaSourceReceived(PlayQueueItem, ManagedMediaSource) */ private static final int WINDOW_SIZE = 1; /** * Determines the maximum number of disposables allowed in the {@link #loaderReactor}. * Once exceeded, new calls to {@link #loadImmediate()} will evict all disposables in the * {@link #loaderReactor} in order to load a new set of items. * * @see #loadImmediate() * @see #maybeLoadItem(PlayQueueItem) */ private static final int MAXIMUM_LOADER_SIZE = WINDOW_SIZE * 2 + 1; @NonNull private final PlaybackListener playbackListener; @NonNull private final PlayQueue playQueue; /** * Determines the gap time between the playback position and the playback duration which * the {@link #getEdgeIntervalSignal()} begins to request loading. * * @see #progressUpdateIntervalMillis */ private final long playbackNearEndGapMillis; /** * Determines the interval which the {@link #getEdgeIntervalSignal()} waits for between * each request for loading, once {@link #playbackNearEndGapMillis} has reached. */ private final long progressUpdateIntervalMillis; @NonNull private final Observable nearEndIntervalSignal; /** * Process only the last load order when receiving a stream of load orders (lessens I/O). *

* The higher it is, the less loading occurs during rapid noncritical timeline changes. *

*

* Not recommended to go below 100ms. *

* * @see #loadDebounced() */ private final long loadDebounceMillis; @NonNull private final Disposable debouncedLoader; @NonNull private final PublishSubject debouncedSignal; @NonNull private Subscription playQueueReactor; @NonNull private final CompositeDisposable loaderReactor; @NonNull private final Set loadingItems; @NonNull private final AtomicBoolean isBlocked; @NonNull private ManagedMediaSourcePlaylist playlist; private final Handler removeMediaSourceHandler = new Handler(); public MediaSourceManager(@NonNull final PlaybackListener listener, @NonNull final PlayQueue playQueue) { this(listener, playQueue, 400L, /*playbackNearEndGapMillis=*/TimeUnit.MILLISECONDS.convert(30, TimeUnit.SECONDS), /*progressUpdateIntervalMillis*/TimeUnit.MILLISECONDS.convert(2, TimeUnit.SECONDS)); } private MediaSourceManager(@NonNull final PlaybackListener listener, @NonNull final PlayQueue playQueue, final long loadDebounceMillis, final long playbackNearEndGapMillis, final long progressUpdateIntervalMillis) { if (playQueue.getBroadcastReceiver() == null) { throw new IllegalArgumentException("Play Queue has not been initialized."); } if (playbackNearEndGapMillis < progressUpdateIntervalMillis) { throw new IllegalArgumentException("Playback end gap=[" + playbackNearEndGapMillis + " ms] must be longer than update interval=[ " + progressUpdateIntervalMillis + " ms] for them to be useful."); } this.playbackListener = listener; this.playQueue = playQueue; this.playbackNearEndGapMillis = playbackNearEndGapMillis; this.progressUpdateIntervalMillis = progressUpdateIntervalMillis; this.nearEndIntervalSignal = getEdgeIntervalSignal(); this.loadDebounceMillis = loadDebounceMillis; this.debouncedSignal = PublishSubject.create(); this.debouncedLoader = getDebouncedLoader(); this.playQueueReactor = EmptySubscription.INSTANCE; this.loaderReactor = new CompositeDisposable(); this.isBlocked = new AtomicBoolean(false); this.playlist = new ManagedMediaSourcePlaylist(); this.loadingItems = Collections.synchronizedSet(new ArraySet<>()); playQueue.getBroadcastReceiver() .observeOn(AndroidSchedulers.mainThread()) .subscribe(getReactor()); } /*////////////////////////////////////////////////////////////////////////// // Exposed Methods //////////////////////////////////////////////////////////////////////////*/ /** * Dispose the manager and releases all message buses and loaders. */ public void dispose() { if (DEBUG) { Log.d(TAG, "close() called."); } debouncedSignal.onComplete(); debouncedLoader.dispose(); playQueueReactor.cancel(); loaderReactor.dispose(); } /*////////////////////////////////////////////////////////////////////////// // Event Reactor //////////////////////////////////////////////////////////////////////////*/ private Subscriber getReactor() { return new Subscriber<>() { @Override public void onSubscribe(@NonNull final Subscription d) { playQueueReactor.cancel(); playQueueReactor = d; playQueueReactor.request(1); } @Override public void onNext(@NonNull final PlayQueueEvent playQueueMessage) { onPlayQueueChanged(playQueueMessage); } @Override public void onError(@NonNull final Throwable e) { } @Override public void onComplete() { } }; } private void onPlayQueueChanged(final PlayQueueEvent event) { if (playQueue.isEmpty() && playQueue.isComplete()) { playbackListener.onPlaybackShutdown(); return; } // Event specific action switch (event.type()) { case INIT: case ERROR: maybeBlock(); case APPEND: populateSources(); break; case SELECT: maybeRenewCurrentIndex(); break; case REMOVE: final RemoveEvent removeEvent = (RemoveEvent) event; playlist.remove(removeEvent.getRemoveIndex()); break; case MOVE: final MoveEvent moveEvent = (MoveEvent) event; playlist.move(moveEvent.getFromIndex(), moveEvent.getToIndex()); break; case REORDER: // Need to move to ensure the playing index from play queue matches that of // the source timeline, and then window correction can take care of the rest final ReorderEvent reorderEvent = (ReorderEvent) event; playlist.move(reorderEvent.getFromSelectedIndex(), reorderEvent.getToSelectedIndex()); break; case RECOVERY: default: break; } // Loading and Syncing switch (event.type()) { case INIT: case REORDER: case ERROR: case SELECT: loadImmediate(); // low frequency, critical events break; case APPEND: case REMOVE: case MOVE: case RECOVERY: default: loadDebounced(); // high frequency or noncritical events break; } // update ui and notification switch (event.type()) { case APPEND: case REMOVE: case MOVE: case REORDER: playbackListener.onPlayQueueEdited(); } if (!isPlayQueueReady()) { maybeBlock(); playQueue.fetch(); } playQueueReactor.request(1); } /*////////////////////////////////////////////////////////////////////////// // Playback Locking //////////////////////////////////////////////////////////////////////////*/ private boolean isPlayQueueReady() { final boolean isWindowLoaded = playQueue.size() - playQueue.getIndex() > WINDOW_SIZE; return playQueue.isComplete() || isWindowLoaded; } private boolean isPlaybackReady() { if (playlist.size() != playQueue.size()) { return false; } final ManagedMediaSource mediaSource = playlist.get(playQueue.getIndex()); final PlayQueueItem playQueueItem = playQueue.getItem(); if (mediaSource == null || playQueueItem == null) { return false; } return mediaSource.isStreamEqual(playQueueItem); } private void maybeBlock() { if (DEBUG) { Log.d(TAG, "maybeBlock() called."); } if (isBlocked.get()) { return; } playbackListener.onPlaybackBlock(); resetSources(); isBlocked.set(true); } private boolean maybeUnblock() { if (DEBUG) { Log.d(TAG, "maybeUnblock() called."); } if (isBlocked.get()) { isBlocked.set(false); playbackListener.onPlaybackUnblock(playlist.getParentMediaSource()); return true; } return false; } /*////////////////////////////////////////////////////////////////////////// // Metadata Synchronization //////////////////////////////////////////////////////////////////////////*/ private void maybeSync(final boolean wasBlocked) { if (DEBUG) { Log.d(TAG, "maybeSync() called."); } final PlayQueueItem currentItem = playQueue.getItem(); if (isBlocked.get() || currentItem == null) { return; } playbackListener.onPlaybackSynchronize(currentItem, wasBlocked); } private synchronized void maybeSynchronizePlayer() { if (isPlayQueueReady() && isPlaybackReady()) { final boolean isBlockReleased = maybeUnblock(); maybeSync(isBlockReleased); } } /*////////////////////////////////////////////////////////////////////////// // MediaSource Loading //////////////////////////////////////////////////////////////////////////*/ private Observable getEdgeIntervalSignal() { return Observable.interval(progressUpdateIntervalMillis, TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) .filter(ignored -> playbackListener.isApproachingPlaybackEdge(playbackNearEndGapMillis)); } private Disposable getDebouncedLoader() { return debouncedSignal.mergeWith(nearEndIntervalSignal) .debounce(loadDebounceMillis, TimeUnit.MILLISECONDS) .subscribeOn(Schedulers.single()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(timestamp -> loadImmediate()); } private void loadDebounced() { debouncedSignal.onNext(System.currentTimeMillis()); } private void loadImmediate() { if (DEBUG) { Log.d(TAG, "MediaSource - loadImmediate() called"); } final ItemsToLoad itemsToLoad = getItemsToLoad(playQueue); if (itemsToLoad == null) { return; } // Evict the previous items being loaded to free up memory, before start loading new ones maybeClearLoaders(); maybeLoadItem(itemsToLoad.center); for (final PlayQueueItem item : itemsToLoad.neighbors) { maybeLoadItem(item); } } private void maybeLoadItem(@NonNull final PlayQueueItem item) { if (DEBUG) { Log.d(TAG, "maybeLoadItem() called."); } if (playQueue.indexOf(item) >= playlist.size()) { return; } if (!loadingItems.contains(item) && isCorrectionNeeded(item)) { if (DEBUG) { Log.d(TAG, "MediaSource - Loading=[" + item.getTitle() + "] " + "with url=[" + item.getUrl() + "]"); } loadingItems.add(item); final Disposable loader = getLoadedMediaSource(item) .observeOn(AndroidSchedulers.mainThread()) /* No exception handling since getLoadedMediaSource guarantees nonnull return */ .subscribe(mediaSource -> onMediaSourceReceived(item, mediaSource)); loaderReactor.add(loader); } } private Single getLoadedMediaSource(@NonNull final PlayQueueItem stream) { return stream.getStream() .map(streamInfo -> Optional .ofNullable(playbackListener.sourceOf(stream, streamInfo)) .flatMap(source -> MediaItemTag.from(source.getMediaItem()) .map(tag -> { final int serviceId = streamInfo.getServiceId(); final long expiration = System.currentTimeMillis() + getCacheExpirationMillis(serviceId); return new LoadedMediaSource(source, tag, stream, expiration); }) ) .orElseGet(() -> { final String message = "Unable to resolve source from stream info. " + "URL: " + stream.getUrl() + ", audio count: " + streamInfo.getAudioStreams().size() + ", video count: " + streamInfo.getVideoOnlyStreams().size() + ", " + streamInfo.getVideoStreams().size(); return FailedMediaSource.of(stream, new MediaSourceResolutionException(message)); }) ) .onErrorReturn(throwable -> { if (throwable instanceof ExtractionException) { return FailedMediaSource.of(stream, new StreamInfoLoadException(throwable)); } // Non-source related error expected here (e.g. network), // should allow retry shortly after the error. final long allowRetryIn = TimeUnit.MILLISECONDS.convert(3, TimeUnit.SECONDS); return FailedMediaSource.of(stream, new Exception(throwable), allowRetryIn); }); } private void onMediaSourceReceived(@NonNull final PlayQueueItem item, @NonNull final ManagedMediaSource mediaSource) { if (DEBUG) { Log.d(TAG, "MediaSource - Loaded=[" + item.getTitle() + "] with url=[" + item.getUrl() + "]"); } loadingItems.remove(item); final int itemIndex = playQueue.indexOf(item); // Only update the playlist timeline for items at the current index or after. if (isCorrectionNeeded(item)) { if (DEBUG) { Log.d(TAG, "MediaSource - Updating index=[" + itemIndex + "] with " + "title=[" + item.getTitle() + "] at url=[" + item.getUrl() + "]"); } playlist.update(itemIndex, mediaSource, removeMediaSourceHandler, this::maybeSynchronizePlayer); } } /** * Checks if the corresponding MediaSource in * {@link com.google.android.exoplayer2.source.ConcatenatingMediaSource} * for a given {@link PlayQueueItem} needs replacement, either due to gapless playback * readiness or playlist desynchronization. *

* If the given {@link PlayQueueItem} is currently being played and is already loaded, * then correction is not only needed if the playlist is desynchronized. Otherwise, the * check depends on the status (e.g. expiration or placeholder) of the * {@link ManagedMediaSource}. *

* * @param item {@link PlayQueueItem} to check * @return whether a correction is needed */ private boolean isCorrectionNeeded(@NonNull final PlayQueueItem item) { final int index = playQueue.indexOf(item); final ManagedMediaSource mediaSource = playlist.get(index); return mediaSource != null && mediaSource.shouldBeReplacedWith(item, index != playQueue.getIndex()); } /** * Checks if the current playing index contains an expired {@link ManagedMediaSource}. * If so, the expired source is replaced by a dummy {@link ManagedMediaSource} and * {@link #loadImmediate()} is called to reload the current item. *

* If not, then the media source at the current index is ready for playback, and * {@link #maybeSynchronizePlayer()} is called. *

* Under both cases, {@link #maybeSync(boolean)} will be called to ensure the listener * is up-to-date. */ private void maybeRenewCurrentIndex() { final int currentIndex = playQueue.getIndex(); final PlayQueueItem currentItem = playQueue.getItem(); final ManagedMediaSource currentSource = playlist.get(currentIndex); if (currentItem == null || currentSource == null) { return; } if (!currentSource.shouldBeReplacedWith(currentItem, true)) { maybeSynchronizePlayer(); return; } if (DEBUG) { Log.d(TAG, "MediaSource - Reloading currently playing, " + "index=[" + currentIndex + "], item=[" + currentItem.getTitle() + "]"); } playlist.invalidate(currentIndex, removeMediaSourceHandler, this::loadImmediate); } private void maybeClearLoaders() { if (DEBUG) { Log.d(TAG, "MediaSource - maybeClearLoaders() called."); } if (!loadingItems.contains(playQueue.getItem()) && loaderReactor.size() > MAXIMUM_LOADER_SIZE) { loaderReactor.clear(); loadingItems.clear(); } } /*////////////////////////////////////////////////////////////////////////// // MediaSource Playlist Helpers //////////////////////////////////////////////////////////////////////////*/ private void resetSources() { if (DEBUG) { Log.d(TAG, "resetSources() called."); } playlist = new ManagedMediaSourcePlaylist(); } private void populateSources() { if (DEBUG) { Log.d(TAG, "populateSources() called."); } while (playlist.size() < playQueue.size()) { playlist.expand(); } } /*////////////////////////////////////////////////////////////////////////// // Manager Helpers //////////////////////////////////////////////////////////////////////////*/ @Nullable private static ItemsToLoad getItemsToLoad(@NonNull final PlayQueue playQueue) { // The current item has higher priority final int currentIndex = playQueue.getIndex(); final PlayQueueItem currentItem = playQueue.getItem(currentIndex); if (currentItem == null) { return null; } // The rest are just for seamless playback // Although timeline is not updated prior to the current index, these sources are still // loaded into the cache for faster retrieval at a potentially later time. final int leftBound = Math.max(0, currentIndex - MediaSourceManager.WINDOW_SIZE); final int rightLimit = currentIndex + MediaSourceManager.WINDOW_SIZE + 1; final int rightBound = Math.min(playQueue.size(), rightLimit); final Set neighbors = new ArraySet<>( playQueue.getStreams().subList(leftBound, rightBound)); // Do a round robin final int excess = rightLimit - playQueue.size(); if (excess >= 0) { neighbors.addAll(playQueue.getStreams() .subList(0, Math.min(playQueue.size(), excess))); } neighbors.remove(currentItem); return new ItemsToLoad(currentItem, neighbors); } private static class ItemsToLoad { @NonNull private final PlayQueueItem center; @NonNull private final Collection neighbors; ItemsToLoad(@NonNull final PlayQueueItem center, @NonNull final Collection neighbors) { this.center = center; this.neighbors = neighbors; } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java ================================================ package org.schabi.newpipe.player.playback; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.android.exoplayer2.source.MediaSource; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.player.playqueue.PlayQueueItem; public interface PlaybackListener { /** * Called to check if the currently playing stream is approaching the end of its playback. * Implementation should return true when the current playback position is progressing within * timeToEndMillis or less to its playback during. *

* May be called at any time. *

* * @param timeToEndMillis * @return whether the stream is approaching end of playback */ boolean isApproachingPlaybackEdge(long timeToEndMillis); /** * Called when the stream at the current queue index is not ready yet. * Signals to the listener to block the player from playing anything and notify the source * is now invalid. *

* May be called at any time. *

*/ void onPlaybackBlock(); /** * Called when the stream at the current queue index is ready. * Signals to the listener to resume the player by preparing a new source. *

* May be called only when the player is blocked. *

* * @param mediaSource */ void onPlaybackUnblock(MediaSource mediaSource); /** * Called when the queue index is refreshed. * Signals to the listener to synchronize the player's window to the manager's * window. *

* May be called anytime at any amount once unblock is called. *

* * @param item item the player should be playing/synchronized to * @param wasBlocked was the player recently released from blocking state */ void onPlaybackSynchronize(@NonNull PlayQueueItem item, boolean wasBlocked); /** * Requests the listener to resolve a stream info into a media source * according to the listener's implementation (background, popup or main video player). *

* May be called at any time. *

* @param item * @param info * @return the corresponding {@link MediaSource} */ @Nullable MediaSource sourceOf(PlayQueueItem item, StreamInfo info); /** * Called when the play queue can no longer be played or used. * Currently, this means the play queue is empty and complete. * Signals to the listener that it should shutdown. *

* May be called at any time. *

*/ void onPlaybackShutdown(); /** * Called whenever the play queue was edited (items were added, deleted or moved), * use this to e.g. update notification buttons or fragment ui. *

* May be called at any time. *

*/ void onPlayQueueEdited(); } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java ================================================ package org.schabi.newpipe.player.playback; import android.content.Context; import android.view.SurfaceHolder; import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.video.PlaceholderSurface; /** * Prevent error message: 'Unrecoverable player error occurred' * In case of rotation some users see this kind of an error which is preventable * having a Callback that handles the lifecycle of the surface. *

* How?: In case we are no longer able to write to the surface eg. through rotation/putting in * background we set set a DummySurface. Although it it works on API >= 23 only. * Result: we get a little video interruption (audio is still fine) but we won't get the * 'Unrecoverable player error occurred' error message. *

* This implementation is based on: * 'ExoPlayer stuck in buffering after re-adding the surface view a few time #2703' *

* -> exoplayer fix suggestion link * https://github.com/google/ExoPlayer/issues/2703#issuecomment-300599981 */ public final class SurfaceHolderCallback implements SurfaceHolder.Callback { private final Context context; private final Player player; private PlaceholderSurface placeholderSurface; public SurfaceHolderCallback(final Context context, final Player player) { this.context = context; this.player = player; } @Override public void surfaceCreated(final SurfaceHolder holder) { player.setVideoSurface(holder.getSurface()); } @Override public void surfaceChanged(final SurfaceHolder holder, final int format, final int width, final int height) { } @Override public void surfaceDestroyed(final SurfaceHolder holder) { if (placeholderSurface == null) { placeholderSurface = PlaceholderSurface.newInstanceV17(context, false); } player.setVideoSurface(placeholderSurface); } public void release() { if (placeholderSurface != null) { placeholderSurface.release(); placeholderSurface = null; } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java ================================================ package org.schabi.newpipe.player.playqueue; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import java.util.List; import java.util.stream.Collectors; import io.reactivex.rxjava3.core.SingleObserver; import io.reactivex.rxjava3.disposables.Disposable; abstract class AbstractInfoPlayQueue> extends PlayQueue { boolean isInitial; private boolean isComplete; final int serviceId; final String baseUrl; @Nullable Page nextPage; private transient Disposable fetchReactor; protected AbstractInfoPlayQueue(final T info) { this(info, 0); } protected AbstractInfoPlayQueue(final T info, final int index) { this(info.getServiceId(), info.getUrl(), info.getNextPage(), info.getRelatedItems() .stream() .filter(StreamInfoItem.class::isInstance) .map(StreamInfoItem.class::cast) .collect(Collectors.toList()), index); } protected AbstractInfoPlayQueue(final int serviceId, final String url, final Page nextPage, final List streams, final int index) { super(index, extractListItems(streams)); this.baseUrl = url; this.nextPage = nextPage; this.serviceId = serviceId; this.isInitial = streams.isEmpty(); this.isComplete = !isInitial && !Page.isValid(nextPage); } protected abstract String getTag(); @Override public boolean isComplete() { return isComplete; } SingleObserver getHeadListObserver() { return new SingleObserver<>() { @Override public void onSubscribe(@NonNull final Disposable d) { if (isComplete || !isInitial || (fetchReactor != null && !fetchReactor.isDisposed())) { d.dispose(); } else { fetchReactor = d; } } @Override public void onSuccess(@NonNull final T result) { isInitial = false; if (!result.hasNextPage()) { isComplete = true; } nextPage = result.getNextPage(); append(extractListItems(result.getRelatedItems() .stream() .filter(StreamInfoItem.class::isInstance) .map(StreamInfoItem.class::cast) .collect(Collectors.toList()))); fetchReactor.dispose(); fetchReactor = null; } @Override public void onError(@NonNull final Throwable e) { Log.e(getTag(), "Error fetching more playlist, marking playlist as complete.", e); isComplete = true; notifyChange(); } }; } SingleObserver> getNextPageObserver() { return new SingleObserver<>() { @Override public void onSubscribe(@NonNull final Disposable d) { if (isComplete || isInitial || (fetchReactor != null && !fetchReactor.isDisposed())) { d.dispose(); } else { fetchReactor = d; } } @Override public void onSuccess( @NonNull final ListExtractor.InfoItemsPage result) { if (!result.hasNextPage()) { isComplete = true; } nextPage = result.getNextPage(); append(extractListItems(result.getItems() .stream() .filter(StreamInfoItem.class::isInstance) .map(StreamInfoItem.class::cast) .collect(Collectors.toList()))); fetchReactor.dispose(); fetchReactor = null; } @Override public void onError(@NonNull final Throwable e) { Log.e(getTag(), "Error fetching more playlist, marking playlist as complete.", e); isComplete = true; notifyChange(); } }; } @Override public void dispose() { super.dispose(); if (fetchReactor != null) { fetchReactor.dispose(); } fetchReactor = null; } private static List extractListItems(final List infoItems) { return infoItems.stream().map(PlayQueueItem::new).collect(Collectors.toList()); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java ================================================ package org.schabi.newpipe.player.playqueue; import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.util.ExtractorHelper; import java.util.Collections; import java.util.List; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.schedulers.Schedulers; public final class ChannelTabPlayQueue extends AbstractInfoPlayQueue { final ListLinkHandler linkHandler; public ChannelTabPlayQueue(final int serviceId, final ListLinkHandler linkHandler, final Page nextPage, final List streams, final int index) { super(serviceId, linkHandler.getUrl(), nextPage, streams, index); this.linkHandler = linkHandler; } public ChannelTabPlayQueue(final int serviceId, final ListLinkHandler linkHandler) { this(serviceId, linkHandler, null, Collections.emptyList(), 0); } @Override protected String getTag() { return "ChannelTabPlayQueue@" + Integer.toHexString(hashCode()); } @Override public void fetch() { if (isInitial) { ExtractorHelper.getChannelTab(this.serviceId, this.linkHandler, false) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(getHeadListObserver()); } else { ExtractorHelper.getMoreChannelTabItems(this.serviceId, this.linkHandler, this.nextPage) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(getNextPageObserver()); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java ================================================ package org.schabi.newpipe.player.playqueue; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.player.playqueue.PlayQueueEvent.AppendEvent; import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ErrorEvent; import org.schabi.newpipe.player.playqueue.PlayQueueEvent.InitEvent; import org.schabi.newpipe.player.playqueue.PlayQueueEvent.MoveEvent; import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RecoveryEvent; import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RemoveEvent; import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ReorderEvent; import org.schabi.newpipe.player.playqueue.PlayQueueEvent.SelectEvent; import java.io.Serializable; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.BackpressureStrategy; import io.reactivex.rxjava3.core.Flowable; import io.reactivex.rxjava3.subjects.PublishSubject; /** * PlayQueue is responsible for keeping track of a list of streams and the index of * the stream that should be currently playing. *

* This class contains basic manipulation of a playlist while also functions as a * message bus, providing all listeners with new updates to the play queue. *

*

* This class can be serialized for passing intents, but in order to start the * message bus, it must be initialized. *

*/ public abstract class PlayQueue implements Serializable { public static final boolean DEBUG = MainActivity.DEBUG; @NonNull private final AtomicInteger queueIndex; private final List history = new ArrayList<>(); private List backup; private List streams; private transient PublishSubject eventBroadcast; private transient Flowable broadcastReceiver; private transient boolean disposed = false; PlayQueue(final int index, final List startWith) { streams = new ArrayList<>(startWith); if (streams.size() > index) { history.add(streams.get(index)); } queueIndex = new AtomicInteger(index); } /*////////////////////////////////////////////////////////////////////////// // Playlist actions //////////////////////////////////////////////////////////////////////////*/ /** * Initializes the play queue message buses. *

* Also starts a self reporter for logging if debug mode is enabled. *

*/ public void init() { eventBroadcast = PublishSubject.create(); broadcastReceiver = eventBroadcast.toFlowable(BackpressureStrategy.BUFFER) .observeOn(AndroidSchedulers.mainThread()) .startWithItem(new InitEvent()); } /** * Dispose the play queue by stopping all message buses. */ public void dispose() { if (eventBroadcast != null) { eventBroadcast.onComplete(); } eventBroadcast = null; broadcastReceiver = null; disposed = true; } /** * Checks if the queue is complete. *

* A queue is complete if it has loaded all items in an external playlist * single stream or local queues are always complete. *

* * @return whether the queue is complete */ public abstract boolean isComplete(); /** * Load partial queue in the background, does nothing if the queue is complete. */ public abstract void fetch(); /*////////////////////////////////////////////////////////////////////////// // Readonly ops //////////////////////////////////////////////////////////////////////////*/ /** * @return the current index that should be played */ public int getIndex() { return queueIndex.get(); } /** * Changes the current playing index to a new index. *

* This method is guarded using in a circular manner for index exceeding the play queue size. *

*

* Will emit a {@link SelectEvent} if the index is not the current playing index. *

* * @param index the index to be set */ public synchronized void setIndex(final int index) { final int oldIndex = getIndex(); final int newIndex; if (index < 0) { newIndex = 0; } else if (index < streams.size()) { // Regular assignment for index in bounds newIndex = index; } else if (streams.isEmpty()) { // Out of bounds from here on // Need to check if stream is empty to prevent arithmetic error and negative index newIndex = 0; } else if (isComplete()) { // Circular indexing newIndex = index % streams.size(); } else { // Index of last element newIndex = streams.size() - 1; } queueIndex.set(newIndex); if (oldIndex != newIndex) { history.add(streams.get(newIndex)); } /* TODO: Documentation states that a SelectEvent will only be emitted if the new index is... different from the old one but this is emitted regardless? Not sure what this what it does exactly so I won't touch it */ broadcast(new SelectEvent(oldIndex, newIndex)); } /** * @return the current item that should be played, or null if the queue is empty */ @Nullable public PlayQueueItem getItem() { return getItem(getIndex()); } /** * @param index the index of the item to return * @return the item at the given index, or null if the index is out of bounds */ @Nullable public PlayQueueItem getItem(final int index) { if (index < 0 || index >= streams.size()) { return null; } return streams.get(index); } /** * Returns the index of the given item using referential equality. * May be null despite play queue contains identical item. * * @param item the item to find the index of * @return the index of the given item */ public int indexOf(@NonNull final PlayQueueItem item) { return streams.indexOf(item); } /** * @return the current size of play queue. */ public int size() { return streams.size(); } /** * Checks if the play queue is empty. * * @return whether the play queue is empty */ public boolean isEmpty() { return streams.isEmpty(); } /** * Determines if the current play queue is shuffled. * * @return whether the play queue is shuffled */ public boolean isShuffled() { return backup != null; } /** * @return an immutable view of the play queue */ @NonNull public List getStreams() { return Collections.unmodifiableList(streams); } /*////////////////////////////////////////////////////////////////////////// // Write ops //////////////////////////////////////////////////////////////////////////*/ /** * Returns the play queue's update broadcast. * May be null if the play queue message bus is not initialized. * * @return the play queue's update broadcast */ @Nullable public Flowable getBroadcastReceiver() { return broadcastReceiver; } /** * Changes the current playing index by an offset amount. *

* Will emit a {@link SelectEvent} if offset is non-zero. *

* * @param offset the offset relative to the current index */ public synchronized void offsetIndex(final int offset) { setIndex(getIndex() + offset); } /** * Notifies that a change has occurred. */ public synchronized void notifyChange() { broadcast(new AppendEvent(0)); } /** * Appends the given {@link PlayQueueItem}s to the current play queue. *

* If the play queue is shuffled, then append the items to the backup queue as is and * append the shuffle items to the play queue. *

*

* Will emit a {@link AppendEvent} on any given context. *

* * @param items {@link PlayQueueItem}s to append */ public synchronized void append(@NonNull final List items) { final List itemList = new ArrayList<>(items); if (isShuffled()) { backup.addAll(itemList); Collections.shuffle(itemList); } if (!streams.isEmpty() && streams.get(streams.size() - 1).isAutoQueued() && !itemList.get(0).isAutoQueued()) { streams.remove(streams.size() - 1); } streams.addAll(itemList); broadcast(new AppendEvent(itemList.size())); } /** * Add the given item after the current stream. * * @param item item to add. * @param skipIfSame if set, skip adding if the next stream is the same stream. */ public void enqueueNext(@NonNull final PlayQueueItem item, final boolean skipIfSame) { final int currentIndex = getIndex(); // if the next item is the same item as the one we want to enqueue, skip if flag is true if (skipIfSame && item.isSameItem(getItem(currentIndex + 1))) { return; } append(List.of(item)); move(size() - 1, currentIndex + 1); } /** * Removes the item at the given index from the play queue. *

* The current playing index will decrement if it is greater than the index being removed. * On cases where the current playing index exceeds the playlist range, it is set to 0. *

*

* Will emit a {@link RemoveEvent} if the index is within the play queue index range. *

* * @param index the index of the item to remove */ public synchronized void remove(final int index) { if (index >= streams.size() || index < 0) { return; } removeInternal(index); broadcast(new RemoveEvent(index, getIndex())); } /** * Report an exception for the item at the current index in order and skip to the next one *

* This is done as a separate event as the underlying manager may have * different implementation regarding exceptions. *

*/ public synchronized void error() { final int oldIndex = getIndex(); queueIndex.incrementAndGet(); if (streams.size() > queueIndex.get()) { history.add(streams.get(queueIndex.get())); } broadcast(new ErrorEvent(oldIndex, getIndex())); } private synchronized void removeInternal(final int removeIndex) { final int currentIndex = queueIndex.get(); final int size = size(); if (currentIndex > removeIndex) { queueIndex.decrementAndGet(); } else if (currentIndex >= size) { queueIndex.set(currentIndex % (size - 1)); } else if (currentIndex == removeIndex && currentIndex == size - 1) { queueIndex.set(0); } if (backup != null) { backup.remove(getItem(removeIndex)); } history.remove(streams.remove(removeIndex)); if (streams.size() > queueIndex.get()) { history.add(streams.get(queueIndex.get())); } } /** * Moves a queue item at the source index to the target index. *

* If the item being moved is the currently playing, then the current playing index is set * to that of the target. * If the moved item is not the currently playing and moves to an index AFTER the * current playing index, then the current playing index is decremented. * Vice versa if the an item after the currently playing is moved BEFORE. *

* * @param source the original index of the item * @param target the new index of the item */ public synchronized void move(final int source, final int target) { if (source < 0 || target < 0) { return; } if (source >= streams.size() || target >= streams.size()) { return; } final int current = getIndex(); if (source == current) { queueIndex.set(target); } else if (source < current && target >= current) { queueIndex.decrementAndGet(); } else if (source > current && target <= current) { queueIndex.incrementAndGet(); } final PlayQueueItem playQueueItem = streams.remove(source); playQueueItem.setAutoQueued(false); streams.add(target, playQueueItem); broadcast(new MoveEvent(source, target)); } /** * Sets the recovery record of the item at the index. *

* Broadcasts a recovery event. *

* * @param index index of the item * @param position the recovery position */ public synchronized void setRecovery(final int index, final long position) { if (index < 0 || index >= streams.size()) { return; } streams.get(index).setRecoveryPosition(position); broadcast(new RecoveryEvent(index, position)); } /** * Revoke the recovery record of the item at the index. *

* Broadcasts a recovery event. *

* * @param index index of the item */ public synchronized void unsetRecovery(final int index) { setRecovery(index, PlayQueueItem.RECOVERY_UNSET); } /** * Shuffles the current play queue *

* This method first backs up the existing play queue and item being played. Then a newly * shuffled play queue will be generated along with currently playing item placed at the * beginning of the queue. This item will also be added to the history. *

*

* Will emit a {@link ReorderEvent} if shuffled. *

* * @implNote Does nothing if the queue has a size <= 2 (the currently playing video must stay on * top, so shuffling a size-2 list does nothing) */ public synchronized void shuffle() { // Create a backup if it doesn't already exist // Note: The backup-list has to be created at all cost (even when size <= 2). // Otherwise it's not possible to enter shuffle-mode! if (backup == null) { backup = new ArrayList<>(streams); } // Can't shuffle a list that's empty or only has one element if (size() <= 2) { return; } final int originalIndex = getIndex(); final PlayQueueItem currentItem = getItem(); Collections.shuffle(streams); // Move currentItem to the head of the queue streams.remove(currentItem); streams.add(0, currentItem); queueIndex.set(0); history.add(currentItem); broadcast(new ReorderEvent(originalIndex, 0)); } /** * Unshuffles the current play queue if a backup play queue exists. *

* This method undoes shuffling and index will be set to the previously playing item if found, * otherwise, the index will reset to 0. *

*

* Will emit a {@link ReorderEvent} if a backup exists. *

*/ public synchronized void unshuffle() { if (backup == null) { return; } final int originIndex = getIndex(); final PlayQueueItem current = getItem(); streams = backup; backup = null; final int newIndex = streams.indexOf(current); if (newIndex != -1) { queueIndex.set(newIndex); } else { queueIndex.set(0); } if (streams.size() > queueIndex.get()) { history.add(streams.get(queueIndex.get())); } broadcast(new ReorderEvent(originIndex, queueIndex.get())); } /** * Selects previous played item. * * This method removes currently playing item from history and * starts playing the last item from history if it exists * * @return true if history is not empty and the item can be played * */ public synchronized boolean previous() { if (history.size() <= 1) { return false; } history.remove(history.size() - 1); final PlayQueueItem last = history.remove(history.size() - 1); setIndex(indexOf(last)); return true; } /* * Compares two PlayQueues. Useful when a user switches players but queue is the same so * we don't have to do anything with new queue. * This method also gives a chance to track history of items in a queue in * VideoDetailFragment without duplicating items from two identical queues */ public boolean equalStreams(@Nullable final PlayQueue other) { if (other == null) { return false; } if (size() != other.size()) { return false; } for (int i = 0; i < size(); i++) { final PlayQueueItem stream = streams.get(i); final PlayQueueItem otherStream = other.streams.get(i); // Check is based on serviceId and URL if (!stream.isSameItem(otherStream)) { return false; } } return true; } public boolean equalStreamsAndIndex(@Nullable final PlayQueue other) { if (equalStreams(other)) { //noinspection ConstantConditions return other.getIndex() == getIndex(); //NOSONAR: other is not null } return false; } public boolean isDisposed() { return disposed; } /*////////////////////////////////////////////////////////////////////////// // Rx Broadcast //////////////////////////////////////////////////////////////////////////*/ private void broadcast(@NonNull final PlayQueueEvent event) { if (eventBroadcast != null) { eventBroadcast.onNext(event); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java ================================================ package org.schabi.newpipe.player.playqueue; import android.content.Context; import android.util.Log; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.RecyclerView; import org.schabi.newpipe.R; import org.schabi.newpipe.player.playqueue.PlayQueueEvent.AppendEvent; import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ErrorEvent; import org.schabi.newpipe.player.playqueue.PlayQueueEvent.MoveEvent; import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RemoveEvent; import org.schabi.newpipe.player.playqueue.PlayQueueEvent.SelectEvent; import org.schabi.newpipe.util.FallbackViewHolder; import java.util.List; import io.reactivex.rxjava3.core.Observer; import io.reactivex.rxjava3.disposables.Disposable; /** * Created by Christian Schabesberger on 01.08.16. *

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

*

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

*

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

*

* You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . *

*/ public class PlayQueueAdapter extends RecyclerView.Adapter { private static final String TAG = PlayQueueAdapter.class.toString(); private static final int ITEM_VIEW_TYPE_ID = 0; private static final int FOOTER_VIEW_TYPE_ID = 1; private final PlayQueueItemBuilder playQueueItemBuilder; private final PlayQueue playQueue; private boolean showFooter = false; private View footer = null; private Disposable playQueueReactor; public PlayQueueAdapter(final Context context, final PlayQueue playQueue) { if (playQueue.getBroadcastReceiver() == null) { throw new IllegalStateException("Play Queue has not been initialized."); } this.playQueueItemBuilder = new PlayQueueItemBuilder(context); this.playQueue = playQueue; playQueue.getBroadcastReceiver().toObservable().subscribe(getReactor()); } private Observer getReactor() { return new Observer() { @Override public void onSubscribe(@NonNull final Disposable d) { if (playQueueReactor != null) { playQueueReactor.dispose(); } playQueueReactor = d; } @Override public void onNext(@NonNull final PlayQueueEvent playQueueMessage) { if (playQueueReactor != null) { onPlayQueueChanged(playQueueMessage); } } @Override public void onError(@NonNull final Throwable e) { } @Override public void onComplete() { dispose(); } }; } private void onPlayQueueChanged(final PlayQueueEvent message) { switch (message.type()) { case RECOVERY: // Do nothing. break; case SELECT: final SelectEvent selectEvent = (SelectEvent) message; notifyItemChanged(selectEvent.getOldIndex()); notifyItemChanged(selectEvent.getNewIndex()); break; case APPEND: final AppendEvent appendEvent = (AppendEvent) message; notifyItemRangeInserted(playQueue.size(), appendEvent.getAmount()); break; case ERROR: final ErrorEvent errorEvent = (ErrorEvent) message; notifyItemChanged(errorEvent.getErrorIndex()); notifyItemChanged(errorEvent.getQueueIndex()); break; case REMOVE: final RemoveEvent removeEvent = (RemoveEvent) message; notifyItemRemoved(removeEvent.getRemoveIndex()); notifyItemChanged(removeEvent.getQueueIndex()); break; case MOVE: final MoveEvent moveEvent = (MoveEvent) message; notifyItemMoved(moveEvent.getFromIndex(), moveEvent.getToIndex()); break; case INIT: case REORDER: default: notifyDataSetChanged(); break; } } public void dispose() { if (playQueueReactor != null) { playQueueReactor.dispose(); } playQueueReactor = null; } public void setSelectedListener(final PlayQueueItemBuilder.OnSelectedListener listener) { playQueueItemBuilder.setOnSelectedListener(listener); } public void unsetSelectedListener() { playQueueItemBuilder.setOnSelectedListener(null); } public void setFooter(final View footer) { this.footer = footer; notifyItemChanged(playQueue.size()); } public void showFooter(final boolean show) { showFooter = show; notifyItemChanged(playQueue.size()); } public List getItems() { return playQueue.getStreams(); } @Override public int getItemCount() { int count = playQueue.getStreams().size(); if (footer != null && showFooter) { count++; } return count; } @Override public int getItemViewType(final int position) { if (footer != null && position == playQueue.getStreams().size() && showFooter) { return FOOTER_VIEW_TYPE_ID; } return ITEM_VIEW_TYPE_ID; } @NonNull @Override public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int type) { switch (type) { case FOOTER_VIEW_TYPE_ID: return new HFHolder(footer); case ITEM_VIEW_TYPE_ID: return new PlayQueueItemHolder(LayoutInflater.from(parent.getContext()) .inflate(R.layout.play_queue_item, parent, false)); default: Log.e(TAG, "Attempting to create view holder with undefined type: " + type); return new FallbackViewHolder(new View(parent.getContext())); } } @Override public void onBindViewHolder(@NonNull final RecyclerView.ViewHolder holder, final int position) { if (holder instanceof PlayQueueItemHolder) { final PlayQueueItemHolder itemHolder = (PlayQueueItemHolder) holder; // Build the list item playQueueItemBuilder .buildStreamInfoItem(itemHolder, playQueue.getStreams().get(position)); // Check if the current item should be selected/highlighted final boolean isSelected = playQueue.getIndex() == position; itemHolder.itemView.setSelected(isSelected); } else if (holder instanceof HFHolder && position == playQueue.getStreams().size() && footer != null && showFooter) { ((HFHolder) holder).view = footer; } } public static class HFHolder extends RecyclerView.ViewHolder { public View view; public HFHolder(final View v) { super(v); view = v; } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueEvent.kt ================================================ /* * SPDX-FileCopyrightText: 2017-2026 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.player.playqueue import java.io.Serializable sealed interface PlayQueueEvent : Serializable { fun type(): Type class InitEvent : PlayQueueEvent { override fun type() = Type.INIT } // sent when the index is changed class SelectEvent(val oldIndex: Int, val newIndex: Int) : PlayQueueEvent { override fun type() = Type.SELECT } // sent when more streams are added to the play queue class AppendEvent(val amount: Int) : PlayQueueEvent { override fun type() = Type.APPEND } // sent when a pending stream is removed from the play queue class RemoveEvent(val removeIndex: Int, val queueIndex: Int) : PlayQueueEvent { override fun type() = Type.REMOVE } // sent when two streams swap place in the play queue class MoveEvent(val fromIndex: Int, val toIndex: Int) : PlayQueueEvent { override fun type() = Type.MOVE } // sent when queue is shuffled class ReorderEvent(val fromSelectedIndex: Int, val toSelectedIndex: Int) : PlayQueueEvent { override fun type() = Type.REORDER } // sent when recovery record is set on a stream class RecoveryEvent(val index: Int, val position: Long) : PlayQueueEvent { override fun type() = Type.RECOVERY } // sent when the item at index has caused an exception class ErrorEvent(val errorIndex: Int, val queueIndex: Int) : PlayQueueEvent { override fun type() = Type.ERROR } // It is necessary only for use in java code. Remove it and use kotlin pattern // matching when all users of this enum are converted to kotlin enum class Type { INIT, SELECT, APPEND, REMOVE, MOVE, REORDER, RECOVERY, ERROR } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java ================================================ package org.schabi.newpipe.player.playqueue; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.schabi.newpipe.extractor.Image; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.util.ExtractorHelper; import java.io.Serializable; import java.util.List; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.schedulers.Schedulers; public class PlayQueueItem implements Serializable { public static final long RECOVERY_UNSET = Long.MIN_VALUE; private static final String EMPTY_STRING = ""; @NonNull private final String title; @NonNull private final String url; private final int serviceId; private final long duration; @NonNull private final List thumbnails; @NonNull private final String uploader; private final String uploaderUrl; @NonNull private final StreamType streamType; private boolean isAutoQueued; private long recoveryPosition; private Throwable error; public PlayQueueItem(@NonNull final StreamInfo info) { this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(), info.getThumbnails(), info.getUploaderName(), info.getUploaderUrl(), info.getStreamType()); if (info.getStartPosition() > 0) { setRecoveryPosition(info.getStartPosition() * 1000); } } PlayQueueItem(@NonNull final StreamInfoItem item) { this(item.getName(), item.getUrl(), item.getServiceId(), item.getDuration(), item.getThumbnails(), item.getUploaderName(), item.getUploaderUrl(), item.getStreamType()); } @SuppressWarnings("ParameterNumber") private PlayQueueItem(@Nullable final String name, @Nullable final String url, final int serviceId, final long duration, final List thumbnails, @Nullable final String uploader, final String uploaderUrl, @NonNull final StreamType streamType) { this.title = name != null ? name : EMPTY_STRING; this.url = url != null ? url : EMPTY_STRING; this.serviceId = serviceId; this.duration = duration; this.thumbnails = thumbnails; this.uploader = uploader != null ? uploader : EMPTY_STRING; this.uploaderUrl = uploaderUrl; this.streamType = streamType; this.recoveryPosition = RECOVERY_UNSET; } /** Whether these two items should be treated as the same stream * for the sake of keeping the same player running when e.g. jumping between timestamps. * * @param other the {@link PlayQueueItem} to compare against. * @return whether the two items are the same so the stream can be re-used. */ public boolean isSameItem(@Nullable final PlayQueueItem other) { if (other == null) { return false; } // We assume that the same service & URL uniquely determines // that we can keep the same stream running. return serviceId == other.serviceId && url.equals(other.url); } @NonNull public String getTitle() { return title; } @NonNull public String getUrl() { return url; } public int getServiceId() { return serviceId; } public long getDuration() { return duration; } @NonNull public List getThumbnails() { return thumbnails; } @NonNull public String getUploader() { return uploader; } public String getUploaderUrl() { return uploaderUrl; } @NonNull public StreamType getStreamType() { return streamType; } public long getRecoveryPosition() { return recoveryPosition; } /*package-private*/ void setRecoveryPosition(final long recoveryPosition) { this.recoveryPosition = recoveryPosition; } @Nullable public Throwable getError() { return error; } @NonNull public Single getStream() { return ExtractorHelper.getStreamInfo(this.serviceId, this.url, false) .subscribeOn(Schedulers.io()) .doOnError(throwable -> error = throwable); } public boolean isAutoQueued() { return isAutoQueued; } //////////////////////////////////////////////////////////////////////////// // Item States, keep external access out //////////////////////////////////////////////////////////////////////////// public void setAutoQueued(final boolean autoQueued) { isAutoQueued = autoQueued; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.java ================================================ package org.schabi.newpipe.player.playqueue; import android.content.Context; import android.text.TextUtils; import android.view.MotionEvent; import android.view.View; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.image.CoilHelper; public class PlayQueueItemBuilder { private static final String TAG = PlayQueueItemBuilder.class.toString(); private OnSelectedListener onItemClickListener; public PlayQueueItemBuilder(final Context context) { } public void setOnSelectedListener(final OnSelectedListener listener) { this.onItemClickListener = listener; } public void buildStreamInfoItem(final PlayQueueItemHolder holder, final PlayQueueItem item) { if (!TextUtils.isEmpty(item.getTitle())) { holder.itemVideoTitleView.setText(item.getTitle()); } holder.itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.getUploader(), ServiceHelper.getNameOfServiceById(item.getServiceId()))); if (item.getDuration() > 0) { holder.itemDurationView.setText(Localization.getDurationString(item.getDuration())); } else { holder.itemDurationView.setVisibility(View.GONE); } CoilHelper.INSTANCE.loadThumbnail(holder.itemThumbnailView, item.getThumbnails()); holder.itemRoot.setOnClickListener(view -> { if (onItemClickListener != null) { onItemClickListener.selected(item, view); } }); holder.itemRoot.setOnLongClickListener(view -> { if (onItemClickListener != null) { onItemClickListener.held(item, view); return true; } return false; }); holder.itemHandle.setOnTouchListener(getOnTouchListener(holder)); } private View.OnTouchListener getOnTouchListener(final PlayQueueItemHolder holder) { return (view, motionEvent) -> { view.performClick(); if (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN && onItemClickListener != null) { onItemClickListener.onStartDrag(holder); } return false; }; } public interface OnSelectedListener { void selected(PlayQueueItem item, View view); void held(PlayQueueItem item, View view); void onStartDrag(PlayQueueItemHolder viewHolder); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemHolder.java ================================================ package org.schabi.newpipe.player.playqueue; import android.view.View; import android.widget.ImageView; import android.widget.TextView; import androidx.recyclerview.widget.RecyclerView; import org.schabi.newpipe.R; /** * Created by Christian Schabesberger on 01.08.16. *

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

*

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

*

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

*

* You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . *

*/ public class PlayQueueItemHolder extends RecyclerView.ViewHolder { public final TextView itemVideoTitleView; public final TextView itemDurationView; final TextView itemAdditionalDetailsView; public final ImageView itemThumbnailView; final ImageView itemHandle; public final View itemRoot; PlayQueueItemHolder(final View v) { super(v); itemRoot = v.findViewById(R.id.itemRoot); itemVideoTitleView = v.findViewById(R.id.itemVideoTitleView); itemDurationView = v.findViewById(R.id.itemDurationView); itemAdditionalDetailsView = v.findViewById(R.id.itemAdditionalDetails); itemThumbnailView = v.findViewById(R.id.itemThumbnailView); itemHandle = v.findViewById(R.id.itemHandle); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTouchCallback.java ================================================ package org.schabi.newpipe.player.playqueue; import androidx.annotation.NonNull; import androidx.core.math.MathUtils; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; public abstract class PlayQueueItemTouchCallback extends ItemTouchHelper.SimpleCallback { private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 10; private static final int MAXIMUM_INITIAL_DRAG_VELOCITY = 25; public PlayQueueItemTouchCallback() { super(ItemTouchHelper.UP | ItemTouchHelper.DOWN, ItemTouchHelper.RIGHT); } public abstract void onMove(int sourceIndex, int targetIndex); public abstract void onSwiped(int index); @Override public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView, final int viewSize, final int viewSizeOutOfBounds, final int totalSize, final long msSinceStartScroll) { final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll); final int clampedAbsVelocity = MathUtils.clamp(Math.abs(standardSpeed), MINIMUM_INITIAL_DRAG_VELOCITY, MAXIMUM_INITIAL_DRAG_VELOCITY); return clampedAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); } @Override public boolean onMove(@NonNull final RecyclerView recyclerView, final RecyclerView.ViewHolder source, final RecyclerView.ViewHolder target) { if (source.getItemViewType() != target.getItemViewType()) { return false; } final int sourceIndex = source.getLayoutPosition(); final int targetIndex = target.getLayoutPosition(); onMove(sourceIndex, targetIndex); return true; } @Override public boolean isLongPressDragEnabled() { return false; } @Override public boolean isItemViewSwipeEnabled() { return true; } @Override public void onSwiped(final RecyclerView.ViewHolder viewHolder, final int swipeDir) { onSwiped(viewHolder.getBindingAdapterPosition()); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java ================================================ package org.schabi.newpipe.player.playqueue; import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.util.ExtractorHelper; import java.util.List; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.schedulers.Schedulers; public final class PlaylistPlayQueue extends AbstractInfoPlayQueue { public PlaylistPlayQueue(final PlaylistInfo info) { super(info); } public PlaylistPlayQueue(final PlaylistInfo info, final int index) { super(info, index); } public PlaylistPlayQueue(final int serviceId, final String url, final Page nextPage, final List streams, final int index) { super(serviceId, url, nextPage, streams, index); } @Override protected String getTag() { return "PlaylistPlayQueue@" + Integer.toHexString(hashCode()); } @Override public void fetch() { if (this.isInitial) { ExtractorHelper.getPlaylistInfo(this.serviceId, this.baseUrl, false) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(getHeadListObserver()); } else { ExtractorHelper.getMorePlaylistItems(this.serviceId, this.baseUrl, this.nextPage) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(getNextPageObserver()); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java ================================================ package org.schabi.newpipe.player.playqueue; import androidx.annotation.NonNull; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import java.util.List; import java.util.stream.Collectors; public final class SinglePlayQueue extends PlayQueue { public SinglePlayQueue(final StreamInfoItem item) { super(0, List.of(new PlayQueueItem(item))); } public SinglePlayQueue(final StreamInfo info) { super(0, List.of(new PlayQueueItem(info))); } public SinglePlayQueue(final PlayQueueItem item) { super(0, List.of(item)); } public SinglePlayQueue(final StreamInfo info, final long startPosition) { super(0, List.of(new PlayQueueItem(info))); getItem().setRecoveryPosition(startPosition); } public SinglePlayQueue(@NonNull final List items, final int index) { super(index, playQueueItemsOf(items)); } private static List playQueueItemsOf(@NonNull final List items) { return items.stream().map(PlayQueueItem::new).collect(Collectors.toList()); } @Override public boolean isComplete() { return true; } @Override public void fetch() { // Item was already passed in constructor. // No further items need to be fetched as this is a PlayQueue with only one item } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java ================================================ package org.schabi.newpipe.player.resolver; import static org.schabi.newpipe.util.ListHelper.getFilteredAudioStreams; import static org.schabi.newpipe.util.ListHelper.getPlayableStreams; import android.content.Context; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.android.exoplayer2.source.MediaSource; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.player.helper.PlayerDataSource; import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.mediaitem.StreamInfoTag; import org.schabi.newpipe.util.ListHelper; import java.util.List; public class AudioPlaybackResolver implements PlaybackResolver { private static final String TAG = AudioPlaybackResolver.class.getSimpleName(); @NonNull private final Context context; @NonNull private final PlayerDataSource dataSource; @Nullable private String audioTrack; public AudioPlaybackResolver(@NonNull final Context context, @NonNull final PlayerDataSource dataSource) { this.context = context; this.dataSource = dataSource; } /** * Get a media source providing audio. If a service has no separate {@link AudioStream}s we * use a video stream as audio source to support audio background playback. * * @param info of the stream * @return the audio source to use or null if none could be found */ @Override @Nullable public MediaSource resolve(@NonNull final StreamInfo info) { final MediaSource liveSource = PlaybackResolver.maybeBuildLiveMediaSource(dataSource, info); if (liveSource != null) { return liveSource; } final List audioStreams = getFilteredAudioStreams(context, info.getAudioStreams()); final Stream stream; final MediaItemTag tag; if (!audioStreams.isEmpty()) { final int audioIndex = ListHelper.getAudioFormatIndex(context, audioStreams, audioTrack); stream = getStreamForIndex(audioIndex, audioStreams); tag = StreamInfoTag.of(info, audioStreams, audioIndex); } else { final List videoStreams = getPlayableStreams(info.getVideoStreams(), info.getServiceId()); if (!videoStreams.isEmpty()) { final int index = ListHelper.getDefaultResolutionIndex(context, videoStreams); stream = getStreamForIndex(index, videoStreams); tag = StreamInfoTag.of(info); } else { return null; } } try { return PlaybackResolver.buildMediaSource( dataSource, stream, info, PlaybackResolver.cacheKeyOf(info, stream), tag); } catch (final ResolverException e) { Log.e(TAG, "Unable to create audio source", e); return null; } } @Nullable Stream getStreamForIndex(final int index, @NonNull final List streams) { if (index >= 0 && index < streams.size()) { return streams.get(index); } return null; } @Nullable public String getAudioTrack() { return audioTrack; } public void setAudioTrack(@Nullable final String audioLanguage) { this.audioTrack = audioLanguage; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java ================================================ package org.schabi.newpipe.player.resolver; import static org.schabi.newpipe.extractor.stream.AudioStream.UNKNOWN_BITRATE; import static org.schabi.newpipe.extractor.stream.VideoStream.RESOLUTION_UNKNOWN; import static org.schabi.newpipe.player.helper.PlayerDataSource.LIVE_STREAM_EDGE_GAP_MILLIS; import android.net.Uri; import android.util.Log; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.source.dash.DashMediaSource; import com.google.android.exoplayer2.source.dash.manifest.DashManifest; import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; import com.google.android.exoplayer2.source.hls.HlsMediaSource; import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest; import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifestParser; import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.services.youtube.ItagItem; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.CreationException; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeOtfDashManifestCreator; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubePostLiveStreamDvrDashManifestCreator; import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeProgressiveDashManifestCreator; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.DeliveryMethod; import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.player.datasource.NonUriHlsDataSourceFactory; import org.schabi.newpipe.player.helper.PlayerDataSource; import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.mediaitem.StreamInfoTag; import org.schabi.newpipe.util.StreamTypeUtil; import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Objects; /** * This interface is just a shorthand for {@link Resolver} with {@link StreamInfo} as source and * {@link MediaSource} as product. It contains many static methods that can be used by classes * implementing this interface, and nothing else. */ public interface PlaybackResolver extends Resolver { String TAG = PlaybackResolver.class.getSimpleName(); //region Cache key generation private static StringBuilder commonCacheKeyOf(final StreamInfo info, final Stream stream, final boolean resolutionOrBitrateUnknown) { // stream info service id final StringBuilder cacheKey = new StringBuilder(info.getServiceId()); // stream info id cacheKey.append(" "); cacheKey.append(info.getId()); // stream id (even if unknown) cacheKey.append(" "); cacheKey.append(stream.getId()); // mediaFormat (if not null) final MediaFormat mediaFormat = stream.getFormat(); if (mediaFormat != null) { cacheKey.append(" "); cacheKey.append(mediaFormat.getName()); } // content (only if other information is missing) // If the media format and the resolution/bitrate are both missing, then we don't have // enough information to distinguish this stream from other streams. // So, only in that case, we use the content (i.e. url or manifest) to differentiate // between streams. // Note that if the content were used even when other information is present, then two // streams with the same stats but with different contents (e.g. because the url was // refreshed) will be considered different (i.e. with a different cacheKey), making the // cache useless. if (resolutionOrBitrateUnknown && mediaFormat == null) { cacheKey.append(" "); cacheKey.append(Objects.hash(stream.getContent(), stream.getManifestUrl())); } return cacheKey; } /** * Builds the cache key of a {@link VideoStream video stream}. * *

* A cache key is unique to the features of the provided video stream, and when possible * independent of transient parameters (such as the URL of the stream). * This ensures that there are no conflicts, but also that the cache is used as much as * possible: the same cache should be used for two streams which have the same features but * e.g. a different URL, since the URL might have been reloaded in the meantime, but the stream * actually referenced by the URL is still the same. *

* * @param info the {@link StreamInfo stream info}, to distinguish between streams with * the same features but coming from different stream infos * @param videoStream the {@link VideoStream video stream} for which the cache key should be * created * @return a key to be used to store the cache of the provided {@link VideoStream video stream} */ static String cacheKeyOf(final StreamInfo info, final VideoStream videoStream) { final boolean resolutionUnknown = videoStream.getResolution().equals(RESOLUTION_UNKNOWN); final StringBuilder cacheKey = commonCacheKeyOf(info, videoStream, resolutionUnknown); // resolution (if known) if (!resolutionUnknown) { cacheKey.append(" "); cacheKey.append(videoStream.getResolution()); } // isVideoOnly cacheKey.append(" "); cacheKey.append(videoStream.isVideoOnly()); return cacheKey.toString(); } /** * Builds the cache key of an audio stream. * *

* A cache key is unique to the features of the provided {@link AudioStream audio stream}, and * when possible independent of transient parameters (such as the URL of the stream). * This ensures that there are no conflicts, but also that the cache is used as much as * possible: the same cache should be used for two streams which have the same features but * e.g. a different URL, since the URL might have been reloaded in the meantime, but the stream * actually referenced by the URL is still the same. *

* * @param info the {@link StreamInfo stream info}, to distinguish between streams with * the same features but coming from different stream infos * @param audioStream the {@link AudioStream audio stream} for which the cache key should be * created * @return a key to be used to store the cache of the provided {@link AudioStream audio stream} */ static String cacheKeyOf(final StreamInfo info, final AudioStream audioStream) { final boolean averageBitrateUnknown = audioStream.getAverageBitrate() == UNKNOWN_BITRATE; final StringBuilder cacheKey = commonCacheKeyOf(info, audioStream, averageBitrateUnknown); // averageBitrate (if known) if (!averageBitrateUnknown) { cacheKey.append(" "); cacheKey.append(audioStream.getAverageBitrate()); } if (audioStream.getAudioTrackId() != null) { cacheKey.append(" "); cacheKey.append(audioStream.getAudioTrackId()); } if (audioStream.getAudioLocale() != null) { cacheKey.append(" "); cacheKey.append(audioStream.getAudioLocale().getISO3Language()); } return cacheKey.toString(); } /** * Use common base type {@link Stream} to handle {@link AudioStream} or {@link VideoStream} * transparently. For more info see {@link #cacheKeyOf(StreamInfo, AudioStream)} or * {@link #cacheKeyOf(StreamInfo, VideoStream)}. * * @param info the {@link StreamInfo stream info}, to distinguish between streams with * the same features but coming from different stream infos * @param stream the {@link Stream} ({@link AudioStream} or {@link VideoStream}) * for which the cache key should be created * @return a key to be used to store the cache of the provided {@link Stream} */ static String cacheKeyOf(final StreamInfo info, final Stream stream) { if (stream instanceof AudioStream) { return cacheKeyOf(info, (AudioStream) stream); } else if (stream instanceof VideoStream) { return cacheKeyOf(info, (VideoStream) stream); } throw new RuntimeException("no audio or video stream. That should never happen"); } //endregion //region Live media sources @Nullable static MediaSource maybeBuildLiveMediaSource(final PlayerDataSource dataSource, final StreamInfo info) { if (!StreamTypeUtil.isLiveStream(info.getStreamType())) { return null; } try { final StreamInfoTag tag = StreamInfoTag.of(info); // Prefer DASH over HLS because of an exoPlayer bug that causes the background player to // also fetch the video stream even if it is supposed to just fetch the audio stream. if (!info.getDashMpdUrl().isEmpty()) { return buildLiveMediaSource( dataSource, info.getDashMpdUrl(), C.CONTENT_TYPE_DASH, tag); } if (!info.getHlsUrl().isEmpty()) { return buildLiveMediaSource(dataSource, info.getHlsUrl(), C.CONTENT_TYPE_HLS, tag); } } catch (final Exception e) { Log.w(TAG, "Error when generating live media source, falling back to standard sources", e); } return null; } static MediaSource buildLiveMediaSource(final PlayerDataSource dataSource, final String sourceUrl, @C.ContentType final int type, final MediaItemTag metadata) throws ResolverException { final MediaSource.Factory factory; switch (type) { case C.CONTENT_TYPE_SS: factory = dataSource.getLiveSsMediaSourceFactory(); break; case C.CONTENT_TYPE_DASH: if (metadata.getServiceId() == ServiceList.YouTube.getServiceId()) { factory = dataSource.getLiveYoutubeDashMediaSourceFactory(); } else { factory = dataSource.getLiveDashMediaSourceFactory(); } break; case C.CONTENT_TYPE_HLS: factory = dataSource.getLiveHlsMediaSourceFactory(); break; case C.CONTENT_TYPE_OTHER: case C.CONTENT_TYPE_RTSP: default: throw new ResolverException("Unsupported type: " + type); } return factory.createMediaSource( new MediaItem.Builder() .setTag(metadata) .setUri(Uri.parse(sourceUrl)) .setLiveConfiguration( new MediaItem.LiveConfiguration.Builder() .setTargetOffsetMs(LIVE_STREAM_EDGE_GAP_MILLIS) .build()) .build()); } //endregion //region Generic media sources static MediaSource buildMediaSource(final PlayerDataSource dataSource, final Stream stream, final StreamInfo streamInfo, final String cacheKey, final MediaItemTag metadata) throws ResolverException { if (streamInfo.getService() == ServiceList.YouTube) { return createYoutubeMediaSource(stream, streamInfo, dataSource, cacheKey, metadata); } final DeliveryMethod deliveryMethod = stream.getDeliveryMethod(); switch (deliveryMethod) { case PROGRESSIVE_HTTP: return buildProgressiveMediaSource(dataSource, stream, cacheKey, metadata); case DASH: return buildDashMediaSource(dataSource, stream, cacheKey, metadata); case HLS: return buildHlsMediaSource(dataSource, stream, cacheKey, metadata); case SS: return buildSSMediaSource(dataSource, stream, cacheKey, metadata); // Torrent streams are not supported by ExoPlayer default: throw new ResolverException("Unsupported delivery type: " + deliveryMethod); } } private static ProgressiveMediaSource buildProgressiveMediaSource( final PlayerDataSource dataSource, final Stream stream, final String cacheKey, final MediaItemTag metadata) throws ResolverException { if (!stream.isUrl()) { throw new ResolverException("Non URI progressive contents are not supported"); } throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); return dataSource.getProgressiveMediaSourceFactory().createMediaSource( new MediaItem.Builder() .setTag(metadata) .setUri(Uri.parse(stream.getContent())) .setCustomCacheKey(cacheKey) .build()); } private static DashMediaSource buildDashMediaSource(final PlayerDataSource dataSource, final Stream stream, final String cacheKey, final MediaItemTag metadata) throws ResolverException { if (stream.isUrl()) { throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); return dataSource.getDashMediaSourceFactory().createMediaSource( new MediaItem.Builder() .setTag(metadata) .setUri(Uri.parse(stream.getContent())) .setCustomCacheKey(cacheKey) .build()); } try { return dataSource.getDashMediaSourceFactory().createMediaSource( createDashManifest(stream.getContent(), stream), new MediaItem.Builder() .setTag(metadata) .setUri(manifestUrlToUri(stream.getManifestUrl())) .setCustomCacheKey(cacheKey) .build()); } catch (final IOException e) { throw new ResolverException( "Could not create a DASH media source/manifest from the manifest text", e); } } private static DashManifest createDashManifest(final String manifestContent, final Stream stream) throws IOException { return new DashManifestParser().parse(manifestUrlToUri(stream.getManifestUrl()), new ByteArrayInputStream(manifestContent.getBytes(StandardCharsets.UTF_8))); } private static HlsMediaSource buildHlsMediaSource(final PlayerDataSource dataSource, final Stream stream, final String cacheKey, final MediaItemTag metadata) throws ResolverException { if (stream.isUrl()) { throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); return dataSource.getHlsMediaSourceFactory(null).createMediaSource( new MediaItem.Builder() .setTag(metadata) .setUri(Uri.parse(stream.getContent())) .setCustomCacheKey(cacheKey) .build()); } final NonUriHlsDataSourceFactory.Builder hlsDataSourceFactoryBuilder = new NonUriHlsDataSourceFactory.Builder(); hlsDataSourceFactoryBuilder.setPlaylistString(stream.getContent()); return dataSource.getHlsMediaSourceFactory(hlsDataSourceFactoryBuilder) .createMediaSource(new MediaItem.Builder() .setTag(metadata) .setUri(manifestUrlToUri(stream.getManifestUrl())) .setCustomCacheKey(cacheKey) .build()); } private static SsMediaSource buildSSMediaSource(final PlayerDataSource dataSource, final Stream stream, final String cacheKey, final MediaItemTag metadata) throws ResolverException { if (stream.isUrl()) { throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); return dataSource.getSSMediaSourceFactory().createMediaSource( new MediaItem.Builder() .setTag(metadata) .setUri(Uri.parse(stream.getContent())) .setCustomCacheKey(cacheKey) .build()); } final Uri manifestUri = manifestUrlToUri(stream.getManifestUrl()); final SsManifest smoothStreamingManifest; try { final ByteArrayInputStream smoothStreamingManifestInput = new ByteArrayInputStream( stream.getContent().getBytes(StandardCharsets.UTF_8)); smoothStreamingManifest = new SsManifestParser().parse(manifestUri, smoothStreamingManifestInput); } catch (final IOException e) { throw new ResolverException("Error when parsing manual SS manifest", e); } return dataSource.getSSMediaSourceFactory().createMediaSource( smoothStreamingManifest, new MediaItem.Builder() .setTag(metadata) .setUri(manifestUri) .setCustomCacheKey(cacheKey) .build()); } //endregion //region YouTube media sources private static MediaSource createYoutubeMediaSource(final Stream stream, final StreamInfo streamInfo, final PlayerDataSource dataSource, final String cacheKey, final MediaItemTag metadata) throws ResolverException { if (!(stream instanceof AudioStream || stream instanceof VideoStream)) { throw new ResolverException("Generation of YouTube DASH manifest for " + stream.getClass().getSimpleName() + " is not supported"); } final StreamType streamType = streamInfo.getStreamType(); if (streamType == StreamType.VIDEO_STREAM) { return createYoutubeMediaSourceOfVideoStreamType(dataSource, stream, streamInfo, cacheKey, metadata); } else if (streamType == StreamType.POST_LIVE_STREAM) { // If the content is not an URL, uses the DASH delivery method and if the stream type // of the stream is a post live stream, it means that the content is an ended // livestream so we need to generate the manifest corresponding to the content // (which is the last segment of the stream) try { final ItagItem itagItem = Objects.requireNonNull(stream.getItagItem()); final String manifestString = YoutubePostLiveStreamDvrDashManifestCreator .fromPostLiveStreamDvrStreamingUrl(stream.getContent(), itagItem, itagItem.getTargetDurationSec(), streamInfo.getDuration()); return buildYoutubeManualDashMediaSource(dataSource, createDashManifest(manifestString, stream), stream, cacheKey, metadata); } catch (final CreationException | IOException | NullPointerException e) { throw new ResolverException( "Error when generating the DASH manifest of YouTube ended live stream", e); } } else { throw new ResolverException( "DASH manifest generation of YouTube livestreams is not supported"); } } private static MediaSource createYoutubeMediaSourceOfVideoStreamType( final PlayerDataSource dataSource, final Stream stream, final StreamInfo streamInfo, final String cacheKey, final MediaItemTag metadata) throws ResolverException { final DeliveryMethod deliveryMethod = stream.getDeliveryMethod(); switch (deliveryMethod) { case PROGRESSIVE_HTTP: if ((stream instanceof VideoStream && ((VideoStream) stream).isVideoOnly()) || stream instanceof AudioStream) { try { final String manifestString = YoutubeProgressiveDashManifestCreator .fromProgressiveStreamingUrl(stream.getContent(), Objects.requireNonNull(stream.getItagItem()), streamInfo.getDuration()); return buildYoutubeManualDashMediaSource(dataSource, createDashManifest(manifestString, stream), stream, cacheKey, metadata); } catch (final CreationException | IOException | NullPointerException e) { Log.w(TAG, "Error when generating or parsing DASH manifest of " + "YouTube progressive stream, falling back to a " + "ProgressiveMediaSource.", e); return buildYoutubeProgressiveMediaSource(dataSource, stream, cacheKey, metadata); } } else { // Legacy progressive streams, subtitles are handled by // VideoPlaybackResolver return buildYoutubeProgressiveMediaSource(dataSource, stream, cacheKey, metadata); } case DASH: // If the content is not a URL, uses the DASH delivery method and if the stream // type of the stream is a video stream, it means the content is an OTF stream // so we need to generate the manifest corresponding to the content (which is // the base URL of the OTF stream). try { final String manifestString = YoutubeOtfDashManifestCreator .fromOtfStreamingUrl(stream.getContent(), Objects.requireNonNull(stream.getItagItem()), streamInfo.getDuration()); return buildYoutubeManualDashMediaSource(dataSource, createDashManifest(manifestString, stream), stream, cacheKey, metadata); } catch (final CreationException | IOException | NullPointerException e) { Log.e(TAG, "Error when generating the DASH manifest of YouTube OTF stream", e); throw new ResolverException( "Error when generating the DASH manifest of YouTube OTF stream", e); } case HLS: return dataSource.getYoutubeHlsMediaSourceFactory().createMediaSource( new MediaItem.Builder() .setTag(metadata) .setUri(Uri.parse(stream.getContent())) .setCustomCacheKey(cacheKey) .build()); default: throw new ResolverException("Unsupported delivery method for YouTube contents: " + deliveryMethod); } } private static DashMediaSource buildYoutubeManualDashMediaSource( final PlayerDataSource dataSource, final DashManifest dashManifest, final Stream stream, final String cacheKey, final MediaItemTag metadata) { return dataSource.getYoutubeDashMediaSourceFactory().createMediaSource(dashManifest, new MediaItem.Builder() .setTag(metadata) .setUri(Uri.parse(stream.getContent())) .setCustomCacheKey(cacheKey) .build()); } private static ProgressiveMediaSource buildYoutubeProgressiveMediaSource( final PlayerDataSource dataSource, final Stream stream, final String cacheKey, final MediaItemTag metadata) { return dataSource.getYoutubeProgressiveMediaSourceFactory() .createMediaSource(new MediaItem.Builder() .setTag(metadata) .setUri(Uri.parse(stream.getContent())) .setCustomCacheKey(cacheKey) .build()); } //endregion //region Utils private static Uri manifestUrlToUri(final String manifestUrl) { return Uri.parse(Objects.requireNonNullElse(manifestUrl, "")); } private static void throwResolverExceptionIfUrlNullOrEmpty(@Nullable final String url) throws ResolverException { if (url == null) { throw new ResolverException("Null stream URL"); } else if (url.isEmpty()) { throw new ResolverException("Empty stream URL"); } } //endregion //region Resolver exception final class ResolverException extends Exception { public ResolverException(final String message) { super(message); } public ResolverException(final String message, final Throwable cause) { super(message, cause); } } //endregion } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/resolver/Resolver.java ================================================ package org.schabi.newpipe.player.resolver; import androidx.annotation.NonNull; import androidx.annotation.Nullable; public interface Resolver { @Nullable Product resolve(@NonNull Source source); } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java ================================================ package org.schabi.newpipe.player.resolver; import android.content.Context; import android.net.Uri; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MergingMediaSource; import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.player.helper.PlayerDataSource; import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.mediaitem.StreamInfoTag; import org.schabi.newpipe.util.ListHelper; import java.util.ArrayList; import java.util.List; import java.util.Optional; import static com.google.android.exoplayer2.C.TIME_UNSET; import static org.schabi.newpipe.util.ListHelper.getFilteredAudioStreams; import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams; import static org.schabi.newpipe.util.ListHelper.getPlayableStreams; public class VideoPlaybackResolver implements PlaybackResolver { private static final String TAG = VideoPlaybackResolver.class.getSimpleName(); @NonNull private final Context context; @NonNull private final PlayerDataSource dataSource; @NonNull private final QualityResolver qualityResolver; private SourceType streamSourceType; @Nullable private String playbackQuality; @Nullable private String audioTrack; public enum SourceType { LIVE_STREAM, VIDEO_WITH_SEPARATED_AUDIO, VIDEO_WITH_AUDIO_OR_AUDIO_ONLY } public VideoPlaybackResolver(@NonNull final Context context, @NonNull final PlayerDataSource dataSource, @NonNull final QualityResolver qualityResolver) { this.context = context; this.dataSource = dataSource; this.qualityResolver = qualityResolver; } @Override @Nullable public MediaSource resolve(@NonNull final StreamInfo info) { final MediaSource liveSource = PlaybackResolver.maybeBuildLiveMediaSource(dataSource, info); if (liveSource != null) { streamSourceType = SourceType.LIVE_STREAM; return liveSource; } final List mediaSources = new ArrayList<>(); // Create video stream source final List videoStreamsList = ListHelper.getSortedStreamVideosList(context, getPlayableStreams(info.getVideoStreams(), info.getServiceId()), getPlayableStreams(info.getVideoOnlyStreams(), info.getServiceId()), false, true); final List audioStreamsList = getFilteredAudioStreams(context, info.getAudioStreams()); final int videoIndex; if (videoStreamsList.isEmpty()) { videoIndex = -1; } else if (playbackQuality == null) { videoIndex = qualityResolver.getDefaultResolutionIndex(videoStreamsList); } else { videoIndex = qualityResolver.getOverrideResolutionIndex(videoStreamsList, getPlaybackQuality()); } final int audioIndex = ListHelper.getAudioFormatIndex(context, audioStreamsList, audioTrack); final MediaItemTag tag = StreamInfoTag.of(info, videoStreamsList, videoIndex, audioStreamsList, audioIndex); @Nullable final VideoStream video = tag.getMaybeQuality() .map(MediaItemTag.Quality::getSelectedVideoStream) .orElse(null); @Nullable final AudioStream audio = tag.getMaybeAudioTrack() .map(MediaItemTag.AudioTrack::getSelectedAudioStream) .orElse(null); if (video != null) { try { final MediaSource streamSource = PlaybackResolver.buildMediaSource( dataSource, video, info, PlaybackResolver.cacheKeyOf(info, video), tag); mediaSources.add(streamSource); } catch (final ResolverException e) { Log.e(TAG, "Unable to create video source", e); return null; } } // Use the audio stream if there is no video stream, or // merge with audio stream in case if video does not contain audio if (audio != null && (video == null || video.isVideoOnly() || audioTrack != null)) { try { final MediaSource audioSource = PlaybackResolver.buildMediaSource( dataSource, audio, info, PlaybackResolver.cacheKeyOf(info, audio), tag); mediaSources.add(audioSource); streamSourceType = SourceType.VIDEO_WITH_SEPARATED_AUDIO; } catch (final ResolverException e) { Log.e(TAG, "Unable to create audio source", e); return null; } } else { streamSourceType = SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY; } // If there is no audio or video sources, then this media source cannot be played back if (mediaSources.isEmpty()) { return null; } // Below are auxiliary media sources // Create subtitle sources final List subtitlesStreams = info.getSubtitles(); if (subtitlesStreams != null) { // Torrent and non URL subtitles are not supported by ExoPlayer final List nonTorrentAndUrlStreams = getUrlAndNonTorrentStreams( subtitlesStreams); for (final SubtitlesStream subtitle : nonTorrentAndUrlStreams) { final MediaFormat mediaFormat = subtitle.getFormat(); if (mediaFormat != null) { @C.RoleFlags final int textRoleFlag = subtitle.isAutoGenerated() ? C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND : C.ROLE_FLAG_CAPTION; final MediaItem.SubtitleConfiguration textMediaItem = new MediaItem.SubtitleConfiguration.Builder( Uri.parse(subtitle.getContent())) .setMimeType(mediaFormat.getMimeType()) .setRoleFlags(textRoleFlag) .setLanguage(PlayerHelper.captionLanguageOf(context, subtitle)) .build(); final MediaSource textSource = dataSource.getSingleSampleMediaSourceFactory() .createMediaSource(textMediaItem, TIME_UNSET); mediaSources.add(textSource); } } } if (mediaSources.size() == 1) { return mediaSources.get(0); } else { return new MergingMediaSource(true, mediaSources.toArray(new MediaSource[0])); } } /** * Returns the last resolved {@link StreamInfo}'s {@link SourceType source type}. * * @return {@link Optional#empty()} if nothing was resolved, otherwise the {@link SourceType} * of the last resolved {@link StreamInfo} inside an {@link Optional} */ public Optional getStreamSourceType() { return Optional.ofNullable(streamSourceType); } @Nullable public String getPlaybackQuality() { return playbackQuality; } public void setPlaybackQuality(@Nullable final String playbackQuality) { this.playbackQuality = playbackQuality; } @Nullable public String getAudioTrack() { return audioTrack; } public void setAudioTrack(@Nullable final String audioLanguage) { this.audioTrack = audioLanguage; } public interface QualityResolver { int getDefaultResolutionIndex(List sortedVideos); int getOverrideResolutionIndex(List sortedVideos, String playbackQuality); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHelper.java ================================================ package org.schabi.newpipe.player.seekbarpreview; import android.content.Context; import android.graphics.Bitmap; import android.util.Log; import android.view.View; import android.widget.ImageView; import androidx.annotation.IntDef; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.graphics.BitmapCompat; import androidx.core.math.MathUtils; import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; import org.schabi.newpipe.util.DeviceUtils; import java.lang.annotation.Retention; import java.util.function.IntSupplier; import static java.lang.annotation.RetentionPolicy.SOURCE; import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType.HIGH_QUALITY; import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType.LOW_QUALITY; import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType.NONE; /** * Helper for the seekbar preview. */ public final class SeekbarPreviewThumbnailHelper { // This has to be <= 23 chars on devices running Android 7 or lower (API <= 25) // or it fails with an IllegalArgumentException // https://stackoverflow.com/a/54744028 public static final String TAG = "SeekbarPrevThumbHelper"; private SeekbarPreviewThumbnailHelper() { // No impl pls } @Retention(SOURCE) @IntDef({HIGH_QUALITY, LOW_QUALITY, NONE}) public @interface SeekbarPreviewThumbnailType { int HIGH_QUALITY = 0; int LOW_QUALITY = 1; int NONE = 2; } //////////////////////////////////////////////////////////////////////////// // Settings Resolution /////////////////////////////////////////////////////////////////////////// @SeekbarPreviewThumbnailType public static int getSeekbarPreviewThumbnailType(@NonNull final Context context) { final String type = PreferenceManager.getDefaultSharedPreferences(context).getString( context.getString(R.string.seekbar_preview_thumbnail_key), ""); if (type.equals(context.getString(R.string.seekbar_preview_thumbnail_none))) { return NONE; } else if (type.equals(context.getString(R.string.seekbar_preview_thumbnail_low_quality))) { return LOW_QUALITY; } else { return HIGH_QUALITY; // default } } public static void tryResizeAndSetSeekbarPreviewThumbnail( @NonNull final Context context, @Nullable final Bitmap previewThumbnail, @NonNull final ImageView currentSeekbarPreviewThumbnail, @NonNull final IntSupplier baseViewWidthSupplier) { if (previewThumbnail == null) { currentSeekbarPreviewThumbnail.setVisibility(View.GONE); return; } currentSeekbarPreviewThumbnail.setVisibility(View.VISIBLE); // Resize original bitmap try { final int srcWidth = previewThumbnail.getWidth() > 0 ? previewThumbnail.getWidth() : 1; final int newWidth = MathUtils.clamp( // Use 1/4 of the width for the preview Math.round(baseViewWidthSupplier.getAsInt() / 4f), // But have a min width of 10dp DeviceUtils.dpToPx(10, context), // And scaling more than that factor looks really pixelated -> max Math.round(srcWidth * 2.5f)); final float scaleFactor = (float) newWidth / srcWidth; final int newHeight = (int) (previewThumbnail.getHeight() * scaleFactor); currentSeekbarPreviewThumbnail.setImageBitmap(BitmapCompat .createScaledBitmap(previewThumbnail, newWidth, newHeight, null, true)); } catch (final Exception ex) { Log.e(TAG, "Failed to resize and set seekbar preview thumbnail", ex); currentSeekbarPreviewThumbnail.setVisibility(View.GONE); } finally { previewThumbnail.recycle(); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java ================================================ package org.schabi.newpipe.player.seekbarpreview; import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType; import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.getSeekbarPreviewThumbnailType; import android.content.Context; import android.graphics.Bitmap; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.collection.SparseArrayCompat; import com.google.common.base.Stopwatch; import org.schabi.newpipe.App; import org.schabi.newpipe.extractor.stream.Frameset; import org.schabi.newpipe.util.image.CoilHelper; import java.util.Comparator; import java.util.List; import java.util.Optional; import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Supplier; public class SeekbarPreviewThumbnailHolder { // This has to be <= 23 chars on devices running Android 7 or lower (API <= 25) // or it fails with an IllegalArgumentException // https://stackoverflow.com/a/54744028 public static final String TAG = "SeekbarPrevThumbHolder"; // Key = Position of the picture in milliseconds // Supplier = Supplies the bitmap for that position private final SparseArrayCompat> seekbarPreviewData = new SparseArrayCompat<>(); // This ensures that if the reset is still undergoing // and another reset starts, only the last reset is processed private UUID currentUpdateRequestIdentifier = UUID.randomUUID(); public void resetFrom(@NonNull final Context context, final List framesets) { final int seekbarPreviewType = getSeekbarPreviewThumbnailType(context); final UUID updateRequestIdentifier = UUID.randomUUID(); this.currentUpdateRequestIdentifier = updateRequestIdentifier; final ExecutorService executorService = Executors.newSingleThreadExecutor(); executorService.submit(() -> { try { resetFromAsync(seekbarPreviewType, framesets, updateRequestIdentifier); } catch (final Exception ex) { Log.e(TAG, "Failed to execute async", ex); } }); // ensure that the executorService stops/destroys it's threads // after the task is finished executorService.shutdown(); } private void resetFromAsync(final int seekbarPreviewType, final List framesets, final UUID updateRequestIdentifier) { Log.d(TAG, "Clearing seekbarPreviewData"); synchronized (seekbarPreviewData) { seekbarPreviewData.clear(); } if (seekbarPreviewType == SeekbarPreviewThumbnailType.NONE) { Log.d(TAG, "Not processing seekbarPreviewData due to settings"); return; } final Frameset frameset = getFrameSetForType(framesets, seekbarPreviewType); if (frameset == null) { Log.d(TAG, "No frameset was found to fill seekbarPreviewData"); return; } Log.d(TAG, "Frameset quality info: " + "[width=" + frameset.getFrameWidth() + ", heigh=" + frameset.getFrameHeight() + "]"); // Abort method execution if we are not the latest request if (!isRequestIdentifierCurrent(updateRequestIdentifier)) { return; } generateDataFrom(frameset, updateRequestIdentifier); } private Frameset getFrameSetForType(final List framesets, final int seekbarPreviewType) { if (seekbarPreviewType == SeekbarPreviewThumbnailType.HIGH_QUALITY) { Log.d(TAG, "Strategy for seekbarPreviewData: high quality"); return framesets.stream() .max(Comparator.comparingInt(fs -> fs.getFrameHeight() * fs.getFrameWidth())) .orElse(null); } else { Log.d(TAG, "Strategy for seekbarPreviewData: low quality"); return framesets.stream() .min(Comparator.comparingInt(fs -> fs.getFrameHeight() * fs.getFrameWidth())) .orElse(null); } } private void generateDataFrom(final Frameset frameset, final UUID updateRequestIdentifier) { Log.d(TAG, "Starting generation of seekbarPreviewData"); final Stopwatch sw = Log.isLoggable(TAG, Log.DEBUG) ? Stopwatch.createStarted() : null; int currentPosMs = 0; int pos = 1; final int urlFrameCount = frameset.getFramesPerPageX() * frameset.getFramesPerPageY(); // Process each url in the frameset for (final String url : frameset.getUrls()) { // get the bitmap final Bitmap srcBitMap = getBitMapFrom(url); // The data is not added directly to "seekbarPreviewData" due to // concurrency and checks for "updateRequestIdentifier" final var generatedDataForUrl = new SparseArrayCompat>(urlFrameCount); // The bitmap consists of several images, which we process here // foreach frame in the returned bitmap for (int i = 0; i < urlFrameCount; i++) { // Frames outside the video length are skipped if (pos > frameset.getTotalCount()) { break; } // Get the bounds where the frame is found final int[] bounds = frameset.getFrameBoundsAt(currentPosMs); generatedDataForUrl.put(currentPosMs, createBitmapSupplier(srcBitMap, bounds, frameset)); currentPosMs += frameset.getDurationPerFrame(); pos++; } // Check if we are still the latest request // If not abort method execution if (isRequestIdentifierCurrent(updateRequestIdentifier)) { synchronized (seekbarPreviewData) { seekbarPreviewData.putAll(generatedDataForUrl); } } else { Log.d(TAG, "Aborted of generation of seekbarPreviewData"); break; } } if (sw != null) { Log.d(TAG, "Generation of seekbarPreviewData took " + sw.stop()); } } private Supplier createBitmapSupplier(final Bitmap srcBitMap, final int[] bounds, final Frameset frameset) { return () -> { // It can happen, that the original bitmap could not be downloaded // (or it was recycled though that should not happen) // In such a case - we don't want a NullPointer/ // "cannot use a recycled source in createBitmap" Exception -> simply return null if (srcBitMap == null || srcBitMap.isRecycled()) { return null; } // Under some rare circumstances the YouTube API returns slightly too small storyboards, // (or not the matching frame width/height) // This would lead to createBitmap cutting out a bitmap that is out of bounds, // so we need to adjust the bounds accordingly if (srcBitMap.getWidth() < bounds[1] + frameset.getFrameWidth()) { bounds[1] = srcBitMap.getWidth() - frameset.getFrameWidth(); } if (srcBitMap.getHeight() < bounds[2] + frameset.getFrameHeight()) { bounds[2] = srcBitMap.getHeight() - frameset.getFrameHeight(); } // Cut out the corresponding bitmap form the "srcBitMap" final Bitmap cutOutBitmap = Bitmap.createBitmap(srcBitMap, bounds[1], bounds[2], frameset.getFrameWidth(), frameset.getFrameHeight()); // If the cut out bitmap is identical to its source, // we need to copy the bitmap to create a new instance. // createBitmap allows itself to return the original object that is was created with // this leads to recycled bitmaps being returned (if they are identical) // Reference: https://stackoverflow.com/a/23683075 + first comment // Fixes: https://github.com/TeamNewPipe/NewPipe/issues/11461 return cutOutBitmap == srcBitMap ? cutOutBitmap.copy(cutOutBitmap.getConfig(), true) : cutOutBitmap; }; } @Nullable private Bitmap getBitMapFrom(final String url) { if (url == null) { Log.w(TAG, "url is null; This should never happen"); return null; } final Stopwatch sw = Log.isLoggable(TAG, Log.DEBUG) ? Stopwatch.createStarted() : null; try { Log.d(TAG, "Downloading bitmap for seekbarPreview from '" + url + "'"); // Gets the bitmap within the timeout of 15 seconds imposed by default by OkHttpClient // Ensure that you are not running on the main thread, otherwise this will hang final var bitmap = CoilHelper.INSTANCE.loadBitmapBlocking(App.getInstance(), url); if (sw != null) { Log.d(TAG, "Download of bitmap for seekbarPreview from '" + url + "' took " + sw.stop()); } return bitmap; } catch (final Exception ex) { Log.w(TAG, "Failed to get bitmap for seekbarPreview from url='" + url + "' in time", ex); return null; } } private boolean isRequestIdentifierCurrent(final UUID requestIdentifier) { return this.currentUpdateRequestIdentifier.equals(requestIdentifier); } public Optional getBitmapAt(final int positionInMs) { // Get the frame supplier closest to the requested position Supplier closestFrame = () -> null; synchronized (seekbarPreviewData) { int min = Integer.MAX_VALUE; for (int i = 0; i < seekbarPreviewData.size(); i++) { final int pos = Math.abs(seekbarPreviewData.keyAt(i) - positionInMs); if (pos < min) { closestFrame = seekbarPreviewData.valueAt(i); min = pos; } } } return Optional.ofNullable(closestFrame.get()); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/ui/BackgroundPlayerUi.java ================================================ package org.schabi.newpipe.player.ui; import androidx.annotation.NonNull; import org.schabi.newpipe.player.Player; /** * This is not a "graphical" UI for the background player, but it is used to disable fetching video * and text tracks with it. * *

* This allows reducing data usage for manifest sources with demuxed audio and video, * such as livestreams. *

*/ public class BackgroundPlayerUi extends PlayerUi { public BackgroundPlayerUi(@NonNull final Player player) { super(player); } @Override public void initPlayback() { super.initPlayback(); // Make sure to disable video and subtitles track types player.useVideoAndSubtitles(false); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java ================================================ package org.schabi.newpipe.player.ui; import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; import static org.schabi.newpipe.MainActivity.DEBUG; import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu; import static org.schabi.newpipe.extractor.ServiceList.YouTube; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.player.Player.STATE_COMPLETED; import static org.schabi.newpipe.player.Player.STATE_PAUSED; import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_BACKGROUND; import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_NONE; import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_POPUP; import static org.schabi.newpipe.player.helper.PlayerHelper.getMinimizeOnExitAction; import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString; import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientationLocked; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.content.res.Resources; import android.database.ContentObserver; import android.graphics.Bitmap; import android.graphics.Color; import android.os.Handler; import android.os.Looper; import android.provider.Settings; import android.util.Log; import android.view.KeyEvent; import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; import android.view.WindowManager; import android.widget.FrameLayout; import android.widget.LinearLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.content.res.AppCompatResources; import androidx.fragment.app.FragmentActivity; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; import com.google.android.exoplayer2.ui.SubtitleView; import com.google.android.exoplayer2.video.VideoSize; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.PlayerBinding; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamSegment; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; import org.schabi.newpipe.fragments.detail.VideoDetailFragment; import org.schabi.newpipe.info_list.StreamSegmentAdapter; import org.schabi.newpipe.info_list.StreamSegmentItem; import org.schabi.newpipe.ktx.AnimationType; import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.event.PlayerServiceEventListener; import org.schabi.newpipe.player.gesture.BasePlayerGestureListener; import org.schabi.newpipe.player.gesture.MainPlayerGestureListener; import org.schabi.newpipe.player.helper.PlaybackParameterDialog; import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueAdapter; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.player.playqueue.PlayQueueItemBuilder; import org.schabi.newpipe.player.playqueue.PlayQueueItemHolder; import org.schabi.newpipe.player.playqueue.PlayQueueItemTouchCallback; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutChangeListener { private static final String TAG = MainPlayerUi.class.getSimpleName(); // see the Javadoc of calculateMaxEndScreenThumbnailHeight for information private static final int DETAIL_ROOT_MINIMUM_HEIGHT = 85; // dp private static final int DETAIL_TITLE_TEXT_SIZE_TV = 16; // sp private static final int DETAIL_TITLE_TEXT_SIZE_TABLET = 15; // sp private boolean isFullscreen = false; private boolean isVerticalVideo = false; private boolean fragmentIsVisible = false; private ContentObserver settingsContentObserver; private PlayQueueAdapter playQueueAdapter; private StreamSegmentAdapter segmentAdapter; private boolean isQueueVisible = false; private boolean areSegmentsVisible = false; // fullscreen player private ItemTouchHelper itemTouchHelper; /*////////////////////////////////////////////////////////////////////////// // Constructor, setup, destroy //////////////////////////////////////////////////////////////////////////*/ //region Constructor, setup, destroy public MainPlayerUi(@NonNull final Player player, @NonNull final PlayerBinding playerBinding) { super(player, playerBinding); } /** * Open fullscreen on tablets where the option to have the main player start automatically in * fullscreen mode is on. Rotating the device to landscape is already done in {@link * VideoDetailFragment#openVideoPlayer(boolean)} when the thumbnail is clicked, and that's * enough for phones, but not for tablets since the mini player can be also shown in landscape. */ private void directlyOpenFullscreenIfNeeded() { if (PlayerHelper.isStartMainPlayerFullscreenEnabled(player.getService()) && DeviceUtils.isTablet(player.getService()) && PlayerHelper.globalScreenOrientationLocked(player.getService())) { player.getFragmentListener().ifPresent( PlayerServiceEventListener::onScreenRotationButtonClicked); } } @Override public void setupAfterIntent() { // needed for tablets, check the function for a better explanation directlyOpenFullscreenIfNeeded(); super.setupAfterIntent(); initVideoPlayer(); // Android TV: without it focus will frame the whole player binding.playPauseButton.requestFocus(); // Note: This is for automatically playing (when "Resume playback" is off), see #6179 if (player.getPlayWhenReady()) { player.play(); } else { player.pause(); } } @Override BasePlayerGestureListener buildGestureListener() { return new MainPlayerGestureListener(this); } @Override protected void initListeners() { super.initListeners(); binding.screenRotationButton.setOnClickListener(makeOnClickListener(() -> { // Only if it's not a vertical video or vertical video but in landscape with locked // orientation a screen orientation can be changed automatically if (!isVerticalVideo || (isLandscape() && globalScreenOrientationLocked(context))) { player.getFragmentListener() .ifPresent(PlayerServiceEventListener::onScreenRotationButtonClicked); } else { toggleFullscreen(); } })); binding.queueButton.setOnClickListener(v -> onQueueClicked()); binding.segmentsButton.setOnClickListener(v -> onSegmentsClicked()); binding.addToPlaylistButton.setOnClickListener(v -> getParentActivity().map(FragmentActivity::getSupportFragmentManager) .ifPresent(fragmentManager -> PlaylistDialog.showForPlayQueue(player, fragmentManager))); settingsContentObserver = new ContentObserver(new Handler(Looper.getMainLooper())) { @Override public void onChange(final boolean selfChange) { setupScreenRotationButton(); } }; context.getContentResolver().registerContentObserver( Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false, settingsContentObserver); binding.getRoot().addOnLayoutChangeListener(this); binding.moreOptionsButton.setOnLongClickListener(v -> { player.getFragmentListener() .ifPresent(PlayerServiceEventListener::onMoreOptionsLongClicked); hideControls(0, 0); hideSystemUIIfNeeded(); return true; }); } @Override protected void deinitListeners() { super.deinitListeners(); binding.queueButton.setOnClickListener(null); binding.segmentsButton.setOnClickListener(null); binding.addToPlaylistButton.setOnClickListener(null); context.getContentResolver().unregisterContentObserver(settingsContentObserver); binding.getRoot().removeOnLayoutChangeListener(this); } @Override public void initPlayback() { super.initPlayback(); if (playQueueAdapter != null) { playQueueAdapter.dispose(); } playQueueAdapter = new PlayQueueAdapter(context, Objects.requireNonNull(player.getPlayQueue())); segmentAdapter = new StreamSegmentAdapter(getStreamSegmentListener()); // Make sure video and text tracks are enabled if the user is in the app, in the case user // switched from background player to main player player.useVideoAndSubtitles(fragmentIsVisible); } @Override public void removeViewFromParent() { // view was added to fragment final ViewParent parent = binding.getRoot().getParent(); if (parent instanceof ViewGroup) { ((ViewGroup) parent).removeView(binding.getRoot()); } } @Override public void destroy() { super.destroy(); // Exit from fullscreen when user closes the player via notification if (isFullscreen) { toggleFullscreen(); } removeViewFromParent(); } @Override public void destroyPlayer() { super.destroyPlayer(); if (playQueueAdapter != null) { playQueueAdapter.unsetSelectedListener(); playQueueAdapter.dispose(); } } @Override public void smoothStopForImmediateReusing() { super.smoothStopForImmediateReusing(); // Android TV will handle back button in case controls will be visible // (one more additional unneeded click while the player is hidden) hideControls(0, 0); closeItemsList(); } private void initVideoPlayer() { // restore last resize mode setResizeMode(PlayerHelper.retrieveResizeModeFromPrefs(player)); binding.getRoot().setLayoutParams(new FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)); } @Override protected void setupElementsVisibility() { super.setupElementsVisibility(); closeItemsList(); showHideKodiButton(); binding.fullScreenButton.setVisibility(View.GONE); setupScreenRotationButton(); binding.resizeTextView.setVisibility(View.VISIBLE); binding.getRoot().findViewById(R.id.metadataView).setVisibility(View.VISIBLE); binding.moreOptionsButton.setVisibility(View.VISIBLE); binding.topControls.setOrientation(LinearLayout.VERTICAL); binding.primaryControls.getLayoutParams().width = MATCH_PARENT; binding.secondaryControls.setVisibility(View.INVISIBLE); binding.moreOptionsButton.setImageDrawable(AppCompatResources.getDrawable(context, R.drawable.ic_expand_more)); binding.share.setVisibility(View.VISIBLE); binding.openInBrowser.setVisibility(View.VISIBLE); binding.switchMute.setVisibility(View.VISIBLE); binding.playerCloseButton.setVisibility(isFullscreen ? View.GONE : View.VISIBLE); // Top controls have a large minHeight which is allows to drag the player // down in fullscreen mode (just larger area to make easy to locate by finger) binding.topControls.setClickable(true); binding.topControls.setFocusable(true); binding.metadataView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE); // Reset workaround changes from popup player binding.audioTrackTextView.setMaxWidth(Integer.MAX_VALUE); } @Override protected void setupElementsSize(final Resources resources) { setupElementsSize( resources.getDimensionPixelSize(R.dimen.player_main_buttons_min_width), resources.getDimensionPixelSize(R.dimen.player_main_top_padding), resources.getDimensionPixelSize(R.dimen.player_main_controls_padding), resources.getDimensionPixelSize(R.dimen.player_main_buttons_padding) ); } //endregion /*////////////////////////////////////////////////////////////////////////// // Broadcast receiver //////////////////////////////////////////////////////////////////////////*/ //region Broadcast receiver @Override public void onBroadcastReceived(final Intent intent) { super.onBroadcastReceived(intent); if (Intent.ACTION_CONFIGURATION_CHANGED.equals(intent.getAction())) { // Close it because when changing orientation from portrait // (in fullscreen mode) the size of queue layout can be larger than the screen size closeItemsList(); } else if (ACTION_PLAY_PAUSE.equals(intent.getAction())) { // Ensure that we have audio-only stream playing when a user // started to play from notification's play button from outside of the app if (!fragmentIsVisible) { onFragmentStopped(); } } else if (VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED.equals(intent.getAction())) { fragmentIsVisible = false; onFragmentStopped(); } else if (VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED.equals(intent.getAction())) { // Restore video source when user returns to the fragment fragmentIsVisible = true; player.useVideoAndSubtitles(true); // When a user returns from background, the system UI will always be shown even if // controls are invisible: hide it in that case if (!isControlsVisible()) { hideSystemUIIfNeeded(); } } } //endregion /*////////////////////////////////////////////////////////////////////////// // Fragment binding //////////////////////////////////////////////////////////////////////////*/ //region Fragment binding @Override public void onFragmentListenerSet() { super.onFragmentListenerSet(); fragmentIsVisible = true; // Apply window insets because Android will not do it when orientation changes // from landscape to portrait if (!isFullscreen) { binding.playbackControlRoot.setPadding(0, 0, 0, 0); } binding.itemsListPanel.setPadding(0, 0, 0, 0); player.getFragmentListener().ifPresent(PlayerServiceEventListener::onViewCreated); } /** * This will be called when a user goes to another app/activity, turns off a screen. * We don't want to interrupt playback and don't want to see notification so * next lines of code will enable audio-only playback only if needed */ private void onFragmentStopped() { if (player.isPlaying() || player.isLoading()) { switch (getMinimizeOnExitAction(context)) { case MINIMIZE_ON_EXIT_MODE_BACKGROUND: player.useVideoAndSubtitles(false); break; case MINIMIZE_ON_EXIT_MODE_POPUP: getParentActivity().ifPresent(activity -> { player.setRecovery(); NavigationHelper.playOnPopupPlayer(activity, player.getPlayQueue(), true); }); break; case MINIMIZE_ON_EXIT_MODE_NONE: default: player.pause(); break; } } } //endregion /*////////////////////////////////////////////////////////////////////////// // Playback states //////////////////////////////////////////////////////////////////////////*/ //region Playback states @Override public void onUpdateProgress(final int currentProgress, final int duration, final int bufferPercent) { super.onUpdateProgress(currentProgress, duration, bufferPercent); if (areSegmentsVisible) { segmentAdapter.selectSegmentAt(getNearestStreamSegmentPosition(currentProgress)); } if (isQueueVisible) { updateQueueTime(currentProgress); } } @Override public void onPlaying() { super.onPlaying(); checkLandscape(); } @Override public void onCompleted() { super.onCompleted(); if (isFullscreen) { toggleFullscreen(); } } //endregion /*////////////////////////////////////////////////////////////////////////// // Controls showing / hiding //////////////////////////////////////////////////////////////////////////*/ //region Controls showing / hiding @Override protected void showOrHideButtons() { super.showOrHideButtons(); @Nullable final PlayQueue playQueue = player.getPlayQueue(); if (playQueue == null) { return; } final boolean showQueue = !playQueue.getStreams().isEmpty(); final boolean showSegment = !player.getCurrentStreamInfo() .map(StreamInfo::getStreamSegments) .map(List::isEmpty) .orElse(/*no stream info=*/true); binding.queueButton.setVisibility(showQueue ? View.VISIBLE : View.GONE); binding.queueButton.setAlpha(showQueue ? 1.0f : 0.0f); binding.segmentsButton.setVisibility(showSegment ? View.VISIBLE : View.GONE); binding.segmentsButton.setAlpha(showSegment ? 1.0f : 0.0f); } @Override public void showSystemUIPartially() { if (isFullscreen) { getParentActivity().map(Activity::getWindow).ifPresent(window -> { window.setStatusBarColor(Color.TRANSPARENT); window.setNavigationBarColor(Color.TRANSPARENT); final int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION; window.getDecorView().setSystemUiVisibility(visibility); window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); }); } } @Override public void hideSystemUIIfNeeded() { player.getFragmentListener().ifPresent(PlayerServiceEventListener::hideSystemUiIfNeeded); } /** * Calculate the maximum allowed height for the {@link R.id.endScreen} * to prevent it from enlarging the player. *

* The calculating follows these rules: *

    *
  • * Show at least stream title and content creator on TVs and tablets when in landscape * (always the case for TVs) and not in fullscreen mode. This requires to have at least * {@link #DETAIL_ROOT_MINIMUM_HEIGHT} free space for {@link R.id.detail_root} and * additional space for the stream title text size ({@link R.id.detail_title_root_layout}). * The text size is {@link #DETAIL_TITLE_TEXT_SIZE_TABLET} on tablets and * {@link #DETAIL_TITLE_TEXT_SIZE_TV} on TVs, see {@link R.id.titleTextView}. *
  • *
  • * Otherwise, the max thumbnail height is the screen height. *
  • *
* * @param bitmap the bitmap that needs to be resized to fit the end screen * @return the maximum height for the end screen thumbnail */ @Override protected float calculateMaxEndScreenThumbnailHeight(@NonNull final Bitmap bitmap) { final int screenHeight = context.getResources().getDisplayMetrics().heightPixels; if (DeviceUtils.isTv(context) && !isFullscreen()) { final int videoInfoHeight = DeviceUtils.dpToPx(DETAIL_ROOT_MINIMUM_HEIGHT, context) + DeviceUtils.spToPx(DETAIL_TITLE_TEXT_SIZE_TV, context); return Math.min(bitmap.getHeight(), screenHeight - videoInfoHeight); } else if (DeviceUtils.isTablet(context) && isLandscape() && !isFullscreen()) { final int videoInfoHeight = DeviceUtils.dpToPx(DETAIL_ROOT_MINIMUM_HEIGHT, context) + DeviceUtils.spToPx(DETAIL_TITLE_TEXT_SIZE_TABLET, context); return Math.min(bitmap.getHeight(), screenHeight - videoInfoHeight); } else { // fullscreen player: max height is the device height return Math.min(bitmap.getHeight(), screenHeight); } } private void showHideKodiButton() { // show kodi button if it supports the current service and it is enabled in settings @Nullable final PlayQueue playQueue = player.getPlayQueue(); binding.playWithKodi.setVisibility(playQueue != null && playQueue.getItem() != null && KoreUtils.shouldShowPlayWithKodi(context, playQueue.getItem().getServiceId()) ? View.VISIBLE : View.GONE); } //endregion /*////////////////////////////////////////////////////////////////////////// // Captions (text tracks) //////////////////////////////////////////////////////////////////////////*/ //region Captions (text tracks) @Override protected void setupSubtitleView(final float captionScale) { binding.subtitleView.setFractionalTextSize( SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * captionScale); } //endregion /*////////////////////////////////////////////////////////////////////////// // Gestures //////////////////////////////////////////////////////////////////////////*/ //region Gestures @SuppressWarnings("checkstyle:ParameterNumber") @Override public void onLayoutChange(final View view, final int l, final int t, final int r, final int b, final int ol, final int ot, final int or, final int ob) { if (l != ol || t != ot || r != or || b != ob) { // Use a smaller value to be consistent across screen orientations, and to make usage // easier. Multiply by 3/4 to ensure the user does not need to move the finger up to the // screen border, in order to reach the maximum volume/brightness. final int width = r - l; final int height = b - t; final int min = Math.min(width, height); final int maxGestureLength = (int) (min * 0.75); if (DEBUG) { Log.d(TAG, "maxGestureLength = " + maxGestureLength); } binding.volumeProgressBar.setMax(maxGestureLength); binding.brightnessProgressBar.setMax(maxGestureLength); setInitialGestureValues(); binding.itemsListPanel.getLayoutParams().height = height - binding.itemsListPanel.getTop(); } } private void setInitialGestureValues() { if (player.getAudioReactor() != null) { final float currentVolumeNormalized = (float) player.getAudioReactor().getVolume() / player.getAudioReactor().getMaxVolume(); binding.volumeProgressBar.setProgress( (int) (binding.volumeProgressBar.getMax() * currentVolumeNormalized)); } } //endregion /*////////////////////////////////////////////////////////////////////////// // Play queue, segments and streams //////////////////////////////////////////////////////////////////////////*/ //region Play queue, segments and streams @Override public void onMetadataChanged(@NonNull final StreamInfo info) { super.onMetadataChanged(info); showHideKodiButton(); if (areSegmentsVisible) { if (segmentAdapter.setItems(info)) { final int adapterPosition = getNearestStreamSegmentPosition( player.getExoPlayer().getCurrentPosition()); segmentAdapter.selectSegmentAt(adapterPosition); binding.itemsList.scrollToPosition(adapterPosition); } else { closeItemsList(); } } } @Override public void onPlayQueueEdited() { super.onPlayQueueEdited(); showOrHideButtons(); } private void onQueueClicked() { isQueueVisible = true; hideSystemUIIfNeeded(); buildQueue(); binding.itemsListHeaderTitle.setVisibility(View.GONE); binding.itemsListHeaderDuration.setVisibility(View.VISIBLE); binding.shuffleButton.setVisibility(View.VISIBLE); binding.repeatButton.setVisibility(View.VISIBLE); binding.addToPlaylistButton.setVisibility(View.VISIBLE); hideControls(0, 0); binding.itemsListPanel.requestFocus(); animate(binding.itemsListPanel, true, DEFAULT_CONTROLS_DURATION, AnimationType.SLIDE_AND_ALPHA); @Nullable final PlayQueue playQueue = player.getPlayQueue(); if (playQueue != null) { binding.itemsList.scrollToPosition(playQueue.getIndex()); } updateQueueTime((int) player.getExoPlayer().getCurrentPosition()); } private void buildQueue() { binding.itemsList.setAdapter(playQueueAdapter); binding.itemsList.setClickable(true); binding.itemsList.setLongClickable(true); binding.itemsList.clearOnScrollListeners(); binding.itemsList.addOnScrollListener(getQueueScrollListener()); itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); itemTouchHelper.attachToRecyclerView(binding.itemsList); playQueueAdapter.setSelectedListener(getOnSelectedListener()); binding.itemsListClose.setOnClickListener(view -> closeItemsList()); } private void onSegmentsClicked() { areSegmentsVisible = true; hideSystemUIIfNeeded(); buildSegments(); binding.itemsListHeaderTitle.setVisibility(View.VISIBLE); binding.itemsListHeaderDuration.setVisibility(View.GONE); binding.shuffleButton.setVisibility(View.GONE); binding.repeatButton.setVisibility(View.GONE); binding.addToPlaylistButton.setVisibility(View.GONE); hideControls(0, 0); binding.itemsListPanel.requestFocus(); animate(binding.itemsListPanel, true, DEFAULT_CONTROLS_DURATION, AnimationType.SLIDE_AND_ALPHA); final int adapterPosition = getNearestStreamSegmentPosition( player.getExoPlayer().getCurrentPosition()); segmentAdapter.selectSegmentAt(adapterPosition); binding.itemsList.scrollToPosition(adapterPosition); } private void buildSegments() { binding.itemsList.setAdapter(segmentAdapter); binding.itemsList.setClickable(true); binding.itemsList.setLongClickable(true); binding.itemsList.clearOnScrollListeners(); if (itemTouchHelper != null) { itemTouchHelper.attachToRecyclerView(null); } player.getCurrentStreamInfo().ifPresent(segmentAdapter::setItems); binding.shuffleButton.setVisibility(View.GONE); binding.repeatButton.setVisibility(View.GONE); binding.addToPlaylistButton.setVisibility(View.GONE); binding.itemsListClose.setOnClickListener(view -> closeItemsList()); } public void closeItemsList() { if (isQueueVisible || areSegmentsVisible) { isQueueVisible = false; areSegmentsVisible = false; if (itemTouchHelper != null) { itemTouchHelper.attachToRecyclerView(null); } animate(binding.itemsListPanel, false, DEFAULT_CONTROLS_DURATION, AnimationType.SLIDE_AND_ALPHA, 0, () -> // Even when queueLayout is GONE it receives touch events // and ruins normal behavior of the app. This line fixes it binding.itemsListPanel.setTranslationY( -binding.itemsListPanel.getHeight() * 5.0f)); // clear focus, otherwise a white rectangle remains on top of the player binding.itemsListClose.clearFocus(); binding.playPauseButton.requestFocus(); } } private OnScrollBelowItemsListener getQueueScrollListener() { return new OnScrollBelowItemsListener() { @Override public void onScrolledDown(final RecyclerView recyclerView) { @Nullable final PlayQueue playQueue = player.getPlayQueue(); if (playQueue != null && !playQueue.isComplete()) { playQueue.fetch(); } else if (binding != null) { binding.itemsList.clearOnScrollListeners(); } } }; } private StreamSegmentAdapter.StreamSegmentListener getStreamSegmentListener() { return new StreamSegmentAdapter.StreamSegmentListener() { @Override public void onItemClick(@NonNull final StreamSegmentItem item, final int seconds) { segmentAdapter.selectSegment(item); player.seekTo(seconds * 1000L); player.triggerProgressUpdate(); } @Override public void onItemLongClick(@NonNull final StreamSegmentItem item, final int seconds) { @Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata(); if (currentMetadata == null || currentMetadata.getServiceId() != YouTube.getServiceId()) { return; } final PlayQueueItem currentItem = player.getCurrentItem(); if (currentItem != null) { String videoUrl = player.getVideoUrl(); videoUrl += ("&t=" + seconds); ShareUtils.shareText(context, currentItem.getTitle(), videoUrl, currentItem.getThumbnails()); } } }; } private int getNearestStreamSegmentPosition(final long playbackPosition) { final List segments = player.getCurrentStreamInfo() .map(StreamInfo::getStreamSegments) .orElse(Collections.emptyList()); int nearestPosition = 0; for (final var segment : segments) { if (segment.getStartTimeSeconds() * 1000L > playbackPosition) { break; } nearestPosition++; } return Math.max(0, nearestPosition - 1); } private ItemTouchHelper.SimpleCallback getItemTouchCallback() { return new PlayQueueItemTouchCallback() { @Override public void onMove(final int sourceIndex, final int targetIndex) { @Nullable final PlayQueue playQueue = player.getPlayQueue(); if (playQueue != null) { playQueue.move(sourceIndex, targetIndex); } } @Override public void onSwiped(final int index) { @Nullable final PlayQueue playQueue = player.getPlayQueue(); if (playQueue != null && index != -1) { playQueue.remove(index); } } }; } private PlayQueueItemBuilder.OnSelectedListener getOnSelectedListener() { return new PlayQueueItemBuilder.OnSelectedListener() { @Override public void selected(final PlayQueueItem item, final View view) { player.selectQueueItem(item); } @Override public void held(final PlayQueueItem item, final View view) { @Nullable final PlayQueue playQueue = player.getPlayQueue(); @Nullable final AppCompatActivity parentActivity = getParentActivity().orElse(null); if (playQueue != null && parentActivity != null && playQueue.indexOf(item) != -1) { openPopupMenu(player.getPlayQueue(), item, view, true, parentActivity.getSupportFragmentManager(), context); } } @Override public void onStartDrag(final PlayQueueItemHolder viewHolder) { if (itemTouchHelper != null) { itemTouchHelper.startDrag(viewHolder); } } }; } private void updateQueueTime(final int currentTime) { @Nullable final PlayQueue playQueue = player.getPlayQueue(); if (playQueue == null) { return; } final int currentStream = playQueue.getIndex(); final List streams = playQueue.getStreams(); final long before = streams.subList(0, currentStream).stream() .collect(Collectors.summingLong(PlayQueueItem::getDuration)) * 1000; final long after = streams.subList(currentStream, streams.size()).stream() .collect(Collectors.summingLong(PlayQueueItem::getDuration)) * 1000; binding.itemsListHeaderDuration.setText( String.format("%s/%s", getTimeString(currentTime + before), getTimeString(before + after) )); } @Override protected boolean isAnyListViewOpen() { return isQueueVisible || areSegmentsVisible; } @Override public boolean isFullscreen() { return isFullscreen; } public boolean isVerticalVideo() { return isVerticalVideo; } //endregion /*////////////////////////////////////////////////////////////////////////// // Click listeners //////////////////////////////////////////////////////////////////////////*/ //region Click listeners @Override protected void onPlaybackSpeedClicked() { getParentActivity().ifPresent(activity -> PlaybackParameterDialog.newInstance(player.getPlaybackSpeed(), player.getPlaybackPitch(), player.getPlaybackSkipSilence(), player::setPlaybackParameters) .show(activity.getSupportFragmentManager(), null)); } @Override public boolean onKeyDown(final int keyCode) { if (keyCode == KeyEvent.KEYCODE_SPACE && isFullscreen) { player.playPause(); if (player.isPlaying()) { hideControls(0, 0); } return true; } return super.onKeyDown(keyCode); } //endregion /*////////////////////////////////////////////////////////////////////////// // Video size, orientation, fullscreen //////////////////////////////////////////////////////////////////////////*/ //region Video size, orientation, fullscreen private void setupScreenRotationButton() { binding.screenRotationButton.setVisibility(globalScreenOrientationLocked(context) || isVerticalVideo || DeviceUtils.isTablet(context) ? View.VISIBLE : View.GONE); binding.screenRotationButton.setImageDrawable(AppCompatResources.getDrawable(context, isFullscreen ? R.drawable.ic_fullscreen_exit : R.drawable.ic_fullscreen)); } @Override public void onVideoSizeChanged(@NonNull final VideoSize videoSize) { super.onVideoSizeChanged(videoSize); isVerticalVideo = videoSize.width < videoSize.height; if (globalScreenOrientationLocked(context) && isFullscreen && isLandscape() == isVerticalVideo && !DeviceUtils.isTv(context) && !DeviceUtils.isTablet(context)) { // set correct orientation player.getFragmentListener().ifPresent( PlayerServiceEventListener::onScreenRotationButtonClicked); } setupScreenRotationButton(); } public void toggleFullscreen() { if (DEBUG) { Log.d(TAG, "toggleFullscreen() called"); } final PlayerServiceEventListener fragmentListener = player.getFragmentListener() .orElse(null); if (fragmentListener == null || player.exoPlayerIsNull()) { return; } isFullscreen = !isFullscreen; if (isFullscreen) { // Android needs tens milliseconds to send new insets but a user is able to see // how controls changes it's position from `0` to `nav bar height` padding. // So just hide the controls to hide this visual inconsistency hideControls(0, 0); } else { // Apply window insets because Android will not do it when orientation changes // from landscape to portrait (open vertical video to reproduce) binding.playbackControlRoot.setPadding(0, 0, 0, 0); } fragmentListener.onFullscreenStateChanged(isFullscreen); binding.metadataView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE); binding.playerCloseButton.setVisibility(isFullscreen ? View.GONE : View.VISIBLE); setupScreenRotationButton(); } public void checkLandscape() { // check if landscape is correct final boolean videoInLandscapeButNotInFullscreen = isLandscape() && !isFullscreen && !player.isAudioOnly(); final boolean notPaused = player.getCurrentState() != STATE_COMPLETED && player.getCurrentState() != STATE_PAUSED; if (videoInLandscapeButNotInFullscreen && notPaused && !DeviceUtils.isTablet(context)) { toggleFullscreen(); } } //endregion /*////////////////////////////////////////////////////////////////////////// // Getters //////////////////////////////////////////////////////////////////////////*/ //region Getters private Optional getParentContext() { return Optional.ofNullable(binding.getRoot().getParent()) .filter(ViewGroup.class::isInstance) .map(parent -> ((ViewGroup) parent).getContext()); } public Optional getParentActivity() { return getParentContext() .filter(AppCompatActivity.class::isInstance) .map(AppCompatActivity.class::cast); } public boolean isLandscape() { // DisplayMetrics from activity context knows about MultiWindow feature // while DisplayMetrics from app context doesn't return DeviceUtils.isLandscape(getParentContext().orElse(player.getService())); } //endregion } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/ui/PlayerUi.java ================================================ package org.schabi.newpipe.player.ui; import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player.RepeatMode; import com.google.android.exoplayer2.Tracks; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.video.VideoSize; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.player.Player; import java.util.List; /** * A player UI is a component that can seamlessly connect and disconnect from the {@link Player} and * provide a user interface of some sort. Try to extend this class instead of adding more code to * {@link Player}! */ public abstract class PlayerUi { @NonNull protected final Context context; @NonNull protected final Player player; /** * @param player the player instance that will be usable throughout the lifetime of this UI; its * context should already have been initialized */ protected PlayerUi(@NonNull final Player player) { this.context = player.getContext(); this.player = player; } /** * @return the player instance this UI was constructed with */ @NonNull public Player getPlayer() { return player; } /** * Called after the player received an intent and processed it. */ public void setupAfterIntent() { } /** * Called right after the exoplayer instance is constructed, or right after this UI is * constructed if the exoplayer is already available then. Note that the exoplayer instance * could be built and destroyed multiple times during the lifetime of the player, so this method * might be called multiple times. */ public void initPlayer() { } /** * Called when playback in the exoplayer is about to start, or right after this UI is * constructed if the exoplayer and the play queue are already available then. The play queue * will therefore always be not null. */ public void initPlayback() { } /** * Called when the exoplayer instance is about to be destroyed. Note that the exoplayer instance * could be built and destroyed multiple times during the lifetime of the player, so this method * might be called multiple times. Be sure to unset any video surface view or play queue * listeners! This will also be called when this UI is being discarded, just before {@link * #destroy()}. */ public void destroyPlayer() { } /** * Called when this UI is being discarded, either because the player is switching to a different * UI or because the player is shutting down completely. */ public void destroy() { } /** * Called when the player is smooth-stopping, that is, transitioning smoothly to a new play * queue after the user tapped on a new video stream while a stream was playing in the video * detail fragment. */ public void smoothStopForImmediateReusing() { } /** * Called when the video detail fragment listener is connected with the player, or right after * this UI is constructed if the listener is already connected then. */ public void onFragmentListenerSet() { } /** * Broadcasts that the player receives will also be notified to UIs here. If you want to * register new broadcast actions to receive here, add them to {@link * Player#setupBroadcastReceiver()}. * @param intent the broadcast intent received by the player */ public void onBroadcastReceived(final Intent intent) { } /** * Called when stream progress (i.e. the current time in the seekbar) or stream duration change. * Will surely be called every {@link Player#PROGRESS_LOOP_INTERVAL_MILLIS} while a stream is * playing. * @param currentProgress the current progress in milliseconds * @param duration the duration of the stream being played * @param bufferPercent the percentage of stream already buffered, see {@link * com.google.android.exoplayer2.BasePlayer#getBufferedPercentage()} */ public void onUpdateProgress(final int currentProgress, final int duration, final int bufferPercent) { } public void onPrepared() { } public void onBlocked() { } public void onPlaying() { } public void onBuffering() { } public void onPaused() { } public void onPausedSeek() { } public void onCompleted() { } public void onRepeatModeChanged(@RepeatMode final int repeatMode) { } public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) { } public void onMuteUnmuteChanged(final boolean isMuted) { } /** * @see com.google.android.exoplayer2.Player.Listener#onTracksChanged(Tracks) * @param currentTracks the available tracks information */ public void onTextTracksChanged(@NonNull final Tracks currentTracks) { } /** * @see com.google.android.exoplayer2.Player.Listener#onPlaybackParametersChanged * @param playbackParameters the new playback parameters */ public void onPlaybackParametersChanged(@NonNull final PlaybackParameters playbackParameters) { } /** * @see com.google.android.exoplayer2.Player.Listener#onRenderedFirstFrame */ public void onRenderedFirstFrame() { } /** * @see com.google.android.exoplayer2.text.TextOutput#onCues * @param cues the cues to pass to the subtitle view */ public void onCues(@NonNull final List cues) { } /** * Called when the stream being played changes. * @param info the {@link StreamInfo} metadata object, along with data about the selected and * available video streams (to be used to build the resolution menus, for example) */ public void onMetadataChanged(@NonNull final StreamInfo info) { } /** * Called when the thumbnail for the current metadata was loaded. * @param bitmap the thumbnail to process, or null if there is no thumbnail or there was an * error when loading the thumbnail */ public void onThumbnailLoaded(@Nullable final Bitmap bitmap) { } /** * Called when the play queue was edited: a stream was appended, moved or removed. */ public void onPlayQueueEdited() { } /** * @param videoSize the new video size, useful to set the surface aspect ratio * @see com.google.android.exoplayer2.Player.Listener#onVideoSizeChanged */ public void onVideoSizeChanged(@NonNull final VideoSize videoSize) { } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.java ================================================ package org.schabi.newpipe.player.ui; import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.function.Consumer; public final class PlayerUiList { final List playerUis = new ArrayList<>(); /** * Creates a {@link PlayerUiList} starting with the provided player uis. The provided player uis * will not be prepared like those passed to {@link #addAndPrepare(PlayerUi)}, because when * the {@link PlayerUiList} constructor is called, the player is still not running and it * wouldn't make sense to initialize uis then. Instead the player will initialize them by doing * proper calls to {@link #call(Consumer)}. * * @param initialPlayerUis the player uis this list should start with; the order will be kept */ public PlayerUiList(final PlayerUi... initialPlayerUis) { playerUis.addAll(List.of(initialPlayerUis)); } /** * Adds the provided player ui to the list and calls on it the initialization functions that * apply based on the current player state. The preparation step needs to be done since when UIs * are removed and re-added, the player will not call e.g. initPlayer again since the exoplayer * is already initialized, but we need to notify the newly built UI that the player is ready * nonetheless. * @param playerUi the player ui to prepare and add to the list; its {@link * PlayerUi#getPlayer()} will be used to query information about the player * state */ public void addAndPrepare(final PlayerUi playerUi) { if (playerUi.getPlayer().getFragmentListener().isPresent()) { // make sure UIs know whether a service is connected or not playerUi.onFragmentListenerSet(); } if (!playerUi.getPlayer().exoPlayerIsNull()) { playerUi.initPlayer(); if (playerUi.getPlayer().getPlayQueue() != null) { playerUi.initPlayback(); } } playerUis.add(playerUi); } /** * Destroys all matching player UIs and removes them from the list. * @param playerUiType the class of the player UI to destroy; the {@link * Class#isInstance(Object)} method will be used, so even subclasses will be * destroyed and removed * @param the class type parameter */ public void destroyAll(final Class playerUiType) { playerUis.stream() .filter(playerUiType::isInstance) .forEach(playerUi -> { playerUi.destroyPlayer(); playerUi.destroy(); }); playerUis.removeIf(playerUiType::isInstance); } /** * @param playerUiType the class of the player UI to return; the {@link * Class#isInstance(Object)} method will be used, so even subclasses could * be returned * @param the class type parameter * @return the first player UI of the required type found in the list, or an empty {@link * Optional} otherwise */ public Optional get(final Class playerUiType) { return playerUis.stream() .filter(playerUiType::isInstance) .map(playerUiType::cast) .findFirst(); } /** * Calls the provided consumer on all player UIs in the list, in order of addition. * @param consumer the consumer to call with player UIs */ public void call(final Consumer consumer) { //noinspection SimplifyStreamApiCallChains playerUis.stream().forEachOrdered(consumer); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java ================================================ package org.schabi.newpipe.player.ui; import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; import static org.schabi.newpipe.MainActivity.DEBUG; import static org.schabi.newpipe.player.helper.PlayerHelper.getMinimumVideoHeight; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.annotation.SuppressLint; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.PixelFormat; import android.os.Build; import android.util.DisplayMetrics; import android.util.Log; import android.view.Gravity; import android.view.LayoutInflater; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; import android.view.WindowManager; import android.view.animation.AnticipateInterpolator; import android.widget.LinearLayout; import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; import androidx.core.math.MathUtils; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; import com.google.android.exoplayer2.ui.SubtitleView; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.PlayerBinding; import org.schabi.newpipe.databinding.PlayerPopupCloseOverlayBinding; import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.gesture.BasePlayerGestureListener; import org.schabi.newpipe.player.gesture.PopupPlayerGestureListener; import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.util.DeviceUtils; public final class PopupPlayerUi extends VideoPlayerUi { private static final String TAG = PopupPlayerUi.class.getSimpleName(); /** * Maximum opacity allowed for Android 12 and higher to allow touches on other apps when using * NewPipe's popup player. * *

* This value is hardcoded instead of being get dynamically with the method linked of the * constant documentation below, because it is not static and popup player layout parameters * are generated with static methods. *

* * @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE */ private static final float MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER = 0.8f; /*////////////////////////////////////////////////////////////////////////// // Popup player //////////////////////////////////////////////////////////////////////////*/ private PlayerPopupCloseOverlayBinding closeOverlayBinding; private boolean isPopupClosing = false; private int screenWidth; private int screenHeight; /*////////////////////////////////////////////////////////////////////////// // Popup player window manager //////////////////////////////////////////////////////////////////////////*/ public static final int IDLE_WINDOW_FLAGS = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM; public static final int ONGOING_PLAYBACK_WINDOW_FLAGS = IDLE_WINDOW_FLAGS | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON; private WindowManager.LayoutParams popupLayoutParams; // null if player is not popup private final WindowManager windowManager; /*////////////////////////////////////////////////////////////////////////// // Constructor, setup, destroy //////////////////////////////////////////////////////////////////////////*/ //region Constructor, setup, destroy public PopupPlayerUi(@NonNull final Player player, @NonNull final PlayerBinding playerBinding) { super(player, playerBinding); windowManager = ContextCompat.getSystemService(context, WindowManager.class); } @Override public void setupAfterIntent() { super.setupAfterIntent(); initPopup(); initPopupCloseOverlay(); } @Override BasePlayerGestureListener buildGestureListener() { return new PopupPlayerGestureListener(this); } @SuppressLint("RtlHardcoded") private void initPopup() { if (DEBUG) { Log.d(TAG, "initPopup() called"); } // Popup is already added to windowManager if (popupHasParent()) { return; } updateScreenSize(); popupLayoutParams = retrievePopupLayoutParamsFromPrefs(); binding.surfaceView.setHeights(popupLayoutParams.height, popupLayoutParams.height); checkPopupPositionBounds(); binding.loadingPanel.setMinimumWidth(popupLayoutParams.width); binding.loadingPanel.setMinimumHeight(popupLayoutParams.height); windowManager.addView(binding.getRoot(), popupLayoutParams); setupVideoSurfaceIfNeeded(); // now there is a parent, we can setup video surface // Popup doesn't have aspectRatio selector, using FIT automatically setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FIT); } @SuppressLint("RtlHardcoded") private void initPopupCloseOverlay() { if (DEBUG) { Log.d(TAG, "initPopupCloseOverlay() called"); } // closeOverlayView is already added to windowManager if (closeOverlayBinding != null) { return; } closeOverlayBinding = PlayerPopupCloseOverlayBinding.inflate(LayoutInflater.from(context)); final WindowManager.LayoutParams closeOverlayLayoutParams = buildCloseOverlayLayoutParams(); closeOverlayBinding.closeButton.setVisibility(View.GONE); windowManager.addView(closeOverlayBinding.getRoot(), closeOverlayLayoutParams); } @Override public void initPlayback() { super.initPlayback(); // Make sure video and text tracks are enabled if the screen is turned on (which should // always be the case), in the case user switched from background player to popup player player.useVideoAndSubtitles(player.isScreenOn()); } @Override protected void setupElementsVisibility() { binding.fullScreenButton.setVisibility(View.VISIBLE); binding.screenRotationButton.setVisibility(View.GONE); binding.resizeTextView.setVisibility(View.GONE); binding.getRoot().findViewById(R.id.metadataView).setVisibility(View.GONE); binding.queueButton.setVisibility(View.GONE); binding.segmentsButton.setVisibility(View.GONE); binding.moreOptionsButton.setVisibility(View.GONE); binding.topControls.setOrientation(LinearLayout.HORIZONTAL); binding.primaryControls.getLayoutParams().width = WRAP_CONTENT; binding.secondaryControls.setAlpha(1.0f); binding.secondaryControls.setVisibility(View.VISIBLE); binding.secondaryControls.setTranslationY(0); binding.share.setVisibility(View.GONE); binding.playWithKodi.setVisibility(View.GONE); binding.openInBrowser.setVisibility(View.GONE); binding.switchMute.setVisibility(View.GONE); binding.playerCloseButton.setVisibility(View.GONE); binding.topControls.bringToFront(); binding.topControls.setClickable(false); binding.topControls.setFocusable(false); binding.bottomControls.bringToFront(); // Workaround that UI elements are pushed off screen binding.audioTrackTextView.setMaxWidth(DeviceUtils.dpToPx(48, context)); super.setupElementsVisibility(); } @Override protected void setupElementsSize(final Resources resources) { setupElementsSize( 0, 0, resources.getDimensionPixelSize(R.dimen.player_popup_controls_padding), resources.getDimensionPixelSize(R.dimen.player_popup_buttons_padding) ); } @Override public void removeViewFromParent() { // view was added by windowManager for popup player windowManager.removeViewImmediate(binding.getRoot()); } @Override public void destroy() { super.destroy(); removePopupFromView(); } //endregion /*////////////////////////////////////////////////////////////////////////// // Broadcast receiver //////////////////////////////////////////////////////////////////////////*/ //region Broadcast receiver @Override public void onBroadcastReceived(final Intent intent) { super.onBroadcastReceived(intent); if (Intent.ACTION_CONFIGURATION_CHANGED.equals(intent.getAction())) { updateScreenSize(); changePopupSize(popupLayoutParams.width); checkPopupPositionBounds(); } else if (player.isPlaying() || player.isLoading()) { if (Intent.ACTION_SCREEN_OFF.equals(intent.getAction())) { // Use only audio source when screen turns off while popup player is playing player.useVideoAndSubtitles(false); } else if (Intent.ACTION_SCREEN_ON.equals(intent.getAction())) { // Restore video source when screen turns on and user was watching video in popup player.useVideoAndSubtitles(true); } } } //endregion /*////////////////////////////////////////////////////////////////////////// // Popup position and size //////////////////////////////////////////////////////////////////////////*/ //region Popup position and size /** * Check if {@link #popupLayoutParams}' position is within a arbitrary boundary * that goes from (0, 0) to (screenWidth, screenHeight). *

* If it's out of these boundaries, {@link #popupLayoutParams}' position is changed * and {@code true} is returned to represent this change. *

*/ public void checkPopupPositionBounds() { if (DEBUG) { Log.d(TAG, "checkPopupPositionBounds() called with: " + "screenWidth = [" + screenWidth + "], " + "screenHeight = [" + screenHeight + "]"); } if (popupLayoutParams == null) { return; } popupLayoutParams.x = MathUtils.clamp(popupLayoutParams.x, 0, screenWidth - popupLayoutParams.width); popupLayoutParams.y = MathUtils.clamp(popupLayoutParams.y, 0, screenHeight - popupLayoutParams.height); } public void updateScreenSize() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { final var windowMetrics = windowManager.getCurrentWindowMetrics(); final var bounds = windowMetrics.getBounds(); final var windowInsets = windowMetrics.getWindowInsets(); final var insets = windowInsets.getInsetsIgnoringVisibility( WindowInsets.Type.navigationBars() | WindowInsets.Type.displayCutout()); screenWidth = bounds.width() - (insets.left + insets.right); screenHeight = bounds.height() - (insets.top + insets.bottom); } else { final DisplayMetrics metrics = new DisplayMetrics(); windowManager.getDefaultDisplay().getMetrics(metrics); screenWidth = metrics.widthPixels; screenHeight = metrics.heightPixels; } if (DEBUG) { Log.d(TAG, "updateScreenSize() called: screenWidth = [" + screenWidth + "], screenHeight = [" + screenHeight + "]"); } } /** * Changes the size of the popup based on the width. * @param width the new width, height is calculated with * {@link PlayerHelper#getMinimumVideoHeight(float)} */ public void changePopupSize(final int width) { if (DEBUG) { Log.d(TAG, "changePopupSize() called with: width = [" + width + "]"); } if (anyPopupViewIsNull()) { return; } final float minimumWidth = context.getResources().getDimension(R.dimen.popup_minimum_width); final int actualWidth = MathUtils.clamp(width, (int) minimumWidth, screenWidth); final int actualHeight = (int) getMinimumVideoHeight(width); if (DEBUG) { Log.d(TAG, "updatePopupSize() updated values:" + " width = [" + actualWidth + "], height = [" + actualHeight + "]"); } popupLayoutParams.width = actualWidth; popupLayoutParams.height = actualHeight; binding.surfaceView.setHeights(popupLayoutParams.height, popupLayoutParams.height); windowManager.updateViewLayout(binding.getRoot(), popupLayoutParams); } @Override protected float calculateMaxEndScreenThumbnailHeight(@NonNull final Bitmap bitmap) { // no need for the end screen thumbnail to be resized on popup player: it's only needed // for the main player so that it is enlarged correctly inside the fragment return bitmap.getHeight(); } //endregion /*////////////////////////////////////////////////////////////////////////// // Popup closing //////////////////////////////////////////////////////////////////////////*/ //region Popup closing public void closePopup() { if (DEBUG) { Log.d(TAG, "closePopup() called, isPopupClosing = " + isPopupClosing); } if (isPopupClosing) { return; } isPopupClosing = true; player.saveStreamProgressState(); windowManager.removeView(binding.getRoot()); animatePopupOverlayAndFinishService(); } public boolean isPopupClosing() { return isPopupClosing; } public void removePopupFromView() { // wrap in try-catch since it could sometimes generate errors randomly try { if (popupHasParent()) { windowManager.removeView(binding.getRoot()); } } catch (final IllegalArgumentException e) { Log.w(TAG, "Failed to remove popup from window manager", e); } try { final boolean closeOverlayHasParent = closeOverlayBinding != null && closeOverlayBinding.getRoot().getParent() != null; if (closeOverlayHasParent) { windowManager.removeView(closeOverlayBinding.getRoot()); } } catch (final IllegalArgumentException e) { Log.w(TAG, "Failed to remove popup overlay from window manager", e); } } private void animatePopupOverlayAndFinishService() { final int targetTranslationY = (int) (closeOverlayBinding.closeButton.getRootView().getHeight() - closeOverlayBinding.closeButton.getY()); closeOverlayBinding.closeButton.animate().setListener(null).cancel(); closeOverlayBinding.closeButton.animate() .setInterpolator(new AnticipateInterpolator()) .translationY(targetTranslationY) .setDuration(400) .setListener(new AnimatorListenerAdapter() { @Override public void onAnimationCancel(final Animator animation) { end(); } @Override public void onAnimationEnd(final Animator animation) { end(); } private void end() { windowManager.removeView(closeOverlayBinding.getRoot()); closeOverlayBinding = null; player.getService().destroyPlayerAndStopService(); } }).start(); } //endregion /*////////////////////////////////////////////////////////////////////////// // Playback states //////////////////////////////////////////////////////////////////////////*/ //region Playback states private void changePopupWindowFlags(final int flags) { if (DEBUG) { Log.d(TAG, "changePopupWindowFlags() called with: flags = [" + flags + "]"); } if (!anyPopupViewIsNull()) { popupLayoutParams.flags = flags; windowManager.updateViewLayout(binding.getRoot(), popupLayoutParams); } } @Override public void onPlaying() { super.onPlaying(); changePopupWindowFlags(ONGOING_PLAYBACK_WINDOW_FLAGS); } @Override public void onPaused() { super.onPaused(); changePopupWindowFlags(IDLE_WINDOW_FLAGS); } @Override public void onCompleted() { super.onCompleted(); changePopupWindowFlags(IDLE_WINDOW_FLAGS); } @Override protected void setupSubtitleView(final float captionScale) { binding.subtitleView.setFractionalTextSize( SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * captionScale); } @Override protected void onPlaybackSpeedClicked() { playbackSpeedPopupMenu.show(); isSomePopupMenuVisible = true; } //endregion /*////////////////////////////////////////////////////////////////////////// // Gestures //////////////////////////////////////////////////////////////////////////*/ //region Gestures private int distanceFromCloseButton(@NonNull final MotionEvent popupMotionEvent) { final int closeOverlayButtonX = closeOverlayBinding.closeButton.getLeft() + closeOverlayBinding.closeButton.getWidth() / 2; final int closeOverlayButtonY = closeOverlayBinding.closeButton.getTop() + closeOverlayBinding.closeButton.getHeight() / 2; final float fingerX = popupLayoutParams.x + popupMotionEvent.getX(); final float fingerY = popupLayoutParams.y + popupMotionEvent.getY(); return (int) Math.sqrt(Math.pow(closeOverlayButtonX - fingerX, 2) + Math.pow(closeOverlayButtonY - fingerY, 2)); } private float getClosingRadius() { final int buttonRadius = closeOverlayBinding.closeButton.getWidth() / 2; // 20% wider than the button itself return buttonRadius * 1.2f; } public boolean isInsideClosingRadius(@NonNull final MotionEvent popupMotionEvent) { return distanceFromCloseButton(popupMotionEvent) <= getClosingRadius(); } //endregion /*////////////////////////////////////////////////////////////////////////// // Popup & closing overlay layout params + saving popup position and size //////////////////////////////////////////////////////////////////////////*/ //region Popup & closing overlay layout params + saving popup position and size /** * {@code screenWidth} and {@code screenHeight} must have been initialized. * @return the popup starting layout params */ @SuppressLint("RtlHardcoded") public WindowManager.LayoutParams retrievePopupLayoutParamsFromPrefs() { final SharedPreferences prefs = getPlayer().getPrefs(); final Context context = getPlayer().getContext(); final boolean popupRememberSizeAndPos = prefs.getBoolean( context.getString(R.string.popup_remember_size_pos_key), true); final float defaultSize = context.getResources().getDimension(R.dimen.popup_default_width); final float popupWidth = popupRememberSizeAndPos ? prefs.getFloat(context.getString(R.string.popup_saved_width_key), defaultSize) : defaultSize; final float popupHeight = getMinimumVideoHeight(popupWidth); final WindowManager.LayoutParams params = new WindowManager.LayoutParams( (int) popupWidth, (int) popupHeight, popupLayoutParamType(), IDLE_WINDOW_FLAGS, PixelFormat.TRANSLUCENT); params.gravity = Gravity.LEFT | Gravity.TOP; params.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE; final int centerX = (int) (screenWidth / 2f - popupWidth / 2f); final int centerY = (int) (screenHeight / 2f - popupHeight / 2f); params.x = popupRememberSizeAndPos ? prefs.getInt(context.getString(R.string.popup_saved_x_key), centerX) : centerX; params.y = popupRememberSizeAndPos ? prefs.getInt(context.getString(R.string.popup_saved_y_key), centerY) : centerY; return params; } public void savePopupPositionAndSizeToPrefs() { if (getPopupLayoutParams() != null) { final Context context = getPlayer().getContext(); getPlayer().getPrefs().edit() .putFloat(context.getString(R.string.popup_saved_width_key), popupLayoutParams.width) .putInt(context.getString(R.string.popup_saved_x_key), popupLayoutParams.x) .putInt(context.getString(R.string.popup_saved_y_key), popupLayoutParams.y) .apply(); } } @SuppressLint("RtlHardcoded") public static WindowManager.LayoutParams buildCloseOverlayLayoutParams() { final int flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE | WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM; final WindowManager.LayoutParams closeOverlayLayoutParams = new WindowManager.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, popupLayoutParamType(), flags, PixelFormat.TRANSLUCENT); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Setting maximum opacity allowed for touch events to other apps for Android 12 and // higher to prevent non interaction when using other apps with the popup player closeOverlayLayoutParams.alpha = MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER; } closeOverlayLayoutParams.gravity = Gravity.LEFT | Gravity.TOP; closeOverlayLayoutParams.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE; return closeOverlayLayoutParams; } public static int popupLayoutParamType() { return Build.VERSION.SDK_INT < Build.VERSION_CODES.O ? WindowManager.LayoutParams.TYPE_PHONE : WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; } //endregion /*////////////////////////////////////////////////////////////////////////// // Getters //////////////////////////////////////////////////////////////////////////*/ //region Getters private boolean popupHasParent() { return binding != null && binding.getRoot().getLayoutParams() instanceof WindowManager.LayoutParams && binding.getRoot().getParent() != null; } private boolean anyPopupViewIsNull() { return popupLayoutParams == null || windowManager == null || binding.getRoot().getParent() == null; } public PlayerPopupCloseOverlayBinding getCloseOverlayBinding() { return closeOverlayBinding; } public WindowManager.LayoutParams getPopupLayoutParams() { return popupLayoutParams; } public WindowManager getWindowManager() { return windowManager; } public int getScreenHeight() { return screenHeight; } public int getScreenWidth() { return screenWidth; } //endregion } ================================================ FILE: app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java ================================================ package org.schabi.newpipe.player.ui; import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; import static org.schabi.newpipe.MainActivity.DEBUG; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateRotation; import static org.schabi.newpipe.player.Player.RENDERER_UNAVAILABLE; import static org.schabi.newpipe.player.Player.STATE_BUFFERING; import static org.schabi.newpipe.player.Player.STATE_COMPLETED; import static org.schabi.newpipe.player.Player.STATE_PAUSED; import static org.schabi.newpipe.player.Player.STATE_PAUSED_SEEK; import static org.schabi.newpipe.player.Player.STATE_PLAYING; import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString; import static org.schabi.newpipe.player.helper.PlayerHelper.nextResizeModeAndSaveToPrefs; import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences; import android.content.Intent; import android.content.res.Resources; import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.PorterDuff; import android.graphics.PorterDuffColorFilter; import android.net.Uri; import android.os.Build; import android.os.Handler; import android.os.Looper; import android.util.Log; import android.view.GestureDetector; import android.view.Gravity; import android.view.KeyEvent; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.widget.LinearLayout; import android.widget.RelativeLayout; import android.widget.SeekBar; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.view.ContextThemeWrapper; import androidx.appcompat.widget.AppCompatImageButton; import androidx.appcompat.widget.PopupMenu; import androidx.core.graphics.BitmapCompat; import androidx.core.graphics.Insets; import androidx.core.math.MathUtils; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player.RepeatMode; import com.google.android.exoplayer2.Tracks; import com.google.android.exoplayer2.text.Cue; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; import com.google.android.exoplayer2.ui.CaptionStyleCompat; import com.google.android.exoplayer2.video.VideoSize; import org.schabi.newpipe.App; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.PlayerBinding; import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.fragments.detail.VideoDetailFragment; import org.schabi.newpipe.ktx.AnimationType; import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.gesture.BasePlayerGestureListener; import org.schabi.newpipe.player.gesture.DisplayPortion; import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.playback.SurfaceHolderCallback; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper; 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.external_communication.KoreUtils; import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.views.player.PlayerFastSeekOverlay; import java.util.List; import java.util.Objects; import java.util.Optional; import java.util.stream.Collectors; public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBarChangeListener, PopupMenu.OnMenuItemClickListener, PopupMenu.OnDismissListener { private static final String TAG = VideoPlayerUi.class.getSimpleName(); // time constants public static final long DEFAULT_CONTROLS_DURATION = 300; // 300 millis public static final long DEFAULT_CONTROLS_HIDE_TIME = 2000; // 2 Seconds public static final long DPAD_CONTROLS_HIDE_TIME = 7000; // 7 Seconds public static final int SEEK_OVERLAY_DURATION = 450; // 450 millis // other constants (TODO remove playback speeds and use normal menu for popup, too) private static final float[] PLAYBACK_SPEEDS = {0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f}; private enum PlayButtonAction { PLAY, PAUSE, REPLAY } /*////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////*/ protected PlayerBinding binding; private final Handler controlsVisibilityHandler = new Handler(Looper.getMainLooper()); @Nullable private SurfaceHolderCallback surfaceHolderCallback; boolean surfaceIsSetup = false; /*////////////////////////////////////////////////////////////////////////// // Popup menus ("popup" means that they pop up, not that they belong to the popup player) //////////////////////////////////////////////////////////////////////////*/ private static final int POPUP_MENU_ID_QUALITY = 69; private static final int POPUP_MENU_ID_AUDIO_TRACK = 70; private static final int POPUP_MENU_ID_PLAYBACK_SPEED = 79; private static final int POPUP_MENU_ID_CAPTION = 89; protected boolean isSomePopupMenuVisible = false; private PopupMenu qualityPopupMenu; private PopupMenu audioTrackPopupMenu; protected PopupMenu playbackSpeedPopupMenu; private PopupMenu captionPopupMenu; /*////////////////////////////////////////////////////////////////////////// // Gestures //////////////////////////////////////////////////////////////////////////*/ private GestureDetector gestureDetector; private BasePlayerGestureListener playerGestureListener; @Nullable private View.OnLayoutChangeListener onLayoutChangeListener = null; @NonNull private final SeekbarPreviewThumbnailHolder seekbarPreviewThumbnailHolder = new SeekbarPreviewThumbnailHolder(); /*////////////////////////////////////////////////////////////////////////// // Constructor, setup, destroy //////////////////////////////////////////////////////////////////////////*/ //region Constructor, setup, destroy protected VideoPlayerUi(@NonNull final Player player, @NonNull final PlayerBinding playerBinding) { super(player); binding = playerBinding; setupFromView(); } public void setupFromView() { initViews(); initListeners(); setupPlayerSeekOverlay(); } private void initViews() { setupSubtitleView(); binding.resizeTextView .setText(PlayerHelper.resizeTypeOf(context, binding.surfaceView.getResizeMode())); binding.playbackSeekBar.getThumb() .setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN)); binding.playbackSeekBar.getProgressDrawable() .setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.MULTIPLY)); final ContextThemeWrapper themeWrapper = new ContextThemeWrapper(context, R.style.DarkPopupMenu); qualityPopupMenu = new PopupMenu(themeWrapper, binding.qualityTextView); audioTrackPopupMenu = new PopupMenu(themeWrapper, binding.audioTrackTextView); playbackSpeedPopupMenu = new PopupMenu(context, binding.playbackSpeed); captionPopupMenu = new PopupMenu(themeWrapper, binding.captionTextView); binding.progressBarLoadingPanel.getIndeterminateDrawable() .setColorFilter(new PorterDuffColorFilter(Color.WHITE, PorterDuff.Mode.MULTIPLY)); binding.titleTextView.setSelected(true); binding.channelTextView.setSelected(true); // Prevent hiding of bottom sheet via swipe inside queue binding.itemsList.setNestedScrollingEnabled(false); } abstract BasePlayerGestureListener buildGestureListener(); protected void initListeners() { binding.qualityTextView.setOnClickListener(makeOnClickListener(this::onQualityClicked)); binding.audioTrackTextView.setOnClickListener( makeOnClickListener(this::onAudioTracksClicked)); binding.playbackSpeed.setOnClickListener(makeOnClickListener(this::onPlaybackSpeedClicked)); binding.playbackSeekBar.setOnSeekBarChangeListener(this); binding.captionTextView.setOnClickListener(makeOnClickListener(this::onCaptionClicked)); binding.resizeTextView.setOnClickListener(makeOnClickListener(this::onResizeClicked)); binding.playbackLiveSync.setOnClickListener(makeOnClickListener(player::seekToDefault)); playerGestureListener = buildGestureListener(); gestureDetector = new GestureDetector(context, playerGestureListener); binding.getRoot().setOnTouchListener(playerGestureListener); binding.repeatButton.setOnClickListener(v -> onRepeatClicked()); binding.shuffleButton.setOnClickListener(v -> onShuffleClicked()); binding.playPauseButton.setOnClickListener(makeOnClickListener(player::playPause)); binding.playPreviousButton.setOnClickListener(makeOnClickListener(player::playPrevious)); binding.playNextButton.setOnClickListener(makeOnClickListener(player::playNext)); binding.moreOptionsButton.setOnClickListener( makeOnClickListener(this::onMoreOptionsClicked)); binding.share.setOnClickListener(makeOnClickListener(() -> { final PlayQueueItem currentItem = player.getCurrentItem(); if (currentItem != null) { ShareUtils.shareText(context, currentItem.getTitle(), player.getVideoUrlAtCurrentTime(), currentItem.getThumbnails()); } })); binding.share.setOnLongClickListener(v -> { ShareUtils.copyToClipboard(context, player.getVideoUrlAtCurrentTime()); return true; }); binding.fullScreenButton.setOnClickListener(makeOnClickListener(() -> { player.setRecovery(); NavigationHelper.playOnMainPlayer(context, Objects.requireNonNull(player.getPlayQueue()), true); })); binding.playWithKodi.setOnClickListener(makeOnClickListener(this::onPlayWithKodiClicked)); binding.openInBrowser.setOnClickListener(makeOnClickListener(this::onOpenInBrowserClicked)); binding.playerCloseButton.setOnClickListener(makeOnClickListener(() -> // set package to this app's package to prevent the intent from being seen outside context.sendBroadcast(new Intent(VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER) .setPackage(App.PACKAGE_NAME)) )); binding.switchMute.setOnClickListener(makeOnClickListener(player::toggleMute)); ViewCompat.setOnApplyWindowInsetsListener(binding.itemsListPanel, (view, windowInsets) -> { final Insets cutout = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()); if (!cutout.equals(Insets.NONE)) { view.setPadding(cutout.left, cutout.top, cutout.right, cutout.bottom); } return windowInsets; }); // PlaybackControlRoot already consumed window insets but we should pass them to // player_overlays and fast_seek_overlay too. Without it they will be off-centered. onLayoutChangeListener = (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { binding.playerOverlays.setPadding(v.getPaddingLeft(), v.getPaddingTop(), v.getPaddingRight(), v.getPaddingBottom()); // If we added padding to the fast seek overlay, too, it would not go under the // system ui. Instead we apply negative margins equal to the window insets of // the opposite side, so that the view covers all of the player (overflowing on // some sides) and its center coincides with the center of other controls. final RelativeLayout.LayoutParams fastSeekParams = (RelativeLayout.LayoutParams) binding.fastSeekOverlay.getLayoutParams(); fastSeekParams.leftMargin = -v.getPaddingRight(); fastSeekParams.topMargin = -v.getPaddingBottom(); fastSeekParams.rightMargin = -v.getPaddingLeft(); fastSeekParams.bottomMargin = -v.getPaddingTop(); }; binding.playbackControlRoot.addOnLayoutChangeListener(onLayoutChangeListener); } protected void deinitListeners() { binding.qualityTextView.setOnClickListener(null); binding.audioTrackTextView.setOnClickListener(null); binding.playbackSpeed.setOnClickListener(null); binding.playbackSeekBar.setOnSeekBarChangeListener(null); binding.captionTextView.setOnClickListener(null); binding.resizeTextView.setOnClickListener(null); binding.playbackLiveSync.setOnClickListener(null); binding.getRoot().setOnTouchListener(null); playerGestureListener = null; gestureDetector = null; binding.repeatButton.setOnClickListener(null); binding.shuffleButton.setOnClickListener(null); binding.playPauseButton.setOnClickListener(null); binding.playPreviousButton.setOnClickListener(null); binding.playNextButton.setOnClickListener(null); binding.moreOptionsButton.setOnClickListener(null); binding.moreOptionsButton.setOnLongClickListener(null); binding.share.setOnClickListener(null); binding.share.setOnLongClickListener(null); binding.fullScreenButton.setOnClickListener(null); binding.screenRotationButton.setOnClickListener(null); binding.playWithKodi.setOnClickListener(null); binding.openInBrowser.setOnClickListener(null); binding.playerCloseButton.setOnClickListener(null); binding.switchMute.setOnClickListener(null); ViewCompat.setOnApplyWindowInsetsListener(binding.itemsListPanel, null); binding.playbackControlRoot.removeOnLayoutChangeListener(onLayoutChangeListener); } /** * Initializes the Fast-For/Backward overlay. */ private void setupPlayerSeekOverlay() { binding.fastSeekOverlay .seekSecondsSupplier(() -> retrieveSeekDurationFromPreferences(player) / 1000) .performListener(new PlayerFastSeekOverlay.PerformListener() { @Override public void onDoubleTap() { animate(binding.fastSeekOverlay, true, SEEK_OVERLAY_DURATION); } @Override public void onDoubleTapEnd() { animate(binding.fastSeekOverlay, false, SEEK_OVERLAY_DURATION); } @NonNull @Override public FastSeekDirection getFastSeekDirection( @NonNull final DisplayPortion portion ) { if (player.exoPlayerIsNull()) { // Abort seeking playerGestureListener.endMultiDoubleTap(); return FastSeekDirection.NONE; } if (portion == DisplayPortion.LEFT) { // Check if it's possible to rewind // Small puffer to eliminate infinite rewind seeking if (player.getExoPlayer().getCurrentPosition() < 500L) { return FastSeekDirection.NONE; } return FastSeekDirection.BACKWARD; } else if (portion == DisplayPortion.RIGHT) { // Check if it's possible to fast-forward if (player.getCurrentState() == STATE_COMPLETED || player.getExoPlayer().getCurrentPosition() >= player.getExoPlayer().getDuration()) { return FastSeekDirection.NONE; } return FastSeekDirection.FORWARD; } /* portion == DisplayPortion.MIDDLE */ return FastSeekDirection.NONE; } @Override public void seek(final boolean forward) { playerGestureListener.keepInDoubleTapMode(); if (forward) { player.fastForward(); } else { player.fastRewind(); } } }); playerGestureListener.doubleTapControls(binding.fastSeekOverlay); } public void deinitPlayerSeekOverlay() { binding.fastSeekOverlay .seekSecondsSupplier(null) .performListener(null); } @Override public void setupAfterIntent() { super.setupAfterIntent(); setupElementsVisibility(); setupElementsSize(context.getResources()); binding.getRoot().setVisibility(View.VISIBLE); binding.playPauseButton.requestFocus(); } @Override public void initPlayer() { super.initPlayer(); setupVideoSurfaceIfNeeded(); } @Override public void initPlayback() { super.initPlayback(); // #6825 - Ensure that the shuffle-button is in the correct state on the UI setShuffleButton(player.getExoPlayer().getShuffleModeEnabled()); } public abstract void removeViewFromParent(); @Override public void destroyPlayer() { super.destroyPlayer(); clearVideoSurface(); } @Override public void destroy() { super.destroy(); binding.endScreen.setImageDrawable(null); deinitPlayerSeekOverlay(); deinitListeners(); } protected void setupElementsVisibility() { setMuteButton(player.isMuted()); animateRotation(binding.moreOptionsButton, DEFAULT_CONTROLS_DURATION, 0); } protected abstract void setupElementsSize(Resources resources); protected void setupElementsSize(final int buttonsMinWidth, final int playerTopPad, final int controlsPad, final int buttonsPad) { binding.topControls.setPaddingRelative(controlsPad, playerTopPad, controlsPad, 0); binding.bottomControls.setPaddingRelative(controlsPad, 0, controlsPad, 0); binding.qualityTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad); binding.audioTrackTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad); binding.playbackSpeed.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad); binding.playbackSpeed.setMinimumWidth(buttonsMinWidth); binding.captionTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad); } //endregion /*////////////////////////////////////////////////////////////////////////// // Broadcast receiver //////////////////////////////////////////////////////////////////////////*/ //region Broadcast receiver @Override public void onBroadcastReceived(final Intent intent) { super.onBroadcastReceived(intent); if (Intent.ACTION_CONFIGURATION_CHANGED.equals(intent.getAction())) { // When the orientation changes, the screen height might be smaller. If the end screen // thumbnail is not re-scaled, it can be larger than the current screen height and thus // enlarging the whole player. This causes the seekbar to be out of the visible area. updateEndScreenThumbnail(player.getThumbnail()); } } //endregion /*////////////////////////////////////////////////////////////////////////// // Thumbnail //////////////////////////////////////////////////////////////////////////*/ //region Thumbnail /** * Scale the player audio / end screen thumbnail down if necessary. *

* This is necessary when the thumbnail's height is larger than the device's height * and thus is enlarging the player's height * causing the bottom playback controls to be out of the visible screen. *

*/ @Override public void onThumbnailLoaded(@Nullable final Bitmap bitmap) { super.onThumbnailLoaded(bitmap); updateEndScreenThumbnail(bitmap); } private void updateEndScreenThumbnail(@Nullable final Bitmap thumbnail) { if (thumbnail == null) { // remove end screen thumbnail binding.endScreen.setImageDrawable(null); return; } final float endScreenHeight = calculateMaxEndScreenThumbnailHeight(thumbnail); final Bitmap endScreenBitmap = BitmapCompat.createScaledBitmap( thumbnail, (int) (thumbnail.getWidth() / (thumbnail.getHeight() / endScreenHeight)), (int) endScreenHeight, null, true); if (DEBUG) { Log.d(TAG, "Thumbnail - onThumbnailLoaded() called with: " + "currentThumbnail = [" + thumbnail + "], " + thumbnail.getWidth() + "x" + thumbnail.getHeight() + ", scaled end screen height = " + endScreenHeight + ", scaled end screen width = " + endScreenBitmap.getWidth()); } binding.endScreen.setImageBitmap(endScreenBitmap); } protected abstract float calculateMaxEndScreenThumbnailHeight(@NonNull Bitmap bitmap); //endregion /*////////////////////////////////////////////////////////////////////////// // Progress loop and updates //////////////////////////////////////////////////////////////////////////*/ //region Progress loop and updates @Override public void onUpdateProgress(final int currentProgress, final int duration, final int bufferPercent) { if (duration != binding.playbackSeekBar.getMax()) { setVideoDurationToControls(duration); } if (player.getCurrentState() != STATE_PAUSED) { updatePlayBackElementsCurrentDuration(currentProgress); } if (player.isLoading() || bufferPercent > 90) { binding.playbackSeekBar.setSecondaryProgress( (int) (binding.playbackSeekBar.getMax() * ((float) bufferPercent / 100))); } if (DEBUG && bufferPercent % 20 == 0) { //Limit log Log.d(TAG, "notifyProgressUpdateToListeners() called with: " + "isVisible = " + isControlsVisible() + ", " + "currentProgress = [" + currentProgress + "], " + "duration = [" + duration + "], bufferPercent = [" + bufferPercent + "]"); } binding.playbackLiveSync.setClickable(!player.isLiveEdge()); } /** * Sets the current duration into the corresponding elements. * * @param currentProgress the current progress, in milliseconds */ private void updatePlayBackElementsCurrentDuration(final int currentProgress) { // Don't set seekbar progress while user is seeking if (player.getCurrentState() != STATE_PAUSED_SEEK) { binding.playbackSeekBar.setProgress(currentProgress); } binding.playbackCurrentTime.setText(getTimeString(currentProgress)); } /** * Sets the video duration time into all control components (e.g. seekbar). * * @param duration the video duration, in milliseconds */ private void setVideoDurationToControls(final int duration) { binding.playbackEndTime.setText(getTimeString(duration)); binding.playbackSeekBar.setMax(duration); // This is important for Android TVs otherwise it would apply the default from // setMax/Min methods which is (max - min) / 20 binding.playbackSeekBar.setKeyProgressIncrement( PlayerHelper.retrieveSeekDurationFromPreferences(player)); } @Override // seekbar listener public void onProgressChanged(final SeekBar seekBar, final int progress, final boolean fromUser) { // Currently we don't need method execution when fromUser is false if (!fromUser) { return; } if (DEBUG) { Log.d(TAG, "onProgressChanged() called with: " + "seekBar = [" + seekBar + "], progress = [" + progress + "]"); } binding.currentDisplaySeek.setText(getTimeString(progress)); // Seekbar Preview Thumbnail SeekbarPreviewThumbnailHelper .tryResizeAndSetSeekbarPreviewThumbnail( player.getContext(), seekbarPreviewThumbnailHolder.getBitmapAt(progress).orElse(null), binding.currentSeekbarPreviewThumbnail, binding.subtitleView::getWidth); adjustSeekbarPreviewContainer(); } private void adjustSeekbarPreviewContainer() { try { // Should only be required when an error occurred before // and the layout was positioned in the center binding.bottomSeekbarPreviewLayout.setGravity(Gravity.NO_GRAVITY); // Calculate the current left position of seekbar progress in px // More info: https://stackoverflow.com/q/20493577 final int currentSeekbarLeft = binding.playbackSeekBar.getLeft() + binding.playbackSeekBar.getPaddingLeft() + binding.playbackSeekBar.getThumb().getBounds().left; // Calculate the (unchecked) left position of the container final int uncheckedContainerLeft = currentSeekbarLeft - (binding.seekbarPreviewContainer.getWidth() / 2); // Fix the position so it's within the boundaries final int checkedContainerLeft = MathUtils.clamp(uncheckedContainerLeft, 0, binding.playbackWindowRoot.getWidth() - binding.seekbarPreviewContainer.getWidth()); // See also: https://stackoverflow.com/a/23249734 final LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( binding.seekbarPreviewContainer.getLayoutParams()); params.setMarginStart(checkedContainerLeft); binding.seekbarPreviewContainer.setLayoutParams(params); } catch (final Exception ex) { Log.e(TAG, "Failed to adjust seekbarPreviewContainer", ex); // Fallback - position in the middle binding.bottomSeekbarPreviewLayout.setGravity(Gravity.CENTER); } } @Override // seekbar listener public void onStartTrackingTouch(final SeekBar seekBar) { if (DEBUG) { Log.d(TAG, "onStartTrackingTouch() called with: seekBar = [" + seekBar + "]"); } if (player.getCurrentState() != STATE_PAUSED_SEEK) { player.changeState(STATE_PAUSED_SEEK); } showControls(0); animate(binding.currentDisplaySeek, true, DEFAULT_CONTROLS_DURATION, AnimationType.SCALE_AND_ALPHA); animate(binding.currentSeekbarPreviewThumbnail, true, DEFAULT_CONTROLS_DURATION, AnimationType.SCALE_AND_ALPHA); } @Override // seekbar listener public void onStopTrackingTouch(final SeekBar seekBar) { if (DEBUG) { Log.d(TAG, "onStopTrackingTouch() called with: seekBar = [" + seekBar + "]"); } player.seekTo(seekBar.getProgress()); if (player.getExoPlayer().getDuration() == seekBar.getProgress()) { player.getExoPlayer().play(); } binding.playbackCurrentTime.setText(getTimeString(seekBar.getProgress())); animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA); animate(binding.currentSeekbarPreviewThumbnail, false, 200, AnimationType.SCALE_AND_ALPHA); if (player.getCurrentState() == STATE_PAUSED_SEEK) { player.changeState(STATE_BUFFERING); } if (!player.isProgressLoopRunning()) { player.startProgressLoop(); } showControlsThenHide(); } //endregion /*////////////////////////////////////////////////////////////////////////// // Controls showing / hiding //////////////////////////////////////////////////////////////////////////*/ //region Controls showing / hiding public boolean isControlsVisible() { return binding != null && binding.playbackControlRoot.getVisibility() == View.VISIBLE; } public void showControlsThenHide() { if (DEBUG) { Log.d(TAG, "showControlsThenHide() called"); } showOrHideButtons(); showSystemUIPartially(); final long hideTime = binding.playbackControlRoot.isInTouchMode() ? DEFAULT_CONTROLS_HIDE_TIME : DPAD_CONTROLS_HIDE_TIME; showHideShadow(true, DEFAULT_CONTROLS_DURATION); animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION, AnimationType.ALPHA, 0, () -> hideControls(DEFAULT_CONTROLS_DURATION, hideTime)); } public void showControls(final long duration) { if (DEBUG) { Log.d(TAG, "showControls() called"); } showOrHideButtons(); showSystemUIPartially(); controlsVisibilityHandler.removeCallbacksAndMessages(null); showHideShadow(true, duration); animate(binding.playbackControlRoot, true, duration); } public void hideControls(final long duration, final long delay) { if (DEBUG) { Log.d(TAG, "hideControls() called with: duration = [" + duration + "], delay = [" + delay + "]"); } showOrHideButtons(); controlsVisibilityHandler.removeCallbacksAndMessages(null); controlsVisibilityHandler.postDelayed(() -> { showHideShadow(false, duration); animate(binding.playbackControlRoot, false, duration, AnimationType.ALPHA, 0, this::hideSystemUIIfNeeded); }, delay); } public void showHideShadow(final boolean show, final long duration) { animate(binding.playbackControlsShadow, show, duration, AnimationType.ALPHA, 0, null); animate(binding.playerTopShadow, show, duration, AnimationType.ALPHA, 0, null); animate(binding.playerBottomShadow, show, duration, AnimationType.ALPHA, 0, null); } protected void showOrHideButtons() { @Nullable final PlayQueue playQueue = player.getPlayQueue(); if (playQueue == null) { return; } final boolean showPrev = playQueue.getIndex() != 0; final boolean showNext = playQueue.getIndex() + 1 != playQueue.getStreams().size(); binding.playPreviousButton.setVisibility(showPrev ? View.VISIBLE : View.INVISIBLE); binding.playPreviousButton.setAlpha(showPrev ? 1.0f : 0.0f); binding.playNextButton.setVisibility(showNext ? View.VISIBLE : View.INVISIBLE); binding.playNextButton.setAlpha(showNext ? 1.0f : 0.0f); } protected void showSystemUIPartially() { // system UI is really changed only by MainPlayerUi, so overridden there } protected void hideSystemUIIfNeeded() { // system UI is really changed only by MainPlayerUi, so overridden there } protected boolean isAnyListViewOpen() { // only MainPlayerUi has list views for the queue and for segments, so overridden there return false; } public boolean isFullscreen() { // only MainPlayerUi can be in fullscreen, so overridden there return false; } /** * Update the play/pause button ({@link R.id.playPauseButton}) to reflect the action * that will be performed when the button is clicked.. * @param action the action that is performed when the play/pause button is clicked */ private void updatePlayPauseButton(final PlayButtonAction action) { final AppCompatImageButton button = binding.playPauseButton; switch (action) { case PLAY: button.setContentDescription(context.getString(R.string.play)); button.setImageResource(R.drawable.ic_play_arrow); break; case PAUSE: button.setContentDescription(context.getString(R.string.pause)); button.setImageResource(R.drawable.ic_pause); break; case REPLAY: button.setContentDescription(context.getString(R.string.replay)); button.setImageResource(R.drawable.ic_replay); break; } } //endregion /*////////////////////////////////////////////////////////////////////////// // Playback states //////////////////////////////////////////////////////////////////////////*/ //region Playback states @Override public void onPrepared() { super.onPrepared(); setVideoDurationToControls((int) player.getExoPlayer().getDuration()); binding.playbackSpeed.setText(formatSpeed(player.getPlaybackSpeed())); } @Override public void onBlocked() { super.onBlocked(); // if we are e.g. switching players, hide controls hideControls(DEFAULT_CONTROLS_DURATION, 0); binding.playbackSeekBar.setEnabled(false); binding.playbackSeekBar.getThumb() .setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN)); binding.loadingPanel.setBackgroundColor(Color.BLACK); animate(binding.loadingPanel, true, 0); animate(binding.surfaceForeground, true, 100); updatePlayPauseButton(PlayButtonAction.PLAY); animatePlayButtons(false, 100); binding.getRoot().setKeepScreenOn(false); } @Override public void onPlaying() { super.onPlaying(); updateStreamRelatedViews(); binding.playbackSeekBar.setEnabled(true); binding.playbackSeekBar.getThumb() .setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN)); binding.loadingPanel.setVisibility(View.GONE); animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA); animate(binding.playPauseButton, false, 80, AnimationType.SCALE_AND_ALPHA, 0, () -> { updatePlayPauseButton(PlayButtonAction.PAUSE); animatePlayButtons(true, 200); if (!isAnyListViewOpen()) { binding.playPauseButton.requestFocus(); } }); binding.getRoot().setKeepScreenOn(true); } @Override public void onBuffering() { super.onBuffering(); binding.loadingPanel.setBackgroundColor(Color.TRANSPARENT); binding.loadingPanel.setVisibility(View.VISIBLE); binding.getRoot().setKeepScreenOn(true); } @Override public void onPaused() { super.onPaused(); // Don't let UI elements popup during double tap seeking. This state is entered sometimes // during seeking/loading. This if-else check ensures that the controls aren't popping up. if (!playerGestureListener.isDoubleTapping()) { showControls(400); binding.loadingPanel.setVisibility(View.GONE); animate(binding.playPauseButton, false, 80, AnimationType.SCALE_AND_ALPHA, 0, () -> { updatePlayPauseButton(PlayButtonAction.PLAY); animatePlayButtons(true, 200); if (!isAnyListViewOpen()) { binding.playPauseButton.requestFocus(); } }); } binding.getRoot().setKeepScreenOn(false); } @Override public void onPausedSeek() { super.onPausedSeek(); animatePlayButtons(false, 100); binding.getRoot().setKeepScreenOn(true); } @Override public void onCompleted() { super.onCompleted(); animate(binding.playPauseButton, false, 0, AnimationType.SCALE_AND_ALPHA, 0, () -> { updatePlayPauseButton(PlayButtonAction.REPLAY); animatePlayButtons(true, DEFAULT_CONTROLS_DURATION); }); binding.getRoot().setKeepScreenOn(false); // When a (short) video ends the elements have to display the correct values - see #6180 updatePlayBackElementsCurrentDuration(binding.playbackSeekBar.getMax()); showControls(500); animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA); binding.loadingPanel.setVisibility(View.GONE); animate(binding.surfaceForeground, true, 100); } private void animatePlayButtons(final boolean show, final long duration) { animate(binding.playPauseButton, show, duration, AnimationType.SCALE_AND_ALPHA); @Nullable final PlayQueue playQueue = player.getPlayQueue(); if (playQueue == null) { return; } if (!show || playQueue.getIndex() > 0) { animate( binding.playPreviousButton, show, duration, AnimationType.SCALE_AND_ALPHA); } if (!show || playQueue.getIndex() + 1 < playQueue.getStreams().size()) { animate( binding.playNextButton, show, duration, AnimationType.SCALE_AND_ALPHA); } } //endregion /*////////////////////////////////////////////////////////////////////////// // Repeat, shuffle, mute //////////////////////////////////////////////////////////////////////////*/ //region Repeat, shuffle, mute public void onRepeatClicked() { if (DEBUG) { Log.d(TAG, "onRepeatClicked() called"); } player.cycleNextRepeatMode(); } public void onShuffleClicked() { if (DEBUG) { Log.d(TAG, "onShuffleClicked() called"); } player.toggleShuffleModeEnabled(); } @Override public void onRepeatModeChanged(@RepeatMode final int repeatMode) { super.onRepeatModeChanged(repeatMode); if (repeatMode == REPEAT_MODE_ALL) { binding.repeatButton.setImageResource( com.google.android.exoplayer2.ui.R.drawable.exo_controls_repeat_all); } else if (repeatMode == REPEAT_MODE_ONE) { binding.repeatButton.setImageResource( com.google.android.exoplayer2.ui.R.drawable.exo_controls_repeat_one); } else /* repeatMode == REPEAT_MODE_OFF */ { binding.repeatButton.setImageResource( com.google.android.exoplayer2.ui.R.drawable.exo_controls_repeat_off); } } @Override public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) { super.onShuffleModeEnabledChanged(shuffleModeEnabled); setShuffleButton(shuffleModeEnabled); } @Override public void onMuteUnmuteChanged(final boolean isMuted) { super.onMuteUnmuteChanged(isMuted); setMuteButton(isMuted); } private void setMuteButton(final boolean isMuted) { binding.switchMute.setImageDrawable(AppCompatResources.getDrawable(context, isMuted ? R.drawable.ic_volume_off : R.drawable.ic_volume_up)); } private void setShuffleButton(final boolean shuffled) { binding.shuffleButton.setImageAlpha(shuffled ? 255 : 77); } //endregion /*////////////////////////////////////////////////////////////////////////// // Other player listeners //////////////////////////////////////////////////////////////////////////*/ //region Other player listeners @Override public void onPlaybackParametersChanged(@NonNull final PlaybackParameters playbackParameters) { super.onPlaybackParametersChanged(playbackParameters); binding.playbackSpeed.setText(formatSpeed(playbackParameters.speed)); } @Override public void onRenderedFirstFrame() { super.onRenderedFirstFrame(); //TODO check if this causes black screen when switching to fullscreen animate(binding.surfaceForeground, false, DEFAULT_CONTROLS_DURATION); } //endregion /*////////////////////////////////////////////////////////////////////////// // Metadata & stream related views //////////////////////////////////////////////////////////////////////////*/ //region Metadata & stream related views @Override public void onMetadataChanged(@NonNull final StreamInfo info) { super.onMetadataChanged(info); updateStreamRelatedViews(); binding.titleTextView.setText(info.getName()); binding.channelTextView.setText(info.getUploaderName()); this.seekbarPreviewThumbnailHolder.resetFrom(player.getContext(), info.getPreviewFrames()); } private void updateStreamRelatedViews() { player.getCurrentStreamInfo().ifPresent(info -> { binding.qualityTextView.setVisibility(View.GONE); binding.audioTrackTextView.setVisibility(View.GONE); binding.playbackSpeed.setVisibility(View.GONE); binding.playbackEndTime.setVisibility(View.GONE); binding.playbackLiveSync.setVisibility(View.GONE); switch (info.getStreamType()) { case AUDIO_STREAM: case POST_LIVE_AUDIO_STREAM: binding.surfaceView.setVisibility(View.GONE); binding.endScreen.setVisibility(View.VISIBLE); binding.playbackEndTime.setVisibility(View.VISIBLE); break; case AUDIO_LIVE_STREAM: binding.surfaceView.setVisibility(View.GONE); binding.endScreen.setVisibility(View.VISIBLE); binding.playbackLiveSync.setVisibility(View.VISIBLE); break; case LIVE_STREAM: binding.surfaceView.setVisibility(View.VISIBLE); binding.endScreen.setVisibility(View.GONE); binding.playbackLiveSync.setVisibility(View.VISIBLE); break; case VIDEO_STREAM: case POST_LIVE_STREAM: if (player.getCurrentMetadata() != null && player.getCurrentMetadata().getMaybeQuality().isEmpty() || (info.getVideoStreams().isEmpty() && info.getVideoOnlyStreams().isEmpty())) { break; } buildQualityMenu(); buildAudioTrackMenu(); binding.qualityTextView.setVisibility(View.VISIBLE); binding.surfaceView.setVisibility(View.VISIBLE); // fallthrough default: binding.endScreen.setVisibility(View.GONE); binding.playbackEndTime.setVisibility(View.VISIBLE); break; } buildPlaybackSpeedMenu(); binding.playbackSpeed.setVisibility(View.VISIBLE); }); } //endregion /*////////////////////////////////////////////////////////////////////////// // Popup menus ("popup" means that they pop up, not that they belong to the popup player) //////////////////////////////////////////////////////////////////////////*/ //region Popup menus ("popup" means that they pop up, not that they belong to the popup player) private void buildQualityMenu() { if (qualityPopupMenu == null) { return; } qualityPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_QUALITY); final List availableStreams = Optional.ofNullable(player.getCurrentMetadata()) .flatMap(MediaItemTag::getMaybeQuality) .map(MediaItemTag.Quality::getSortedVideoStreams) .orElse(null); if (availableStreams == null) { return; } for (int i = 0; i < availableStreams.size(); i++) { final VideoStream videoStream = availableStreams.get(i); qualityPopupMenu.getMenu().add(POPUP_MENU_ID_QUALITY, i, Menu.NONE, MediaFormat .getNameById(videoStream.getFormatId()) + " " + videoStream.getResolution()); } qualityPopupMenu.setOnMenuItemClickListener(this); qualityPopupMenu.setOnDismissListener(this); player.getSelectedVideoStream() .ifPresent(s -> binding.qualityTextView.setText(s.getResolution())); } private void buildAudioTrackMenu() { if (audioTrackPopupMenu == null) { return; } audioTrackPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_AUDIO_TRACK); final List availableStreams = Optional.ofNullable(player.getCurrentMetadata()) .flatMap(MediaItemTag::getMaybeAudioTrack) .map(MediaItemTag.AudioTrack::getAudioStreams) .orElse(null); if (availableStreams == null || availableStreams.size() < 2) { return; } for (int i = 0; i < availableStreams.size(); i++) { final AudioStream audioStream = availableStreams.get(i); audioTrackPopupMenu.getMenu().add(POPUP_MENU_ID_AUDIO_TRACK, i, Menu.NONE, Localization.audioTrackName(context, audioStream)); } player.getSelectedAudioStream() .ifPresent(s -> binding.audioTrackTextView.setText( Localization.audioTrackName(context, s))); binding.audioTrackTextView.setVisibility(View.VISIBLE); audioTrackPopupMenu.setOnMenuItemClickListener(this); audioTrackPopupMenu.setOnDismissListener(this); } private void buildPlaybackSpeedMenu() { if (playbackSpeedPopupMenu == null) { return; } playbackSpeedPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_PLAYBACK_SPEED); for (int i = 0; i < PLAYBACK_SPEEDS.length; i++) { playbackSpeedPopupMenu.getMenu().add(POPUP_MENU_ID_PLAYBACK_SPEED, i, Menu.NONE, formatSpeed(PLAYBACK_SPEEDS[i])); } binding.playbackSpeed.setText(formatSpeed(player.getPlaybackSpeed())); playbackSpeedPopupMenu.setOnMenuItemClickListener(this); playbackSpeedPopupMenu.setOnDismissListener(this); } private void buildCaptionMenu(@NonNull final List availableLanguages) { if (captionPopupMenu == null) { return; } captionPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_CAPTION); captionPopupMenu.setOnDismissListener(this); // Add option for turning off caption final MenuItem captionOffItem = captionPopupMenu.getMenu().add(POPUP_MENU_ID_CAPTION, 0, Menu.NONE, R.string.caption_none); captionOffItem.setOnMenuItemClickListener(menuItem -> { final int textRendererIndex = player.getCaptionRendererIndex(); if (textRendererIndex != RENDERER_UNAVAILABLE) { player.getTrackSelector().setParameters(player.getTrackSelector() .buildUponParameters().setRendererDisabled(textRendererIndex, true)); } player.getPrefs().edit() .remove(context.getString(R.string.caption_user_set_key)).apply(); return true; }); // Add all available captions for (int i = 0; i < availableLanguages.size(); i++) { final String captionLanguage = availableLanguages.get(i); final MenuItem captionItem = captionPopupMenu.getMenu().add(POPUP_MENU_ID_CAPTION, i + 1, Menu.NONE, captionLanguage); captionItem.setOnMenuItemClickListener(menuItem -> { final int textRendererIndex = player.getCaptionRendererIndex(); if (textRendererIndex != RENDERER_UNAVAILABLE) { // DefaultTrackSelector will select for text tracks in the following order. // When multiple tracks share the same rank, a random track will be chosen. // 1. ANY track exactly matching preferred language name // 2. ANY track exactly matching preferred language stem // 3. ROLE_FLAG_CAPTION track matching preferred language stem // 4. ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND track matching preferred language stem // This means if a caption track of preferred language is not available, // then an auto-generated track of that language will be chosen automatically. player.getTrackSelector().setParameters(player.getTrackSelector() .buildUponParameters() .setPreferredTextLanguages(captionLanguage, PlayerHelper.captionLanguageStemOf(captionLanguage)) .setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION) .setRendererDisabled(textRendererIndex, false)); player.getPrefs().edit().putString(context.getString( R.string.caption_user_set_key), captionLanguage).apply(); } return true; }); } captionPopupMenu.setOnDismissListener(this); // apply caption language from previous user preference final int textRendererIndex = player.getCaptionRendererIndex(); if (textRendererIndex == RENDERER_UNAVAILABLE) { return; } // If user prefers to show no caption, then disable the renderer. // Otherwise, DefaultTrackSelector may automatically find an available caption // and display that. final String userPreferredLanguage = player.getPrefs().getString(context.getString(R.string.caption_user_set_key), null); if (userPreferredLanguage == null) { player.getTrackSelector().setParameters(player.getTrackSelector().buildUponParameters() .setRendererDisabled(textRendererIndex, true)); return; } // Only set preferred language if it does not match the user preference, // otherwise there might be an infinite cycle at onTextTracksChanged. final List selectedPreferredLanguages = player.getTrackSelector().getParameters().preferredTextLanguages; if (!selectedPreferredLanguages.contains(userPreferredLanguage)) { player.getTrackSelector().setParameters(player.getTrackSelector().buildUponParameters() .setPreferredTextLanguages(userPreferredLanguage, PlayerHelper.captionLanguageStemOf(userPreferredLanguage)) .setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION) .setRendererDisabled(textRendererIndex, false)); } } protected abstract void onPlaybackSpeedClicked(); private void onQualityClicked() { qualityPopupMenu.show(); isSomePopupMenuVisible = true; player.getSelectedVideoStream() .map(s -> MediaFormat.getNameById(s.getFormatId()) + " " + s.getResolution()) .ifPresent(binding.qualityTextView::setText); } private void onAudioTracksClicked() { audioTrackPopupMenu.show(); isSomePopupMenuVisible = true; } /** * Called when an item of the quality selector or the playback speed selector is selected. */ @Override public boolean onMenuItemClick(@NonNull final MenuItem menuItem) { if (DEBUG) { Log.d(TAG, "onMenuItemClick() called with: " + "menuItem = [" + menuItem + "], " + "menuItem.getItemId = [" + menuItem.getItemId() + "]"); } if (menuItem.getGroupId() == POPUP_MENU_ID_QUALITY) { onQualityItemClick(menuItem); return true; } else if (menuItem.getGroupId() == POPUP_MENU_ID_AUDIO_TRACK) { onAudioTrackItemClick(menuItem); return true; } else if (menuItem.getGroupId() == POPUP_MENU_ID_PLAYBACK_SPEED) { final int speedIndex = menuItem.getItemId(); final float speed = PLAYBACK_SPEEDS[speedIndex]; player.setPlaybackSpeed(speed); binding.playbackSpeed.setText(formatSpeed(speed)); } return false; } private void onQualityItemClick(@NonNull final MenuItem menuItem) { final int menuItemIndex = menuItem.getItemId(); @Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata(); if (currentMetadata == null || currentMetadata.getMaybeQuality().isEmpty()) { return; } final MediaItemTag.Quality quality = currentMetadata.getMaybeQuality().get(); final List availableStreams = quality.getSortedVideoStreams(); final int selectedStreamIndex = quality.getSelectedVideoStreamIndex(); if (selectedStreamIndex == menuItemIndex || availableStreams.size() <= menuItemIndex) { return; } final String newResolution = availableStreams.get(menuItemIndex).getResolution(); player.setPlaybackQuality(newResolution); binding.qualityTextView.setText(menuItem.getTitle()); } private void onAudioTrackItemClick(@NonNull final MenuItem menuItem) { final int menuItemIndex = menuItem.getItemId(); @Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata(); if (currentMetadata == null || currentMetadata.getMaybeAudioTrack().isEmpty()) { return; } final MediaItemTag.AudioTrack audioTrack = currentMetadata.getMaybeAudioTrack().get(); final List availableStreams = audioTrack.getAudioStreams(); final int selectedStreamIndex = audioTrack.getSelectedAudioStreamIndex(); if (selectedStreamIndex == menuItemIndex || availableStreams.size() <= menuItemIndex) { return; } final String newAudioTrack = availableStreams.get(menuItemIndex).getAudioTrackId(); player.setAudioTrack(newAudioTrack); binding.audioTrackTextView.setText(menuItem.getTitle()); } /** * Called when some popup menu is dismissed. */ @Override public void onDismiss(@Nullable final PopupMenu menu) { if (DEBUG) { Log.d(TAG, "onDismiss() called with: menu = [" + menu + "]"); } isSomePopupMenuVisible = false; //TODO check if this works player.getSelectedVideoStream() .ifPresent(s -> binding.qualityTextView.setText(s.getResolution())); if (player.isPlaying()) { hideControls(DEFAULT_CONTROLS_DURATION, 0); hideSystemUIIfNeeded(); } } private void onCaptionClicked() { if (DEBUG) { Log.d(TAG, "onCaptionClicked() called"); } captionPopupMenu.show(); isSomePopupMenuVisible = true; } public boolean isSomePopupMenuVisible() { return isSomePopupMenuVisible; } //endregion /*////////////////////////////////////////////////////////////////////////// // Captions (text tracks) //////////////////////////////////////////////////////////////////////////*/ //region Captions (text tracks) @Override public void onTextTracksChanged(@NonNull final Tracks currentTracks) { super.onTextTracksChanged(currentTracks); final boolean trackTypeTextSupported = !currentTracks.containsType(C.TRACK_TYPE_TEXT) || currentTracks.isTypeSupported(C.TRACK_TYPE_TEXT, false); if (getPlayer().getTrackSelector().getCurrentMappedTrackInfo() == null || !trackTypeTextSupported) { binding.captionTextView.setVisibility(View.GONE); return; } // Extract all loaded languages final List textTracks = currentTracks .getGroups() .stream() .filter(trackGroupInfo -> C.TRACK_TYPE_TEXT == trackGroupInfo.getType()) .collect(Collectors.toList()); final List availableLanguages = textTracks.stream() .map(Tracks.Group::getMediaTrackGroup) .filter(textTrack -> textTrack.length > 0) .map(textTrack -> textTrack.getFormat(0).language) .collect(Collectors.toList()); // Find selected text track final Optional selectedTracks = textTracks.stream() .filter(Tracks.Group::isSelected) .filter(info -> info.getMediaTrackGroup().length >= 1) .map(info -> info.getMediaTrackGroup().getFormat(0)) .findFirst(); // Build UI buildCaptionMenu(availableLanguages); if (player.getTrackSelector().getParameters().getRendererDisabled( player.getCaptionRendererIndex()) || selectedTracks.isEmpty()) { binding.captionTextView.setText(R.string.caption_none); } else { binding.captionTextView.setText(selectedTracks.get().language); } binding.captionTextView.setVisibility( availableLanguages.isEmpty() ? View.GONE : View.VISIBLE); } @Override public void onCues(@NonNull final List cues) { super.onCues(cues); binding.subtitleView.setCues(cues); } private void setupSubtitleView() { setupSubtitleView(PlayerHelper.getCaptionScale(context)); final CaptionStyleCompat captionStyle = PlayerHelper.getCaptionStyle(context); binding.subtitleView.setApplyEmbeddedStyles(captionStyle == CaptionStyleCompat.DEFAULT); binding.subtitleView.setStyle(captionStyle); } /** * * @param captionScale Value returned by {@link PlayerHelper#getCaptionScale}. */ protected abstract void setupSubtitleView(float captionScale); //endregion /*////////////////////////////////////////////////////////////////////////// // Click listeners //////////////////////////////////////////////////////////////////////////*/ //region Click listeners /** * Create on-click listener which manages the player controls after the view on-click action. * * @param runnable The action to be executed. * @return The view click listener. */ protected View.OnClickListener makeOnClickListener(@NonNull final Runnable runnable) { return v -> { if (DEBUG) { Log.d(TAG, "onClick() called with: v = [" + v + "]"); } runnable.run(); // Manages the player controls after handling the view click. if (player.getCurrentState() == STATE_COMPLETED) { return; } controlsVisibilityHandler.removeCallbacksAndMessages(null); showHideShadow(true, DEFAULT_CONTROLS_DURATION); animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION, AnimationType.ALPHA, 0, () -> { if (player.getCurrentState() == STATE_PLAYING && !isSomePopupMenuVisible) { if (v == binding.playPauseButton // Hide controls in fullscreen immediately || (v == binding.screenRotationButton && isFullscreen())) { hideControls(0, 0); } else { hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); } } }); }; } public boolean onKeyDown(final int keyCode) { switch (keyCode) { case KeyEvent.KEYCODE_BACK: if (DeviceUtils.isTv(context) && isControlsVisible()) { hideControls(0, 0); return true; } break; case KeyEvent.KEYCODE_DPAD_UP: case KeyEvent.KEYCODE_DPAD_LEFT: case KeyEvent.KEYCODE_DPAD_DOWN: case KeyEvent.KEYCODE_DPAD_RIGHT: case KeyEvent.KEYCODE_DPAD_CENTER: if ((binding.getRoot().hasFocus() && !binding.playbackControlRoot.hasFocus()) || isAnyListViewOpen()) { // do not interfere with focus in playlist and play queue etc. break; } if (player.getCurrentState() == org.schabi.newpipe.player.Player.STATE_BLOCKED) { return true; } if (isControlsVisible()) { hideControls(DEFAULT_CONTROLS_DURATION, DPAD_CONTROLS_HIDE_TIME); } else { binding.playPauseButton.requestFocus(); showControlsThenHide(); showSystemUIPartially(); return true; } break; default: break; // ignore other keys } return false; } private void onMoreOptionsClicked() { if (DEBUG) { Log.d(TAG, "onMoreOptionsClicked() called"); } final boolean isMoreControlsVisible = binding.secondaryControls.getVisibility() == View.VISIBLE; animateRotation(binding.moreOptionsButton, DEFAULT_CONTROLS_DURATION, isMoreControlsVisible ? 0 : 180); animate(binding.secondaryControls, !isMoreControlsVisible, DEFAULT_CONTROLS_DURATION, AnimationType.SLIDE_AND_ALPHA, 0, () -> { // Fix for a ripple effect on background drawable. // When view returns from GONE state it takes more milliseconds than returning // from INVISIBLE state. And the delay makes ripple background end to fast if (isMoreControlsVisible) { binding.secondaryControls.setVisibility(View.INVISIBLE); } }); showControls(DEFAULT_CONTROLS_DURATION); } private void onPlayWithKodiClicked() { if (player.getCurrentMetadata() != null) { player.pause(); KoreUtils.playWithKore(context, Uri.parse(player.getVideoUrl())); } } private void onOpenInBrowserClicked() { player.getCurrentStreamInfo().ifPresent(streamInfo -> ShareUtils.openUrlInBrowser(player.getContext(), streamInfo.getOriginalUrl())); } //endregion /*////////////////////////////////////////////////////////////////////////// // Video size //////////////////////////////////////////////////////////////////////////*/ //region Video size protected void setResizeMode(@AspectRatioFrameLayout.ResizeMode final int resizeMode) { binding.surfaceView.setResizeMode(resizeMode); binding.resizeTextView.setText(PlayerHelper.resizeTypeOf(context, resizeMode)); } void onResizeClicked() { setResizeMode(nextResizeModeAndSaveToPrefs(player, binding.surfaceView.getResizeMode())); } @Override public void onVideoSizeChanged(@NonNull final VideoSize videoSize) { super.onVideoSizeChanged(videoSize); // Starting with ExoPlayer 2.19.0, the VideoSize will report a width and height of 0 // if the renderer is disabled. In that case, we skip updating the aspect ratio. if (videoSize.width == 0 || videoSize.height == 0) { return; } binding.surfaceView.setAspectRatio(((float) videoSize.width) / videoSize.height); } //endregion /*////////////////////////////////////////////////////////////////////////// // SurfaceHolderCallback helpers //////////////////////////////////////////////////////////////////////////*/ //region SurfaceHolderCallback helpers /** * Connects the video surface to the exo player. This can be called anytime without the risk for * issues to occur, since the player will run just fine when no surface is connected. Therefore * the video surface will be setup only when all of these conditions are true: it is not already * setup (this just prevents wasting resources to setup the surface again), there is an exo * player, the root view is attached to a parent and the surface view is valid/unreleased (the * latter two conditions prevent "The surface has been released" errors). So this function can * be called many times and even while the UI is in unready states. */ public void setupVideoSurfaceIfNeeded() { if (!surfaceIsSetup && player.getExoPlayer() != null && binding.getRoot().getParent() != null) { // make sure there is nothing left over from previous calls clearVideoSurface(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // >=API23 surfaceHolderCallback = new SurfaceHolderCallback(context, player.getExoPlayer()); binding.surfaceView.getHolder().addCallback(surfaceHolderCallback); // ensure player is using an unreleased surface, which the surfaceView might not be // when starting playback on background or during player switching if (binding.surfaceView.getHolder().getSurface().isValid()) { // initially set the surface manually otherwise // onRenderedFirstFrame() will not be called player.getExoPlayer().setVideoSurfaceHolder(binding.surfaceView.getHolder()); } } else { player.getExoPlayer().setVideoSurfaceView(binding.surfaceView); } surfaceIsSetup = true; } } private void clearVideoSurface() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M // >=API23 && surfaceHolderCallback != null) { binding.surfaceView.getHolder().removeCallback(surfaceHolderCallback); surfaceHolderCallback.release(); surfaceHolderCallback = null; } Optional.ofNullable(player.getExoPlayer()).ifPresent(ExoPlayer::clearVideoSurface); surfaceIsSetup = false; } //endregion /*////////////////////////////////////////////////////////////////////////// // Getters //////////////////////////////////////////////////////////////////////////*/ //region Getters public PlayerBinding getBinding() { return binding; } public GestureDetector getGestureDetector() { return gestureDetector; } //endregion } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java ================================================ package org.schabi.newpipe.settings; import android.content.ActivityNotFoundException; import android.content.Intent; import android.os.Bundle; import android.provider.Settings; import android.widget.Toast; import androidx.core.app.ActivityCompat; import androidx.preference.Preference; import org.schabi.newpipe.R; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.ThemeHelper; public class AppearanceSettingsFragment extends BasePreferenceFragment { @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { addPreferencesFromResourceRegistry(); final String themeKey = getString(R.string.theme_key); // the key of the active theme when settings were opened (or recreated after theme change) final String startThemeKey = defaultPreferences .getString(themeKey, getString(R.string.default_theme_value)); final String autoDeviceThemeKey = getString(R.string.auto_device_theme_key); findPreference(themeKey).setOnPreferenceChangeListener((preference, newValue) -> { if (newValue.toString().equals(autoDeviceThemeKey)) { Toast.makeText(getContext(), getString(R.string.select_night_theme_toast), Toast.LENGTH_LONG).show(); } applyThemeChange(startThemeKey, themeKey, newValue); return false; }); final String nightThemeKey = getString(R.string.night_theme_key); if (startThemeKey.equals(autoDeviceThemeKey)) { final String startNightThemeKey = defaultPreferences .getString(nightThemeKey, getString(R.string.default_night_theme_value)); findPreference(nightThemeKey).setOnPreferenceChangeListener((preference, newValue) -> { applyThemeChange(startNightThemeKey, nightThemeKey, newValue); return false; }); } else { // disable the night theme selection final Preference preference = findPreference(nightThemeKey); if (preference != null) { preference.setEnabled(false); preference.setSummary(getString(R.string.night_theme_available, getString(R.string.auto_device_theme_title))); } } } @Override public boolean onPreferenceTreeClick(final Preference preference) { if (getString(R.string.caption_settings_key).equals(preference.getKey())) { try { startActivity(new Intent(Settings.ACTION_CAPTIONING_SETTINGS)); } catch (final ActivityNotFoundException e) { Toast.makeText(getActivity(), R.string.general_error, Toast.LENGTH_SHORT).show(); } } return super.onPreferenceTreeClick(preference); } private void applyThemeChange(final String beginningThemeKey, final String themeKey, final Object newValue) { defaultPreferences.edit().putBoolean(Constants.KEY_THEME_CHANGE, true).apply(); defaultPreferences.edit().putString(themeKey, newValue.toString()).apply(); ThemeHelper.setDayNightMode(requireContext(), newValue.toString()); if (!newValue.equals(beginningThemeKey) && getActivity() != null) { // if it's not the current theme ActivityCompat.recreate(getActivity()); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java ================================================ package org.schabi.newpipe.settings; import static org.schabi.newpipe.extractor.utils.Utils.isBlank; import android.app.Activity; import android.app.AlertDialog; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.net.Uri; import android.os.Bundle; import android.widget.Toast; import androidx.activity.result.ActivityResult; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.preference.Preference; import androidx.preference.PreferenceManager; import com.grack.nanojson.JsonParserException; import org.schabi.newpipe.NewPipeDatabase; 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.local.subscription.SubscriptionsImportExportHelper; import org.schabi.newpipe.settings.export.BackupFileLocator; import org.schabi.newpipe.settings.export.ImportExportManager; import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard; import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.ZipHelper; import java.io.IOException; import java.text.SimpleDateFormat; import java.util.Date; import java.util.Locale; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class BackupRestoreSettingsFragment extends BasePreferenceFragment { private static final String ZIP_MIME_TYPE = "application/zip"; private final SimpleDateFormat exportDateFormat = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US); private ImportExportManager manager; private String importExportDataPathKey; private final ActivityResultLauncher requestImportPathLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), this::requestImportPathResult); private final ActivityResultLauncher requestExportPathLauncher = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), this::requestExportPathResult); private SubscriptionsImportExportHelper importExportHelper; @Override public void onAttach(@NonNull final Context context) { super.onAttach(context); importExportHelper = new SubscriptionsImportExportHelper(this); } @Override public void onCreatePreferences(@Nullable final Bundle savedInstanceState, @Nullable final String rootKey) { manager = new ImportExportManager(new BackupFileLocator(requireContext())); importExportDataPathKey = getString(R.string.import_export_data_path); addPreferencesFromResourceRegistry(); final Preference importDataPreference = requirePreference(R.string.import_data); importDataPreference.setOnPreferenceClickListener((Preference p) -> { NoFileManagerSafeGuard.launchSafe( requestImportPathLauncher, StoredFileHelper.getPicker(requireContext(), ZIP_MIME_TYPE, getImportExportDataUri()), TAG, getContext() ); return true; }); final Preference exportDataPreference = requirePreference(R.string.export_data); exportDataPreference.setOnPreferenceClickListener((final Preference p) -> { NoFileManagerSafeGuard.launchSafe( requestExportPathLauncher, StoredFileHelper.getNewPicker(requireContext(), "NewPipeData-" + exportDateFormat.format(new Date()) + ".zip", ZIP_MIME_TYPE, getImportExportDataUri()), TAG, getContext() ); return true; }); final Preference resetSettings = requirePreference(R.string.reset_settings); // Resets all settings by deleting shared preference and restarting the app // A dialogue will pop up to confirm if user intends to reset all settings resetSettings.setOnPreferenceClickListener(preference -> { // Show Alert Dialogue final AlertDialog.Builder builder = new AlertDialog.Builder(getContext()); builder.setMessage(R.string.reset_all_settings); builder.setCancelable(true); builder.setPositiveButton(R.string.ok, (dialogInterface, i) -> { // Deletes all shared preferences xml files. final SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); sharedPreferences.edit().clear().apply(); // Restarts the app if (getActivity() == null) { return; } NavigationHelper.restartApp(getActivity()); }); builder.setNegativeButton(R.string.cancel, (dialogInterface, i) -> { }); final AlertDialog alertDialog = builder.create(); alertDialog.show(); return true; }); final Preference exportSubsPreference = requirePreference(R.string.export_subscriptions_key); exportSubsPreference.setOnPreferenceClickListener(reference -> { importExportHelper.onExportSelected(); return true; }); final Preference importSubsPreference = requirePreference(R.string.import_subscriptions_key); importSubsPreference.setOnPreferenceClickListener(preference -> { importExportHelper.onImportPreviousSelected(); return true; }); } private void requestExportPathResult(final ActivityResult result) { if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { // will be saved only on success final Uri lastExportDataUri = result.getData().getData(); final StoredFileHelper file = new StoredFileHelper( requireContext(), result.getData().getData(), ZIP_MIME_TYPE); exportDatabase(file, lastExportDataUri); } } private void requestImportPathResult(final ActivityResult result) { if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { // will be saved only on success final Uri lastImportDataUri = result.getData().getData(); final StoredFileHelper file = new StoredFileHelper( requireContext(), result.getData().getData(), ZIP_MIME_TYPE); new androidx.appcompat.app.AlertDialog.Builder(requireActivity()) .setMessage(R.string.override_current_data) .setPositiveButton(R.string.ok, (d, id) -> importDatabase(file, lastImportDataUri)) .setNegativeButton(R.string.cancel, (d, id) -> d.cancel()) .show(); } } private void exportDatabase(final StoredFileHelper file, final Uri exportDataUri) { try (ExecutorService executor = Executors.newSingleThreadExecutor()) { //checkpoint before export executor.submit(NewPipeDatabase::checkpoint).get(); final SharedPreferences preferences = PreferenceManager .getDefaultSharedPreferences(requireContext()); manager.exportDatabase(preferences, file); saveLastImportExportDataUri(exportDataUri); // save export path only on success Toast.makeText(requireContext(), R.string.export_complete_toast, Toast.LENGTH_SHORT) .show(); } catch (final Exception e) { showErrorSnackbar(e, "Exporting database and settings"); } } private void importDatabase(final StoredFileHelper file, final Uri importDataUri) { // check if file is supported if (!ZipHelper.isValidZipFile(file)) { Toast.makeText(requireContext(), R.string.no_valid_zip_file, Toast.LENGTH_SHORT) .show(); return; } try { manager.ensureDbDirectoryExists(); // replace the current database if (!manager.extractDb(file)) { Toast.makeText(requireContext(), R.string.could_not_import_all_files, Toast.LENGTH_LONG) .show(); } // if settings file exist, ask if it should be imported. final boolean hasJsonPrefs = manager.exportHasJsonPrefs(file); if (hasJsonPrefs || manager.exportHasSerializedPrefs(file)) { new androidx.appcompat.app.AlertDialog.Builder(requireContext()) .setTitle(R.string.import_settings) .setMessage(hasJsonPrefs ? null : requireContext() .getString(R.string.import_settings_vulnerable_format)) .setOnDismissListener(dialog -> finishImport(importDataUri)) .setNegativeButton(R.string.cancel, (dialog, which) -> { dialog.dismiss(); finishImport(importDataUri); }) .setPositiveButton(R.string.ok, (dialog, which) -> { dialog.dismiss(); final Context context = requireContext(); final SharedPreferences prefs = PreferenceManager .getDefaultSharedPreferences(context); try { if (hasJsonPrefs) { manager.loadJsonPrefs(file, prefs); } else { manager.loadSerializedPrefs(file, prefs); } } catch (IOException | ClassNotFoundException | JsonParserException e) { createErrorNotification(e, "Importing preferences"); return; } cleanImport(context, prefs); finishImport(importDataUri); }) .show(); } else { finishImport(importDataUri); } } catch (final Exception e) { showErrorSnackbar(e, "Importing database and settings"); } } /** * Remove settings that are not supposed to be imported on different devices * and reset them to default values. * @param context the context used for the import * @param prefs the preferences used while running the import */ private void cleanImport(@NonNull final Context context, @NonNull final SharedPreferences prefs) { // Check if media tunnelling needs to be disabled automatically, // if it was disabled automatically in the imported preferences. final String tunnelingKey = context.getString(R.string.disable_media_tunneling_key); final String automaticTunnelingKey = context.getString(R.string.disabled_media_tunneling_automatically_key); // R.string.disable_media_tunneling_key should always be true // if R.string.disabled_media_tunneling_automatically_key equals 1, // but we double check here just to be sure and to avoid regressions // caused by possible later modification of the media tunneling functionality. // R.string.disabled_media_tunneling_automatically_key == 0: // automatic value overridden by user in settings // R.string.disabled_media_tunneling_automatically_key == -1: not set final boolean wasMediaTunnelingDisabledAutomatically = prefs.getInt(automaticTunnelingKey, -1) == 1 && prefs.getBoolean(tunnelingKey, false); if (wasMediaTunnelingDisabledAutomatically) { prefs.edit() .putInt(automaticTunnelingKey, -1) .putBoolean(tunnelingKey, false) .apply(); NewPipeSettings.setMediaTunneling(context); } } /** * Save import path and restart app. * * @param importDataUri The import path to save */ private void finishImport(final Uri importDataUri) { // save import path only on success saveLastImportExportDataUri(importDataUri); // restart app to properly load db NavigationHelper.restartApp(requireActivity()); } private Uri getImportExportDataUri() { final String path = defaultPreferences.getString(importExportDataPathKey, null); return isBlank(path) ? null : Uri.parse(path); } private void saveLastImportExportDataUri(final Uri importExportDataUri) { final SharedPreferences.Editor editor = defaultPreferences.edit() .putString(importExportDataPathKey, importExportDataUri.toString()); editor.apply(); } private void showErrorSnackbar(final Throwable e, final String request) { ErrorUtil.showSnackbar(this, new ErrorInfo(e, UserAction.DATABASE_IMPORT_EXPORT, request)); } private void createErrorNotification(final Throwable e, final String request) { ErrorUtil.createNotification( requireContext(), new ErrorInfo(e, UserAction.DATABASE_IMPORT_EXPORT, request) ); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java ================================================ package org.schabi.newpipe.settings; import android.content.SharedPreferences; import android.os.Bundle; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.util.ThemeHelper; import java.util.Objects; public abstract class BasePreferenceFragment extends PreferenceFragmentCompat { protected final String TAG = getClass().getSimpleName() + "@" + Integer.toHexString(hashCode()); protected static final boolean DEBUG = MainActivity.DEBUG; SharedPreferences defaultPreferences; @Override public void onCreate(@Nullable final Bundle savedInstanceState) { defaultPreferences = PreferenceManager.getDefaultSharedPreferences(requireActivity()); super.onCreate(savedInstanceState); } protected void addPreferencesFromResourceRegistry() { addPreferencesFromResource( SettingsResourceRegistry.getInstance().getPreferencesResId(this.getClass())); } @Override public void onViewCreated(@NonNull final View rootView, @Nullable final Bundle savedInstanceState) { super.onViewCreated(rootView, savedInstanceState); setDivider(null); ThemeHelper.setTitleToAppCompatActivity(getActivity(), getPreferenceScreen().getTitle()); } @Override public void onResume() { super.onResume(); ThemeHelper.setTitleToAppCompatActivity(getActivity(), getPreferenceScreen().getTitle()); } @NonNull public final T requirePreference(@StringRes final int resId) { final T preference = findPreference(getString(resId)); Objects.requireNonNull(preference); return preference; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java ================================================ package org.schabi.newpipe.settings; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.provider.Settings; import android.util.Log; import android.widget.Toast; import androidx.appcompat.app.AppCompatDelegate; import androidx.core.os.LocaleListCompat; import androidx.preference.Preference; import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.image.ImageStrategy; import org.schabi.newpipe.util.image.PreferredImageQuality; import java.util.Locale; import coil3.SingletonImageLoader; public class ContentSettingsFragment extends BasePreferenceFragment { private String youtubeRestrictedModeEnabledKey; @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled); addPreferencesFromResourceRegistry(); setupAppLanguagePreferences(); setupImageQualityPref(); } private void setupAppLanguagePreferences() { final Preference appLanguagePref = requirePreference(R.string.app_language_key); // Android 13+ allows to set app specific languages if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { appLanguagePref.setVisible(false); final Preference newAppLanguagePref = requirePreference(R.string.app_language_android_13_and_up_key); newAppLanguagePref.setSummaryProvider(preference -> { final Locale loc = AppCompatDelegate.getApplicationLocales().get(0); return loc != null ? loc.getDisplayName() : getString(R.string.systems_language); }); newAppLanguagePref.setOnPreferenceClickListener(preference -> { final Intent intent = new Intent(Settings.ACTION_APP_LOCALE_SETTINGS) .setData(Uri.fromParts("package", requireContext().getPackageName(), null)); startActivity(intent); return true; }); newAppLanguagePref.setVisible(true); return; } appLanguagePref.setOnPreferenceChangeListener((preference, newValue) -> { final String language = (String) newValue; final String systemLang = getString(R.string.default_localization_key); final String tag = systemLang.equals(language) ? null : language; AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(tag)); return true; }); } private void setupImageQualityPref() { requirePreference(R.string.image_quality_key).setOnPreferenceChangeListener( (preference, newValue) -> { ImageStrategy.setPreferredImageQuality(PreferredImageQuality .fromPreferenceKey(requireContext(), (String) newValue)); final var loader = SingletonImageLoader.get(preference.getContext()); loader.getMemoryCache().clear(); loader.getDiskCache().clear(); Toast.makeText(preference.getContext(), R.string.thumbnail_cache_wipe_complete_notice, Toast.LENGTH_SHORT) .show(); return true; }); } @Override public boolean onPreferenceTreeClick(final Preference preference) { if (preference.getKey().equals(youtubeRestrictedModeEnabledKey)) { final Context context = getContext(); if (context != null) { DownloaderImpl.getInstance().updateYoutubeRestrictedModeCookies(context); } else { Log.w(TAG, "onPreferenceTreeClick: null context"); } } return super.onPreferenceTreeClick(preference); } @Override public void onDestroy() { super.onDestroy(); final Context context = requireContext(); NewPipe.setupLocalization( Localization.getPreferredLocalization(context), Localization.getPreferredContentCountry(context)); PlayerHelper.resetFormat(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java ================================================ package org.schabi.newpipe.settings; import android.content.Intent; import android.os.Bundle; import androidx.preference.Preference; 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.local.feed.notifications.NotificationWorker; import java.util.Optional; public class DebugSettingsFragment extends BasePreferenceFragment { private static final String DUMMY = "Dummy"; @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { addPreferencesFromResourceRegistry(); final Preference allowHeapDumpingPreference = requirePreference(R.string.allow_heap_dumping_key); final Preference showMemoryLeaksPreference = requirePreference(R.string.show_memory_leaks_key); final Preference checkNewStreamsPreference = requirePreference(R.string.check_new_streams_key); final Preference crashTheAppPreference = requirePreference(R.string.crash_the_app_key); final Preference showErrorSnackbarPreference = requirePreference(R.string.show_error_snackbar_key); final Preference createErrorNotificationPreference = requirePreference(R.string.create_error_notification_key); final Optional optBVLeakCanary = getBVDLeakCanary(); allowHeapDumpingPreference.setEnabled(optBVLeakCanary.isPresent()); showMemoryLeaksPreference.setEnabled(optBVLeakCanary.isPresent()); if (optBVLeakCanary.isPresent()) { final DebugSettingsBVDLeakCanaryAPI pdLeakCanary = optBVLeakCanary.get(); showMemoryLeaksPreference.setOnPreferenceClickListener(preference -> { startActivity(pdLeakCanary.getNewLeakDisplayActivityIntent()); return true; }); } else { allowHeapDumpingPreference.setSummary(R.string.leak_canary_not_available); showMemoryLeaksPreference.setSummary(R.string.leak_canary_not_available); } checkNewStreamsPreference.setOnPreferenceClickListener(preference -> { NotificationWorker.runNow(preference.getContext()); return true; }); crashTheAppPreference.setOnPreferenceClickListener(preference -> { throw new RuntimeException(DUMMY); }); showErrorSnackbarPreference.setOnPreferenceClickListener(preference -> { ErrorUtil.showUiErrorSnackbar(DebugSettingsFragment.this, DUMMY, new RuntimeException(DUMMY)); return true; }); createErrorNotificationPreference.setOnPreferenceClickListener(preference -> { ErrorUtil.createNotification(requireContext(), new ErrorInfo(new RuntimeException(DUMMY), UserAction.UI_ERROR, DUMMY)); return true; }); } /** * Tries to find the {@link DebugSettingsBVDLeakCanaryAPI#IMPL_CLASS} and loads it if available. * @return An {@link Optional} which is empty if the implementation class couldn't be loaded. */ private Optional getBVDLeakCanary() { try { // Try to find the implementation of the LeakCanary API return Optional.of((DebugSettingsBVDLeakCanaryAPI) Class.forName(DebugSettingsBVDLeakCanaryAPI.IMPL_CLASS) .getDeclaredConstructor() .newInstance()); } catch (final Exception e) { return Optional.empty(); } } /** * Build variant dependent (BVD) leak canary API for this fragment. * Why is LeakCanary not used directly? Because it can't be assured */ public interface DebugSettingsBVDLeakCanaryAPI { String IMPL_CLASS = "org.schabi.newpipe.settings.DebugSettingsBVDLeakCanary"; Intent getNewLeakDisplayActivityIntent(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java ================================================ package org.schabi.newpipe.settings; import android.app.Activity; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.util.Log; import androidx.activity.result.ActivityResult; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; import androidx.annotation.NonNull; import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; import androidx.preference.Preference; import androidx.preference.SwitchPreferenceCompat; import com.nononsenseapps.filepicker.Utils; import org.schabi.newpipe.R; import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard; import org.schabi.newpipe.streams.io.StoredDirectoryHelper; import org.schabi.newpipe.util.FilePickerActivityHelper; import java.io.File; import java.io.IOException; public class DownloadSettingsFragment extends BasePreferenceFragment { public static final boolean IGNORE_RELEASE_ON_OLD_PATH = true; private String downloadPathVideoPreference; private String downloadPathAudioPreference; private String storageUseSafPreference; private Preference prefPathVideo; private Preference prefPathAudio; private Preference prefStorageAsk; private Context ctx; private final ActivityResultLauncher requestDownloadVideoPathLauncher = registerForActivityResult( new StartActivityForResult(), this::requestDownloadVideoPathResult); private final ActivityResultLauncher requestDownloadAudioPathLauncher = registerForActivityResult( new StartActivityForResult(), this::requestDownloadAudioPathResult); @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { addPreferencesFromResourceRegistry(); downloadPathVideoPreference = getString(R.string.download_path_video_key); downloadPathAudioPreference = getString(R.string.download_path_audio_key); storageUseSafPreference = getString(R.string.storage_use_saf); final String downloadStorageAsk = getString(R.string.downloads_storage_ask); prefPathVideo = findPreference(downloadPathVideoPreference); prefPathAudio = findPreference(downloadPathAudioPreference); prefStorageAsk = findPreference(downloadStorageAsk); final SwitchPreferenceCompat prefUseSaf = findPreference(storageUseSafPreference); prefUseSaf.setChecked(NewPipeSettings.useStorageAccessFramework(ctx)); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { prefUseSaf.setEnabled(false); prefUseSaf.setSummary(R.string.downloads_storage_use_saf_summary_api_29); prefStorageAsk.setSummary(R.string.downloads_storage_ask_summary_no_saf_notice); } updatePreferencesSummary(); updatePathPickers(!defaultPreferences.getBoolean(downloadStorageAsk, false)); if (hasInvalidPath(downloadPathVideoPreference) || hasInvalidPath(downloadPathAudioPreference)) { updatePreferencesSummary(); } prefStorageAsk.setOnPreferenceChangeListener((preference, value) -> { updatePathPickers(!(boolean) value); return true; }); } @Override public void onAttach(@NonNull final Context context) { super.onAttach(context); ctx = context; } @Override public void onDetach() { super.onDetach(); ctx = null; prefStorageAsk.setOnPreferenceChangeListener(null); } private void updatePreferencesSummary() { showPathInSummary(downloadPathVideoPreference, R.string.download_path_summary, prefPathVideo); showPathInSummary(downloadPathAudioPreference, R.string.download_path_audio_summary, prefPathAudio); } private void showPathInSummary(final String prefKey, @StringRes final int defaultString, final Preference target) { final Uri uri = Uri.parse(defaultPreferences.getString(prefKey, "")); if (uri.equals(Uri.EMPTY)) { target.setSummary(getString(defaultString)); return; } final String summary = ContentResolver.SCHEME_FILE.equals(uri.getScheme()) ? uri.getPath() : uri.toString(); target.setSummary(summary); } private boolean isFileUri(final String path) { return path.charAt(0) == File.separatorChar || path.startsWith(ContentResolver.SCHEME_FILE); } private boolean hasInvalidPath(final String prefKey) { final String value = defaultPreferences.getString(prefKey, null); return value == null || value.isEmpty(); } private void updatePathPickers(final boolean enabled) { prefPathVideo.setEnabled(enabled); prefPathAudio.setEnabled(enabled); } // FIXME: after releasing the old path, all downloads created on the folder becomes inaccessible private void forgetSAFTree(final Context context, final String oldPath) { if (IGNORE_RELEASE_ON_OLD_PATH) { return; } if (oldPath == null || oldPath.isEmpty() || isFileUri(oldPath)) { return; } try { final Uri uri = Uri.parse(oldPath); context.getContentResolver() .releasePersistableUriPermission(uri, StoredDirectoryHelper.PERMISSION_FLAGS); context.revokeUriPermission(uri, StoredDirectoryHelper.PERMISSION_FLAGS); Log.i(TAG, "Revoke old path permissions success on " + oldPath); } catch (final Exception err) { Log.e(TAG, "Error revoking old path permissions on " + oldPath, err); } } private void showMessageDialog(@StringRes final int title, @StringRes final int message) { new AlertDialog.Builder(ctx) .setTitle(title) .setMessage(message) .setPositiveButton(getString(R.string.ok), null) .show(); } @Override public boolean onPreferenceTreeClick(@NonNull final Preference preference) { if (DEBUG) { Log.d(TAG, "onPreferenceTreeClick() called with: " + "preference = [" + preference + "]"); } final String key = preference.getKey(); if (key.equals(storageUseSafPreference)) { if (!NewPipeSettings.useStorageAccessFramework(ctx)) { NewPipeSettings.saveDefaultVideoDownloadDirectory(ctx); NewPipeSettings.saveDefaultAudioDownloadDirectory(ctx); } else { defaultPreferences.edit().putString(downloadPathVideoPreference, null) .putString(downloadPathAudioPreference, null).apply(); } updatePreferencesSummary(); return true; } else if (key.equals(downloadPathVideoPreference)) { launchDirectoryPicker(requestDownloadVideoPathLauncher); } else if (key.equals(downloadPathAudioPreference)) { launchDirectoryPicker(requestDownloadAudioPathLauncher); } else { return super.onPreferenceTreeClick(preference); } return true; } private void launchDirectoryPicker(final ActivityResultLauncher launcher) { NoFileManagerSafeGuard.launchSafe( launcher, StoredDirectoryHelper.getPicker(ctx), TAG, ctx ); } private void requestDownloadVideoPathResult(final ActivityResult result) { requestDownloadPathResult(result, downloadPathVideoPreference); } private void requestDownloadAudioPathResult(final ActivityResult result) { requestDownloadPathResult(result, downloadPathAudioPreference); } private void requestDownloadPathResult(final ActivityResult result, final String key) { if (result.getResultCode() != Activity.RESULT_OK) { return; } Uri uri = null; if (result.getData() != null) { uri = result.getData().getData(); } if (uri == null) { showMessageDialog(R.string.general_error, R.string.invalid_directory); return; } // revoke permissions on the old save path (required for SAF only) final Context context = requireContext(); forgetSAFTree(context, defaultPreferences.getString(key, "")); if (!FilePickerActivityHelper.isOwnFileUri(context, uri)) { // steps to acquire the selected path: // 1. acquire permissions on the new save path // 2. save the new path, if step(2) was successful try { context.grantUriPermission(context.getPackageName(), uri, StoredDirectoryHelper.PERMISSION_FLAGS); final StoredDirectoryHelper mainStorage = new StoredDirectoryHelper(context, uri, null); Log.i(TAG, "Acquiring tree success from " + uri.toString()); if (!mainStorage.canWrite()) { throw new IOException("No write permissions on " + uri.toString()); } } catch (final IOException err) { Log.e(TAG, "Error acquiring tree from " + uri.toString(), err); showMessageDialog(R.string.general_error, R.string.no_available_dir); return; } } else { final File target = Utils.getFileForUri(uri); if (!target.canWrite()) { showMessageDialog(R.string.download_to_sdcard_error_title, R.string.download_to_sdcard_error_message); return; } uri = Uri.fromFile(target); } defaultPreferences.edit().putString(key, uri.toString()).apply(); updatePreferencesSummary(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/ExoPlayerSettingsFragment.java ================================================ package org.schabi.newpipe.settings; import android.content.SharedPreferences; import android.os.Bundle; import androidx.annotation.Nullable; import androidx.preference.Preference; import androidx.preference.PreferenceManager; import androidx.preference.SwitchPreferenceCompat; import org.schabi.newpipe.R; public class ExoPlayerSettingsFragment extends BasePreferenceFragment { @Override public void onCreatePreferences(@Nullable final Bundle savedInstanceState, @Nullable final String rootKey) { addPreferencesFromResourceRegistry(); final String disabledMediaTunnelingAutomaticallyKey = getString(R.string.disabled_media_tunneling_automatically_key); final SwitchPreferenceCompat disableMediaTunnelingPref = (SwitchPreferenceCompat) requirePreference(R.string.disable_media_tunneling_key); final SharedPreferences prefs = PreferenceManager .getDefaultSharedPreferences(requireContext()); final boolean mediaTunnelingAutomaticallyDisabled = prefs.getInt(disabledMediaTunnelingAutomaticallyKey, -1) == 1; final String summaryText = getString(R.string.disable_media_tunneling_summary); disableMediaTunnelingPref.setSummary(mediaTunnelingAutomaticallyDisabled ? summaryText + " " + getString(R.string.disable_media_tunneling_automatic_info) : summaryText); disableMediaTunnelingPref.setOnPreferenceChangeListener((Preference p, Object enabled) -> { if (Boolean.FALSE.equals(enabled)) { PreferenceManager.getDefaultSharedPreferences(requireContext()) .edit() .putInt(disabledMediaTunnelingAutomaticallyKey, 0) .apply(); // the info text might have been shown before p.setSummary(R.string.disable_media_tunneling_summary); } return true; }); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java ================================================ package org.schabi.newpipe.settings; import android.content.Context; import android.os.Bundle; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.preference.Preference; import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.ReCaptchaActivity; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.InfoCache; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; public class HistorySettingsFragment extends BasePreferenceFragment { private String cacheWipeKey; private String viewsHistoryClearKey; private String playbackStatesClearKey; private String searchHistoryClearKey; private HistoryRecordManager recordManager; private CompositeDisposable disposables; @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { addPreferencesFromResourceRegistry(); cacheWipeKey = getString(R.string.metadata_cache_wipe_key); viewsHistoryClearKey = getString(R.string.clear_views_history_key); playbackStatesClearKey = getString(R.string.clear_playback_states_key); searchHistoryClearKey = getString(R.string.clear_search_history_key); recordManager = new HistoryRecordManager(getActivity()); disposables = new CompositeDisposable(); final Preference clearCookiePref = requirePreference(R.string.clear_cookie_key); clearCookiePref.setOnPreferenceClickListener(preference -> { defaultPreferences.edit() .putString(getString(R.string.recaptcha_cookies_key), "").apply(); DownloaderImpl.getInstance().setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, ""); Toast.makeText(getActivity(), R.string.recaptcha_cookies_cleared, Toast.LENGTH_SHORT).show(); clearCookiePref.setEnabled(false); return true; }); if (defaultPreferences.getString(getString(R.string.recaptcha_cookies_key), "").isEmpty()) { clearCookiePref.setEnabled(false); } } @Override public boolean onPreferenceTreeClick(final Preference preference) { if (preference.getKey().equals(cacheWipeKey)) { InfoCache.getInstance().clearCache(); Toast.makeText(requireContext(), R.string.metadata_cache_wipe_complete_notice, Toast.LENGTH_SHORT).show(); } else if (preference.getKey().equals(viewsHistoryClearKey)) { openDeleteWatchHistoryDialog(requireContext(), recordManager, disposables); } else if (preference.getKey().equals(playbackStatesClearKey)) { openDeletePlaybackStatesDialog(requireContext(), recordManager, disposables); } else if (preference.getKey().equals(searchHistoryClearKey)) { openDeleteSearchHistoryDialog(requireContext(), recordManager, disposables); } else { return super.onPreferenceTreeClick(preference); } return true; } private static Disposable getDeletePlaybackStatesDisposable( @NonNull final Context context, final HistoryRecordManager recordManager) { return recordManager.deleteCompleteStreamStateHistory() .observeOn(AndroidSchedulers.mainThread()) .subscribe( howManyDeleted -> Toast.makeText(context, R.string.watch_history_states_deleted, Toast.LENGTH_SHORT).show(), throwable -> ErrorUtil.openActivity(context, new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, "Delete playback states"))); } private static Disposable getWholeStreamHistoryDisposable( @NonNull final Context context, final HistoryRecordManager recordManager) { return recordManager.deleteWholeStreamHistory() .observeOn(AndroidSchedulers.mainThread()) .subscribe( howManyDeleted -> Toast.makeText(context, R.string.watch_history_deleted, Toast.LENGTH_SHORT).show(), throwable -> ErrorUtil.openActivity(context, new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, "Delete from history"))); } private static Disposable getRemoveOrphanedRecordsDisposable( @NonNull final Context context, final HistoryRecordManager recordManager) { return recordManager.removeOrphanedRecords() .observeOn(AndroidSchedulers.mainThread()) .subscribe( howManyDeleted -> { }, throwable -> ErrorUtil.openActivity(context, new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, "Clear orphaned records"))); } private static Disposable getDeleteSearchHistoryDisposable( @NonNull final Context context, final HistoryRecordManager recordManager) { return recordManager.deleteCompleteSearchHistory() .observeOn(AndroidSchedulers.mainThread()) .subscribe( howManyDeleted -> Toast.makeText(context, R.string.search_history_deleted, Toast.LENGTH_SHORT).show(), throwable -> ErrorUtil.openActivity(context, new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, "Delete search history"))); } public static void openDeleteWatchHistoryDialog(@NonNull final Context context, final HistoryRecordManager recordManager, final CompositeDisposable disposables) { new AlertDialog.Builder(context) .setTitle(R.string.delete_view_history_alert) .setNegativeButton(R.string.cancel, ((dialog, which) -> dialog.dismiss())) .setPositiveButton(R.string.delete, ((dialog, which) -> { disposables.add(getDeletePlaybackStatesDisposable(context, recordManager)); disposables.add(getWholeStreamHistoryDisposable(context, recordManager)); disposables.add(getRemoveOrphanedRecordsDisposable(context, recordManager)); })) .show(); } public static void openDeletePlaybackStatesDialog(@NonNull final Context context, final HistoryRecordManager recordManager, final CompositeDisposable disposables) { new AlertDialog.Builder(context) .setTitle(R.string.delete_playback_states_alert) .setNegativeButton(R.string.cancel, ((dialog, which) -> dialog.dismiss())) .setPositiveButton(R.string.delete, ((dialog, which) -> disposables.add(getDeletePlaybackStatesDisposable(context, recordManager)))) .show(); } public static void openDeleteSearchHistoryDialog(@NonNull final Context context, final HistoryRecordManager recordManager, final CompositeDisposable disposables) { new AlertDialog.Builder(context) .setTitle(R.string.delete_search_history_alert) .setNegativeButton(R.string.cancel, ((dialog, which) -> dialog.dismiss())) .setPositiveButton(R.string.delete, ((dialog, which) -> disposables.add(getDeleteSearchHistoryDisposable(context, recordManager)))) .show(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java ================================================ package org.schabi.newpipe.settings; import android.os.Bundle; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import androidx.annotation.NonNull; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.util.ReleaseVersionUtil; public class MainSettingsFragment extends BasePreferenceFragment { public static final boolean DEBUG = MainActivity.DEBUG; private SettingsActivity settingsActivity; @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { addPreferencesFromResourceRegistry(); setHasOptionsMenu(true); // Otherwise onCreateOptionsMenu is not called // Check if the app is updatable if (!ReleaseVersionUtil.INSTANCE.isReleaseApk()) { getPreferenceScreen().removePreference( requirePreference(R.string.update_pref_screen_key)); defaultPreferences.edit().putBoolean(getString(R.string.update_app_key), false).apply(); } // Hide debug preferences in RELEASE build variant if (!DEBUG) { getPreferenceScreen().removePreference( requirePreference(R.string.debug_pref_screen_key)); } } @Override public void onCreateOptionsMenu( @NonNull final Menu menu, @NonNull final MenuInflater inflater ) { super.onCreateOptionsMenu(menu, inflater); // -- Link settings activity and register menu -- settingsActivity = (SettingsActivity) getActivity(); inflater.inflate(R.menu.menu_settings_main_fragment, menu); final MenuItem menuSearchItem = menu.getItem(0); settingsActivity.setMenuSearchItem(menuSearchItem); menuSearchItem.setOnMenuItemClickListener(ev -> { settingsActivity.setSearchActive(true); return true; }); } @Override public void onDestroy() { // Unlink activity so that we don't get memory problems if (settingsActivity != null) { settingsActivity.setMenuSearchItem(null); settingsActivity = null; } super.onDestroy(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java ================================================ package org.schabi.newpipe.settings; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; import android.content.Context; import android.content.SharedPreferences; import android.os.Build; import android.os.Environment; import androidx.annotation.NonNull; import androidx.annotation.StringRes; import androidx.preference.PreferenceManager; import org.schabi.newpipe.App; import org.schabi.newpipe.R; import org.schabi.newpipe.settings.migration.MigrationManager; import org.schabi.newpipe.util.DeviceUtils; import java.io.File; import java.util.Set; /* * Created by k3b on 07.01.2016. * * Copyright (C) Christian Schabesberger 2015 * NewPipeSettings.java is part of NewPipe. * * NewPipe is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * NewPipe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ /** * Helper class for global settings. */ public final class NewPipeSettings { private NewPipeSettings() { } public static void initSettings(final Context context) { // first run migrations, then setDefaultValues, since the latter requires the correct types MigrationManager.runMigrationsIfNeeded(context); // readAgain is true so that if new settings are added their default value is set PreferenceManager.setDefaultValues(context, R.xml.main_settings, true); PreferenceManager.setDefaultValues(context, R.xml.video_audio_settings, true); PreferenceManager.setDefaultValues(context, R.xml.download_settings, true); PreferenceManager.setDefaultValues(context, R.xml.appearance_settings, true); PreferenceManager.setDefaultValues(context, R.xml.history_settings, true); PreferenceManager.setDefaultValues(context, R.xml.content_settings, true); 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.backup_restore_settings, true); saveDefaultVideoDownloadDirectory(context); saveDefaultAudioDownloadDirectory(context); disableMediaTunnelingIfNecessary(context); } static void saveDefaultVideoDownloadDirectory(final Context context) { saveDefaultDirectory(context, R.string.download_path_video_key, Environment.DIRECTORY_MOVIES); } static void saveDefaultAudioDownloadDirectory(final Context context) { saveDefaultDirectory(context, R.string.download_path_audio_key, Environment.DIRECTORY_MUSIC); } private static void saveDefaultDirectory(final Context context, final int keyID, final String defaultDirectoryName) { if (!useStorageAccessFramework(context)) { final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); final String key = context.getString(keyID); final String downloadPath = prefs.getString(key, null); if (!isNullOrEmpty(downloadPath)) { return; } final SharedPreferences.Editor spEditor = prefs.edit(); spEditor.putString(key, getNewPipeChildFolderPathForDir(getDir(defaultDirectoryName))); spEditor.apply(); } } @NonNull public static File getDir(final String defaultDirectoryName) { return new File(Environment.getExternalStorageDirectory(), defaultDirectoryName); } private static String getNewPipeChildFolderPathForDir(final File dir) { return new File(dir, "NewPipe").toURI().toString(); } public static boolean useStorageAccessFramework(final Context context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return true; } else if (DeviceUtils.isFireTv()) { // There's a FireOS bug which prevents SAF open/close dialogs from being confirmed with // a remote (see #6455). return false; } final String key = context.getString(R.string.storage_use_saf); final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); return prefs.getBoolean(key, true); } private static boolean showSearchSuggestions(final Context context, final SharedPreferences sharedPreferences, @StringRes final int key) { final Set enabledSearchSuggestions = sharedPreferences.getStringSet( context.getString(R.string.show_search_suggestions_key), null); if (enabledSearchSuggestions == null) { return true; // defaults to true } else { return enabledSearchSuggestions.contains(context.getString(key)); } } public static boolean showLocalSearchSuggestions(final Context context, final SharedPreferences sharedPreferences) { return showSearchSuggestions(context, sharedPreferences, R.string.show_local_search_suggestions_key); } public static boolean showRemoteSearchSuggestions(final Context context, final SharedPreferences sharedPreferences) { return showSearchSuggestions(context, sharedPreferences, R.string.show_remote_search_suggestions_key); } private static void disableMediaTunnelingIfNecessary(@NonNull final Context context) { final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); final String disabledTunnelingKey = context.getString(R.string.disable_media_tunneling_key); final String disabledTunnelingAutomaticallyKey = context.getString(R.string.disabled_media_tunneling_automatically_key); final String blacklistVersionKey = context.getString(R.string.media_tunneling_device_blacklist_version); final int lastMediaTunnelingUpdate = prefs.getInt(blacklistVersionKey, 0); final boolean wasDeviceBlacklistUpdated = DeviceUtils.MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION != lastMediaTunnelingUpdate; final boolean wasMediaTunnelingEnabledByUser = prefs.getInt(disabledTunnelingAutomaticallyKey, -1) == 0 && !prefs.getBoolean(disabledTunnelingKey, false); if (App.getInstance().isFirstRun() || (wasDeviceBlacklistUpdated && !wasMediaTunnelingEnabledByUser)) { setMediaTunneling(context); } } /** * Check if device does not support media tunneling * and disable that exoplayer feature if necessary. * @see DeviceUtils#shouldSupportMediaTunneling() * @param context */ public static void setMediaTunneling(@NonNull final Context context) { final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); if (!DeviceUtils.shouldSupportMediaTunneling()) { prefs.edit() .putBoolean(context.getString(R.string.disable_media_tunneling_key), true) .putInt(context.getString( R.string.disabled_media_tunneling_automatically_key), 1) .putInt(context.getString(R.string.media_tunneling_device_blacklist_version), DeviceUtils.MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION) .apply(); } else { prefs.edit() .putInt(context.getString(R.string.media_tunneling_device_blacklist_version), DeviceUtils.MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION).apply(); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/NotificationSettingsFragment.kt ================================================ package org.schabi.newpipe.settings import android.os.Bundle class NotificationSettingsFragment : BasePreferenceFragment() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResourceRegistry() } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/NotificationsSettingsFragment.kt ================================================ package org.schabi.newpipe.settings import android.content.SharedPreferences import android.content.SharedPreferences.OnSharedPreferenceChangeListener import android.graphics.Color import android.os.Build import android.os.Bundle import androidx.preference.Preference import androidx.preference.SwitchPreference import com.google.android.material.snackbar.Snackbar import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.disposables.Disposable import org.schabi.newpipe.R import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.ErrorUtil import org.schabi.newpipe.error.UserAction import org.schabi.newpipe.local.feed.notifications.NotificationHelper import org.schabi.newpipe.local.feed.notifications.NotificationWorker import org.schabi.newpipe.local.feed.notifications.ScheduleOptions import org.schabi.newpipe.local.subscription.SubscriptionManager class NotificationsSettingsFragment : BasePreferenceFragment(), OnSharedPreferenceChangeListener { private var streamsNotificationsPreference: SwitchPreference? = null private var notificationWarningSnackbar: Snackbar? = null private var loader: Disposable? = null override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.notifications_settings) streamsNotificationsPreference = requirePreference(R.string.enable_streams_notifications) // main check is done in onResume, but also do it here to prevent flickering updateEnabledState(NotificationHelper.areNotificationsEnabledOnDevice(requireContext())) } override fun onStart() { super.onStart() defaultPreferences.registerOnSharedPreferenceChangeListener(this) } override fun onStop() { defaultPreferences.unregisterOnSharedPreferenceChangeListener(this) super.onStop() } override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { val context = context ?: return if (key == getString(R.string.streams_notifications_interval_key) || key == getString(R.string.streams_notifications_network_key) ) { // apply new configuration NotificationWorker.schedule(context, ScheduleOptions.from(context), true) } else if (key == getString(R.string.enable_streams_notifications)) { if (NotificationHelper.areNewStreamsNotificationsEnabled(context)) { // Start the worker, because notifications were disabled previously. NotificationWorker.schedule(context) } else { // The user disabled the notifications. Cancel the worker to save energy. // A new one will be created once the notifications are enabled again. NotificationWorker.cancel(context) } } } override fun onResume() { super.onResume() // Check whether the notifications are disabled in the device's app settings. // If they are disabled, show a snackbar informing the user about that // while allowing them to open the device's app settings. val enabled = NotificationHelper.areNotificationsEnabledOnDevice(requireContext()) updateEnabledState(enabled) if (!enabled) { if (notificationWarningSnackbar == null) { notificationWarningSnackbar = Snackbar.make( listView, R.string.notifications_disabled, Snackbar.LENGTH_INDEFINITE ).apply { setAction(R.string.settings) { NotificationHelper.openNewPipeSystemNotificationSettings(it.context) } setActionTextColor(Color.YELLOW) addCallback(object : Snackbar.Callback() { override fun onDismissed(transientBottomBar: Snackbar, event: Int) { super.onDismissed(transientBottomBar, event) notificationWarningSnackbar = null } }) show() } } } // (Re-)Create loader loader?.dispose() loader = SubscriptionManager(requireContext()) .subscriptions() .observeOn(AndroidSchedulers.mainThread()) .subscribe(this::updateSubscriptions, this::onError) } override fun onPause() { loader?.dispose() loader = null notificationWarningSnackbar?.dismiss() notificationWarningSnackbar = null super.onPause() } private fun updateEnabledState(enabled: Boolean) { // On Android 13 player notifications are exempt from notification settings // so the preferences in app should always be available. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { streamsNotificationsPreference?.isEnabled = enabled } else { preferenceScreen.isEnabled = enabled } } private fun updateSubscriptions(subscriptions: List) { val notified = subscriptions.count { it.notificationMode != NotificationMode.DISABLED } val preference = requirePreference(R.string.streams_notifications_channels_key) preference.summary = "$notified/${subscriptions.size}" } private fun onError(e: Throwable) { ErrorUtil.showSnackbar( this, ErrorInfo(e, UserAction.SUBSCRIPTION_GET, "Get subscriptions list") ) } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java ================================================ package org.schabi.newpipe.settings; import android.annotation.SuppressLint; import android.content.Context; import android.content.SharedPreferences; import android.os.Bundle; import android.text.InputType; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.RadioButton; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; import com.grack.nanojson.JsonStringWriter; import com.grack.nanojson.JsonWriter; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.DialogEditTextBinding; import org.schabi.newpipe.databinding.FragmentInstanceListBinding; import org.schabi.newpipe.databinding.ItemInstanceBinding; import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.PeertubeHelper; import org.schabi.newpipe.util.ThemeHelper; import java.util.ArrayList; import java.util.Collections; 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; public class PeertubeInstanceListFragment extends Fragment { private PeertubeInstance selectedInstance; private String savedInstanceListKey; private InstanceListAdapter instanceListAdapter; private FragmentInstanceListBinding binding; private SharedPreferences sharedPreferences; private CompositeDisposable disposables = new CompositeDisposable(); /*////////////////////////////////////////////////////////////////////////// // Lifecycle //////////////////////////////////////////////////////////////////////////*/ @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); savedInstanceListKey = getString(R.string.peertube_instance_list_key); selectedInstance = PeertubeHelper.getCurrentInstance(); setHasOptionsMenu(true); } @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { binding = FragmentInstanceListBinding.inflate(inflater, container, false); return binding.getRoot(); } @Override public void onViewCreated(@NonNull final View rootView, @Nullable final Bundle savedInstanceState) { super.onViewCreated(rootView, savedInstanceState); binding.instanceHelpTV.setText(getString(R.string.peertube_instance_url_help, getString(R.string.peertube_instance_list_url))); binding.addInstanceButton.setOnClickListener(v -> showAddItemDialog(requireContext())); binding.instances.setLayoutManager(new LinearLayoutManager(requireContext())); final ItemTouchHelper itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); itemTouchHelper.attachToRecyclerView(binding.instances); instanceListAdapter = new InstanceListAdapter(requireContext(), itemTouchHelper); binding.instances.setAdapter(instanceListAdapter); instanceListAdapter.submitList(PeertubeHelper.getInstanceList(requireContext())); } @Override public void onResume() { super.onResume(); ThemeHelper.setTitleToAppCompatActivity(getActivity(), getString(R.string.peertube_instance_url_title)); } @Override public void onPause() { super.onPause(); saveChanges(); } @Override public void onDestroy() { super.onDestroy(); if (disposables != null) { disposables.clear(); } disposables = null; } @Override public void onDestroyView() { binding = null; super.onDestroyView(); } /*////////////////////////////////////////////////////////////////////////// // Menu //////////////////////////////////////////////////////////////////////////*/ @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); inflater.inflate(R.menu.menu_chooser_fragment, menu); } @Override public boolean onOptionsItemSelected(final MenuItem item) { if (item.getItemId() == R.id.menu_item_restore_default) { restoreDefaults(); return true; } return super.onOptionsItemSelected(item); } /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ private void selectInstance(final PeertubeInstance instance) { selectedInstance = PeertubeHelper.selectInstance(instance, requireContext()); sharedPreferences.edit().putBoolean(Constants.KEY_MAIN_PAGE_CHANGE, true).apply(); } private void saveChanges() { final JsonStringWriter jsonWriter = JsonWriter.string().object().array("instances"); for (final PeertubeInstance instance : instanceListAdapter.getCurrentList()) { jsonWriter.object(); jsonWriter.value("name", instance.getName()); jsonWriter.value("url", instance.getUrl()); jsonWriter.end(); } final String jsonToSave = jsonWriter.end().end().done(); sharedPreferences.edit().putString(savedInstanceListKey, jsonToSave).apply(); } private void restoreDefaults() { final Context context = requireContext(); new AlertDialog.Builder(context) .setTitle(R.string.restore_defaults) .setMessage(R.string.restore_defaults_confirmation) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok, (dialog, which) -> { sharedPreferences.edit().remove(savedInstanceListKey).apply(); selectInstance(PeertubeInstance.DEFAULT_INSTANCE); instanceListAdapter.submitList(PeertubeHelper.getInstanceList(context)); }) .show(); } private void showAddItemDialog(final Context c) { final var dialogBinding = DialogEditTextBinding.inflate(getLayoutInflater()); dialogBinding.dialogEditText.setInputType( InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); dialogBinding.dialogEditText.setHint(R.string.peertube_instance_add_help); new AlertDialog.Builder(c) .setTitle(R.string.peertube_instance_add_title) .setIcon(R.drawable.ic_placeholder_peertube) .setView(dialogBinding.getRoot()) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok, (dialog1, which) -> { final String url = dialogBinding.dialogEditText.getText().toString(); addInstance(url); }) .show(); } private void addInstance(final String url) { final String cleanUrl = cleanUrl(url); if (cleanUrl == null) { return; } binding.loadingProgressBar.setVisibility(View.VISIBLE); final Disposable disposable = Single.fromCallable(() -> { final PeertubeInstance instance = new PeertubeInstance(cleanUrl); instance.fetchInstanceMetaData(); return instance; }).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) .subscribe((instance) -> { binding.loadingProgressBar.setVisibility(View.GONE); add(instance); }, e -> { binding.loadingProgressBar.setVisibility(View.GONE); Toast.makeText(getActivity(), R.string.peertube_instance_add_fail, Toast.LENGTH_SHORT).show(); }); disposables.add(disposable); } @Nullable private String cleanUrl(final String url) { String cleanUrl = url.trim(); // if protocol not present, add https if (!cleanUrl.startsWith("http")) { cleanUrl = "https://" + cleanUrl; } // remove trailing slash cleanUrl = cleanUrl.replaceAll("/$", ""); // only allow https if (!cleanUrl.startsWith("https://")) { Toast.makeText(getActivity(), R.string.peertube_instance_add_https_only, Toast.LENGTH_SHORT).show(); return null; } // only allow if not already exists for (final PeertubeInstance instance : instanceListAdapter.getCurrentList()) { if (instance.getUrl().equals(cleanUrl)) { Toast.makeText(getActivity(), R.string.peertube_instance_add_exists, Toast.LENGTH_SHORT).show(); return null; } } return cleanUrl; } private void add(final PeertubeInstance instance) { final var list = new ArrayList<>(instanceListAdapter.getCurrentList()); list.add(instance); instanceListAdapter.submitList(list); } private ItemTouchHelper.SimpleCallback getItemTouchCallback() { return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, ItemTouchHelper.START | ItemTouchHelper.END) { @Override public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView, final int viewSize, final int viewSizeOutOfBounds, final int totalSize, final long msSinceStartScroll) { final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll); final int minimumAbsVelocity = Math.max(12, Math.abs(standardSpeed)); return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); } @Override public boolean onMove(@NonNull final RecyclerView recyclerView, @NonNull final RecyclerView.ViewHolder source, @NonNull final RecyclerView.ViewHolder target) { if (source.getItemViewType() != target.getItemViewType() || instanceListAdapter == null) { return false; } final int sourceIndex = source.getBindingAdapterPosition(); final int targetIndex = target.getBindingAdapterPosition(); instanceListAdapter.swapItems(sourceIndex, targetIndex); return true; } @Override public boolean isLongPressDragEnabled() { return false; } @Override public boolean isItemViewSwipeEnabled() { return true; } @Override public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, final int swipeDir) { final int position = viewHolder.getBindingAdapterPosition(); // do not allow swiping the selected instance if (instanceListAdapter.getCurrentList().get(position).getUrl() .equals(selectedInstance.getUrl())) { instanceListAdapter.notifyItemChanged(position); return; } final var list = new ArrayList<>(instanceListAdapter.getCurrentList()); list.remove(position); if (list.isEmpty()) { list.add(selectedInstance); } instanceListAdapter.submitList(list); } }; } /*////////////////////////////////////////////////////////////////////////// // List Handling //////////////////////////////////////////////////////////////////////////*/ private class InstanceListAdapter extends ListAdapter { private final LayoutInflater inflater; private final ItemTouchHelper itemTouchHelper; private RadioButton lastChecked; InstanceListAdapter(final Context context, final ItemTouchHelper itemTouchHelper) { super(new PeertubeInstanceCallback()); this.itemTouchHelper = itemTouchHelper; this.inflater = LayoutInflater.from(context); } public void swapItems(final int fromPosition, final int toPosition) { final var list = new ArrayList<>(getCurrentList()); Collections.swap(list, fromPosition, toPosition); submitList(list); } @NonNull @Override public InstanceListAdapter.TabViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { return new InstanceListAdapter.TabViewHolder(ItemInstanceBinding.inflate(inflater, parent, false)); } @Override public void onBindViewHolder(@NonNull final InstanceListAdapter.TabViewHolder holder, final int position) { holder.bind(position); } class TabViewHolder extends RecyclerView.ViewHolder { private final ItemInstanceBinding itemBinding; TabViewHolder(final ItemInstanceBinding binding) { super(binding.getRoot()); this.itemBinding = binding; } @SuppressLint("ClickableViewAccessibility") void bind(final int position) { itemBinding.handle.setOnTouchListener((view, motionEvent) -> { if (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { if (itemTouchHelper != null && getItemCount() > 1) { itemTouchHelper.startDrag(this); return true; } } return false; }); final PeertubeInstance instance = getItem(position); itemBinding.instanceName.setText(instance.getName()); itemBinding.instanceUrl.setText(instance.getUrl()); itemBinding.selectInstanceRB.setOnCheckedChangeListener(null); if (selectedInstance.getUrl().equals(instance.getUrl())) { if (lastChecked != null && lastChecked != itemBinding.selectInstanceRB) { lastChecked.setChecked(false); } itemBinding.selectInstanceRB.setChecked(true); lastChecked = itemBinding.selectInstanceRB; } itemBinding.selectInstanceRB.setOnCheckedChangeListener((buttonView, isChecked) -> { if (isChecked) { selectInstance(instance); if (lastChecked != null && lastChecked != itemBinding.selectInstanceRB) { lastChecked.setChecked(false); } lastChecked = itemBinding.selectInstanceRB; } }); itemBinding.instanceIcon.setImageResource(R.drawable.ic_placeholder_peertube); } } } private static final class PeertubeInstanceCallback extends DiffUtil.ItemCallback { @Override public boolean areItemsTheSame(@NonNull final PeertubeInstance oldItem, @NonNull final PeertubeInstance newItem) { return oldItem.getUrl().equals(newItem.getUrl()); } @Override public boolean areContentsTheSame(@NonNull final PeertubeInstance oldItem, @NonNull final PeertubeInstance newItem) { return oldItem.getName().equals(newItem.getName()) && oldItem.getUrl().equals(newItem.getUrl()); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/PlayerNotificationSettingsFragment.kt ================================================ package org.schabi.newpipe.settings import android.os.Bundle class PlayerNotificationSettingsFragment : BasePreferenceFragment() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResourceRegistry() } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java ================================================ package org.schabi.newpipe.settings; import android.content.DialogInterface; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.ProgressBar; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import org.schabi.newpipe.R; import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.local.subscription.SubscriptionManager; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.image.CoilHelper; import java.util.List; import java.util.Vector; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observer; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; /** * Created by Christian Schabesberger on 26.09.17. * SelectChannelFragment.java is part of NewPipe. *

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

*

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

*

* You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . *

*/ public class SelectChannelFragment extends DialogFragment { private OnSelectedListener onSelectedListener = null; private OnCancelListener onCancelListener = null; private ProgressBar progressBar; private TextView emptyView; private RecyclerView recyclerView; private List subscriptions = new Vector<>(); public void setOnSelectedListener(final OnSelectedListener listener) { onSelectedListener = listener; } public void setOnCancelListener(final OnCancelListener listener) { onCancelListener = listener; } /*////////////////////////////////////////////////////////////////////////// // Init //////////////////////////////////////////////////////////////////////////*/ @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext())); } @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { final View v = inflater.inflate(R.layout.select_channel_fragment, container, false); recyclerView = v.findViewById(R.id.items_list); recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); final SelectChannelAdapter channelAdapter = new SelectChannelAdapter(); recyclerView.setAdapter(channelAdapter); progressBar = v.findViewById(R.id.progressBar); emptyView = v.findViewById(R.id.empty_state_view); progressBar.setVisibility(View.VISIBLE); recyclerView.setVisibility(View.GONE); emptyView.setVisibility(View.GONE); final SubscriptionManager subscriptionManager = new SubscriptionManager(requireContext()); subscriptionManager.subscriptions().toObservable() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(getSubscriptionObserver()); return v; } /*////////////////////////////////////////////////////////////////////////// // Handle actions //////////////////////////////////////////////////////////////////////////*/ @Override public void onCancel(@NonNull final DialogInterface dialogInterface) { super.onCancel(dialogInterface); if (onCancelListener != null) { onCancelListener.onCancel(); } } private void clickedItem(final int position) { if (onSelectedListener != null) { final SubscriptionEntity entry = subscriptions.get(position); onSelectedListener .onChannelSelected(entry.getServiceId(), entry.getUrl(), entry.getName()); } dismiss(); } /*////////////////////////////////////////////////////////////////////////// // Item handling //////////////////////////////////////////////////////////////////////////*/ private void displayChannels(final List newSubscriptions) { this.subscriptions = newSubscriptions; progressBar.setVisibility(View.GONE); if (newSubscriptions.isEmpty()) { emptyView.setVisibility(View.VISIBLE); return; } recyclerView.setVisibility(View.VISIBLE); } private Observer> getSubscriptionObserver() { return new Observer>() { @Override public void onSubscribe(@NonNull final Disposable disposable) { } @Override public void onNext(@NonNull final List newSubscriptions) { displayChannels(newSubscriptions); } @Override public void onError(@NonNull final Throwable exception) { ErrorUtil.showUiErrorSnackbar(SelectChannelFragment.this, "Loading subscription", exception); } @Override public void onComplete() { } }; } /*////////////////////////////////////////////////////////////////////////// // Interfaces //////////////////////////////////////////////////////////////////////////*/ public interface OnSelectedListener { void onChannelSelected(int serviceId, String url, String name); } public interface OnCancelListener { void onCancel(); } private final class SelectChannelAdapter extends RecyclerView.Adapter { @NonNull @Override public SelectChannelItemHolder onCreateViewHolder(final ViewGroup parent, final int viewType) { final View item = LayoutInflater.from(parent.getContext()) .inflate(R.layout.select_channel_item, parent, false); return new SelectChannelItemHolder(item); } @Override public void onBindViewHolder(final SelectChannelItemHolder holder, final int position) { final SubscriptionEntity entry = subscriptions.get(position); holder.titleView.setText(entry.getName()); holder.view.setOnClickListener(view -> clickedItem(position)); CoilHelper.INSTANCE.loadAvatar(holder.thumbnailView, entry.getAvatarUrl()); } @Override public int getItemCount() { return subscriptions.size(); } public class SelectChannelItemHolder extends RecyclerView.ViewHolder { public final View view; final ImageView thumbnailView; final TextView titleView; SelectChannelItemHolder(final View v) { super(v); this.view = v; thumbnailView = v.findViewById(R.id.itemThumbnailView); titleView = v.findViewById(R.id.itemTitleView); } } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/SelectFeedGroupFragment.java ================================================ package org.schabi.newpipe.settings; import android.content.DialogInterface; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.ProgressBar; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.database.AppDatabase; import org.schabi.newpipe.database.feed.model.FeedGroupEntity; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.util.ThemeHelper; import java.util.List; import java.util.Vector; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observer; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; /** * Created by Christian Schabesberger on 26.09.17. * SelectChannelFragment.java is part of NewPipe. *

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

*

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

*

* You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . *

*/ public class SelectFeedGroupFragment extends DialogFragment { private OnSelectedListener onSelectedListener = null; private OnCancelListener onCancelListener = null; private ProgressBar progressBar; private TextView emptyView; private RecyclerView recyclerView; private List feedGroups = new Vector<>(); public void setOnSelectedListener(final OnSelectedListener listener) { onSelectedListener = listener; } public void setOnCancelListener(final OnCancelListener listener) { onCancelListener = listener; } /*////////////////////////////////////////////////////////////////////////// // Init //////////////////////////////////////////////////////////////////////////*/ @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext())); } @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { final View v = inflater.inflate(R.layout.select_feed_group_fragment, container, false); recyclerView = v.findViewById(R.id.items_list); recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); final SelectFeedGroupAdapter feedGroupAdapter = new SelectFeedGroupAdapter(); recyclerView.setAdapter(feedGroupAdapter); progressBar = v.findViewById(R.id.progressBar); emptyView = v.findViewById(R.id.empty_state_view); progressBar.setVisibility(View.VISIBLE); recyclerView.setVisibility(View.GONE); emptyView.setVisibility(View.GONE); final AppDatabase database = NewPipeDatabase.getInstance(requireContext()); database.feedGroupDAO().getAll().toObservable() .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(getFeedGroupObserver()); return v; } /*////////////////////////////////////////////////////////////////////////// // Handle actions //////////////////////////////////////////////////////////////////////////*/ @Override public void onCancel(@NonNull final DialogInterface dialogInterface) { super.onCancel(dialogInterface); if (onCancelListener != null) { onCancelListener.onCancel(); } } private void clickedItem(final int position) { if (onSelectedListener != null) { final FeedGroupEntity entry = feedGroups.get(position); onSelectedListener .onFeedGroupSelected(entry.getUid(), entry.getName(), entry.getIcon().getDrawableResource()); } dismiss(); } /*////////////////////////////////////////////////////////////////////////// // Item handling //////////////////////////////////////////////////////////////////////////*/ private void displayFeedGroups(final List newFeedGroups) { this.feedGroups = newFeedGroups; progressBar.setVisibility(View.GONE); if (newFeedGroups.isEmpty()) { emptyView.setVisibility(View.VISIBLE); return; } recyclerView.setVisibility(View.VISIBLE); } private Observer> getFeedGroupObserver() { return new Observer>() { @Override public void onSubscribe(@NonNull final Disposable disposable) { } @Override public void onNext(@NonNull final List newGroups) { displayFeedGroups(newGroups); } @Override public void onError(@NonNull final Throwable exception) { ErrorUtil.showUiErrorSnackbar(SelectFeedGroupFragment.this, "Loading Feed Groups", exception); } @Override public void onComplete() { } }; } /*////////////////////////////////////////////////////////////////////////// // Interfaces //////////////////////////////////////////////////////////////////////////*/ public interface OnSelectedListener { void onFeedGroupSelected(Long groupId, String name, int icon); } public interface OnCancelListener { void onCancel(); } private final class SelectFeedGroupAdapter extends RecyclerView.Adapter { @NonNull @Override public SelectFeedGroupItemHolder onCreateViewHolder(final ViewGroup parent, final int viewType) { final View item = LayoutInflater.from(parent.getContext()) .inflate(R.layout.select_feed_group_item, parent, false); return new SelectFeedGroupItemHolder(item); } @Override public void onBindViewHolder(final SelectFeedGroupItemHolder holder, final int position) { final FeedGroupEntity entry = feedGroups.get(position); holder.titleView.setText(entry.getName()); holder.view.setOnClickListener(view -> clickedItem(position)); holder.thumbnailView.setImageResource(entry.getIcon().getDrawableResource()); } @Override public int getItemCount() { return feedGroups.size(); } public class SelectFeedGroupItemHolder extends RecyclerView.ViewHolder { public final View view; final ImageView thumbnailView; final TextView titleView; SelectFeedGroupItemHolder(final View v) { super(v); this.view = v; thumbnailView = v.findViewById(R.id.itemThumbnailView); titleView = v.findViewById(R.id.itemTitleView); } } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java ================================================ package org.schabi.newpipe.settings; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; import androidx.fragment.app.DialogFragment; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.util.KioskTranslator; import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.ThemeHelper; import java.util.List; import java.util.Vector; /** * Created by Christian Schabesberger on 09.10.17. * SelectKioskFragment.java is part of NewPipe. *

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

*

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

*

* You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . *

*/ public class SelectKioskFragment extends DialogFragment { private SelectKioskAdapter selectKioskAdapter = null; private OnSelectedListener onSelectedListener = null; public void setOnSelectedListener(final OnSelectedListener listener) { onSelectedListener = listener; } /*////////////////////////////////////////////////////////////////////////// // Init //////////////////////////////////////////////////////////////////////////*/ @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext())); } @Override public View onCreateView(final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { final View v = inflater.inflate(R.layout.select_kiosk_fragment, container, false); final RecyclerView recyclerView = v.findViewById(R.id.items_list); recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); try { selectKioskAdapter = new SelectKioskAdapter(); } catch (final Exception e) { ErrorUtil.showUiErrorSnackbar(this, "Selecting kiosk", e); } recyclerView.setAdapter(selectKioskAdapter); return v; } /*////////////////////////////////////////////////////////////////////////// // Handle actions //////////////////////////////////////////////////////////////////////////*/ private void clickedItem(final SelectKioskAdapter.Entry entry) { if (onSelectedListener != null) { onSelectedListener.onKioskSelected(entry.serviceId, entry.kioskId, entry.kioskName); } dismiss(); } /*////////////////////////////////////////////////////////////////////////// // Interfaces //////////////////////////////////////////////////////////////////////////*/ public interface OnSelectedListener { void onKioskSelected(int serviceId, String kioskId, String kioskName); } private class SelectKioskAdapter extends RecyclerView.Adapter { private final List kioskList = new Vector<>(); SelectKioskAdapter() throws Exception { for (final StreamingService service : NewPipe.getServices()) { for (final String kioskId : service.getKioskList().getAvailableKiosks()) { final String name = String.format(getString(R.string.service_kiosk_string), service.getServiceInfo().getName(), KioskTranslator.getTranslatedKioskName(kioskId, getContext())); kioskList.add(new Entry(ServiceHelper.getIcon(service.getServiceId()), service.getServiceId(), kioskId, name)); } } } public int getItemCount() { return kioskList.size(); } @NonNull public SelectKioskItemHolder onCreateViewHolder(final ViewGroup parent, final int type) { final View item = LayoutInflater.from(parent.getContext()) .inflate(R.layout.select_kiosk_item, parent, false); return new SelectKioskItemHolder(item); } public void onBindViewHolder(final SelectKioskItemHolder holder, final int position) { final Entry entry = kioskList.get(position); holder.titleView.setText(entry.kioskName); holder.thumbnailView .setImageDrawable(AppCompatResources.getDrawable(requireContext(), entry.icon)); holder.view.setOnClickListener(view -> clickedItem(entry)); } class Entry { final int icon; final int serviceId; final String kioskId; final String kioskName; Entry(final int i, final int si, final String ki, final String kn) { icon = i; serviceId = si; kioskId = ki; kioskName = kn; } } public class SelectKioskItemHolder extends RecyclerView.ViewHolder { public final View view; final ImageView thumbnailView; final TextView titleView; SelectKioskItemHolder(final View v) { super(v); this.view = v; thumbnailView = v.findViewById(R.id.itemThumbnailView); titleView = v.findViewById(R.id.itemTitleView); } } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java ================================================ package org.schabi.newpipe.settings; import static org.schabi.newpipe.local.bookmark.MergedPlaylistManager.getMergedOrderedPlaylists; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.ProgressBar; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.fragment.app.DialogFragment; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.database.AppDatabase; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.playlist.PlaylistLocalItem; import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.local.playlist.LocalPlaylistManager; import org.schabi.newpipe.local.playlist.RemotePlaylistManager; import org.schabi.newpipe.util.image.CoilHelper; import java.util.List; import java.util.Vector; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.Disposable; public class SelectPlaylistFragment extends DialogFragment { private OnSelectedListener onSelectedListener = null; private ProgressBar progressBar; private TextView emptyView; private RecyclerView recyclerView; private Disposable disposable = null; private List playlists = new Vector<>(); public void setOnSelectedListener(final OnSelectedListener listener) { onSelectedListener = listener; } /*////////////////////////////////////////////////////////////////////////// // Fragment's Lifecycle //////////////////////////////////////////////////////////////////////////*/ @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { final View v = inflater.inflate(R.layout.select_playlist_fragment, container, false); progressBar = v.findViewById(R.id.progressBar); recyclerView = v.findViewById(R.id.items_list); emptyView = v.findViewById(R.id.empty_state_view); recyclerView.setLayoutManager(new LinearLayoutManager(getContext())); final SelectPlaylistAdapter playlistAdapter = new SelectPlaylistAdapter(); recyclerView.setAdapter(playlistAdapter); loadPlaylists(); return v; } @Override public void onDestroy() { super.onDestroy(); if (disposable != null) { disposable.dispose(); } } /*////////////////////////////////////////////////////////////////////////// // Load and display playlists //////////////////////////////////////////////////////////////////////////*/ private void loadPlaylists() { progressBar.setVisibility(View.VISIBLE); recyclerView.setVisibility(View.GONE); emptyView.setVisibility(View.GONE); final AppDatabase database = NewPipeDatabase.getInstance(requireContext()); final LocalPlaylistManager localPlaylistManager = new LocalPlaylistManager(database); final RemotePlaylistManager remotePlaylistManager = new RemotePlaylistManager(database); disposable = getMergedOrderedPlaylists(localPlaylistManager, remotePlaylistManager) .observeOn(AndroidSchedulers.mainThread()) .subscribe(this::displayPlaylists, this::onError); } private void displayPlaylists(final List newPlaylists) { playlists = newPlaylists; progressBar.setVisibility(View.GONE); emptyView.setVisibility(newPlaylists.isEmpty() ? View.VISIBLE : View.GONE); recyclerView.setVisibility(newPlaylists.isEmpty() ? View.GONE : View.VISIBLE); } protected void onError(final Throwable e) { ErrorUtil.showSnackbar(requireActivity(), new ErrorInfo(e, UserAction.UI_ERROR, "Loading playlists")); } /*////////////////////////////////////////////////////////////////////////// // Handle actions //////////////////////////////////////////////////////////////////////////*/ private void clickedItem(final int position) { if (onSelectedListener != null) { final LocalItem selectedItem = playlists.get(position); if (selectedItem instanceof PlaylistMetadataEntry) { final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem); onSelectedListener.onLocalPlaylistSelected(entry.getUid(), entry.getOrderingName()); } else if (selectedItem instanceof PlaylistRemoteEntity) { final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem); onSelectedListener.onRemotePlaylistSelected( entry.getServiceId(), entry.getUrl(), entry.getOrderingName()); } } dismiss(); } /*////////////////////////////////////////////////////////////////////////// // Interfaces //////////////////////////////////////////////////////////////////////////*/ public interface OnSelectedListener { void onLocalPlaylistSelected(long id, String name); void onRemotePlaylistSelected(int serviceId, String url, String name); } private final class SelectPlaylistAdapter extends RecyclerView.Adapter { @NonNull @Override public SelectPlaylistItemHolder onCreateViewHolder(final ViewGroup parent, final int viewType) { final View item = LayoutInflater.from(parent.getContext()) .inflate(R.layout.list_playlist_mini_item, parent, false); return new SelectPlaylistItemHolder(item); } @Override public void onBindViewHolder(@NonNull final SelectPlaylistItemHolder holder, final int position) { final PlaylistLocalItem selectedItem = playlists.get(position); if (selectedItem instanceof PlaylistMetadataEntry entry) { holder.titleView.setText(entry.getOrderingName()); holder.view.setOnClickListener(view -> clickedItem(position)); CoilHelper.INSTANCE.loadPlaylistThumbnail(holder.thumbnailView, entry.getThumbnailUrl()); } else if (selectedItem instanceof PlaylistRemoteEntity entry) { holder.titleView.setText(entry.getOrderingName()); holder.view.setOnClickListener(view -> clickedItem(position)); CoilHelper.INSTANCE.loadPlaylistThumbnail(holder.thumbnailView, entry.getThumbnailUrl()); } } @Override public int getItemCount() { return playlists.size(); } public class SelectPlaylistItemHolder extends RecyclerView.ViewHolder { public final View view; final ImageView thumbnailView; final TextView titleView; SelectPlaylistItemHolder(final View v) { super(v); this.view = v; thumbnailView = v.findViewById(R.id.itemThumbnailView); titleView = v.findViewById(R.id.itemTitleView); } } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java ================================================ package org.schabi.newpipe.settings; import android.content.Context; import android.os.Bundle; import android.text.TextUtils; import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.widget.EditText; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; import com.evernote.android.state.State; import com.jakewharton.rxbinding4.widget.RxTextView; import com.livefront.bridge.Bridge; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.SettingsLayoutBinding; import org.schabi.newpipe.settings.preferencesearch.PreferenceParser; import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchConfiguration; import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchFragment; import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchItem; import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchResultHighlighter; import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchResultListener; import org.schabi.newpipe.settings.preferencesearch.PreferenceSearcher; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.KeyboardUtil; import org.schabi.newpipe.util.ReleaseVersionUtil; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.views.FocusOverlayView; import java.util.concurrent.TimeUnit; /* * Created by Christian Schabesberger on 31.08.15. * * Copyright (C) Christian Schabesberger 2015 * SettingsActivity.java is part of NewPipe. * * NewPipe is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * NewPipe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ public class SettingsActivity extends AppCompatActivity implements PreferenceFragmentCompat.OnPreferenceStartFragmentCallback, PreferenceSearchResultListener { private static final String TAG = "SettingsActivity"; private static final boolean DEBUG = MainActivity.DEBUG; @IdRes private static final int FRAGMENT_HOLDER_ID = R.id.settings_fragment_holder; private PreferenceSearchFragment searchFragment; @Nullable private MenuItem menuSearchItem; private View searchContainer; private EditText searchEditText; // State @State String searchText; @State boolean wasSearchActive; @Override protected void onCreate(final Bundle savedInstanceBundle) { setTheme(ThemeHelper.getSettingsThemeStyle(this)); super.onCreate(savedInstanceBundle); Bridge.restoreInstanceState(this, savedInstanceBundle); final boolean restored = savedInstanceBundle != null; final SettingsLayoutBinding settingsLayoutBinding = SettingsLayoutBinding.inflate(getLayoutInflater()); setContentView(settingsLayoutBinding.getRoot()); initSearch(settingsLayoutBinding, restored); setSupportActionBar(settingsLayoutBinding.settingsToolbarLayout.toolbar); if (restored) { // Restore state if (this.wasSearchActive) { setSearchActive(true); if (!TextUtils.isEmpty(this.searchText)) { this.searchEditText.setText(this.searchText); } } } else { getSupportFragmentManager().beginTransaction() .replace(R.id.settings_fragment_holder, new MainSettingsFragment()) .commit(); } if (DeviceUtils.isTv(this)) { FocusOverlayView.setupFocusObserver(this); } } @Override protected void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); Bridge.saveInstanceState(this, outState); } @Override public boolean onCreateOptionsMenu(final Menu menu) { final ActionBar actionBar = getSupportActionBar(); if (actionBar != null) { actionBar.setDisplayHomeAsUpEnabled(true); actionBar.setDisplayShowTitleEnabled(true); } return super.onCreateOptionsMenu(menu); } @Override public void onBackPressed() { if (isSearchActive()) { setSearchActive(false); return; } super.onBackPressed(); } @Override public boolean onOptionsItemSelected(final MenuItem item) { final int id = item.getItemId(); if (id == android.R.id.home) { // Check if the search is active and if so: Close it if (isSearchActive()) { setSearchActive(false); return true; } if (getSupportFragmentManager().getBackStackEntryCount() == 0) { finish(); } else { getSupportFragmentManager().popBackStack(); } } return super.onOptionsItemSelected(item); } @Override public boolean onPreferenceStartFragment(@NonNull final PreferenceFragmentCompat caller, final Preference preference) { showSettingsFragment(instantiateFragment(preference.getFragment())); return true; } private Fragment instantiateFragment(@NonNull final String className) { return getSupportFragmentManager() .getFragmentFactory() .instantiate(this.getClassLoader(), className); } private void showSettingsFragment(final Fragment fragment) { getSupportFragmentManager().beginTransaction() .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out) .replace(FRAGMENT_HOLDER_ID, fragment) .addToBackStack(null) .commit(); } @Override protected void onDestroy() { setMenuSearchItem(null); searchFragment = null; super.onDestroy(); } /*////////////////////////////////////////////////////////////////////////// // Search //////////////////////////////////////////////////////////////////////////*/ //region Search private void initSearch( final SettingsLayoutBinding settingsLayoutBinding, final boolean restored ) { searchContainer = settingsLayoutBinding.settingsToolbarLayout.toolbar .findViewById(R.id.toolbar_search_container); // Configure input field for search searchEditText = searchContainer.findViewById(R.id.toolbar_search_edit_text); RxTextView.textChanges(searchEditText) // Wait some time after the last input before actually searching .debounce(200, TimeUnit.MILLISECONDS) .subscribe(v -> runOnUiThread(this::onSearchChanged)); // Configure clear button searchContainer.findViewById(R.id.toolbar_search_clear) .setOnClickListener(ev -> resetSearchText()); ensureSearchRepresentsApplicationState(); // Build search configuration using SettingsResourceRegistry final PreferenceSearchConfiguration config = new PreferenceSearchConfiguration(); // Build search items final Context searchContext = getApplicationContext(); final PreferenceParser parser = new PreferenceParser(searchContext, config); final PreferenceSearcher searcher = new PreferenceSearcher(config); // Find all searchable SettingsResourceRegistry fragments SettingsResourceRegistry.getInstance().getAllEntries().stream() .filter(SettingsResourceRegistry.SettingRegistryEntry::isSearchable) // Get the resId .map(SettingsResourceRegistry.SettingRegistryEntry::getPreferencesResId) // Parse .map(parser::parse) // Add it to the searcher .forEach(searcher::add); if (restored) { searchFragment = (PreferenceSearchFragment) getSupportFragmentManager() .findFragmentByTag(PreferenceSearchFragment.NAME); if (searchFragment != null) { // Hide/Remove the search fragment otherwise we get an exception // when adding it (because it's already present) hideSearchFragment(); } } if (searchFragment == null) { searchFragment = new PreferenceSearchFragment(); } searchFragment.setSearcher(searcher); } /** * Ensures that the search shows the correct/available search results. *
* Some features are e.g. only available for debug builds, these should not * be found when searching inside a release. */ private void ensureSearchRepresentsApplicationState() { // Check if the update settings are available if (!ReleaseVersionUtil.INSTANCE.isReleaseApk()) { SettingsResourceRegistry.getInstance() .getEntryByPreferencesResId(R.xml.update_settings) .setSearchable(false); } // Hide debug preferences in RELEASE build variant if (DEBUG) { SettingsResourceRegistry.getInstance() .getEntryByPreferencesResId(R.xml.debug_settings) .setSearchable(true); } } public void setMenuSearchItem(final MenuItem menuSearchItem) { this.menuSearchItem = menuSearchItem; // Ensure that the item is in the correct state when adding it. This is due to // Android's lifecycle (the Activity is recreated before the Fragment that registers this) if (menuSearchItem != null) { menuSearchItem.setVisible(!isSearchActive()); } } public void setSearchActive(final boolean active) { if (DEBUG) { Log.d(TAG, "setSearchActive called active=" + active); } // Ignore if search is already in correct state if (isSearchActive() == active) { return; } wasSearchActive = active; searchContainer.setVisibility(active ? View.VISIBLE : View.GONE); if (menuSearchItem != null) { menuSearchItem.setVisible(!active); } if (active) { getSupportFragmentManager() .beginTransaction() .add(FRAGMENT_HOLDER_ID, searchFragment, PreferenceSearchFragment.NAME) .addToBackStack(PreferenceSearchFragment.NAME) .commit(); KeyboardUtil.showKeyboard(this, searchEditText); } else if (searchFragment != null) { hideSearchFragment(); getSupportFragmentManager() .popBackStack( PreferenceSearchFragment.NAME, FragmentManager.POP_BACK_STACK_INCLUSIVE); KeyboardUtil.hideKeyboard(this, searchEditText); } resetSearchText(); } private void hideSearchFragment() { getSupportFragmentManager().beginTransaction().remove(searchFragment).commit(); } private void resetSearchText() { searchEditText.setText(""); } private boolean isSearchActive() { return searchContainer.getVisibility() == View.VISIBLE; } private void onSearchChanged() { if (!isSearchActive()) { return; } if (searchFragment != null) { searchText = this.searchEditText.getText().toString(); searchFragment.updateSearchResults(searchText); } } @Override public void onSearchResultClicked(@NonNull final PreferenceSearchItem result) { if (DEBUG) { Log.d(TAG, "onSearchResultClicked called result=" + result); } // Hide the search setSearchActive(false); // -- Highlight the result -- // Find out which fragment class we need final Class targetedFragmentClass = SettingsResourceRegistry.getInstance() .getFragmentClass(result.getSearchIndexItemResId()); if (targetedFragmentClass == null) { // This should never happen Log.w(TAG, "Unable to locate fragment class for resId=" + result.getSearchIndexItemResId()); return; } // Check if the currentFragment is the one which contains the result Fragment currentFragment = getSupportFragmentManager().findFragmentById(FRAGMENT_HOLDER_ID); if (!targetedFragmentClass.equals(currentFragment.getClass())) { // If it's not the correct one display the correct one currentFragment = instantiateFragment(targetedFragmentClass.getName()); showSettingsFragment(currentFragment); } // Run the highlighting if (currentFragment instanceof PreferenceFragmentCompat) { PreferenceSearchResultHighlighter .highlight(result, (PreferenceFragmentCompat) currentFragment); } } //endregion } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java ================================================ package org.schabi.newpipe.settings; import androidx.annotation.NonNull; import androidx.annotation.XmlRes; import androidx.fragment.app.Fragment; import org.schabi.newpipe.R; import java.util.HashSet; import java.util.Objects; import java.util.Set; /** * A registry that contains information about SettingsFragments. *
* includes: *
    *
  • Class of the SettingsFragment
  • *
  • XML-Resource
  • *
  • ...
  • *
* * E.g. used by the preference search. */ public final class SettingsResourceRegistry { private static final SettingsResourceRegistry INSTANCE = new SettingsResourceRegistry(); private final Set registeredEntries = new HashSet<>(); private SettingsResourceRegistry() { add(MainSettingsFragment.class, R.xml.main_settings).setSearchable(false); add(AppearanceSettingsFragment.class, R.xml.appearance_settings); add(ContentSettingsFragment.class, R.xml.content_settings); add(DebugSettingsFragment.class, R.xml.debug_settings).setSearchable(false); add(DownloadSettingsFragment.class, R.xml.download_settings); add(HistorySettingsFragment.class, R.xml.history_settings); add(NotificationSettingsFragment.class, R.xml.notifications_settings); add(PlayerNotificationSettingsFragment.class, R.xml.player_notification_settings); add(UpdateSettingsFragment.class, R.xml.update_settings); add(VideoAudioSettingsFragment.class, R.xml.video_audio_settings); add(ExoPlayerSettingsFragment.class, R.xml.exoplayer_settings); add(BackupRestoreSettingsFragment.class, R.xml.backup_restore_settings); } private SettingRegistryEntry add( @NonNull final Class fragmentClass, @XmlRes final int preferencesResId ) { final SettingRegistryEntry entry = new SettingRegistryEntry(fragmentClass, preferencesResId); this.registeredEntries.add(entry); return entry; } public SettingRegistryEntry getEntryByFragmentClass( final Class fragmentClass ) { Objects.requireNonNull(fragmentClass); return registeredEntries.stream() .filter(e -> Objects.equals(e.getFragmentClass(), fragmentClass)) .findFirst() .orElse(null); } public SettingRegistryEntry getEntryByPreferencesResId(@XmlRes final int preferencesResId) { return registeredEntries.stream() .filter(e -> Objects.equals(e.getPreferencesResId(), preferencesResId)) .findFirst() .orElse(null); } public int getPreferencesResId(@NonNull final Class fragmentClass) { final SettingRegistryEntry entry = getEntryByFragmentClass(fragmentClass); if (entry == null) { return -1; } return entry.getPreferencesResId(); } public Class getFragmentClass(@XmlRes final int preferencesResId) { final SettingRegistryEntry entry = getEntryByPreferencesResId(preferencesResId); if (entry == null) { return null; } return entry.getFragmentClass(); } public Set getAllEntries() { return new HashSet<>(registeredEntries); } public static SettingsResourceRegistry getInstance() { return INSTANCE; } public static class SettingRegistryEntry { @NonNull private final Class fragmentClass; @XmlRes private final int preferencesResId; private boolean searchable = true; public SettingRegistryEntry( @NonNull final Class fragmentClass, @XmlRes final int preferencesResId ) { this.fragmentClass = Objects.requireNonNull(fragmentClass); this.preferencesResId = preferencesResId; } @SuppressWarnings("HiddenField") public SettingRegistryEntry setSearchable(final boolean searchable) { this.searchable = searchable; return this; } @NonNull public Class getFragmentClass() { return fragmentClass; } public int getPreferencesResId() { return preferencesResId; } public boolean isSearchable() { return searchable; } @Override public boolean equals(final Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } final SettingRegistryEntry that = (SettingRegistryEntry) o; return getPreferencesResId() == that.getPreferencesResId() && getFragmentClass().equals(that.getFragmentClass()); } @Override public int hashCode() { return Objects.hash(getFragmentClass(), getPreferencesResId()); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java ================================================ package org.schabi.newpipe.settings; import android.app.AlertDialog; import android.content.Context; import android.os.Bundle; import android.widget.Toast; import androidx.preference.Preference; import androidx.preference.PreferenceManager; import org.schabi.newpipe.NewVersionWorker; import org.schabi.newpipe.R; public class UpdateSettingsFragment extends BasePreferenceFragment { private final Preference.OnPreferenceChangeListener updatePreferenceChange = (p, nVal) -> { final boolean checkForUpdates = (boolean) nVal; defaultPreferences.edit() .putBoolean(getString(R.string.update_app_key), checkForUpdates) .apply(); if (checkForUpdates) { NewVersionWorker.enqueueNewVersionCheckingWork(requireContext(), true); } return true; }; private final Preference.OnPreferenceClickListener manualUpdateClick = preference -> { Toast.makeText(getContext(), R.string.checking_updates_toast, Toast.LENGTH_SHORT).show(); NewVersionWorker.enqueueNewVersionCheckingWork(requireContext(), true); return true; }; @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { addPreferencesFromResourceRegistry(); requirePreference(R.string.update_app_key) .setOnPreferenceChangeListener(updatePreferenceChange); requirePreference(R.string.manual_update_key) .setOnPreferenceClickListener(manualUpdateClick); } public static void askForConsentToUpdateChecks(final Context context) { new AlertDialog.Builder(context) .setTitle(context.getString(R.string.check_for_updates)) .setMessage(context.getString(R.string.auto_update_check_description)) .setPositiveButton(context.getString(R.string.yes), (d, w) -> { d.dismiss(); setAutoUpdateCheckEnabled(context, true); }) .setNegativeButton(R.string.no, (d, w) -> { d.dismiss(); // set explicitly to false, since the default is true on previous versions setAutoUpdateCheckEnabled(context, false); }) .show(); } private static void setAutoUpdateCheckEnabled(final Context context, final boolean enabled) { PreferenceManager.getDefaultSharedPreferences(context) .edit() .putBoolean(context.getString(R.string.update_app_key), enabled) .putBoolean(context.getString(R.string.update_check_consent_key), true) .apply(); } /** * Whether the user was asked for consent to automatically check for app updates. * @param context * @return true if the user was asked for consent, false otherwise */ public static boolean wasUserAskedForConsent(final Context context) { return PreferenceManager.getDefaultSharedPreferences(context) .getBoolean(context.getString(R.string.update_check_consent_key), false); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java ================================================ package org.schabi.newpipe.settings; import android.content.SharedPreferences; import android.content.res.Resources; import android.os.Build; import android.os.Bundle; import android.provider.Settings; import android.text.format.DateUtils; import android.widget.Toast; import androidx.preference.ListPreference; import com.google.android.material.snackbar.Snackbar; import org.schabi.newpipe.R; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.PermissionHelper; import java.util.LinkedList; import java.util.List; public class VideoAudioSettingsFragment extends BasePreferenceFragment { private SharedPreferences.OnSharedPreferenceChangeListener listener; @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { addPreferencesFromResourceRegistry(); updateSeekOptions(); updateResolutionOptions(); listener = (sharedPreferences, key) -> { // on M and above, if user chooses to minimise to popup player on exit // and the app doesn't have display over other apps permission, // show a snackbar to let the user give permission if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && getString(R.string.minimize_on_exit_key).equals(key)) { final String newSetting = sharedPreferences.getString(key, null); if (newSetting != null && newSetting.equals(getString(R.string.minimize_on_exit_popup_key)) && !Settings.canDrawOverlays(getContext())) { Snackbar.make(getListView(), R.string.permission_display_over_apps, Snackbar.LENGTH_INDEFINITE) .setAction(R.string.settings, view -> PermissionHelper.checkSystemAlertWindowPermission(getContext())) .show(); } } else if (getString(R.string.use_inexact_seek_key).equals(key)) { updateSeekOptions(); } else if (getString(R.string.show_higher_resolutions_key).equals(key)) { updateResolutionOptions(); } }; } /** * Update default resolution, default popup resolution & mobile data resolution options. *
* Show high resolutions when "Show higher resolution" option is enabled. * Set default resolution to "best resolution" when "Show higher resolution" option * is disabled. */ private void updateResolutionOptions() { final Resources resources = getResources(); final boolean showHigherResolutions = getPreferenceManager().getSharedPreferences() .getBoolean(resources.getString(R.string.show_higher_resolutions_key), false); // get sorted resolution lists final List resolutionListDescriptions = ListHelper.getSortedResolutionList( resources, R.array.resolution_list_description, R.array.high_resolution_list_descriptions, showHigherResolutions); final List resolutionListValues = ListHelper.getSortedResolutionList( resources, R.array.resolution_list_values, R.array.high_resolution_list_values, showHigherResolutions); final List limitDataUsageResolutionValues = ListHelper.getSortedResolutionList( resources, R.array.limit_data_usage_values_list, R.array.high_resolution_limit_data_usage_values_list, showHigherResolutions); final List limitDataUsageResolutionDescriptions = ListHelper .getSortedResolutionList(resources, R.array.limit_data_usage_description_list, R.array.high_resolution_list_descriptions, showHigherResolutions); // get resolution preferences final ListPreference defaultResolution = requirePreference( R.string.default_resolution_key); final ListPreference defaultPopupResolution = requirePreference( R.string.default_popup_resolution_key); final ListPreference mobileDataResolution = requirePreference( R.string.limit_mobile_data_usage_key); // update resolution preferences with new resolutions, entries & values for each defaultResolution.setEntries(resolutionListDescriptions.toArray(new String[0])); defaultResolution.setEntryValues(resolutionListValues.toArray(new String[0])); defaultPopupResolution.setEntries(resolutionListDescriptions.toArray(new String[0])); defaultPopupResolution.setEntryValues(resolutionListValues.toArray(new String[0])); mobileDataResolution.setEntries( limitDataUsageResolutionDescriptions.toArray(new String[0])); mobileDataResolution.setEntryValues(limitDataUsageResolutionValues.toArray(new String[0])); // if "Show higher resolution" option is disabled, // set default resolution to "best resolution" if (!showHigherResolutions) { if (ListHelper.isHighResolutionSelected(defaultResolution.getValue(), R.array.high_resolution_list_values, resources)) { defaultResolution.setValueIndex(0); } if (ListHelper.isHighResolutionSelected(defaultPopupResolution.getValue(), R.array.high_resolution_list_values, resources)) { defaultPopupResolution.setValueIndex(0); } if (ListHelper.isHighResolutionSelected(mobileDataResolution.getValue(), R.array.high_resolution_limit_data_usage_values_list, resources)) { mobileDataResolution.setValueIndex(0); } } } /** * Update fast-forward/-rewind seek duration options * according to language and inexact seek setting. * Exoplayer can't seek 5 seconds in audio when using inexact seek. */ private void updateSeekOptions() { // initializing R.array.seek_duration_description to display the translation of seconds final Resources res = getResources(); final String[] durationsValues = res.getStringArray(R.array.seek_duration_value); final List displayedDurationValues = new LinkedList<>(); final List displayedDescriptionValues = new LinkedList<>(); int currentDurationValue; final boolean inexactSeek = getPreferenceManager().getSharedPreferences() .getBoolean(res.getString(R.string.use_inexact_seek_key), false); for (final String durationsValue : durationsValues) { currentDurationValue = Integer.parseInt(durationsValue) / (int) DateUtils.SECOND_IN_MILLIS; if (inexactSeek && currentDurationValue % 10 == 5) { continue; } displayedDurationValues.add(durationsValue); try { displayedDescriptionValues.add(String.format( res.getQuantityString(R.plurals.seconds, currentDurationValue), currentDurationValue)); } catch (final Resources.NotFoundException ignored) { // if this happens, the translation is missing, // and the english string will be displayed instead } } final ListPreference durations = requirePreference(R.string.seek_duration_key); durations.setEntryValues(displayedDurationValues.toArray(new CharSequence[0])); durations.setEntries(displayedDescriptionValues.toArray(new CharSequence[0])); final int selectedDuration = Integer.parseInt(durations.getValue()); if (inexactSeek && selectedDuration / (int) DateUtils.SECOND_IN_MILLIS % 10 == 5) { final int newDuration = selectedDuration / (int) DateUtils.SECOND_IN_MILLIS + 5; durations.setValue(Integer.toString(newDuration * (int) DateUtils.SECOND_IN_MILLIS)); final Toast toast = Toast .makeText(getContext(), getString(R.string.new_seek_duration_toast, newDuration), Toast.LENGTH_LONG); toast.show(); } } @Override public void onResume() { super.onResume(); getPreferenceManager().getSharedPreferences() .registerOnSharedPreferenceChangeListener(listener); } @Override public void onPause() { super.onPause(); getPreferenceManager().getSharedPreferences() .unregisterOnSharedPreferenceChangeListener(listener); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/custom/DurationListPreference.kt ================================================ package org.schabi.newpipe.settings.custom import android.content.Context import android.util.AttributeSet import androidx.preference.ListPreference import org.schabi.newpipe.util.Localization /** * An extension of a common ListPreference where it sets the duration values to human readable strings. * * The values in the entry values array will be interpreted as seconds. If the value of a specific position * is less than or equals to zero, its original entry title will be used. * * If the entry values array have anything other than numbers in it, an exception will be raised. */ class DurationListPreference : ListPreference { constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) constructor(context: Context) : super(context) override fun onAttached() { super.onAttached() val originalEntryTitles = entries val originalEntryValues = entryValues val newEntryTitles = arrayOfNulls(originalEntryValues.size) for (i in originalEntryValues.indices) { val currentDurationValue: Int try { currentDurationValue = (originalEntryValues[i] as String).toInt() } catch (e: NumberFormatException) { throw RuntimeException("Invalid number was set in the preference entry values array", e) } if (currentDurationValue <= 0) { newEntryTitles[i] = originalEntryTitles[i] } else { newEntryTitles[i] = Localization.localizeDuration(context, currentDurationValue) } } entries = newEntryTitles } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java ================================================ package org.schabi.newpipe.settings.custom; import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_RECREATE_NOTIFICATION; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.os.Build; import android.util.AttributeSet; import android.view.View; import android.widget.CheckBox; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.preference.Preference; import androidx.preference.PreferenceViewHolder; import org.schabi.newpipe.App; import org.schabi.newpipe.R; import org.schabi.newpipe.player.notification.NotificationConstants; import java.util.ArrayList; import java.util.List; import java.util.stream.IntStream; public class NotificationActionsPreference extends Preference { public NotificationActionsPreference(final Context context, final AttributeSet attrs) { super(context, attrs); setLayoutResource(R.layout.settings_notification); } private NotificationSlot[] notificationSlots; private List compactSlots; //////////////////////////////////////////////////////////////////////////// // Lifecycle //////////////////////////////////////////////////////////////////////////// @Override public void onBindViewHolder(@NonNull final PreferenceViewHolder holder) { super.onBindViewHolder(holder); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ((TextView) holder.itemView.findViewById(R.id.summary)) .setText(R.string.notification_actions_summary_android13); } holder.itemView.setClickable(false); setupActions(holder.itemView); } @Override public void onDetached() { super.onDetached(); saveChanges(); // set package to this app's package to prevent the intent from being seen outside getContext().sendBroadcast(new Intent(ACTION_RECREATE_NOTIFICATION) .setPackage(App.PACKAGE_NAME)); } //////////////////////////////////////////////////////////////////////////// // Setup //////////////////////////////////////////////////////////////////////////// private void setupActions(@NonNull final View view) { compactSlots = new ArrayList<>(NotificationConstants.getCompactSlotsFromPreferences( getContext(), getSharedPreferences())); notificationSlots = IntStream.range(0, 5) .mapToObj(i -> new NotificationSlot(getContext(), getSharedPreferences(), i, view, compactSlots.contains(i), this::onToggleCompactSlot)) .toArray(NotificationSlot[]::new); } private void onToggleCompactSlot(final int i, final CheckBox checkBox) { if (checkBox.isChecked()) { compactSlots.remove((Integer) i); } else if (compactSlots.size() < 3) { compactSlots.add(i); } else { Toast.makeText(getContext(), R.string.notification_actions_at_most_three, Toast.LENGTH_SHORT).show(); return; } checkBox.toggle(); } //////////////////////////////////////////////////////////////////////////// // Saving //////////////////////////////////////////////////////////////////////////// private void saveChanges() { if (compactSlots != null && notificationSlots != null) { final SharedPreferences.Editor editor = getSharedPreferences().edit(); for (int i = 0; i < 3; i++) { editor.putInt(getContext().getString( NotificationConstants.SLOT_COMPACT_PREF_KEYS[i]), (i < compactSlots.size() ? compactSlots.get(i) : -1)); } for (int i = 0; i < 5; i++) { editor.putInt(getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]), notificationSlots[i].getSelectedAction()); } editor.apply(); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.java ================================================ package org.schabi.newpipe.settings.custom; import android.content.Context; import android.content.SharedPreferences; import android.content.res.ColorStateList; import android.os.Build; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.CheckBox; import android.widget.ImageView; import android.widget.RadioButton; import android.widget.RadioGroup; import android.widget.TextView; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.content.res.AppCompatResources; import androidx.core.widget.TextViewCompat; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.ListRadioIconItemBinding; import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding; import org.schabi.newpipe.player.notification.NotificationConstants; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.views.FocusOverlayView; import java.util.Objects; import java.util.function.BiConsumer; class NotificationSlot { private static final int[] SLOT_ITEMS = { R.id.notificationAction0, R.id.notificationAction1, R.id.notificationAction2, R.id.notificationAction3, R.id.notificationAction4, }; private static final int[] SLOT_TITLES = { R.string.notification_action_0_title, R.string.notification_action_1_title, R.string.notification_action_2_title, R.string.notification_action_3_title, R.string.notification_action_4_title, }; private final int i; private @NotificationConstants.Action int selectedAction; private final Context context; private final BiConsumer onToggleCompactSlot; private ImageView icon; private TextView summary; NotificationSlot(final Context context, final SharedPreferences prefs, final int actionIndex, final View parentView, final boolean isCompactSlotChecked, final BiConsumer onToggleCompactSlot) { this.context = context; this.i = actionIndex; this.onToggleCompactSlot = onToggleCompactSlot; selectedAction = Objects.requireNonNull(prefs).getInt( context.getString(NotificationConstants.SLOT_PREF_KEYS[i]), NotificationConstants.SLOT_DEFAULTS[i]); final View view = parentView.findViewById(SLOT_ITEMS[i]); // only show the last two notification slots on Android 13+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || i >= 3) { setupSelectedAction(view); setupTitle(view); setupCheckbox(view, isCompactSlotChecked); } else { view.setVisibility(View.GONE); } } void setupTitle(final View view) { ((TextView) view.findViewById(R.id.notificationActionTitle)) .setText(SLOT_TITLES[i]); view.findViewById(R.id.notificationActionClickableArea).setOnClickListener( v -> openActionChooserDialog()); } void setupCheckbox(final View view, final boolean isCompactSlotChecked) { final CheckBox compactSlotCheckBox = view.findViewById(R.id.notificationActionCheckBox); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // there are no compact slots to customize on Android 13+ compactSlotCheckBox.setVisibility(View.GONE); view.findViewById(R.id.notificationActionCheckBoxClickableArea) .setVisibility(View.GONE); return; } compactSlotCheckBox.setChecked(isCompactSlotChecked); view.findViewById(R.id.notificationActionCheckBoxClickableArea).setOnClickListener( v -> onToggleCompactSlot.accept(i, compactSlotCheckBox)); } void setupSelectedAction(final View view) { icon = view.findViewById(R.id.notificationActionIcon); summary = view.findViewById(R.id.notificationActionSummary); updateInfo(); } void updateInfo() { if (NotificationConstants.ACTION_ICONS[selectedAction] == 0) { icon.setImageDrawable(null); } else { icon.setImageDrawable(AppCompatResources.getDrawable(context, NotificationConstants.ACTION_ICONS[selectedAction])); } summary.setText(NotificationConstants.getActionName(context, selectedAction)); } void openActionChooserDialog() { final LayoutInflater inflater = LayoutInflater.from(context); final SingleChoiceDialogViewBinding binding = SingleChoiceDialogViewBinding.inflate(inflater); final AlertDialog alertDialog = new AlertDialog.Builder(context) .setTitle(SLOT_TITLES[i]) .setView(binding.getRoot()) .setCancelable(true) .create(); final View.OnClickListener radioButtonsClickListener = v -> { selectedAction = NotificationConstants.ALL_ACTIONS[v.getId()]; updateInfo(); alertDialog.dismiss(); }; for (int id = 0; id < NotificationConstants.ALL_ACTIONS.length; ++id) { final int action = NotificationConstants.ALL_ACTIONS[id]; final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater) .getRoot(); // if present set action icon with correct color final int iconId = NotificationConstants.ACTION_ICONS[action]; if (iconId != 0) { radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, iconId, 0); final var color = ColorStateList.valueOf(ThemeHelper .resolveColorFromAttr(context, android.R.attr.textColorPrimary)); TextViewCompat.setCompoundDrawableTintList(radioButton, color); } radioButton.setText(NotificationConstants.getActionName(context, action)); radioButton.setChecked(action == selectedAction); radioButton.setId(id); radioButton.setLayoutParams(new RadioGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); radioButton.setOnClickListener(radioButtonsClickListener); binding.list.addView(radioButton); } alertDialog.show(); if (DeviceUtils.isTv(context)) { FocusOverlayView.setupFocusObserver(alertDialog); } } @NotificationConstants.Action public int getSelectedAction() { return selectedAction; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/export/BackupFileLocator.kt ================================================ package org.schabi.newpipe.settings.export import android.content.Context import java.nio.file.Path import kotlin.io.path.div /** * Locates specific files of NewPipe based on the home directory of the app. */ class BackupFileLocator(context: Context) { companion object { const val FILE_NAME_DB = "newpipe.db" @Deprecated( "Serializing preferences with Java's ObjectOutputStream is vulnerable to injections", replaceWith = ReplaceWith("FILE_NAME_JSON_PREFS") ) const val FILE_NAME_SERIALIZED_PREFS = "newpipe.settings" const val FILE_NAME_JSON_PREFS = "preferences.json" } val db: Path = context.getDatabasePath(FILE_NAME_DB).toPath() val dbJournal: Path = db.resolveSibling("$FILE_NAME_DB-journal") val dbShm: Path = db.resolveSibling("$FILE_NAME_DB-shm") val dbWal: Path = db.resolveSibling("$FILE_NAME_DB-wal") } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/export/ImportExportManager.kt ================================================ package org.schabi.newpipe.settings.export import android.content.SharedPreferences import com.grack.nanojson.JsonArray import com.grack.nanojson.JsonParser import com.grack.nanojson.JsonParserException import com.grack.nanojson.JsonWriter import java.io.FileNotFoundException import java.io.IOException import java.io.ObjectOutputStream import java.util.zip.ZipOutputStream import kotlin.io.path.createParentDirectories import kotlin.io.path.deleteIfExists import org.schabi.newpipe.streams.io.SharpOutputStream import org.schabi.newpipe.streams.io.StoredFileHelper import org.schabi.newpipe.util.ZipHelper class ImportExportManager(private val fileLocator: BackupFileLocator) { companion object { const val TAG = "ImportExportManager" } /** * Exports given [SharedPreferences] to the file in given outputPath. * It also creates the file. */ @Throws(Exception::class) fun exportDatabase(preferences: SharedPreferences, file: StoredFileHelper) { // truncate the file before writing to it, otherwise if the new content is smaller than the // previous file size, the file will retain part of the previous content and be corrupted ZipOutputStream(SharpOutputStream(file.openAndTruncateStream()).buffered()).use { outZip -> // add the database val name = BackupFileLocator.FILE_NAME_DB ZipHelper.addFileToZip(outZip, name, fileLocator.db) // add the legacy vulnerable serialized preferences (will be removed in the future) ZipHelper.addFileToZip( outZip, BackupFileLocator.FILE_NAME_SERIALIZED_PREFS ) { byteOutput -> ObjectOutputStream(byteOutput).use { output -> output.writeObject(preferences.all) output.flush() } } // add the JSON preferences ZipHelper.addFileToZip( outZip, BackupFileLocator.FILE_NAME_JSON_PREFS ) { byteOutput -> JsonWriter .indent("") .on(byteOutput) .`object`(preferences.all) .done() } } } /** * Tries to create database directory if it does not exist. */ @Throws(IOException::class) fun ensureDbDirectoryExists() { fileLocator.db.createParentDirectories() } /** * Extracts the database from the given file to the app's database directory. * The current app's database will be overwritten. * @param file the .zip file to extract the database from * @return true if the database was successfully extracted, false otherwise */ fun extractDb(file: StoredFileHelper): Boolean { val name = BackupFileLocator.FILE_NAME_DB val success = ZipHelper.extractFileFromZip(file, name, fileLocator.db) if (success) { fileLocator.dbJournal.deleteIfExists() fileLocator.dbWal.deleteIfExists() fileLocator.dbShm.deleteIfExists() } return success } @Deprecated( "Serializing preferences with Java's ObjectOutputStream is vulnerable to injections", replaceWith = ReplaceWith("exportHasJsonPrefs") ) fun exportHasSerializedPrefs(zipFile: StoredFileHelper): Boolean { return ZipHelper.zipContainsFile(zipFile, BackupFileLocator.FILE_NAME_SERIALIZED_PREFS) } fun exportHasJsonPrefs(zipFile: StoredFileHelper): Boolean { return ZipHelper.zipContainsFile(zipFile, BackupFileLocator.FILE_NAME_JSON_PREFS) } /** * Remove all shared preferences from the app and load the preferences supplied to the manager. */ @Deprecated( "Serializing preferences with Java's ObjectOutputStream is vulnerable to injections", replaceWith = ReplaceWith("loadJsonPrefs") ) @Throws(IOException::class, ClassNotFoundException::class) fun loadSerializedPrefs(zipFile: StoredFileHelper, preferences: SharedPreferences) { ZipHelper.extractFileFromZip(zipFile, BackupFileLocator.FILE_NAME_SERIALIZED_PREFS) { PreferencesObjectInputStream(it).use { input -> @Suppress("UNCHECKED_CAST") val entries = input.readObject() as Map val editor = preferences.edit() editor.clear() for ((key, value) in entries) { when (value) { is Boolean -> editor.putBoolean(key, value) is Float -> editor.putFloat(key, value) is Int -> editor.putInt(key, value) is Long -> editor.putLong(key, value) is String -> editor.putString(key, value) is Set<*> -> { // There are currently only Sets with type String possible @Suppress("UNCHECKED_CAST") editor.putStringSet(key, value as Set?) } } } if (!editor.commit()) { throw IOException("Unable to commit loadSerializedPrefs") } } }.let { fileExists -> if (!fileExists) { throw FileNotFoundException(BackupFileLocator.FILE_NAME_SERIALIZED_PREFS) } } } /** * Remove all shared preferences from the app and load the preferences supplied to the manager. */ @Throws(IOException::class, JsonParserException::class) fun loadJsonPrefs(zipFile: StoredFileHelper, preferences: SharedPreferences) { ZipHelper.extractFileFromZip(zipFile, BackupFileLocator.FILE_NAME_JSON_PREFS) { val jsonObject = JsonParser.`object`().from(it) val editor = preferences.edit() editor.clear() for ((key, value) in jsonObject) { when (value) { is Boolean -> editor.putBoolean(key, value) is Float -> editor.putFloat(key, value) is Int -> editor.putInt(key, value) is Long -> editor.putLong(key, value) is String -> editor.putString(key, value) is JsonArray -> { editor.putStringSet(key, value.mapNotNull { e -> e as? String }.toSet()) } } } if (!editor.commit()) { throw IOException("Unable to commit loadJsonPrefs") } }.let { fileExists -> if (!fileExists) { throw FileNotFoundException(BackupFileLocator.FILE_NAME_JSON_PREFS) } } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/export/PreferencesObjectInputStream.kt ================================================ /* * SPDX-FileCopyrightText: 2024-2026 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.settings.export import java.io.IOException import java.io.InputStream import java.io.ObjectInputStream import java.io.ObjectStreamClass /** * An [ObjectInputStream] that only allows preferences-related types to be deserialized, to * prevent injections. The only allowed types are: all primitive types, all boxed primitive types, * null, strings. HashMap, HashSet and arrays of previously defined types are also allowed. Sources: * [cmu.edu](https://wiki.sei.cmu.edu/confluence/display/java/SER00-J.+Enable+serialization+compatibility+during+class+evolution) * , * [OWASP cheatsheet](https://cheatsheetseries.owasp.org/cheatsheets/Deserialization_Cheat_Sheet.html#harden-your-own-javaioobjectinputstream) * , * [Apache's `ValidatingObjectInputStream`](https://commons.apache.org/proper/commons-io/apidocs/src-html/org/apache/commons/io/serialization/ValidatingObjectInputStream.html#line-118) * */ class PreferencesObjectInputStream(stream: InputStream) : ObjectInputStream(stream) { @Throws(ClassNotFoundException::class, IOException::class) override fun resolveClass(desc: ObjectStreamClass): Class<*> { if (desc.name in CLASS_WHITELIST) { return super.resolveClass(desc) } else { throw ClassNotFoundException("Class not allowed: $desc.name") } } companion object { /** * Primitive types, strings and other built-in types do not pass through resolveClass() but * instead have a custom encoding; see * [ * official docs](https://docs.oracle.com/javase/6/docs/platform/serialization/spec/protocol.html#10152). */ private val CLASS_WHITELIST = setOf( "java.lang.Boolean", "java.lang.Byte", "java.lang.Character", "java.lang.Short", "java.lang.Integer", "java.lang.Long", "java.lang.Float", "java.lang.Double", "java.lang.Void", "java.util.HashMap", "java.util.HashSet" ) } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/migration/MigrationManager.java ================================================ package org.schabi.newpipe.settings.migration; import android.content.Context; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.core.util.Consumer; import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorUtil; import java.util.ArrayList; import java.util.List; /** * MigrationManager is responsible for running migrations and showing the user information about * the migrations that were applied. */ public final class MigrationManager { private static final String TAG = MigrationManager.class.getSimpleName(); /** * List of UI actions that are performed after the UI is initialized (e.g. showing alert * dialogs) to inform the user about changes that were applied by migrations. */ private static final List> MIGRATION_INFO = new ArrayList<>(); private MigrationManager() { // MigrationManager is a utility class that is completely static } /** * Run all migrations that are needed for the current version of NewPipe. * This method should be called at the start of the application, before any other operations * that depend on the settings. * * @param context Context that can be used to run migrations */ public static void runMigrationsIfNeeded(@NonNull final Context context) { SettingMigrations.runMigrationsIfNeeded(context); } /** * Perform UI actions informing about migrations that took place if they are present. * @param context Context that can be used to show dialogs/snackbars/toasts */ public static void showUserInfoIfPresent(@NonNull final Context context) { if (MIGRATION_INFO.isEmpty()) { return; } try { MIGRATION_INFO.get(0).accept(context); } catch (final Exception e) { ErrorUtil.showUiErrorSnackbar(context, "Showing migration info to the user", e); // Remove the migration that caused the error and continue with the next one MIGRATION_INFO.remove(0); showUserInfoIfPresent(context); } } /** * Add a migration info action that will be executed after the UI is initialized. * This can be used to show dialogs/snackbars/toasts to inform the user about changes that * were applied by migrations. * * @param info the action to be executed */ public static void addMigrationInfo(final Consumer info) { MIGRATION_INFO.add(info); } /** * This method should be called when the user dismisses the migration info * to check if there are any more migration info actions to be shown. * @param context Context that can be used to show dialogs/snackbars/toasts */ public static void onMigrationInfoDismissed(@NonNull final Context context) { MIGRATION_INFO.remove(0); showUserInfoIfPresent(context); } /** * Creates a dialog to inform the user about the migration. * @param uiContext Context that can be used to show dialogs/snackbars/toasts * @param title the title of the dialog * @param message the message of the dialog * @return the dialog that can be shown to the user with a custom dismiss listener */ static AlertDialog createMigrationInfoDialog(@NonNull final Context uiContext, @NonNull final String title, @NonNull final String message) { return new AlertDialog.Builder(uiContext) .setTitle(title) .setMessage(message) .setPositiveButton(R.string.ok, null) .setOnDismissListener(dialog -> MigrationManager.onMigrationInfoDismissed(uiContext)) .setCancelable(false) // prevents the dialog from being dismissed accidentally .create(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/migration/SettingMigrations.java ================================================ package org.schabi.newpipe.settings.migration; import static org.schabi.newpipe.MainActivity.DEBUG; import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; import static org.schabi.newpipe.extractor.ServiceList.YouTube; import android.content.Context; import android.content.SharedPreferences; import android.util.Log; import androidx.annotation.NonNull; import androidx.core.util.Consumer; import androidx.preference.PreferenceManager; import org.schabi.newpipe.App; 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.settings.tabs.Tab; import org.schabi.newpipe.settings.tabs.TabsManager; import org.schabi.newpipe.util.DeviceUtils; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.stream.Collectors; /** * This class contains the code to migrate the settings from one version to another. * Migrations are run automatically when the app is started and the settings version changed. *
* In order to add a migration, follow these steps, given {@code P} is the previous version: *
    *
  • in the class body add a new {@code MIGRATION_P_P+1 = new Migration(P, P+1) { ... }} and put * in the {@code migrate()} method the code that need to be run * when migrating from {@code P} to {@code P+1}
  • *
  • add {@code MIGRATION_P_P+1} at the end of {@link SettingMigrations#SETTING_MIGRATIONS}
  • *
  • increment {@link SettingMigrations#VERSION}'s value by 1 * (so it becomes {@code P+1})
  • *
* Migrations can register UI actions using {@link MigrationManager#addMigrationInfo(Consumer)} * that will be performed after the UI is initialized to inform the user about changes * that were applied by migrations. */ public final class SettingMigrations { private static final String TAG = SettingMigrations.class.toString(); private static SharedPreferences sp; private static final Migration MIGRATION_0_1 = new Migration(0, 1) { @Override public void migrate(@NonNull final Context context) { // We changed the content of the dialog which opens when sharing a link to NewPipe // by removing the "open detail page" option. // Therefore, show the dialog once again to ensure users need to choose again and are // aware of the changed dialog. final SharedPreferences.Editor editor = sp.edit(); editor.putString(context.getString(R.string.preferred_open_action_key), context.getString(R.string.always_ask_open_action_key)); editor.apply(); } }; private static final Migration MIGRATION_1_2 = new Migration(1, 2) { @Override protected void migrate(@NonNull final Context context) { // The new application workflow introduced in #2907 allows minimizing videos // while playing to do other stuff within the app. // For an even better workflow, we minimize a stream when switching the app to play in // background. // Therefore, set default value to background, if it has not been changed yet. final String minimizeOnExitKey = context.getString(R.string.minimize_on_exit_key); if (sp.getString(minimizeOnExitKey, "") .equals(context.getString(R.string.minimize_on_exit_none_key))) { final SharedPreferences.Editor editor = sp.edit(); editor.putString(minimizeOnExitKey, context.getString(R.string.minimize_on_exit_background_key)); editor.apply(); } } }; private static final Migration MIGRATION_2_3 = new Migration(2, 3) { @Override protected void migrate(@NonNull final Context context) { // Storage Access Framework implementation was improved in #5415, allowing the modern // and standard way to access folders and files to be used consistently everywhere. // We reset the setting to its default value, i.e. "use SAF", since now there are no // more issues with SAF and users should use that one instead of the old // NoNonsenseFilePicker. Also, there's a bug on FireOS in which SAF open/close // dialogs cannot be confirmed with a remote (see #6455). sp.edit().putBoolean( context.getString(R.string.storage_use_saf), !DeviceUtils.isFireTv() ).apply(); } }; private static final Migration MIGRATION_3_4 = new Migration(3, 4) { @Override protected void migrate(@NonNull final Context context) { // Pull request #3546 added support for choosing the type of search suggestions to // show, replacing the on-off switch used before, so migrate the previous user choice final String showSearchSuggestionsKey = context.getString(R.string.show_search_suggestions_key); boolean addAllSearchSuggestionTypes; try { addAllSearchSuggestionTypes = sp.getBoolean(showSearchSuggestionsKey, true); } catch (final ClassCastException e) { // just in case it was not a boolean for some reason, let's consider it a "true" addAllSearchSuggestionTypes = true; } final Set showSearchSuggestionsValueList = new HashSet<>(); if (addAllSearchSuggestionTypes) { // if the preference was true, all suggestions will be shown, otherwise none Collections.addAll(showSearchSuggestionsValueList, context.getResources() .getStringArray(R.array.show_search_suggestions_value_list)); } sp.edit().putStringSet( showSearchSuggestionsKey, showSearchSuggestionsValueList).apply(); } }; private static final Migration MIGRATION_4_5 = new Migration(4, 5) { @Override protected void migrate(@NonNull final Context context) { final boolean brightness = sp.getBoolean("brightness_gesture_control", true); final boolean volume = sp.getBoolean("volume_gesture_control", true); final SharedPreferences.Editor editor = sp.edit(); editor.putString(context.getString(R.string.right_gesture_control_key), context.getString(volume ? R.string.volume_control_key : R.string.none_control_key)); editor.putString(context.getString(R.string.left_gesture_control_key), context.getString(brightness ? R.string.brightness_control_key : R.string.none_control_key)); editor.apply(); } }; private static final Migration MIGRATION_5_6 = new Migration(5, 6) { @Override protected void migrate(@NonNull final Context context) { final boolean loadImages = sp.getBoolean("download_thumbnail_key", true); sp.edit() .putString(context.getString(R.string.image_quality_key), context.getString(loadImages ? R.string.image_quality_default : R.string.image_quality_none_key)) .apply(); } }; private static final Migration MIGRATION_6_7 = new Migration(6, 7) { @Override protected void migrate(@NonNull final Context context) { // The SoundCloud Top 50 Kiosk was removed in the extractor, // so we remove the corresponding tab if it exists. final TabsManager tabsManager = TabsManager.getManager(context); final List tabs = tabsManager.getTabs(); final List cleanedTabs = tabs.stream() .filter(tab -> !(tab instanceof Tab.KioskTab kioskTab && kioskTab.getKioskServiceId() == SoundCloud.getServiceId() && kioskTab.getKioskId().equals("Top 50"))) .collect(Collectors.toUnmodifiableList()); if (tabs.size() != cleanedTabs.size()) { tabsManager.saveTabs(cleanedTabs); // create an AlertDialog to inform the user about the change MigrationManager.addMigrationInfo(uiContext -> MigrationManager.createMigrationInfoDialog( uiContext, uiContext.getString(R.string.migration_info_6_7_title), uiContext.getString(R.string.migration_info_6_7_message)) .show()); } } }; private static final Migration MIGRATION_7_8 = new Migration(7, 8) { @Override protected void migrate(@NonNull final Context context) { // YouTube remove the combined Trending kiosk, see // https://github.com/TeamNewPipe/NewPipe/discussions/12445 for more information. // If the user has a dedicated YouTube/Trending kiosk tab, // it is removed and replaced with the new live kiosk tab. // The default trending kiosk tab is not touched // because it uses the default kiosk provided by the extractor // and is thus updated automatically. final TabsManager tabsManager = TabsManager.getManager(context); final List tabs = tabsManager.getTabs(); final List cleanedTabs = tabs.stream() .filter(tab -> !(tab instanceof Tab.KioskTab kioskTab && kioskTab.getKioskServiceId() == YouTube.getServiceId() && kioskTab.getKioskId().equals("Trending"))) .collect(Collectors.toUnmodifiableList()); if (tabs.size() != cleanedTabs.size()) { tabsManager.saveTabs(cleanedTabs); } final boolean hasDefaultTrendingTab = tabs.stream() .anyMatch(tab -> tab instanceof Tab.DefaultKioskTab); if (tabs.size() != cleanedTabs.size() || hasDefaultTrendingTab) { // User is informed about the change MigrationManager.addMigrationInfo(uiContext -> MigrationManager.createMigrationInfoDialog( uiContext, uiContext.getString(R.string.migration_info_7_8_title), uiContext.getString(R.string.migration_info_7_8_message)) .show()); } } }; /** * List of all implemented migrations. *

* Append new migrations to the end of the list to keep it sorted ascending. * If not sorted correctly, migrations which depend on each other, may fail. */ private static final Migration[] SETTING_MIGRATIONS = { MIGRATION_0_1, MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, }; /** * Version number for preferences. Must be incremented every time a migration is necessary. */ private static final int VERSION = 8; static void runMigrationsIfNeeded(@NonNull final Context context) { // setup migrations and check if there is something to do sp = PreferenceManager.getDefaultSharedPreferences(context); final String lastPrefVersionKey = context.getString(R.string.last_used_preferences_version); final int lastPrefVersion = sp.getInt(lastPrefVersionKey, 0); // no migration to run, already up to date if (App.getInstance().isFirstRun()) { sp.edit().putInt(lastPrefVersionKey, VERSION).apply(); return; } else if (lastPrefVersion == VERSION) { return; } // run migrations int currentVersion = lastPrefVersion; for (final Migration currentMigration : SETTING_MIGRATIONS) { try { if (currentMigration.shouldMigrate(currentVersion)) { if (DEBUG) { Log.d(TAG, "Migrating preferences from version " + currentVersion + " to " + currentMigration.newVersion); } currentMigration.migrate(context); currentVersion = currentMigration.newVersion; } } catch (final Exception e) { // save the version with the last successful migration and report the error sp.edit().putInt(lastPrefVersionKey, currentVersion).apply(); ErrorUtil.openActivity(context, new ErrorInfo( e, UserAction.PREFERENCES_MIGRATION, "Migrating preferences from version " + lastPrefVersion + " to " + VERSION + ". " + "Error at " + currentVersion + " => " + ++currentVersion )); return; } } // store the current preferences version sp.edit().putInt(lastPrefVersionKey, currentVersion).apply(); } private SettingMigrations() { } abstract static class Migration { public final int oldVersion; public final int newVersion; protected Migration(final int oldVersion, final int newVersion) { this.oldVersion = oldVersion; this.newVersion = newVersion; } /** * @param currentVersion current settings version * @return Returns whether this migration should be run. * A migration is necessary if the old version of this migration is lower than or equal to * the current settings version. */ private boolean shouldMigrate(final int currentVersion) { return oldVersion >= currentVersion; } protected abstract void migrate(@NonNull Context context); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigAdapter.kt ================================================ package org.schabi.newpipe.settings.notifications import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.databinding.ItemNotificationConfigBinding import org.schabi.newpipe.settings.notifications.NotificationModeConfigAdapter.SubscriptionHolder /** * This [RecyclerView.Adapter] is used in the [NotificationModeConfigFragment]. * The adapter holds all subscribed channels and their [NotificationMode]s * and provides the needed data structures and methods for this task. */ class NotificationModeConfigAdapter( private val listener: ModeToggleListener ) : ListAdapter(DiffCallback) { override fun onCreateViewHolder(parent: ViewGroup, i: Int): SubscriptionHolder { return SubscriptionHolder( ItemNotificationConfigBinding .inflate(LayoutInflater.from(parent.context), parent, false) ) } override fun onBindViewHolder(holder: SubscriptionHolder, position: Int) { holder.bind(currentList[position]) } fun update(newData: List) { val items = newData.map { SubscriptionItem(it.uid, it.name!!, it.notificationMode, it.serviceId, it.url!!) } submitList(items) } inner class SubscriptionHolder( private val itemBinding: ItemNotificationConfigBinding ) : RecyclerView.ViewHolder(itemBinding.root) { init { itemView.setOnClickListener { val mode = if (itemBinding.root.isChecked) { NotificationMode.DISABLED } else { NotificationMode.ENABLED } listener.onModeChange(bindingAdapterPosition, mode) } } fun bind(data: SubscriptionItem) { itemBinding.root.text = data.title itemBinding.root.isChecked = data.notificationMode != NotificationMode.DISABLED } } private object DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: SubscriptionItem, newItem: SubscriptionItem): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame(oldItem: SubscriptionItem, newItem: SubscriptionItem): Boolean { return oldItem == newItem } override fun getChangePayload(oldItem: SubscriptionItem, newItem: SubscriptionItem): Any? { return if (oldItem.notificationMode != newItem.notificationMode) { newItem.notificationMode } else { super.getChangePayload(oldItem, newItem) } } } fun interface ModeToggleListener { /** * Triggered when the UI representation of a notification mode is changed. */ fun onModeChange(position: Int, @NotificationMode mode: Int) } } data class SubscriptionItem( val id: Long, val title: String, @NotificationMode val notificationMode: Int, val serviceId: Int, val url: String ) ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigFragment.kt ================================================ package org.schabi.newpipe.settings.notifications import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.R import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.databinding.FragmentChannelsNotificationsBinding import org.schabi.newpipe.local.subscription.SubscriptionManager /** * [NotificationModeConfigFragment] is a settings fragment * which allows changing the [NotificationMode] of all subscribed channels. * The [NotificationMode] can either be changed one by one or toggled for all channels. */ class NotificationModeConfigFragment : Fragment() { private var _binding: FragmentChannelsNotificationsBinding? = null private val binding get() = _binding!! private val disposables = CompositeDisposable() private var loader: Disposable? = null private lateinit var adapter: NotificationModeConfigAdapter private lateinit var subscriptionManager: SubscriptionManager override fun onAttach(context: Context) { super.onAttach(context) subscriptionManager = SubscriptionManager(context) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setHasOptionsMenu(true) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { _binding = FragmentChannelsNotificationsBinding.inflate(inflater, container, false) return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) adapter = NotificationModeConfigAdapter { position, mode -> // Notification mode has been changed via the UI. // Now change it in the database. updateNotificationMode(adapter.currentList[position], mode) } binding.recyclerView.adapter = adapter loader?.dispose() loader = subscriptionManager.subscriptions() .observeOn(AndroidSchedulers.mainThread()) .subscribe(adapter::update) } override fun onDestroyView() { loader?.dispose() loader = null _binding = null super.onDestroyView() } override fun onDestroy() { disposables.dispose() super.onDestroy() } override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) inflater.inflate(R.menu.menu_notifications_channels, menu) } override fun onOptionsItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.action_toggle_all -> { toggleAll() true } else -> super.onOptionsItemSelected(item) } } private fun toggleAll() { val mode = adapter.currentList.firstOrNull()?.notificationMode ?: return val newMode = when (mode) { NotificationMode.DISABLED -> NotificationMode.ENABLED else -> NotificationMode.DISABLED } adapter.currentList.forEach { updateNotificationMode(it, newMode) } } private fun updateNotificationMode(item: SubscriptionItem, @NotificationMode mode: Int) { disposables.add( subscriptionManager.updateNotificationMode(item.serviceId, item.url, mode) .subscribeOn(Schedulers.io()) .subscribe() ) } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.java ================================================ package org.schabi.newpipe.settings.preferencesearch; import android.text.TextUtils; import android.util.Pair; import org.apache.commons.text.similarity.FuzzyScore; import java.util.Comparator; import java.util.Locale; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.Stream; public class PreferenceFuzzySearchFunction implements PreferenceSearchConfiguration.PreferenceSearchFunction { private static final FuzzyScore FUZZY_SCORE = new FuzzyScore(Locale.ROOT); @Override public Stream search( final Stream allAvailable, final String keyword ) { final int maxScore = (keyword.length() + 1) * 3 - 2; // First can't get +2 bonus score return allAvailable // General search // Check all fields if anyone contains something that kind of matches the keyword .map(item -> new FuzzySearchGeneralDTO(item, keyword)) .filter(dto -> dto.getScore() / maxScore >= 0.3f) .map(FuzzySearchGeneralDTO::getItem) // Specific search - Used for determining order of search results // Calculate a score based on specific search fields .map(item -> new FuzzySearchSpecificDTO(item, keyword)) .sorted(Comparator.comparingDouble(FuzzySearchSpecificDTO::getScore).reversed()) .map(FuzzySearchSpecificDTO::getItem) // Limit the amount of search results .limit(20); } static class FuzzySearchGeneralDTO { private final PreferenceSearchItem item; private final float score; FuzzySearchGeneralDTO( final PreferenceSearchItem item, final String keyword) { this.item = item; this.score = FUZZY_SCORE.fuzzyScore( TextUtils.join(";", item.getAllRelevantSearchFields()), keyword); } public PreferenceSearchItem getItem() { return item; } public float getScore() { return score; } } static class FuzzySearchSpecificDTO { private static final Map, Float> WEIGHT_MAP = Map.of( // The user will most likely look for the title -> prioritize it PreferenceSearchItem::getTitle, 1.5f, // The summary is also important as it usually contains a larger desc // Example: Searching for '4k' → 'show higher resolution' is shown PreferenceSearchItem::getSummary, 1f, // Entries are also important as they provide all known/possible values // Example: Searching where the resolution can be changed to 720p PreferenceSearchItem::getEntries, 1f ); private final PreferenceSearchItem item; private final double score; FuzzySearchSpecificDTO(final PreferenceSearchItem item, final String keyword) { this.item = item; this.score = WEIGHT_MAP.entrySet().stream() .map(entry -> new Pair<>(entry.getKey().apply(item), entry.getValue())) .filter(pair -> !pair.first.isEmpty()) .collect(Collectors.averagingDouble(pair -> FUZZY_SCORE.fuzzyScore(pair.first, keyword) * pair.second)); } public PreferenceSearchItem getItem() { return item; } public double getScore() { return score; } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceParser.java ================================================ package org.schabi.newpipe.settings.preferencesearch; import android.content.Context; import android.text.TextUtils; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.XmlRes; import androidx.preference.PreferenceManager; import org.schabi.newpipe.util.Localization; import org.xmlpull.v1.XmlPullParser; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; /** * Parses the corresponding preference-file(s). */ public class PreferenceParser { private static final String TAG = "PreferenceParser"; private static final String NS_ANDROID = "http://schemas.android.com/apk/res/android"; private static final String NS_SEARCH = "http://schemas.android.com/apk/preferencesearch"; private final Context context; private final Map allPreferences; private final PreferenceSearchConfiguration searchConfiguration; public PreferenceParser( final Context context, final PreferenceSearchConfiguration searchConfiguration ) { this.context = context; this.allPreferences = PreferenceManager.getDefaultSharedPreferences(context).getAll(); this.searchConfiguration = searchConfiguration; } public List parse( @XmlRes final int resId ) { final List results = new ArrayList<>(); final XmlPullParser xpp = context.getResources().getXml(resId); try { xpp.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true); xpp.setFeature(XmlPullParser.FEATURE_REPORT_NAMESPACE_ATTRIBUTES, true); final List breadcrumbs = new ArrayList<>(); while (xpp.getEventType() != XmlPullParser.END_DOCUMENT) { if (xpp.getEventType() == XmlPullParser.START_TAG) { final PreferenceSearchItem result = parseSearchResult( xpp, Localization.concatenateStrings(" > ", breadcrumbs), resId ); if (!searchConfiguration.getParserIgnoreElements().contains(xpp.getName()) && result.hasData() && !"true".equals(getAttribute(xpp, NS_SEARCH, "ignore"))) { results.add(result); } if (searchConfiguration.getParserContainerElements().contains(xpp.getName())) { // This code adds breadcrumbs for certain containers (e.g. PreferenceScreen) // Example: Video and Audio > Player breadcrumbs.add(result.getTitle() == null ? "" : result.getTitle()); } } else if (xpp.getEventType() == XmlPullParser.END_TAG && searchConfiguration.getParserContainerElements() .contains(xpp.getName())) { breadcrumbs.remove(breadcrumbs.size() - 1); } xpp.next(); } } catch (final Exception e) { Log.w(TAG, "Failed to parse resid=" + resId, e); } return results; } private String getAttribute( final XmlPullParser xpp, @NonNull final String attribute ) { final String nsSearchAttr = getAttribute(xpp, NS_SEARCH, attribute); if (nsSearchAttr != null) { return nsSearchAttr; } return getAttribute(xpp, NS_ANDROID, attribute); } private String getAttribute( final XmlPullParser xpp, @NonNull final String namespace, @NonNull final String attribute ) { return xpp.getAttributeValue(namespace, attribute); } private PreferenceSearchItem parseSearchResult( final XmlPullParser xpp, final String breadcrumbs, @XmlRes final int searchIndexItemResId ) { final String key = readString(getAttribute(xpp, "key")); final String[] entries = readStringArray(getAttribute(xpp, "entries")); final String[] entryValues = readStringArray(getAttribute(xpp, "entryValues")); return new PreferenceSearchItem( key, tryFillInPreferenceValue( readString(getAttribute(xpp, "title")), key, entries, entryValues), tryFillInPreferenceValue( readString(getAttribute(xpp, "summary")), key, entries, entryValues), TextUtils.join(",", entries), breadcrumbs, searchIndexItemResId ); } private String[] readStringArray(@Nullable final String s) { if (s == null) { return new String[0]; } if (s.startsWith("@")) { try { return context.getResources().getStringArray(Integer.parseInt(s.substring(1))); } catch (final Exception e) { Log.w(TAG, "Unable to readStringArray from '" + s + "'", e); } } return new String[0]; } private String readString(@Nullable final String s) { if (s == null) { return ""; } if (s.startsWith("@")) { try { return context.getString(Integer.parseInt(s.substring(1))); } catch (final Exception e) { Log.w(TAG, "Unable to readString from '" + s + "'", e); } } return s; } private String tryFillInPreferenceValue( @Nullable final String s, @Nullable final String key, final String[] entries, final String[] entryValues ) { if (s == null) { return ""; } if (key == null) { return s; } // Resolve value Object prefValue = allPreferences.get(key); if (prefValue == null) { return s; } /* * Resolve ListPreference values * * entryValues = Values/Keys that are saved * entries = Actual human readable names */ if (entries.length > 0 && entryValues.length == entries.length) { final int entryIndex = Arrays.asList(entryValues).indexOf(prefValue); if (entryIndex != -1) { prefValue = entries[entryIndex]; } } return String.format(s, prefValue.toString()); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.java ================================================ package org.schabi.newpipe.settings.preferencesearch; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; import org.schabi.newpipe.databinding.SettingsPreferencesearchListItemResultBinding; import java.util.function.Consumer; class PreferenceSearchAdapter extends ListAdapter { private Consumer onItemClickListener; PreferenceSearchAdapter() { super(new PreferenceCallback()); } @NonNull @Override public PreferenceViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { return new PreferenceViewHolder(SettingsPreferencesearchListItemResultBinding.inflate( LayoutInflater.from(parent.getContext()), parent, false)); } @Override public void onBindViewHolder(@NonNull final PreferenceViewHolder holder, final int position) { final PreferenceSearchItem item = getItem(position); holder.binding.title.setText(item.getTitle()); if (item.getSummary().isEmpty()) { holder.binding.summary.setVisibility(View.GONE); } else { holder.binding.summary.setVisibility(View.VISIBLE); holder.binding.summary.setText(item.getSummary()); } if (item.getBreadcrumbs().isEmpty()) { holder.binding.breadcrumbs.setVisibility(View.GONE); } else { holder.binding.breadcrumbs.setVisibility(View.VISIBLE); holder.binding.breadcrumbs.setText(item.getBreadcrumbs()); } holder.itemView.setOnClickListener(v -> { if (onItemClickListener != null) { onItemClickListener.accept(item); } }); } void setOnItemClickListener(final Consumer onItemClickListener) { this.onItemClickListener = onItemClickListener; } static class PreferenceViewHolder extends RecyclerView.ViewHolder { final SettingsPreferencesearchListItemResultBinding binding; PreferenceViewHolder(final SettingsPreferencesearchListItemResultBinding binding) { super(binding.getRoot()); this.binding = binding; } } private static final class PreferenceCallback extends DiffUtil.ItemCallback { @Override public boolean areItemsTheSame(@NonNull final PreferenceSearchItem oldItem, @NonNull final PreferenceSearchItem newItem) { return oldItem.getKey().equals(newItem.getKey()); } @Override public boolean areContentsTheSame(@NonNull final PreferenceSearchItem oldItem, @NonNull final PreferenceSearchItem newItem) { return oldItem.getAllRelevantSearchFields().equals(newItem .getAllRelevantSearchFields()); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java ================================================ package org.schabi.newpipe.settings.preferencesearch; import androidx.preference.PreferenceCategory; import androidx.preference.PreferenceScreen; import java.util.List; import java.util.Objects; import java.util.stream.Stream; public class PreferenceSearchConfiguration { private PreferenceSearchFunction searcher = new PreferenceFuzzySearchFunction(); private final List parserIgnoreElements = List.of( PreferenceCategory.class.getSimpleName()); private final List parserContainerElements = List.of( PreferenceCategory.class.getSimpleName(), PreferenceScreen.class.getSimpleName()); public void setSearcher(final PreferenceSearchFunction searcher) { this.searcher = Objects.requireNonNull(searcher); } public PreferenceSearchFunction getSearcher() { return searcher; } public List getParserIgnoreElements() { return parserIgnoreElements; } public List getParserContainerElements() { return parserContainerElements; } @FunctionalInterface public interface PreferenceSearchFunction { Stream search( Stream allAvailable, String keyword); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java ================================================ package org.schabi.newpipe.settings.preferencesearch; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.LinearLayoutManager; import org.schabi.newpipe.databinding.SettingsPreferencesearchFragmentBinding; import java.util.List; /** * Displays the search results. */ public class PreferenceSearchFragment extends Fragment { public static final String NAME = PreferenceSearchFragment.class.getSimpleName(); private PreferenceSearcher searcher; private SettingsPreferencesearchFragmentBinding binding; private PreferenceSearchAdapter adapter; public void setSearcher(final PreferenceSearcher searcher) { this.searcher = searcher; } @Nullable @Override public View onCreateView( @NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState ) { binding = SettingsPreferencesearchFragmentBinding.inflate(inflater, container, false); binding.searchResults.setLayoutManager(new LinearLayoutManager(getContext())); adapter = new PreferenceSearchAdapter(); adapter.setOnItemClickListener(this::onItemClicked); binding.searchResults.setAdapter(adapter); return binding.getRoot(); } public void updateSearchResults(final String keyword) { if (adapter == null || searcher == null) { return; } final List results = searcher.searchFor(keyword); adapter.submitList(results); setEmptyViewShown(results.isEmpty()); } private void setEmptyViewShown(final boolean shown) { binding.emptyStateView.setVisibility(shown ? View.VISIBLE : View.GONE); binding.searchResults.setVisibility(shown ? View.GONE : View.VISIBLE); } public void onItemClicked(final PreferenceSearchItem item) { if (!(getActivity() instanceof PreferenceSearchResultListener)) { throw new ClassCastException( getActivity().toString() + " must implement SearchPreferenceResultListener"); } ((PreferenceSearchResultListener) getActivity()).onSearchResultClicked(item); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.kt ================================================ /* * SPDX-FileCopyrightText: 2022-2025 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.settings.preferencesearch import androidx.annotation.XmlRes /** * Represents a preference-item inside the search. * * @param key Key of the setting/preference. E.g. used inside [android.content.SharedPreferences]. * @param title Title of the setting, e.g. 'Default resolution' or 'Show higher resolutions'. * @param summary Summary of the setting, e.g. '480p' or 'Only some devices can play 2k/4k'. * @param entries Possible entries of the setting, e.g. 480p,720p,... * @param breadcrumbs Breadcrumbs - a hint where the setting is located e.g. 'Video and Audio > Player' * @param searchIndexItemResId The xml-resource where this item was found/built from. */ data class PreferenceSearchItem( val key: String, val title: String, val summary: String, val entries: String, val breadcrumbs: String, @XmlRes val searchIndexItemResId: Int ) { val allRelevantSearchFields: List get() = listOf(title, summary, entries, breadcrumbs) fun hasData(): Boolean { return !key.isEmpty() && !title.isEmpty() } override fun toString(): String { return "PreferenceItem: $title $summary $key" } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.java ================================================ package org.schabi.newpipe.settings.preferencesearch; import android.content.res.Resources; import android.content.res.TypedArray; import android.graphics.PorterDuff; import android.graphics.PorterDuffColorFilter; import android.graphics.drawable.Drawable; import android.graphics.drawable.RippleDrawable; import android.os.Handler; import android.os.Looper; import android.util.Log; import android.util.TypedValue; import androidx.appcompat.content.res.AppCompatResources; import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceGroup; import androidx.recyclerview.widget.RecyclerView; import org.schabi.newpipe.R; public final class PreferenceSearchResultHighlighter { private static final String TAG = "PrefSearchResHighlter"; private PreferenceSearchResultHighlighter() { } /** * Highlight the specified preference. *
* Note: This function is Thread independent (can be called from outside of the main thread). * * @param item The item to highlight * @param prefsFragment The fragment where the items is located on */ public static void highlight( final PreferenceSearchItem item, final PreferenceFragmentCompat prefsFragment ) { new Handler(Looper.getMainLooper()).post(() -> doHighlight(item, prefsFragment)); } private static void doHighlight( final PreferenceSearchItem item, final PreferenceFragmentCompat prefsFragment ) { final Preference prefResult = prefsFragment.findPreference(item.getKey()); if (prefResult == null) { Log.w(TAG, "Preference '" + item.getKey() + "' not found on '" + prefsFragment + "'"); return; } final RecyclerView recyclerView = prefsFragment.getListView(); final RecyclerView.Adapter adapter = recyclerView.getAdapter(); if (adapter instanceof PreferenceGroup.PreferencePositionCallback) { final int position = ((PreferenceGroup.PreferencePositionCallback) adapter) .getPreferenceAdapterPosition(prefResult); if (position != RecyclerView.NO_POSITION) { recyclerView.scrollToPosition(position); recyclerView.postDelayed(() -> { final RecyclerView.ViewHolder holder = recyclerView.findViewHolderForAdapterPosition(position); if (holder != null) { final Drawable background = holder.itemView.getBackground(); if (background instanceof RippleDrawable) { showRippleAnimation((RippleDrawable) background); return; } } highlightFallback(prefsFragment, prefResult); }, 200); return; } } highlightFallback(prefsFragment, prefResult); } /** * Alternative highlighting (shows an → arrow in front of the setting)if ripple does not work. * * @param prefsFragment * @param prefResult */ private static void highlightFallback( final PreferenceFragmentCompat prefsFragment, final Preference prefResult ) { // Get primary color from text for highlight icon final TypedValue typedValue = new TypedValue(); final Resources.Theme theme = prefsFragment.getActivity().getTheme(); theme.resolveAttribute(android.R.attr.textColorPrimary, typedValue, true); final TypedArray arr = prefsFragment.getActivity() .obtainStyledAttributes( typedValue.data, new int[]{android.R.attr.textColorPrimary}); final int color = arr.getColor(0, 0xffE53935); arr.recycle(); // Show highlight icon final Drawable oldIcon = prefResult.getIcon(); final boolean oldSpaceReserved = prefResult.isIconSpaceReserved(); final Drawable highlightIcon = AppCompatResources.getDrawable( prefsFragment.requireContext(), R.drawable.ic_play_arrow); highlightIcon.setColorFilter(new PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)); prefResult.setIcon(highlightIcon); prefsFragment.scrollToPreference(prefResult); new Handler(Looper.getMainLooper()).postDelayed(() -> { prefResult.setIcon(oldIcon); prefResult.setIconSpaceReserved(oldSpaceReserved); }, 1000); } private static void showRippleAnimation(final RippleDrawable rippleDrawable) { rippleDrawable.setState( new int[]{android.R.attr.state_pressed, android.R.attr.state_enabled}); new Handler(Looper.getMainLooper()) .postDelayed(() -> rippleDrawable.setState(new int[]{}), 1000); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.kt ================================================ /* * SPDX-FileCopyrightText: 2022-2026 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.settings.preferencesearch interface PreferenceSearchResultListener { fun onSearchResultClicked(result: PreferenceSearchItem) } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearcher.java ================================================ package org.schabi.newpipe.settings.preferencesearch; import android.text.TextUtils; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.stream.Collectors; public class PreferenceSearcher { private final List allEntries = new ArrayList<>(); private final PreferenceSearchConfiguration configuration; public PreferenceSearcher(final PreferenceSearchConfiguration configuration) { this.configuration = configuration; } public void add(final List items) { allEntries.addAll(items); } List searchFor(final String keyword) { if (TextUtils.isEmpty(keyword)) { return Collections.emptyList(); } return configuration.getSearcher() .search(allEntries.stream(), keyword) .collect(Collectors.toList()); } public void clear() { allEntries.clear(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/preferencesearch/package-info.java ================================================ /** * Contains classes for searching inside the preferences. *
* This code is based on * ByteHamster/SearchPreference * (MIT license) but was heavily modified/refactored for our use. * * @author litetex */ package org.schabi.newpipe.settings.preferencesearch; ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/tabs/AddTabDialog.java ================================================ package org.schabi.newpipe.settings.tabs; import android.content.Context; import android.content.DialogInterface; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.BaseAdapter; import android.widget.TextView; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.AppCompatImageView; import org.schabi.newpipe.R; public final class AddTabDialog { private final AlertDialog dialog; AddTabDialog(@NonNull final Context context, @NonNull final ChooseTabListItem[] items, @NonNull final DialogInterface.OnClickListener actions) { dialog = new AlertDialog.Builder(context) .setTitle(context.getString(R.string.tab_choose)) .setAdapter(new DialogListAdapter(context, items), actions) .create(); } public void show() { dialog.show(); } static final class ChooseTabListItem { final int tabId; final String itemName; @DrawableRes final int itemIcon; ChooseTabListItem(final Context context, final Tab tab) { this(tab.getTabId(), tab.getTabName(context), tab.getTabIconRes(context)); } ChooseTabListItem(final int tabId, final String itemName, @DrawableRes final int itemIcon) { this.tabId = tabId; this.itemName = itemName; this.itemIcon = itemIcon; } } private static final class DialogListAdapter extends BaseAdapter { private final LayoutInflater inflater; private final ChooseTabListItem[] items; @DrawableRes private final int fallbackIcon; private DialogListAdapter(final Context context, final ChooseTabListItem[] items) { this.inflater = LayoutInflater.from(context); this.items = items; this.fallbackIcon = R.drawable.ic_whatshot; } @Override public int getCount() { return items.length; } @Override public ChooseTabListItem getItem(final int position) { return items[position]; } @Override public long getItemId(final int position) { return getItem(position).tabId; } @Override public View getView(final int position, final View view, final ViewGroup parent) { View convertView = view; if (convertView == null) { convertView = inflater.inflate(R.layout.list_choose_tabs_dialog, parent, false); } final ChooseTabListItem item = getItem(position); final AppCompatImageView tabIconView = convertView.findViewById(R.id.tabIcon); final TextView tabNameView = convertView.findViewById(R.id.tabName); tabIconView.setImageResource(item.itemIcon > 0 ? item.itemIcon : fallbackIcon); tabNameView.setText(item.itemName); return convertView; } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java ================================================ package org.schabi.newpipe.settings.tabs; import static org.schabi.newpipe.settings.tabs.Tab.typeFrom; import static org.schabi.newpipe.util.ServiceHelper.getNameOfServiceById; import android.annotation.SuppressLint; import android.app.Dialog; import android.content.Context; import android.os.Bundle; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.AppCompatImageView; import androidx.fragment.app.Fragment; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.floatingactionbutton.FloatingActionButton; 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.settings.SelectChannelFragment; import org.schabi.newpipe.settings.SelectKioskFragment; import org.schabi.newpipe.settings.SelectPlaylistFragment; import org.schabi.newpipe.settings.SelectFeedGroupFragment; import org.schabi.newpipe.settings.tabs.AddTabDialog.ChooseTabListItem; import org.schabi.newpipe.util.ThemeHelper; import java.util.ArrayList; import java.util.Collections; import java.util.List; public class ChooseTabsFragment extends Fragment { private TabsManager tabsManager; private final List tabList = new ArrayList<>(); private ChooseTabsFragment.SelectedTabsAdapter selectedTabsAdapter; /*////////////////////////////////////////////////////////////////////////// // Lifecycle //////////////////////////////////////////////////////////////////////////*/ @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); tabsManager = TabsManager.getManager(requireContext()); updateTabList(); setHasOptionsMenu(true); } @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { return inflater.inflate(R.layout.fragment_choose_tabs, container, false); } @Override public void onViewCreated(@NonNull final View rootView, @Nullable final Bundle savedInstanceState) { super.onViewCreated(rootView, savedInstanceState); initButton(rootView); final RecyclerView listSelectedTabs = rootView.findViewById(R.id.selectedTabs); listSelectedTabs.setLayoutManager(new LinearLayoutManager(requireContext())); final ItemTouchHelper itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); itemTouchHelper.attachToRecyclerView(listSelectedTabs); selectedTabsAdapter = new SelectedTabsAdapter(requireContext(), itemTouchHelper); listSelectedTabs.setAdapter(selectedTabsAdapter); } @Override public void onResume() { super.onResume(); ThemeHelper.setTitleToAppCompatActivity(getActivity(), getString(R.string.main_page_content)); } @Override public void onPause() { super.onPause(); saveChanges(); } /*////////////////////////////////////////////////////////////////////////// // Menu //////////////////////////////////////////////////////////////////////////*/ @Override public void onCreateOptionsMenu(@NonNull final Menu menu, @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); inflater.inflate(R.menu.menu_chooser_fragment, menu); menu.findItem(R.id.menu_item_restore_default).setOnMenuItemClickListener(item -> { restoreDefaults(); return true; }); } /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ private void updateTabList() { tabList.clear(); tabList.addAll(tabsManager.getTabs()); } private void saveChanges() { tabsManager.saveTabs(tabList); } private void restoreDefaults() { new AlertDialog.Builder(requireContext()) .setTitle(R.string.restore_defaults) .setMessage(R.string.restore_defaults_confirmation) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok, (dialog, which) -> { tabsManager.resetTabs(); updateTabList(); selectedTabsAdapter.notifyDataSetChanged(); }) .show(); } private void initButton(final View rootView) { final FloatingActionButton fab = rootView.findViewById(R.id.addTabsButton); fab.setOnClickListener(v -> { final ChooseTabListItem[] availableTabs = getAvailableTabs(requireContext()); if (availableTabs.length == 0) { //Toast.makeText(requireContext(), "No available tabs", Toast.LENGTH_SHORT).show(); return; } final Dialog.OnClickListener actionListener = (dialog, which) -> { final ChooseTabListItem selected = availableTabs[which]; addTab(selected.tabId); }; new AddTabDialog(requireContext(), availableTabs, actionListener) .show(); }); } private void addTab(final Tab tab) { tabList.add(tab); selectedTabsAdapter.notifyDataSetChanged(); } private void addTab(final int tabId) { final Tab.Type type = typeFrom(tabId); if (type == null) { ErrorUtil.showSnackbar(this, new ErrorInfo(new IllegalStateException("Tab id not found: " + tabId), UserAction.SOMETHING_ELSE, "Choosing tabs on settings")); return; } switch (type) { case KIOSK: final SelectKioskFragment selectKioskFragment = new SelectKioskFragment(); selectKioskFragment.setOnSelectedListener((serviceId, kioskId, kioskName) -> addTab(new Tab.KioskTab(serviceId, kioskId))); selectKioskFragment.show(getParentFragmentManager(), "select_kiosk"); return; case CHANNEL: final SelectChannelFragment selectChannelFragment = new SelectChannelFragment(); selectChannelFragment.setOnSelectedListener((serviceId, url, name) -> addTab(new Tab.ChannelTab(serviceId, url, name))); selectChannelFragment.show(getParentFragmentManager(), "select_channel"); return; case PLAYLIST: final SelectPlaylistFragment selectPlaylistFragment = new SelectPlaylistFragment(); selectPlaylistFragment.setOnSelectedListener( new SelectPlaylistFragment.OnSelectedListener() { @Override public void onLocalPlaylistSelected(final long id, final String name) { addTab(new Tab.PlaylistTab(id, name)); } @Override public void onRemotePlaylistSelected( final int serviceId, final String url, final String name) { addTab(new Tab.PlaylistTab(serviceId, url, name)); } }); selectPlaylistFragment.show(getParentFragmentManager(), "select_playlist"); return; case FEEDGROUP: final SelectFeedGroupFragment selectFeedGroupFragment = new SelectFeedGroupFragment(); selectFeedGroupFragment.setOnSelectedListener( (groupId, name, iconId) -> addTab(new Tab.FeedGroupTab(groupId, name, iconId))); selectFeedGroupFragment.show(getParentFragmentManager(), "select_feed_group"); return; default: addTab(type.getTab()); break; } } private ChooseTabListItem[] getAvailableTabs(final Context context) { final ArrayList returnList = new ArrayList<>(); for (final Tab.Type type : Tab.Type.values()) { final Tab tab = type.getTab(); switch (type) { case BLANK: if (!tabList.contains(tab)) { returnList.add(new ChooseTabListItem(tab.getTabId(), getString(R.string.blank_page_summary), tab.getTabIconRes(context))); } break; case KIOSK: returnList.add(new ChooseTabListItem(tab.getTabId(), getString(R.string.kiosk_page_summary), R.drawable.ic_whatshot)); break; case CHANNEL: returnList.add(new ChooseTabListItem(tab.getTabId(), getString(R.string.channel_page_summary), tab.getTabIconRes(context))); break; case DEFAULT_KIOSK: if (!tabList.contains(tab)) { returnList.add(new ChooseTabListItem(tab.getTabId(), getString(R.string.default_kiosk_page_summary), R.drawable.ic_whatshot)); } break; case PLAYLIST: returnList.add(new ChooseTabListItem(tab.getTabId(), getString(R.string.playlist_page_summary), tab.getTabIconRes(context))); break; case FEEDGROUP: returnList.add(new ChooseTabListItem(tab.getTabId(), getString(R.string.feed_group_page_summary), tab.getTabIconRes(context))); break; default: if (!tabList.contains(tab)) { returnList.add(new ChooseTabListItem(context, tab)); } break; } } return returnList.toArray(new ChooseTabListItem[0]); } /*////////////////////////////////////////////////////////////////////////// // List Handling //////////////////////////////////////////////////////////////////////////*/ private ItemTouchHelper.SimpleCallback getItemTouchCallback() { return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, ItemTouchHelper.START | ItemTouchHelper.END) { @Override public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView, final int viewSize, final int viewSizeOutOfBounds, final int totalSize, final long msSinceStartScroll) { final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll); final int minimumAbsVelocity = Math.max(12, Math.abs(standardSpeed)); return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); } @Override public boolean onMove(@NonNull final RecyclerView recyclerView, @NonNull final RecyclerView.ViewHolder source, @NonNull final RecyclerView.ViewHolder target) { if (source.getItemViewType() != target.getItemViewType() || selectedTabsAdapter == null) { return false; } final int sourceIndex = source.getBindingAdapterPosition(); final int targetIndex = target.getBindingAdapterPosition(); selectedTabsAdapter.swapItems(sourceIndex, targetIndex); return true; } @Override public boolean isLongPressDragEnabled() { return false; } @Override public boolean isItemViewSwipeEnabled() { return true; } @Override public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, final int swipeDir) { final int position = viewHolder.getBindingAdapterPosition(); tabList.remove(position); selectedTabsAdapter.notifyItemRemoved(position); if (tabList.isEmpty()) { tabList.add(Tab.Type.BLANK.getTab()); selectedTabsAdapter.notifyItemInserted(0); } } }; } private class SelectedTabsAdapter extends RecyclerView.Adapter { private final LayoutInflater inflater; private final ItemTouchHelper itemTouchHelper; SelectedTabsAdapter(final Context context, final ItemTouchHelper itemTouchHelper) { this.itemTouchHelper = itemTouchHelper; this.inflater = LayoutInflater.from(context); } public void swapItems(final int fromPosition, final int toPosition) { Collections.swap(tabList, fromPosition, toPosition); notifyItemMoved(fromPosition, toPosition); } @NonNull @Override public ChooseTabsFragment.SelectedTabsAdapter.TabViewHolder onCreateViewHolder( @NonNull final ViewGroup parent, final int viewType) { final View view = inflater.inflate(R.layout.list_choose_tabs, parent, false); return new ChooseTabsFragment.SelectedTabsAdapter.TabViewHolder(view); } @Override public void onBindViewHolder( @NonNull final ChooseTabsFragment.SelectedTabsAdapter.TabViewHolder holder, final int position) { holder.bind(position, holder); } @Override public int getItemCount() { return tabList.size(); } class TabViewHolder extends RecyclerView.ViewHolder { private final AppCompatImageView tabIconView; private final TextView tabNameView; private final ImageView handle; TabViewHolder(final View itemView) { super(itemView); tabNameView = itemView.findViewById(R.id.tabName); tabIconView = itemView.findViewById(R.id.tabIcon); handle = itemView.findViewById(R.id.handle); } @SuppressLint("ClickableViewAccessibility") void bind(final int position, final TabViewHolder holder) { handle.setOnTouchListener(getOnTouchListener(holder)); final Tab tab = tabList.get(position); final Tab.Type type = Tab.typeFrom(tab.getTabId()); if (type == null) { return; } tabNameView.setText(getTabName(type, tab)); tabIconView.setImageResource(tab.getTabIconRes(requireContext())); } private String getTabName(@NonNull final Tab.Type type, @NonNull final Tab tab) { switch (type) { case BLANK: return getString(R.string.blank_page_summary); case DEFAULT_KIOSK: return getString(R.string.default_kiosk_page_summary); case KIOSK: return getNameOfServiceById(((Tab.KioskTab) tab).getKioskServiceId()) + "/" + tab.getTabName(requireContext()); case CHANNEL: return getNameOfServiceById(((Tab.ChannelTab) tab).getChannelServiceId()) + "/" + tab.getTabName(requireContext()); case PLAYLIST: final int serviceId = ((Tab.PlaylistTab) tab).getPlaylistServiceId(); final String serviceName = serviceId == -1 ? getString(R.string.local) : getNameOfServiceById(serviceId); return serviceName + "/" + tab.getTabName(requireContext()); case FEEDGROUP: return getString(R.string.feed_groups_header_title) + "/" + ((Tab.FeedGroupTab) tab).getFeedGroupName(); default: return tab.getTabName(requireContext()); } } @SuppressLint("ClickableViewAccessibility") private View.OnTouchListener getOnTouchListener(final RecyclerView.ViewHolder item) { return (view, motionEvent) -> { if (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { if (itemTouchHelper != null && getItemCount() > 1) { itemTouchHelper.startDrag(item); return true; } } return false; }; } } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java ================================================ package org.schabi.newpipe.settings.tabs; import android.content.Context; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import com.grack.nanojson.JsonObject; import com.grack.nanojson.JsonStringWriter; import org.schabi.newpipe.R; import org.schabi.newpipe.database.LocalItem.LocalItemType; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.fragments.BlankFragment; import org.schabi.newpipe.fragments.list.channel.ChannelFragment; import org.schabi.newpipe.fragments.list.kiosk.DefaultKioskFragment; import org.schabi.newpipe.fragments.list.kiosk.KioskFragment; import org.schabi.newpipe.fragments.list.playlist.PlaylistFragment; import org.schabi.newpipe.local.bookmark.BookmarkFragment; import org.schabi.newpipe.local.feed.FeedFragment; import org.schabi.newpipe.local.history.StatisticsPlaylistFragment; import org.schabi.newpipe.local.playlist.LocalPlaylistFragment; import org.schabi.newpipe.local.subscription.SubscriptionFragment; import org.schabi.newpipe.util.KioskTranslator; import org.schabi.newpipe.util.ServiceHelper; import java.util.Objects; public abstract class Tab { private static final String JSON_TAB_ID_KEY = "tab_id"; private static final String NO_NAME = ""; private static final String NO_ID = ""; private static final String NO_URL = ""; Tab() { } Tab(@NonNull final JsonObject jsonObject) { readDataFromJson(jsonObject); } /*////////////////////////////////////////////////////////////////////////// // Tab Handling //////////////////////////////////////////////////////////////////////////*/ @Nullable public static Tab from(@NonNull final JsonObject jsonObject) { final int tabId = jsonObject.getInt(Tab.JSON_TAB_ID_KEY, -1); if (tabId == -1) { return null; } return from(tabId, jsonObject); } @Nullable public static Tab from(final int tabId) { return from(tabId, null); } @Nullable public static Type typeFrom(final int tabId) { for (final Type available : Type.values()) { if (available.getTabId() == tabId) { return available; } } return null; } @Nullable private static Tab from(final int tabId, @Nullable final JsonObject jsonObject) { final Type type = typeFrom(tabId); if (type == null) { return null; } if (jsonObject != null) { switch (type) { case KIOSK: return new KioskTab(jsonObject); case CHANNEL: return new ChannelTab(jsonObject); case PLAYLIST: return new PlaylistTab(jsonObject); case FEEDGROUP: return new FeedGroupTab(jsonObject); } } return type.getTab(); } public abstract int getTabId(); public abstract String getTabName(Context context); @DrawableRes public abstract int getTabIconRes(Context context); /** * Return a instance of the fragment that this tab represent. * * @param context Android app context * @return the fragment this tab represents */ public abstract Fragment getFragment(Context context) throws ExtractionException; @Override public boolean equals(final Object obj) { if (!(obj instanceof Tab)) { return false; } final Tab other = (Tab) obj; return getTabId() == other.getTabId(); } @Override public int hashCode() { return Objects.hashCode(getTabId()); } /*////////////////////////////////////////////////////////////////////////// // JSON Handling //////////////////////////////////////////////////////////////////////////*/ public void writeJsonOn(final JsonStringWriter jsonSink) { jsonSink.object(); jsonSink.value(JSON_TAB_ID_KEY, getTabId()); writeDataToJson(jsonSink); jsonSink.end(); } protected void writeDataToJson(final JsonStringWriter writerSink) { // No-op } protected void readDataFromJson(final JsonObject jsonObject) { // No-op } /*////////////////////////////////////////////////////////////////////////// // Implementations //////////////////////////////////////////////////////////////////////////*/ public enum Type { BLANK(new BlankTab()), DEFAULT_KIOSK(new DefaultKioskTab()), SUBSCRIPTIONS(new SubscriptionsTab()), FEED(new FeedTab()), BOOKMARKS(new BookmarksTab()), HISTORY(new HistoryTab()), KIOSK(new KioskTab()), CHANNEL(new ChannelTab()), PLAYLIST(new PlaylistTab()), FEEDGROUP(new FeedGroupTab()); private final Tab tab; Type(final Tab tab) { this.tab = tab; } public int getTabId() { return tab.getTabId(); } public Tab getTab() { return tab; } } public static class BlankTab extends Tab { public static final int ID = 0; @Override public int getTabId() { return ID; } @Override public String getTabName(final Context context) { // TODO: find a better name for the blank tab (maybe "blank_tab") or replace it with // context.getString(R.string.app_name); return "NewPipe"; // context.getString(R.string.blank_page_summary); } @DrawableRes @Override public int getTabIconRes(final Context context) { return R.drawable.ic_crop_portrait; } @Override public BlankFragment getFragment(final Context context) { return new BlankFragment(); } } public static class SubscriptionsTab extends Tab { public static final int ID = 1; @Override public int getTabId() { return ID; } @Override public String getTabName(final Context context) { return context.getString(R.string.tab_subscriptions); } @DrawableRes @Override public int getTabIconRes(final Context context) { return R.drawable.ic_tv; } @Override public SubscriptionFragment getFragment(final Context context) { return new SubscriptionFragment(); } } public static class FeedTab extends Tab { public static final int ID = 2; @Override public int getTabId() { return ID; } @Override public String getTabName(final Context context) { return context.getString(R.string.fragment_feed_title); } @DrawableRes @Override public int getTabIconRes(final Context context) { return R.drawable.ic_subscriptions; } @Override public FeedFragment getFragment(final Context context) { return new FeedFragment(); } } public static class BookmarksTab extends Tab { public static final int ID = 3; @Override public int getTabId() { return ID; } @Override public String getTabName(final Context context) { return context.getString(R.string.tab_bookmarks); } @DrawableRes @Override public int getTabIconRes(final Context context) { return R.drawable.ic_bookmark; } @Override public BookmarkFragment getFragment(final Context context) { return new BookmarkFragment(); } } public static class HistoryTab extends Tab { public static final int ID = 4; @Override public int getTabId() { return ID; } @Override public String getTabName(final Context context) { return context.getString(R.string.title_activity_history); } @DrawableRes @Override public int getTabIconRes(final Context context) { return R.drawable.ic_history; } @Override public StatisticsPlaylistFragment getFragment(final Context context) { return new StatisticsPlaylistFragment(); } } public static class KioskTab extends Tab { public static final int ID = 5; private static final String JSON_KIOSK_SERVICE_ID_KEY = "service_id"; private static final String JSON_KIOSK_ID_KEY = "kiosk_id"; private int kioskServiceId; private String kioskId; private KioskTab() { this(-1, NO_ID); } public KioskTab(final int kioskServiceId, final String kioskId) { this.kioskServiceId = kioskServiceId; this.kioskId = kioskId; } public KioskTab(final JsonObject jsonObject) { super(jsonObject); } @Override public int getTabId() { return ID; } @Override public String getTabName(final Context context) { return KioskTranslator.getTranslatedKioskName(kioskId, context); } @DrawableRes @Override public int getTabIconRes(final Context context) { final int kioskIcon = KioskTranslator.getKioskIcon(kioskId); if (kioskIcon <= 0) { throw new IllegalStateException("Kiosk ID is not valid: \"" + kioskId + "\""); } return kioskIcon; } @Override public KioskFragment getFragment(final Context context) throws ExtractionException { return KioskFragment.getInstance(kioskServiceId, kioskId); } @Override protected void writeDataToJson(final JsonStringWriter writerSink) { writerSink.value(JSON_KIOSK_SERVICE_ID_KEY, kioskServiceId) .value(JSON_KIOSK_ID_KEY, kioskId); } @Override protected void readDataFromJson(final JsonObject jsonObject) { kioskServiceId = jsonObject.getInt(JSON_KIOSK_SERVICE_ID_KEY, -1); kioskId = jsonObject.getString(JSON_KIOSK_ID_KEY, NO_ID); } @Override public boolean equals(final Object obj) { if (!(obj instanceof KioskTab)) { return false; } final KioskTab other = (KioskTab) obj; return super.equals(obj) && kioskServiceId == other.kioskServiceId && kioskId.equals(other.kioskId); } @Override public int hashCode() { return Objects.hash(getTabId(), kioskServiceId, kioskId); } public int getKioskServiceId() { return kioskServiceId; } public String getKioskId() { return kioskId; } } public static class ChannelTab extends Tab { public static final int ID = 6; private static final String JSON_CHANNEL_SERVICE_ID_KEY = "channel_service_id"; private static final String JSON_CHANNEL_URL_KEY = "channel_url"; private static final String JSON_CHANNEL_NAME_KEY = "channel_name"; private int channelServiceId; private String channelUrl; private String channelName; private ChannelTab() { this(-1, NO_URL, NO_NAME); } public ChannelTab(final int channelServiceId, final String channelUrl, final String channelName) { this.channelServiceId = channelServiceId; this.channelUrl = channelUrl; this.channelName = channelName; } public ChannelTab(final JsonObject jsonObject) { super(jsonObject); } @Override public int getTabId() { return ID; } @Override public String getTabName(final Context context) { return channelName; } @DrawableRes @Override public int getTabIconRes(final Context context) { return R.drawable.ic_tv; } @Override public ChannelFragment getFragment(final Context context) { return ChannelFragment.getInstance(channelServiceId, channelUrl, channelName); } @Override protected void writeDataToJson(final JsonStringWriter writerSink) { writerSink.value(JSON_CHANNEL_SERVICE_ID_KEY, channelServiceId) .value(JSON_CHANNEL_URL_KEY, channelUrl) .value(JSON_CHANNEL_NAME_KEY, channelName); } @Override protected void readDataFromJson(final JsonObject jsonObject) { channelServiceId = jsonObject.getInt(JSON_CHANNEL_SERVICE_ID_KEY, -1); channelUrl = jsonObject.getString(JSON_CHANNEL_URL_KEY, NO_URL); channelName = jsonObject.getString(JSON_CHANNEL_NAME_KEY, NO_NAME); } @Override public boolean equals(final Object obj) { if (!(obj instanceof ChannelTab)) { return false; } final ChannelTab other = (ChannelTab) obj; return super.equals(obj) && channelServiceId == other.channelServiceId && channelUrl.equals(other.channelUrl) && channelName.equals(other.channelName); } @Override public int hashCode() { return Objects.hash(getTabId(), channelServiceId, channelUrl, channelName); } public int getChannelServiceId() { return channelServiceId; } public String getChannelUrl() { return channelUrl; } public String getChannelName() { return channelName; } } public static class DefaultKioskTab extends Tab { public static final int ID = 7; @Override public int getTabId() { return ID; } @Override public String getTabName(final Context context) { return KioskTranslator.getTranslatedKioskName(getDefaultKioskId(context), context); } @DrawableRes @Override public int getTabIconRes(final Context context) { return KioskTranslator.getKioskIcon(getDefaultKioskId(context)); } @Override public DefaultKioskFragment getFragment(final Context context) { return new DefaultKioskFragment(); } private String getDefaultKioskId(final Context context) { final int kioskServiceId = ServiceHelper.getSelectedServiceId(context); String kioskId = ""; try { final StreamingService service = NewPipe.getService(kioskServiceId); kioskId = service.getKioskList().getDefaultKioskId(); } catch (final ExtractionException e) { ErrorUtil.showSnackbar(context, new ErrorInfo(e, UserAction.REQUESTED_KIOSK, "Loading default kiosk for selected service")); } return kioskId; } } public static class PlaylistTab extends Tab { public static final int ID = 8; private static final String JSON_PLAYLIST_SERVICE_ID_KEY = "playlist_service_id"; private static final String JSON_PLAYLIST_URL_KEY = "playlist_url"; private static final String JSON_PLAYLIST_NAME_KEY = "playlist_name"; private static final String JSON_PLAYLIST_ID_KEY = "playlist_id"; private static final String JSON_PLAYLIST_TYPE_KEY = "playlist_type"; private int playlistServiceId; private String playlistUrl; private String playlistName; private long playlistId; private LocalItemType playlistType; private PlaylistTab() { this(-1, NO_NAME); } public PlaylistTab(final long playlistId, final String playlistName) { this.playlistName = playlistName; this.playlistId = playlistId; this.playlistType = LocalItemType.PLAYLIST_LOCAL_ITEM; this.playlistServiceId = -1; this.playlistUrl = NO_URL; } public PlaylistTab(final int playlistServiceId, final String playlistUrl, final String playlistName) { this.playlistServiceId = playlistServiceId; this.playlistUrl = playlistUrl; this.playlistName = playlistName; this.playlistType = LocalItemType.PLAYLIST_REMOTE_ITEM; this.playlistId = -1; } public PlaylistTab(final JsonObject jsonObject) { super(jsonObject); } @Override public int getTabId() { return ID; } @Override public String getTabName(final Context context) { return playlistName; } @DrawableRes @Override public int getTabIconRes(final Context context) { return R.drawable.ic_bookmark; } @Override public Fragment getFragment(final Context context) { if (playlistType == LocalItemType.PLAYLIST_LOCAL_ITEM) { return LocalPlaylistFragment.getInstance(playlistId, playlistName); } else { // playlistType == LocalItemType.PLAYLIST_REMOTE_ITEM return PlaylistFragment.getInstance(playlistServiceId, playlistUrl, playlistName); } } @Override protected void writeDataToJson(final JsonStringWriter writerSink) { writerSink.value(JSON_PLAYLIST_SERVICE_ID_KEY, playlistServiceId) .value(JSON_PLAYLIST_URL_KEY, playlistUrl) .value(JSON_PLAYLIST_NAME_KEY, playlistName) .value(JSON_PLAYLIST_ID_KEY, playlistId) .value(JSON_PLAYLIST_TYPE_KEY, playlistType.toString()); } @Override protected void readDataFromJson(final JsonObject jsonObject) { playlistServiceId = jsonObject.getInt(JSON_PLAYLIST_SERVICE_ID_KEY, -1); playlistUrl = jsonObject.getString(JSON_PLAYLIST_URL_KEY, NO_URL); playlistName = jsonObject.getString(JSON_PLAYLIST_NAME_KEY, NO_NAME); playlistId = jsonObject.getInt(JSON_PLAYLIST_ID_KEY, -1); playlistType = LocalItemType.valueOf( jsonObject.getString(JSON_PLAYLIST_TYPE_KEY, LocalItemType.PLAYLIST_LOCAL_ITEM.toString()) ); } @Override public boolean equals(final Object obj) { if (!(obj instanceof PlaylistTab)) { return false; } final PlaylistTab other = (PlaylistTab) obj; return super.equals(obj) && playlistServiceId == other.playlistServiceId // Remote && playlistId == other.playlistId // Local && playlistUrl.equals(other.playlistUrl) && playlistName.equals(other.playlistName) && playlistType == other.playlistType; } @Override public int hashCode() { return Objects.hash( getTabId(), playlistServiceId, playlistId, playlistUrl, playlistName, playlistType ); } public int getPlaylistServiceId() { return playlistServiceId; } public String getPlaylistUrl() { return playlistUrl; } public String getPlaylistName() { return playlistName; } public long getPlaylistId() { return playlistId; } public LocalItemType getPlaylistType() { return playlistType; } } public static class FeedGroupTab extends Tab { public static final int ID = 9; private static final String JSON_FEED_GROUP_ID_KEY = "feed_group_id"; private static final String JSON_FEED_GROUP_NAME_KEY = "feed_group_name"; private static final String JSON_FEED_GROUP_ICON_KEY = "feed_group_icon"; private Long feedGroupId; private String feedGroupName; private int iconId; private FeedGroupTab() { this((long) -1, NO_NAME, R.drawable.ic_asterisk); } public FeedGroupTab(final Long feedGroupId, final String feedGroupName, final int iconId) { this.feedGroupId = feedGroupId; this.feedGroupName = feedGroupName; this.iconId = iconId; } public FeedGroupTab(final JsonObject jsonObject) { super(jsonObject); } @Override public int getTabId() { return ID; } @Override public String getTabName(final Context context) { return context.getString(R.string.fragment_feed_title); } @DrawableRes @Override public int getTabIconRes(final Context context) { return this.iconId; } @Override public FeedFragment getFragment(final Context context) { return FeedFragment.newInstance(feedGroupId, feedGroupName); } @Override protected void writeDataToJson(final JsonStringWriter writerSink) { writerSink.value(JSON_FEED_GROUP_ID_KEY, feedGroupId) .value(JSON_FEED_GROUP_NAME_KEY, feedGroupName) .value(JSON_FEED_GROUP_ICON_KEY, iconId); } @Override protected void readDataFromJson(final JsonObject jsonObject) { feedGroupId = jsonObject.getLong(JSON_FEED_GROUP_ID_KEY, -1); feedGroupName = jsonObject.getString(JSON_FEED_GROUP_NAME_KEY, NO_NAME); iconId = jsonObject.getInt(JSON_FEED_GROUP_ICON_KEY, R.drawable.ic_asterisk); } @Override public boolean equals(final Object obj) { if (!(obj instanceof FeedGroupTab)) { return false; } final FeedGroupTab other = (FeedGroupTab) obj; return super.equals(obj) && feedGroupId.equals(other.feedGroupId) && feedGroupName.equals(other.feedGroupName) && iconId == other.iconId; } @Override public int hashCode() { return Objects.hash(getTabId(), feedGroupId, feedGroupName, iconId); } public Long getFeedGroupId() { return feedGroupId; } public String getFeedGroupName() { return feedGroupName; } public int getIconId() { return iconId; } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/tabs/TabsJsonHelper.java ================================================ package org.schabi.newpipe.settings.tabs; import androidx.annotation.Nullable; import com.grack.nanojson.JsonArray; import com.grack.nanojson.JsonObject; import com.grack.nanojson.JsonParser; import com.grack.nanojson.JsonParserException; import com.grack.nanojson.JsonStringWriter; import com.grack.nanojson.JsonWriter; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; /** * Class to get a JSON representation of a list of tabs, and the other way around. */ public final class TabsJsonHelper { private static final String JSON_TABS_ARRAY_KEY = "tabs"; private static final List FALLBACK_INITIAL_TABS_LIST = List.of( Tab.Type.DEFAULT_KIOSK.getTab(), Tab.Type.FEED.getTab(), Tab.Type.SUBSCRIPTIONS.getTab(), Tab.Type.BOOKMARKS.getTab()); private TabsJsonHelper() { } /** * Try to reads the passed JSON and returns the list of tabs if no error were encountered. *

* If the JSON is null or empty, or the list of tabs that it represents is empty, the * {@link #getDefaultTabs fallback list} will be returned. *

* Tabs with invalid ids (i.e. not in the {@link Tab.Type} enum) will be ignored. * * @param tabsJson a JSON string got from {@link #getJsonToSave(List)}. * @return a list of {@link Tab tabs}. * @throws InvalidJsonException if the JSON string is not valid */ public static List getTabsFromJson(@Nullable final String tabsJson) throws InvalidJsonException { if (tabsJson == null || tabsJson.isEmpty()) { return getDefaultTabs(); } try { final JsonObject outerJsonObject = JsonParser.object().from(tabsJson); if (!outerJsonObject.has(JSON_TABS_ARRAY_KEY)) { throw new InvalidJsonException("JSON doesn't contain \"" + JSON_TABS_ARRAY_KEY + "\" array"); } final JsonArray tabsArray = outerJsonObject.getArray(JSON_TABS_ARRAY_KEY, null); final var returnTabs = tabsArray.streamAsJsonObjects() .map(Tab::from) .filter(Objects::nonNull) .collect(Collectors.toUnmodifiableList()); return returnTabs.isEmpty() ? getDefaultTabs() : returnTabs; } catch (final JsonParserException e) { throw new InvalidJsonException(e); } } /** * Get a JSON representation from a list of tabs. * * @param tabList a list of {@link Tab tabs}. * @return a JSON string representing the list of tabs */ public static String getJsonToSave(@Nullable final List tabList) { final JsonStringWriter jsonWriter = JsonWriter.string(); jsonWriter.object(); jsonWriter.array(JSON_TABS_ARRAY_KEY); if (tabList != null) { for (final Tab tab : tabList) { tab.writeJsonOn(jsonWriter); } } jsonWriter.end(); jsonWriter.end(); return jsonWriter.done(); } public static List getDefaultTabs() { return FALLBACK_INITIAL_TABS_LIST; } public static final class InvalidJsonException extends Exception { private InvalidJsonException() { super(); } private InvalidJsonException(final String message) { super(message); } private InvalidJsonException(final Throwable cause) { super(cause); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/settings/tabs/TabsManager.java ================================================ package org.schabi.newpipe.settings.tabs; import android.content.Context; import android.content.SharedPreferences; import android.widget.Toast; import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; import java.util.List; public final class TabsManager { private final SharedPreferences sharedPreferences; private final String savedTabsKey; private final Context context; private SavedTabsChangeListener savedTabsChangeListener; private SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener; private TabsManager(final Context context) { this.context = context; this.sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); this.savedTabsKey = context.getString(R.string.saved_tabs_key); } public static TabsManager getManager(final Context context) { return new TabsManager(context); } public List getTabs() { final String savedJson = sharedPreferences.getString(savedTabsKey, null); try { return TabsJsonHelper.getTabsFromJson(savedJson); } catch (final TabsJsonHelper.InvalidJsonException e) { Toast.makeText(context, R.string.saved_tabs_invalid_json, Toast.LENGTH_SHORT).show(); return getDefaultTabs(); } } public void saveTabs(final List tabList) { final String jsonToSave = TabsJsonHelper.getJsonToSave(tabList); sharedPreferences.edit().putString(savedTabsKey, jsonToSave).apply(); } public void resetTabs() { sharedPreferences.edit().remove(savedTabsKey).apply(); } public List getDefaultTabs() { return TabsJsonHelper.getDefaultTabs(); } /*////////////////////////////////////////////////////////////////////////// // Listener //////////////////////////////////////////////////////////////////////////*/ public void setSavedTabsListener(final SavedTabsChangeListener listener) { if (preferenceChangeListener != null) { sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener); } savedTabsChangeListener = listener; preferenceChangeListener = getPreferenceChangeListener(); sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener); } public void unsetSavedTabsListener() { if (preferenceChangeListener != null) { sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener); } preferenceChangeListener = null; savedTabsChangeListener = null; } private SharedPreferences.OnSharedPreferenceChangeListener getPreferenceChangeListener() { return (sp, key) -> { if (savedTabsKey.equals(key) && savedTabsChangeListener != null) { savedTabsChangeListener.onTabsChanged(); } }; } public interface SavedTabsChangeListener { void onTabsChanged(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/streams/DataReader.java ================================================ package org.schabi.newpipe.streams; import org.schabi.newpipe.streams.io.SharpStream; import java.io.EOFException; import java.io.IOException; import java.io.InputStream; /** * @author kapodamy */ public class DataReader { public static final int SHORT_SIZE = 2; public static final int LONG_SIZE = 8; public static final int INTEGER_SIZE = 4; public static final int FLOAT_SIZE = 4; private static final int BUFFER_SIZE = 128 * 1024; // 128 KiB private long position = 0; private final SharpStream stream; private InputStream view; private int viewSize; public DataReader(final SharpStream stream) { this.stream = stream; this.readOffset = this.readBuffer.length; } public long position() { return position; } public int read() throws IOException { if (fillBuffer()) { return -1; } position++; readCount--; return readBuffer[readOffset++] & 0xFF; } public long skipBytes(final long byteAmount) throws IOException { long amount = byteAmount; if (readCount < 0) { return 0; } else if (readCount == 0) { amount = stream.skip(amount); } else { if (readCount > amount) { readCount -= (int) amount; readOffset += (int) amount; } else { amount = readCount + stream.skip(amount - readCount); readCount = 0; readOffset = readBuffer.length; } } position += amount; return amount; } public int readInt() throws IOException { primitiveRead(INTEGER_SIZE); return primitive[0] << 24 | primitive[1] << 16 | primitive[2] << 8 | primitive[3]; } public long readUnsignedInt() throws IOException { final long value = readInt(); return value & 0xffffffffL; } public short readShort() throws IOException { primitiveRead(SHORT_SIZE); return (short) (primitive[0] << 8 | primitive[1]); } public long readLong() throws IOException { primitiveRead(LONG_SIZE); final long high = primitive[0] << 24 | primitive[1] << 16 | primitive[2] << 8 | primitive[3]; final long low = primitive[4] << 24 | primitive[5] << 16 | primitive[6] << 8 | primitive[7]; return high << 32 | low; } public int read(final byte[] buffer) throws IOException { return read(buffer, 0, buffer.length); } public int read(final byte[] buffer, final int off, final int c) throws IOException { int offset = off; int count = c; if (readCount < 0) { return -1; } int total = 0; if (count >= readBuffer.length) { if (readCount > 0) { System.arraycopy(readBuffer, readOffset, buffer, offset, readCount); readOffset += readCount; offset += readCount; count -= readCount; total = readCount; readCount = 0; } total += Math.max(stream.read(buffer, offset, count), 0); } else { while (count > 0 && !fillBuffer()) { final int read = Math.min(readCount, count); System.arraycopy(readBuffer, readOffset, buffer, offset, read); readOffset += read; readCount -= read; offset += read; count -= read; total += read; } } position += total; return total; } public boolean available() { return readCount > 0 || stream.available() > 0; } public void rewind() throws IOException { stream.rewind(); if ((position - viewSize) > 0) { viewSize = 0; // drop view } else { viewSize += position; } position = 0; readOffset = readBuffer.length; readCount = 0; } public boolean canRewind() { return stream.canRewind(); } /** * Wraps this instance of {@code DataReader} into {@code InputStream} * object. Note: Any read in the {@code DataReader} will not modify * (decrease) the view size * * @param size the size of the view * @return the view */ public InputStream getView(final int size) { if (view == null) { view = new InputStream() { @Override public int read() throws IOException { if (viewSize < 1) { return -1; } final int res = DataReader.this.read(); if (res > 0) { viewSize--; } return res; } @Override public int read(final byte[] buffer) throws IOException { return read(buffer, 0, buffer.length); } @Override public int read(final byte[] buffer, final int offset, final int count) throws IOException { if (viewSize < 1) { return -1; } final int res = DataReader.this.read(buffer, offset, Math.min(viewSize, count)); viewSize -= res; return res; } @Override public long skip(final long amount) throws IOException { if (viewSize < 1) { return 0; } final int res = (int) DataReader.this.skipBytes(Math.min(amount, viewSize)); viewSize -= res; return res; } @Override public int available() { return viewSize; } @Override public void close() { viewSize = 0; } @Override public boolean markSupported() { return false; } }; } viewSize = size; return view; } private final short[] primitive = new short[LONG_SIZE]; private void primitiveRead(final int amount) throws IOException { final byte[] buffer = new byte[amount]; final int read = read(buffer, 0, amount); if (read != amount) { throw new EOFException("Truncated stream, missing " + (amount - read) + " bytes"); } for (int i = 0; i < amount; i++) { // the "byte" data type in java is signed and is very annoying primitive[i] = (short) (buffer[i] & 0xFF); } } private final byte[] readBuffer = new byte[BUFFER_SIZE]; private int readOffset; private int readCount; private boolean fillBuffer() throws IOException { if (readCount < 0) { return true; } if (readOffset >= readBuffer.length) { readCount = stream.read(readBuffer); if (readCount < 1) { readCount = -1; return true; } readOffset = 0; } return readCount < 1; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java ================================================ package org.schabi.newpipe.streams; import org.schabi.newpipe.streams.io.SharpStream; import java.io.EOFException; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.NoSuchElementException; /** * @author kapodamy */ public class Mp4DashReader { private static final int ATOM_MOOF = 0x6D6F6F66; private static final int ATOM_MFHD = 0x6D666864; private static final int ATOM_TRAF = 0x74726166; private static final int ATOM_TFHD = 0x74666864; private static final int ATOM_TFDT = 0x74666474; private static final int ATOM_TRUN = 0x7472756E; private static final int ATOM_MDIA = 0x6D646961; private static final int ATOM_FTYP = 0x66747970; private static final int ATOM_SIDX = 0x73696478; private static final int ATOM_MOOV = 0x6D6F6F76; private static final int ATOM_MDAT = 0x6D646174; private static final int ATOM_MVHD = 0x6D766864; private static final int ATOM_TRAK = 0x7472616B; private static final int ATOM_MVEX = 0x6D766578; private static final int ATOM_TREX = 0x74726578; private static final int ATOM_TKHD = 0x746B6864; private static final int ATOM_MFRA = 0x6D667261; private static final int ATOM_MDHD = 0x6D646864; private static final int ATOM_EDTS = 0x65647473; private static final int ATOM_ELST = 0x656C7374; private static final int ATOM_HDLR = 0x68646C72; private static final int ATOM_MINF = 0x6D696E66; private static final int ATOM_DINF = 0x64696E66; private static final int ATOM_STBL = 0x7374626C; private static final int ATOM_STSD = 0x73747364; private static final int ATOM_VMHD = 0x766D6864; private static final int ATOM_SMHD = 0x736D6864; private static final int BRAND_DASH = 0x64617368; private static final int BRAND_ISO5 = 0x69736F35; private static final int HANDLER_VIDE = 0x76696465; private static final int HANDLER_SOUN = 0x736F756E; private static final int HANDLER_SUBT = 0x73756274; private final DataReader stream; private Mp4Track[] tracks = null; private int[] brands = null; private Box box; private Moof moof; private boolean chunkZero = false; private int selectedTrack = -1; private Box backupBox = null; public enum TrackKind { Audio, Video, Subtitles, Other } public Mp4DashReader(final SharpStream source) { this.stream = new DataReader(source); } public void parse() throws IOException, NoSuchElementException { if (selectedTrack > -1) { return; } box = readBox(ATOM_FTYP); brands = parseFtyp(box); switch (brands[0]) { case BRAND_DASH: case BRAND_ISO5:// ¿why not? break; default: throw new NoSuchElementException( "Not a MPEG-4 DASH container, major brand is not 'dash' or 'iso5' is " + boxName(brands[0]) ); } Moov moov = null; int i; while (box.type != ATOM_MOOF) { ensure(box); box = readBox(); switch (box.type) { case ATOM_MOOV: moov = parseMoov(box); break; case ATOM_SIDX: case ATOM_MFRA: break; } } if (moov == null) { throw new IOException("The provided Mp4 doesn't have the 'moov' box"); } tracks = new Mp4Track[moov.trak.length]; for (i = 0; i < tracks.length; i++) { tracks[i] = new Mp4Track(); tracks[i].trak = moov.trak[i]; if (moov.mvexTrex != null) { for (final Trex mvexTrex : moov.mvexTrex) { if (tracks[i].trak.tkhd.trackId == mvexTrex.trackId) { tracks[i].trex = mvexTrex; } } } switch (moov.trak[i].mdia.hdlr.subType) { case HANDLER_VIDE: tracks[i].kind = TrackKind.Video; break; case HANDLER_SOUN: tracks[i].kind = TrackKind.Audio; break; case HANDLER_SUBT: tracks[i].kind = TrackKind.Subtitles; break; default: tracks[i].kind = TrackKind.Other; break; } } backupBox = box; } Mp4Track selectTrack(final int index) { selectedTrack = index; return tracks[index]; } public int[] getBrands() { if (brands == null) { throw new IllegalStateException("Not parsed"); } return brands; } public void rewind() throws IOException { if (!stream.canRewind()) { throw new IOException("The provided stream doesn't allow seek"); } if (box == null) { return; } box = backupBox; chunkZero = false; stream.rewind(); stream.skipBytes(backupBox.offset + (DataReader.INTEGER_SIZE * 2)); } public Mp4Track[] getAvailableTracks() { return tracks; } public Mp4DashChunk getNextChunk(final boolean infoOnly) throws IOException { final Mp4Track track = tracks[selectedTrack]; while (stream.available()) { if (chunkZero) { ensure(box); if (!stream.available()) { break; } box = readBox(); } else { chunkZero = true; } switch (box.type) { case ATOM_MOOF: if (moof != null) { throw new IOException("moof found without mdat"); } moof = parseMoof(box, track.trak.tkhd.trackId); if (moof.traf != null) { if (hasFlag(moof.traf.trun.bFlags, 0x0001)) { moof.traf.trun.dataOffset -= box.size + 8; if (moof.traf.trun.dataOffset < 0) { throw new IOException("trun box has wrong data offset, " + "points outside of concurrent mdat box"); } } if (moof.traf.trun.chunkSize < 1) { if (hasFlag(moof.traf.tfhd.bFlags, 0x10)) { moof.traf.trun.chunkSize = moof.traf.tfhd.defaultSampleSize * moof.traf.trun.entryCount; } else { moof.traf.trun.chunkSize = (int) (box.size - 8); } } if (!hasFlag(moof.traf.trun.bFlags, 0x900) && moof.traf.trun.chunkDuration == 0) { if (hasFlag(moof.traf.tfhd.bFlags, 0x20)) { moof.traf.trun.chunkDuration = moof.traf.tfhd.defaultSampleDuration * moof.traf.trun.entryCount; } } } break; case ATOM_MDAT: if (moof == null) { throw new IOException("mdat found without moof"); } if (moof.traf == null) { moof = null; continue; // find another chunk } final Mp4DashChunk chunk = new Mp4DashChunk(); chunk.moof = moof; if (!infoOnly) { chunk.data = stream.getView(moof.traf.trun.chunkSize); } moof = null; stream.skipBytes(chunk.moof.traf.trun.dataOffset); return chunk; default: } } return null; } public static boolean hasFlag(final int flags, final int mask) { return (flags & mask) == mask; } private String boxName(final Box ref) { return boxName(ref.type); } private String boxName(final int type) { return new String(ByteBuffer.allocate(4).putInt(type).array(), StandardCharsets.UTF_8); } private Box readBox() throws IOException { final Box b = new Box(); b.offset = stream.position(); b.size = stream.readUnsignedInt(); b.type = stream.readInt(); if (b.size == 1) { b.size = stream.readLong(); } return b; } private Box readBox(final int expected) throws IOException { final Box b = readBox(); if (b.type != expected) { throw new NoSuchElementException("expected " + boxName(expected) + " found " + boxName(b)); } return b; } private byte[] readFullBox(final Box ref) throws IOException { // full box reading is limited to 2 GiB, and should be enough final int size = (int) ref.size; final ByteBuffer buffer = ByteBuffer.allocate(size); buffer.putInt(size); buffer.putInt(ref.type); final int read = size - 8; if (stream.read(buffer.array(), 8, read) != read) { throw new EOFException(String.format("EOF reached in box: type=%s offset=%s size=%s", boxName(ref.type), ref.offset, ref.size)); } return buffer.array(); } private void ensure(final Box ref) throws IOException { final long skip = ref.offset + ref.size - stream.position(); if (skip == 0) { return; } else if (skip < 0) { throw new EOFException(String.format( "parser go beyond limits of the box. type=%s offset=%s size=%s position=%s", boxName(ref), ref.offset, ref.size, stream.position() )); } stream.skipBytes((int) skip); } private Box untilBox(final Box ref, final int... expected) throws IOException { Box b; while (stream.position() < (ref.offset + ref.size)) { b = readBox(); for (final int type : expected) { if (b.type == type) { return b; } } ensure(b); } return null; } private Box untilAnyBox(final Box ref) throws IOException { if (stream.position() >= (ref.offset + ref.size)) { return null; } return readBox(); } private Moof parseMoof(final Box ref, final int trackId) throws IOException { final Moof obj = new Moof(); Box b = readBox(ATOM_MFHD); obj.mfhdSequenceNumber = parseMfhd(); ensure(b); while ((b = untilBox(ref, ATOM_TRAF)) != null) { obj.traf = parseTraf(b, trackId); ensure(b); if (obj.traf != null) { return obj; } } return obj; } private int parseMfhd() throws IOException { // version // flags stream.skipBytes(4); return stream.readInt(); } private Traf parseTraf(final Box ref, final int trackId) throws IOException { final Traf traf = new Traf(); Box b = readBox(ATOM_TFHD); traf.tfhd = parseTfhd(trackId); ensure(b); if (traf.tfhd == null) { return null; } b = untilBox(ref, ATOM_TRUN, ATOM_TFDT); if (b.type == ATOM_TFDT) { traf.tfdt = parseTfdt(); ensure(b); b = readBox(ATOM_TRUN); } traf.trun = parseTrun(); ensure(b); return traf; } private Tfhd parseTfhd(final int trackId) throws IOException { final Tfhd obj = new Tfhd(); obj.bFlags = stream.readInt(); obj.trackId = stream.readInt(); if (trackId != -1 && obj.trackId != trackId) { return null; } if (hasFlag(obj.bFlags, 0x01)) { stream.skipBytes(8); } if (hasFlag(obj.bFlags, 0x02)) { stream.skipBytes(4); } if (hasFlag(obj.bFlags, 0x08)) { obj.defaultSampleDuration = stream.readInt(); } if (hasFlag(obj.bFlags, 0x10)) { obj.defaultSampleSize = stream.readInt(); } if (hasFlag(obj.bFlags, 0x20)) { obj.defaultSampleFlags = stream.readInt(); } return obj; } private long parseTfdt() throws IOException { final int version = stream.read(); stream.skipBytes(3); // flags return version == 0 ? stream.readUnsignedInt() : stream.readLong(); } private Trun parseTrun() throws IOException { final Trun obj = new Trun(); obj.bFlags = stream.readInt(); obj.entryCount = stream.readInt(); // unsigned int obj.entriesRowSize = 0; if (hasFlag(obj.bFlags, 0x0100)) { obj.entriesRowSize += 4; } if (hasFlag(obj.bFlags, 0x0200)) { obj.entriesRowSize += 4; } if (hasFlag(obj.bFlags, 0x0400)) { obj.entriesRowSize += 4; } if (hasFlag(obj.bFlags, 0x0800)) { obj.entriesRowSize += 4; } obj.bEntries = new byte[obj.entriesRowSize * obj.entryCount]; if (hasFlag(obj.bFlags, 0x0001)) { obj.dataOffset = stream.readInt(); } if (hasFlag(obj.bFlags, 0x0004)) { obj.bFirstSampleFlags = stream.readInt(); } stream.read(obj.bEntries); for (int i = 0; i < obj.entryCount; i++) { final TrunEntry entry = obj.getEntry(i); if (hasFlag(obj.bFlags, 0x0100)) { obj.chunkDuration += entry.sampleDuration; } if (hasFlag(obj.bFlags, 0x0200)) { obj.chunkSize += entry.sampleSize; } if (hasFlag(obj.bFlags, 0x0800)) { if (!hasFlag(obj.bFlags, 0x0100)) { obj.chunkDuration += entry.sampleCompositionTimeOffset; } } } return obj; } private int[] parseFtyp(final Box ref) throws IOException { int i = 0; final int[] list = new int[(int) ((ref.offset + ref.size - stream.position() - 4) / 4)]; list[i++] = stream.readInt(); // major brand stream.skipBytes(4); // minor version for (; i < list.length; i++) { list[i] = stream.readInt(); // compatible brands } return list; } private Mvhd parseMvhd() throws IOException { final int version = stream.read(); stream.skipBytes(3); // flags // creation entries_time // modification entries_time stream.skipBytes(2 * (version == 0 ? 4 : 8)); final Mvhd obj = new Mvhd(); obj.timeScale = stream.readUnsignedInt(); // chunkDuration stream.skipBytes(version == 0 ? 4 : 8); // rate // volume // reserved // matrix array // predefined stream.skipBytes(76); obj.nextTrackId = stream.readUnsignedInt(); return obj; } private Tkhd parseTkhd() throws IOException { final int version = stream.read(); final Tkhd obj = new Tkhd(); // flags // creation entries_time // modification entries_time stream.skipBytes(3 + (2 * (version == 0 ? 4 : 8))); obj.trackId = stream.readInt(); stream.skipBytes(4); // reserved obj.duration = version == 0 ? stream.readUnsignedInt() : stream.readLong(); stream.skipBytes(2 * 4); // reserved obj.bLayer = stream.readShort(); obj.bAlternateGroup = stream.readShort(); obj.bVolume = stream.readShort(); stream.skipBytes(2); // reserved obj.matrix = new byte[9 * 4]; stream.read(obj.matrix); obj.bWidth = stream.readInt(); obj.bHeight = stream.readInt(); return obj; } private Trak parseTrak(final Box ref) throws IOException { final Trak trak = new Trak(); Box b = readBox(ATOM_TKHD); trak.tkhd = parseTkhd(); ensure(b); while ((b = untilBox(ref, ATOM_MDIA, ATOM_EDTS)) != null) { switch (b.type) { case ATOM_MDIA: trak.mdia = parseMdia(b); break; case ATOM_EDTS: trak.edstElst = parseEdts(b); break; } ensure(b); } return trak; } private Mdia parseMdia(final Box ref) throws IOException { final Mdia obj = new Mdia(); Box b; while ((b = untilBox(ref, ATOM_MDHD, ATOM_HDLR, ATOM_MINF)) != null) { switch (b.type) { case ATOM_MDHD: obj.mdhd = readFullBox(b); // read time scale final ByteBuffer buffer = ByteBuffer.wrap(obj.mdhd); final byte version = buffer.get(8); buffer.position(12 + ((version == 0 ? 4 : 8) * 2)); obj.mdhdTimeScale = buffer.getInt(); break; case ATOM_HDLR: obj.hdlr = parseHdlr(b); break; case ATOM_MINF: obj.minf = parseMinf(b); break; } ensure(b); } return obj; } private Hdlr parseHdlr(final Box ref) throws IOException { // version // flags stream.skipBytes(4); final Hdlr obj = new Hdlr(); obj.bReserved = new byte[12]; obj.type = stream.readInt(); obj.subType = stream.readInt(); stream.read(obj.bReserved); // component name (is a ansi/ascii string) stream.skipBytes((ref.offset + ref.size) - stream.position()); return obj; } private Moov parseMoov(final Box ref) throws IOException { Box b = readBox(ATOM_MVHD); final Moov moov = new Moov(); moov.mvhd = parseMvhd(); ensure(b); final ArrayList tmp = new ArrayList<>((int) moov.mvhd.nextTrackId); while ((b = untilBox(ref, ATOM_TRAK, ATOM_MVEX)) != null) { switch (b.type) { case ATOM_TRAK: tmp.add(parseTrak(b)); break; case ATOM_MVEX: moov.mvexTrex = parseMvex(b, (int) moov.mvhd.nextTrackId); break; } ensure(b); } moov.trak = tmp.toArray(new Trak[0]); return moov; } private Trex[] parseMvex(final Box ref, final int possibleTrackCount) throws IOException { final ArrayList tmp = new ArrayList<>(possibleTrackCount); Box b; while ((b = untilBox(ref, ATOM_TREX)) != null) { tmp.add(parseTrex()); ensure(b); } return tmp.toArray(new Trex[0]); } private Trex parseTrex() throws IOException { // version // flags stream.skipBytes(4); final Trex obj = new Trex(); obj.trackId = stream.readInt(); obj.defaultSampleDescriptionIndex = stream.readInt(); obj.defaultSampleDuration = stream.readInt(); obj.defaultSampleSize = stream.readInt(); obj.defaultSampleFlags = stream.readInt(); return obj; } private Elst parseEdts(final Box ref) throws IOException { final Box b = untilBox(ref, ATOM_ELST); if (b == null) { return null; } final Elst obj = new Elst(); final boolean v1 = stream.read() == 1; stream.skipBytes(3); // flags final int entryCount = stream.readInt(); if (entryCount < 1) { obj.bMediaRate = 0x00010000; // default media rate (1.0) return obj; } if (v1) { stream.skipBytes(DataReader.LONG_SIZE); // segment duration obj.mediaTime = stream.readLong(); // ignore all remain entries stream.skipBytes((entryCount - 1) * (DataReader.LONG_SIZE * 2)); } else { stream.skipBytes(DataReader.INTEGER_SIZE); // segment duration obj.mediaTime = stream.readInt(); } obj.bMediaRate = stream.readInt(); return obj; } private Minf parseMinf(final Box ref) throws IOException { final Minf obj = new Minf(); Box b; while ((b = untilAnyBox(ref)) != null) { switch (b.type) { case ATOM_DINF: obj.dinf = readFullBox(b); break; case ATOM_STBL: obj.stblStsd = parseStbl(b); break; case ATOM_VMHD: case ATOM_SMHD: obj.mhd = readFullBox(b); break; } ensure(b); } return obj; } /** * This only reads the "stsd" box inside. * * @param ref stbl box * @return stsd box inside */ private byte[] parseStbl(final Box ref) throws IOException { final Box b = untilBox(ref, ATOM_STSD); if (b == null) { return new byte[0]; // this never should happens (missing codec startup data) } return readFullBox(b); } static class Box { int type; long offset; long size; } public static class Moof { int mfhdSequenceNumber; public Traf traf; } public static class Traf { public Tfhd tfhd; long tfdt; public Trun trun; } public static class Tfhd { int bFlags; public int trackId; int defaultSampleDuration; int defaultSampleSize; int defaultSampleFlags; } static class TrunEntry { int sampleDuration; int sampleSize; int sampleFlags; int sampleCompositionTimeOffset; boolean hasCompositionTimeOffset; boolean isKeyframe; } public static class Trun { public int chunkDuration; public int chunkSize; public int bFlags; int bFirstSampleFlags; int dataOffset; public int entryCount; byte[] bEntries; int entriesRowSize; public TrunEntry getEntry(final int i) { final ByteBuffer buffer = ByteBuffer.wrap(bEntries, i * entriesRowSize, entriesRowSize); final TrunEntry entry = new TrunEntry(); if (hasFlag(bFlags, 0x0100)) { entry.sampleDuration = buffer.getInt(); } if (hasFlag(bFlags, 0x0200)) { entry.sampleSize = buffer.getInt(); } if (hasFlag(bFlags, 0x0400)) { entry.sampleFlags = buffer.getInt(); } if (hasFlag(bFlags, 0x0800)) { entry.sampleCompositionTimeOffset = buffer.getInt(); } entry.hasCompositionTimeOffset = hasFlag(bFlags, 0x0800); entry.isKeyframe = !hasFlag(entry.sampleFlags, 0x10000); return entry; } public TrunEntry getAbsoluteEntry(final int i, final Tfhd header) { final TrunEntry entry = getEntry(i); if (!hasFlag(bFlags, 0x0100) && hasFlag(header.bFlags, 0x20)) { entry.sampleFlags = header.defaultSampleFlags; } if (!hasFlag(bFlags, 0x0200) && hasFlag(header.bFlags, 0x10)) { entry.sampleSize = header.defaultSampleSize; } if (!hasFlag(bFlags, 0x0100) && hasFlag(header.bFlags, 0x08)) { entry.sampleDuration = header.defaultSampleDuration; } if (i == 0 && hasFlag(bFlags, 0x0004)) { entry.sampleFlags = bFirstSampleFlags; } return entry; } } public static class Tkhd { int trackId; long duration; short bVolume; int bWidth; int bHeight; byte[] matrix; short bLayer; short bAlternateGroup; } public static class Trak { public Tkhd tkhd; public Elst edstElst; public Mdia mdia; } static class Mvhd { long timeScale; long nextTrackId; } static class Moov { Mvhd mvhd; Trak[] trak; Trex[] mvexTrex; } public static class Trex { private int trackId; int defaultSampleDescriptionIndex; int defaultSampleDuration; int defaultSampleSize; int defaultSampleFlags; } public static class Elst { public long mediaTime; public int bMediaRate; } public static class Mdia { public int mdhdTimeScale; public byte[] mdhd; public Hdlr hdlr; public Minf minf; } public static class Hdlr { public int type; public int subType; public byte[] bReserved; } public static class Minf { public byte[] dinf; public byte[] stblStsd; public byte[] mhd; } public static class Mp4Track { public TrackKind kind; public Trak trak; public Trex trex; } public static class Mp4DashChunk { public InputStream data; public Moof moof; private int i = 0; public TrunEntry getNextSampleInfo() { if (i >= moof.traf.trun.entryCount) { return null; } return moof.traf.trun.getAbsoluteEntry(i++, moof.traf.tfhd); } public Mp4DashSample getNextSample() throws IOException { if (data == null) { throw new IllegalStateException("This chunk has info only"); } if (i >= moof.traf.trun.entryCount) { return null; } final Mp4DashSample sample = new Mp4DashSample(); sample.info = moof.traf.trun.getAbsoluteEntry(i++, moof.traf.tfhd); sample.data = new byte[sample.info.sampleSize]; if (data.read(sample.data) != sample.info.sampleSize) { throw new EOFException("EOF reached while reading a sample"); } return sample; } } public static class Mp4DashSample { public TrunEntry info; public byte[] data; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java ================================================ package org.schabi.newpipe.streams; import org.schabi.newpipe.streams.Mp4DashReader.Hdlr; import org.schabi.newpipe.streams.Mp4DashReader.Mdia; import org.schabi.newpipe.streams.Mp4DashReader.Mp4DashChunk; import org.schabi.newpipe.streams.Mp4DashReader.Mp4DashSample; import org.schabi.newpipe.streams.Mp4DashReader.Mp4Track; import org.schabi.newpipe.streams.Mp4DashReader.TrackKind; import org.schabi.newpipe.streams.Mp4DashReader.TrunEntry; import org.schabi.newpipe.streams.io.SharpStream; import java.io.IOException; import java.nio.ByteBuffer; import java.util.ArrayList; /** * @author kapodamy */ public class Mp4FromDashWriter { private static final int EPOCH_OFFSET = 2082844800; private static final short DEFAULT_TIMESCALE = 1000; private static final byte SAMPLES_PER_CHUNK_INIT = 2; // ffmpeg uses 2, basic uses 1 (with 60fps uses 21 or 22). NewPipe will use 6 private static final byte SAMPLES_PER_CHUNK = 6; // near 3.999 GiB private static final long THRESHOLD_FOR_CO64 = 0xFFFEFFFFL; // 2.2 MiB enough for: 1080p 60fps 00h35m00s private static final int THRESHOLD_MOOV_LENGTH = (256 * 1024) + (2048 * 1024); private final long time; private ByteBuffer auxBuffer; private SharpStream outStream; private long lastWriteOffset = -1; private long writeOffset; private boolean moovSimulation = true; private boolean done = false; private boolean parsed = false; private Mp4Track[] tracks; private SharpStream[] sourceTracks; private Mp4DashReader[] readers; private Mp4DashChunk[] readersChunks; private int overrideMainBrand = 0x00; private final ArrayList compatibleBrands = new ArrayList<>(5); public Mp4FromDashWriter(final SharpStream... sources) throws IOException { for (final SharpStream src : sources) { if (!src.canRewind() && !src.canRead()) { throw new IOException("All sources must be readable and allow rewind"); } } sourceTracks = sources; readers = new Mp4DashReader[sourceTracks.length]; readersChunks = new Mp4DashChunk[readers.length]; time = (System.currentTimeMillis() / 1000L) + EPOCH_OFFSET; compatibleBrands.add(0x6D703431); // mp41 compatibleBrands.add(0x69736F6D); // isom compatibleBrands.add(0x69736F32); // iso2 } public Mp4Track[] getTracksFromSource(final int sourceIndex) throws IllegalStateException { if (!parsed) { throw new IllegalStateException("All sources must be parsed first"); } return readers[sourceIndex].getAvailableTracks(); } public void parseSources() throws IOException, IllegalStateException { if (done) { throw new IllegalStateException("already done"); } if (parsed) { throw new IllegalStateException("already parsed"); } try { for (int i = 0; i < readers.length; i++) { readers[i] = new Mp4DashReader(sourceTracks[i]); readers[i].parse(); } } finally { parsed = true; } } public void selectTracks(final int... trackIndex) throws IOException { if (done) { throw new IOException("already done"); } if (tracks != null) { throw new IOException("tracks already selected"); } try { tracks = new Mp4Track[readers.length]; for (int i = 0; i < readers.length; i++) { tracks[i] = readers[i].selectTrack(trackIndex[i]); } } finally { parsed = true; } } public void setMainBrand(final int brand) { overrideMainBrand = brand; } public boolean isDone() { return done; } public boolean isParsed() { return parsed; } public void close() throws IOException { done = true; parsed = true; for (final SharpStream src : sourceTracks) { src.close(); } tracks = null; sourceTracks = null; readers = null; readersChunks = null; auxBuffer = null; outStream = null; } @SuppressWarnings("MethodLength") public void build(final SharpStream output) throws IOException { if (done) { throw new RuntimeException("already done"); } if (!output.canWrite()) { throw new IOException("the provided output is not writable"); } // // WARNING: the muxer requires at least 8 samples of every track // not allowed for very short tracks (less than 0.5 seconds) // outStream = output; long read = 8; // mdat box header size long totalSampleSize = 0; final int[] sampleExtra = new int[readers.length]; final int[] defaultMediaTime = new int[readers.length]; final int[] defaultSampleDuration = new int[readers.length]; final int[] sampleCount = new int[readers.length]; final TablesInfo[] tablesInfo = new TablesInfo[tracks.length]; for (int i = 0; i < tablesInfo.length; i++) { tablesInfo[i] = new TablesInfo(); } final int singleSampleBuffer; if (tracks.length == 1 && tracks[0].kind == TrackKind.Audio) { // near 1 second of audio data per chunk, avoid split the audio stream in large chunks singleSampleBuffer = tracks[0].trak.mdia.mdhdTimeScale / 1000; } else { singleSampleBuffer = -1; } for (int i = 0; i < readers.length; i++) { int samplesSize = 0; int sampleSizeChanges = 0; int compositionOffsetLast = -1; Mp4DashChunk chunk; while ((chunk = readers[i].getNextChunk(true)) != null) { if (defaultMediaTime[i] < 1 && chunk.moof.traf.tfhd.defaultSampleDuration > 0) { defaultMediaTime[i] = chunk.moof.traf.tfhd.defaultSampleDuration; } read += chunk.moof.traf.trun.chunkSize; sampleExtra[i] += chunk.moof.traf.trun.chunkDuration; // calculate track duration TrunEntry info; while ((info = chunk.getNextSampleInfo()) != null) { if (info.isKeyframe) { tablesInfo[i].stss++; } if (info.sampleDuration > defaultSampleDuration[i]) { defaultSampleDuration[i] = info.sampleDuration; } tablesInfo[i].stsz++; if (samplesSize != info.sampleSize) { samplesSize = info.sampleSize; sampleSizeChanges++; } if (info.hasCompositionTimeOffset) { if (info.sampleCompositionTimeOffset != compositionOffsetLast) { tablesInfo[i].ctts++; compositionOffsetLast = info.sampleCompositionTimeOffset; } } totalSampleSize += info.sampleSize; } } if (defaultMediaTime[i] < 1) { defaultMediaTime[i] = defaultSampleDuration[i]; } readers[i].rewind(); if (singleSampleBuffer > 0) { initChunkTables(tablesInfo[i], singleSampleBuffer, singleSampleBuffer); } else { initChunkTables(tablesInfo[i], SAMPLES_PER_CHUNK_INIT, SAMPLES_PER_CHUNK); } sampleCount[i] = tablesInfo[i].stsz; if (sampleSizeChanges == 1) { tablesInfo[i].stsz = 0; tablesInfo[i].stszDefault = samplesSize; } else { tablesInfo[i].stszDefault = 0; } if (tablesInfo[i].stss == tablesInfo[i].stsz) { tablesInfo[i].stss = -1; // for audio tracks (all samples are keyframes) } // ensure track duration if (tracks[i].trak.tkhd.duration < 1) { tracks[i].trak.tkhd.duration = sampleExtra[i]; // this never should happen } } final boolean is64 = read > THRESHOLD_FOR_CO64; // calculate the moov size final int auxSize = makeMoov(defaultMediaTime, tablesInfo, is64); if (auxSize < THRESHOLD_MOOV_LENGTH) { auxBuffer = ByteBuffer.allocate(auxSize); // cache moov in the memory } moovSimulation = false; writeOffset = 0; final int ftypSize = makeFtyp(); // reserve moov space in the output stream if (auxSize > 0) { int length = auxSize; final byte[] buffer = new byte[64 * 1024]; // 64 KiB while (length > 0) { final int count = Math.min(length, buffer.length); outWrite(buffer, count); length -= count; } } if (auxBuffer == null) { outSeek(ftypSize); } // tablesInfo contains row counts // and after returning from makeMoov() will contain those table offsets makeMoov(defaultMediaTime, tablesInfo, is64); // write tables: stts stsc sbgp // reset for ctts table: sampleCount sampleExtra for (int i = 0; i < readers.length; i++) { writeEntryArray(tablesInfo[i].stts, 2, sampleCount[i], defaultSampleDuration[i]); writeEntryArray(tablesInfo[i].stsc, tablesInfo[i].stscBEntries.length, tablesInfo[i].stscBEntries); tablesInfo[i].stscBEntries = null; if (tablesInfo[i].ctts > 0) { sampleCount[i] = 1; // the index is not base zero sampleExtra[i] = -1; } if (tablesInfo[i].sbgp > 0) { writeEntryArray(tablesInfo[i].sbgp, 1, sampleCount[i]); } } if (auxBuffer == null) { outRestore(); } outWrite(makeMdat(totalSampleSize, is64)); final int[] sampleIndex = new int[readers.length]; final int[] sizes = new int[singleSampleBuffer > 0 ? singleSampleBuffer : SAMPLES_PER_CHUNK]; final int[] sync = new int[singleSampleBuffer > 0 ? singleSampleBuffer : SAMPLES_PER_CHUNK]; int written = readers.length; while (written > 0) { written = 0; for (int i = 0; i < readers.length; i++) { if (sampleIndex[i] < 0) { continue; // track is done } final long chunkOffset = writeOffset; int syncCount = 0; final int limit; if (singleSampleBuffer > 0) { limit = singleSampleBuffer; } else { limit = sampleIndex[i] == 0 ? SAMPLES_PER_CHUNK_INIT : SAMPLES_PER_CHUNK; } int j = 0; for (; j < limit; j++) { final Mp4DashSample sample = getNextSample(i); if (sample == null) { if (tablesInfo[i].ctts > 0 && sampleExtra[i] >= 0) { writeEntryArray(tablesInfo[i].ctts, 1, sampleCount[i], sampleExtra[i]); // flush last entries outRestore(); } sampleIndex[i] = -1; break; } sampleIndex[i]++; if (tablesInfo[i].ctts > 0) { if (sample.info.sampleCompositionTimeOffset == sampleExtra[i]) { sampleCount[i]++; } else { if (sampleExtra[i] >= 0) { tablesInfo[i].ctts = writeEntryArray(tablesInfo[i].ctts, 2, sampleCount[i], sampleExtra[i]); outRestore(); } sampleCount[i] = 1; sampleExtra[i] = sample.info.sampleCompositionTimeOffset; } } if (tablesInfo[i].stss > 0 && sample.info.isKeyframe) { sync[syncCount++] = sampleIndex[i]; } if (tablesInfo[i].stsz > 0) { sizes[j] = sample.data.length; } outWrite(sample.data, sample.data.length); } if (j > 0) { written++; if (tablesInfo[i].stsz > 0) { tablesInfo[i].stsz = writeEntryArray(tablesInfo[i].stsz, j, sizes); } if (syncCount > 0) { tablesInfo[i].stss = writeEntryArray(tablesInfo[i].stss, syncCount, sync); } if (tablesInfo[i].stco > 0) { if (is64) { tablesInfo[i].stco = writeEntry64(tablesInfo[i].stco, chunkOffset); } else { tablesInfo[i].stco = writeEntryArray(tablesInfo[i].stco, 1, (int) chunkOffset); } } outRestore(); } } } if (auxBuffer != null) { // dump moov outSeek(ftypSize); outStream.write(auxBuffer.array(), 0, auxBuffer.capacity()); auxBuffer = null; } } private Mp4DashSample getNextSample(final int track) throws IOException { if (readersChunks[track] == null) { readersChunks[track] = readers[track].getNextChunk(false); if (readersChunks[track] == null) { return null; // EOF reached } } final Mp4DashSample sample = readersChunks[track].getNextSample(); if (sample == null) { readersChunks[track] = null; return getNextSample(track); } else { return sample; } } private int writeEntry64(final int offset, final long value) throws IOException { outBackup(); auxSeek(offset); auxWrite(ByteBuffer.allocate(8).putLong(value).array()); return offset + 8; } private int writeEntryArray(final int offset, final int count, final int... values) throws IOException { outBackup(); auxSeek(offset); final int size = count * 4; final ByteBuffer buffer = ByteBuffer.allocate(size); for (int i = 0; i < count; i++) { buffer.putInt(values[i]); } auxWrite(buffer.array()); return offset + size; } private void outBackup() { if (auxBuffer == null && lastWriteOffset < 0) { lastWriteOffset = writeOffset; } } /** * Restore to the previous position before the first call to writeEntry64() * or writeEntryArray() methods. */ private void outRestore() throws IOException { if (lastWriteOffset > 0) { outSeek(lastWriteOffset); lastWriteOffset = -1; } } private void initChunkTables(final TablesInfo tables, final int firstCount, final int successiveCount) { // tables.stsz holds amount of samples of the track (total) final int totalSamples = (tables.stsz - firstCount); final float chunkAmount = totalSamples / (float) successiveCount; final int remainChunkOffset = (int) Math.ceil(chunkAmount); final boolean remain = remainChunkOffset != (int) chunkAmount; int index = 0; tables.stsc = 1; if (firstCount != successiveCount) { tables.stsc++; } if (remain) { tables.stsc++; } // stsc_table_entry = [first_chunk, samples_per_chunk, sample_description_index] tables.stscBEntries = new int[tables.stsc * 3]; tables.stco = remainChunkOffset + 1; // total entries in chunk offset box tables.stscBEntries[index++] = 1; tables.stscBEntries[index++] = firstCount; tables.stscBEntries[index++] = 1; if (firstCount != successiveCount) { tables.stscBEntries[index++] = 2; tables.stscBEntries[index++] = successiveCount; tables.stscBEntries[index++] = 1; } if (remain) { tables.stscBEntries[index++] = remainChunkOffset + 1; tables.stscBEntries[index++] = totalSamples % successiveCount; tables.stscBEntries[index] = 1; } } private void outWrite(final byte[] buffer) throws IOException { outWrite(buffer, buffer.length); } private void outWrite(final byte[] buffer, final int count) throws IOException { writeOffset += count; outStream.write(buffer, 0, count); } private void outSeek(final long offset) throws IOException { if (outStream.canSeek()) { outStream.seek(offset); writeOffset = offset; } else if (outStream.canRewind()) { outStream.rewind(); writeOffset = 0; outSkip(offset); } else { throw new IOException("cannot seek or rewind the output stream"); } } private void outSkip(final long amount) throws IOException { outStream.skip(amount); writeOffset += amount; } private int lengthFor(final int offset) throws IOException { final int size = auxOffset() - offset; if (moovSimulation) { return size; } auxSeek(offset); auxWrite(size); auxSkip(size - 4); return size; } private int make(final int type, final int extra, final int columns, final int rows) throws IOException { final byte base = 16; final int size = columns * rows * 4; int total = size + base; int offset = auxOffset(); if (extra >= 0) { total += 4; } auxWrite(ByteBuffer.allocate(12) .putInt(total) .putInt(type) .putInt(0x00)// default version & flags .array() ); if (extra >= 0) { offset += 4; auxWrite(extra); } auxWrite(rows); auxSkip(size); return offset + base; } private void auxWrite(final int value) throws IOException { auxWrite(ByteBuffer.allocate(4) .putInt(value) .array() ); } private void auxWrite(final byte[] buffer) throws IOException { if (moovSimulation) { writeOffset += buffer.length; } else if (auxBuffer == null) { outWrite(buffer, buffer.length); } else { auxBuffer.put(buffer); } } private void auxSeek(final int offset) throws IOException { if (moovSimulation) { writeOffset = offset; } else if (auxBuffer == null) { outSeek(offset); } else { auxBuffer.position(offset); } } private void auxSkip(final int amount) throws IOException { if (moovSimulation) { writeOffset += amount; } else if (auxBuffer == null) { outSkip(amount); } else { auxBuffer.position(auxBuffer.position() + amount); } } private int auxOffset() { return auxBuffer == null ? (int) writeOffset : auxBuffer.position(); } private int makeFtyp() throws IOException { int size = 16 + (compatibleBrands.size() * 4); if (overrideMainBrand != 0) { size += 4; } final ByteBuffer buffer = ByteBuffer.allocate(size); buffer.putInt(size); buffer.putInt(0x66747970); // "ftyp" if (overrideMainBrand == 0) { buffer.putInt(0x6D703432); // mayor brand "mp42" buffer.putInt(512); // default minor version } else { buffer.putInt(overrideMainBrand); buffer.putInt(0); buffer.putInt(0x6D703432); // "mp42" compatible brand } for (final Integer brand : compatibleBrands) { buffer.putInt(brand); // compatible brand } outWrite(buffer.array()); return size; } private byte[] makeMdat(final long refSize, final boolean is64) { long size = refSize; if (is64) { size += 16; } else { size += 8; } final ByteBuffer buffer = ByteBuffer.allocate(is64 ? 16 : 8) .putInt(is64 ? 0x01 : (int) size) .putInt(0x6D646174); // mdat if (is64) { buffer.putLong(size); } return buffer.array(); } private void makeMvhd(final long longestTrack) throws IOException { auxWrite(new byte[]{ 0x00, 0x00, 0x00, 0x78, 0x6D, 0x76, 0x68, 0x64, 0x01, 0x00, 0x00, 0x00 }); auxWrite(ByteBuffer.allocate(28) .putLong(time) .putLong(time) .putInt(DEFAULT_TIMESCALE) .putLong(longestTrack) .array() ); auxWrite(new byte[]{ 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, // default volume and rate 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // reserved values // default matrix 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, 0x00 }); auxWrite(new byte[24]); // predefined auxWrite(ByteBuffer.allocate(4) .putInt(tracks.length + 1) .array() ); } private int makeMoov(final int[] defaultMediaTime, final TablesInfo[] tablesInfo, final boolean is64) throws RuntimeException, IOException { final int start = auxOffset(); auxWrite(new byte[]{ 0x00, 0x00, 0x00, 0x00, 0x6D, 0x6F, 0x6F, 0x76 }); long longestTrack = 0; final long[] durations = new long[tracks.length]; for (int i = 0; i < durations.length; i++) { durations[i] = (long) Math.ceil( ((double) tracks[i].trak.tkhd.duration / tracks[i].trak.mdia.mdhdTimeScale) * DEFAULT_TIMESCALE); if (durations[i] > longestTrack) { longestTrack = durations[i]; } } makeMvhd(longestTrack); for (int i = 0; i < tracks.length; i++) { if (tracks[i].trak.tkhd.matrix.length != 36) { throw new RuntimeException("bad track matrix length (expected 36) in track n°" + i); } makeTrak(i, durations[i], defaultMediaTime[i], tablesInfo[i], is64); } return lengthFor(start); } private void makeTrak(final int index, final long duration, final int defaultMediaTime, final TablesInfo tables, final boolean is64) throws IOException { final int start = auxOffset(); auxWrite(new byte[]{ // trak header 0x00, 0x00, 0x00, 0x00, 0x74, 0x72, 0x61, 0x6B, // tkhd header 0x00, 0x00, 0x00, 0x68, 0x74, 0x6B, 0x68, 0x64, 0x01, 0x00, 0x00, 0x03 }); final ByteBuffer buffer = ByteBuffer.allocate(48); buffer.putLong(time); buffer.putLong(time); buffer.putInt(index + 1); buffer.position(24); buffer.putLong(duration); buffer.position(40); buffer.putShort(tracks[index].trak.tkhd.bLayer); buffer.putShort(tracks[index].trak.tkhd.bAlternateGroup); buffer.putShort(tracks[index].trak.tkhd.bVolume); auxWrite(buffer.array()); auxWrite(tracks[index].trak.tkhd.matrix); auxWrite(ByteBuffer.allocate(8) .putInt(tracks[index].trak.tkhd.bWidth) .putInt(tracks[index].trak.tkhd.bHeight) .array() ); auxWrite(new byte[]{ 0x00, 0x00, 0x00, 0x24, 0x65, 0x64, 0x74, 0x73, // edts header 0x00, 0x00, 0x00, 0x1C, 0x65, 0x6C, 0x73, 0x74, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01 // elst header }); final int bMediaRate; final int mediaTime; if (tracks[index].trak.edstElst == null) { // is a audio track ¿is edst/elst optional for audio tracks? mediaTime = 0x00; // ffmpeg set this value as zero, instead of defaultMediaTime bMediaRate = 0x00010000; } else { mediaTime = (int) tracks[index].trak.edstElst.mediaTime; bMediaRate = tracks[index].trak.edstElst.bMediaRate; } auxWrite(ByteBuffer .allocate(12) .putInt((int) duration) .putInt(mediaTime) .putInt(bMediaRate) .array() ); makeMdia(tracks[index].trak.mdia, tables, is64, tracks[index].kind == TrackKind.Audio); lengthFor(start); } private void makeMdia(final Mdia mdia, final TablesInfo tablesInfo, final boolean is64, final boolean isAudio) throws IOException { final int startMdia = auxOffset(); auxWrite(new byte[]{0x00, 0x00, 0x00, 0x00, 0x6D, 0x64, 0x69, 0x61}); // mdia auxWrite(mdia.mdhd); auxWrite(makeHdlr(mdia.hdlr)); final int startMinf = auxOffset(); auxWrite(new byte[]{0x00, 0x00, 0x00, 0x00, 0x6D, 0x69, 0x6E, 0x66}); // minf auxWrite(mdia.minf.mhd); auxWrite(mdia.minf.dinf); final int startStbl = auxOffset(); auxWrite(new byte[]{0x00, 0x00, 0x00, 0x00, 0x73, 0x74, 0x62, 0x6C}); // stbl auxWrite(mdia.minf.stblStsd); // // In audio tracks the following tables is not required: ssts ctts // And stsz can be empty if has a default sample size // if (moovSimulation) { make(0x73747473, -1, 2, 1); // stts if (tablesInfo.stss > 0) { make(0x73747373, -1, 1, tablesInfo.stss); } if (tablesInfo.ctts > 0) { make(0x63747473, -1, 2, tablesInfo.ctts); } make(0x73747363, -1, 3, tablesInfo.stsc); make(0x7374737A, tablesInfo.stszDefault, 1, tablesInfo.stsz); make(is64 ? 0x636F3634 : 0x7374636F, -1, is64 ? 2 : 1, tablesInfo.stco); } else { tablesInfo.stts = make(0x73747473, -1, 2, 1); if (tablesInfo.stss > 0) { tablesInfo.stss = make(0x73747373, -1, 1, tablesInfo.stss); } if (tablesInfo.ctts > 0) { tablesInfo.ctts = make(0x63747473, -1, 2, tablesInfo.ctts); } tablesInfo.stsc = make(0x73747363, -1, 3, tablesInfo.stsc); tablesInfo.stsz = make(0x7374737A, tablesInfo.stszDefault, 1, tablesInfo.stsz); tablesInfo.stco = make(is64 ? 0x636F3634 : 0x7374636F, -1, is64 ? 2 : 1, tablesInfo.stco); } if (isAudio) { auxWrite(makeSgpd()); tablesInfo.sbgp = makeSbgp(); // during simulation the returned offset is ignored } lengthFor(startStbl); lengthFor(startMinf); lengthFor(startMdia); } private byte[] makeHdlr(final Hdlr hdlr) { final ByteBuffer buffer = ByteBuffer.wrap(new byte[]{ 0x00, 0x00, 0x00, 0x21, 0x68, 0x64, 0x6C, 0x72, // hdlr 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00// null string character }); buffer.position(12); buffer.putInt(hdlr.type); buffer.putInt(hdlr.subType); buffer.put(hdlr.bReserved); // always is a zero array return buffer.array(); } private int makeSbgp() throws IOException { final int offset = auxOffset(); auxWrite(new byte[] { 0x00, 0x00, 0x00, 0x1C, // box size 0x73, 0x62, 0x67, 0x70, // "sbpg" 0x00, 0x00, 0x00, 0x00, // default box flags 0x72, 0x6F, 0x6C, 0x6C, // group type "roll" 0x00, 0x00, 0x00, 0x01, // group table size 0x00, 0x00, 0x00, 0x00, // group[0] total samples (to be set later) 0x00, 0x00, 0x00, 0x01 // group[0] description index }); return offset + 0x14; } private byte[] makeSgpd() { /* * Sample Group Description Box * * ¿whats does? * the table inside of this box gives information about the * characteristics of sample groups. The descriptive information is any other * information needed to define or characterize the sample group. * * ¿is replicable this box? * NO due lacks of documentation about this box but... * most of m4a encoders and ffmpeg uses this box with dummy values (same values) */ final ByteBuffer buffer = ByteBuffer.wrap(new byte[] { 0x00, 0x00, 0x00, 0x1A, // box size 0x73, 0x67, 0x70, 0x64, // "sgpd" 0x01, 0x00, 0x00, 0x00, // box flags (unknown flag sets) 0x72, 0x6F, 0x6C, 0x6C, // ¿¿group type?? 0x00, 0x00, 0x00, 0x02, // ¿¿?? 0x00, 0x00, 0x00, 0x01, // ¿¿?? (byte) 0xFF, (byte) 0xFF // ¿¿?? }); return buffer.array(); } static class TablesInfo { int stts; int stsc; int[] stscBEntries; int ctts; int stsz; int stszDefault; int stss; int stco; int sbgp; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java ================================================ package org.schabi.newpipe.streams; import static org.schabi.newpipe.MainActivity.DEBUG; import android.util.Log; import android.util.Pair; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.streams.WebMReader.Cluster; import org.schabi.newpipe.streams.WebMReader.Segment; import org.schabi.newpipe.streams.WebMReader.SimpleBlock; import org.schabi.newpipe.streams.WebMReader.WebMTrack; import org.schabi.newpipe.streams.io.SharpStream; import java.io.Closeable; import java.io.IOException; import java.nio.ByteBuffer; import java.nio.ByteOrder; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; /** * @author kapodamy */ public class OggFromWebMWriter implements Closeable { private static final byte FLAG_UNSET = 0x00; //private static final byte FLAG_CONTINUED = 0x01; private static final byte FLAG_FIRST = 0x02; private static final byte FLAG_LAST = 0x04; private static final byte HEADER_CHECKSUM_OFFSET = 22; private static final byte HEADER_SIZE = 27; private static final int TIME_SCALE_NS = 1000000000; private boolean done = false; private boolean parsed = false; private final SharpStream source; private final SharpStream output; private int sequenceCount = 0; private final int streamId; private byte packetFlag = FLAG_FIRST; private WebMReader webm = null; private WebMTrack webmTrack = null; private Segment webmSegment = null; private Cluster webmCluster = null; private SimpleBlock webmBlock = null; private long webmBlockLastTimecode = 0; private long webmBlockNearDuration = 0; private short segmentTableSize = 0; private final byte[] segmentTable = new byte[255]; private long segmentTableNextTimestamp = TIME_SCALE_NS; private final int[] crc32Table = new int[256]; private final StreamInfo streamInfo; public OggFromWebMWriter(@NonNull final SharpStream source, @NonNull final SharpStream target, @Nullable final StreamInfo streamInfo) { if (!source.canRead() || !source.canRewind()) { throw new IllegalArgumentException("source stream must be readable and allows seeking"); } if (!target.canWrite() || !target.canRewind()) { throw new IllegalArgumentException("output stream must be writable and allows seeking"); } this.source = source; this.output = target; this.streamInfo = streamInfo; this.streamId = (int) System.currentTimeMillis(); populateCrc32Table(); } public boolean isDone() { return done; } public boolean isParsed() { return parsed; } public WebMTrack[] getTracksFromSource() throws IllegalStateException { if (!parsed) { throw new IllegalStateException("source must be parsed first"); } return webm.getAvailableTracks(); } public void parseSource() throws IOException, IllegalStateException { if (done) { throw new IllegalStateException("already done"); } if (parsed) { throw new IllegalStateException("already parsed"); } try { webm = new WebMReader(source); webm.parse(); webmSegment = webm.getNextSegment(); } finally { parsed = true; } } public void selectTrack(final int trackIndex) throws IOException { if (!parsed) { throw new IllegalStateException("source must be parsed first"); } if (done) { throw new IOException("already done"); } if (webmTrack != null) { throw new IOException("tracks already selected"); } switch (webm.getAvailableTracks()[trackIndex].kind) { case Audio: case Video: break; default: throw new UnsupportedOperationException("the track must an audio or video stream"); } try { webmTrack = webm.selectTrack(trackIndex); } finally { parsed = true; } } @Override public void close() throws IOException { done = true; parsed = true; webmTrack = null; webm = null; if (!output.isClosed()) { output.flush(); } source.close(); output.close(); } public void build() throws IOException { final float resolution; SimpleBlock bloq; final ByteBuffer header = ByteBuffer.allocate(27 + (255 * 255)); final ByteBuffer page = ByteBuffer.allocate(64 * 1024); header.order(ByteOrder.LITTLE_ENDIAN); /* step 1: get the amount of frames per seconds */ switch (webmTrack.kind) { case Audio: resolution = getSampleFrequencyFromTrack(webmTrack.bMetadata); if (resolution == 0f) { throw new RuntimeException("cannot get the audio sample rate"); } break; case Video: // WARNING: untested if (webmTrack.defaultDuration == 0) { throw new RuntimeException("missing default frame time"); } resolution = 1000f / ((float) webmTrack.defaultDuration / webmSegment.info.timecodeScale); break; default: throw new RuntimeException("not implemented"); } /* step 2: create packet with code init data */ if (webmTrack.codecPrivate != null) { addPacketSegment(webmTrack.codecPrivate.length); makePacketheader(0x00, header, webmTrack.codecPrivate); write(header); output.write(webmTrack.codecPrivate); } /* step 3: create packet with metadata */ final byte[] buffer = makeMetadata(); if (buffer != null) { addPacketSegment(buffer.length); makePacketheader(0x00, header, buffer); write(header); output.write(buffer); } /* step 4: calculate amount of packets */ while (webmSegment != null) { bloq = getNextBlock(); if (bloq != null && addPacketSegment(bloq)) { final int pos = page.position(); //noinspection ResultOfMethodCallIgnored bloq.data.read(page.array(), pos, bloq.dataSize); page.position(pos + bloq.dataSize); continue; } // calculate the current packet duration using the next block double elapsedNs = webmTrack.codecDelay; if (bloq == null) { packetFlag = FLAG_LAST; // note: if the flag is FLAG_CONTINUED, is changed elapsedNs += webmBlockLastTimecode; if (webmTrack.defaultDuration > 0) { elapsedNs += webmTrack.defaultDuration; } else { // hardcoded way, guess the sample duration elapsedNs += webmBlockNearDuration; } } else { elapsedNs += bloq.absoluteTimeCodeNs; } // get the sample count in the page elapsedNs = elapsedNs / TIME_SCALE_NS; elapsedNs = Math.ceil(elapsedNs * resolution); // create header and calculate page checksum int checksum = makePacketheader((long) elapsedNs, header, null); checksum = calcCrc32(checksum, page.array(), page.position()); header.putInt(HEADER_CHECKSUM_OFFSET, checksum); // dump data write(header); write(page); webmBlock = bloq; } } private int makePacketheader(final long granPos, @NonNull final ByteBuffer buffer, final byte[] immediatePage) { short length = HEADER_SIZE; buffer.putInt(0x5367674f); // "OggS" binary string in little-endian buffer.put((byte) 0x00); // version buffer.put(packetFlag); // type buffer.putLong(granPos); // granulate position buffer.putInt(streamId); // bitstream serial number buffer.putInt(sequenceCount++); // page sequence number buffer.putInt(0x00); // page checksum buffer.put((byte) segmentTableSize); // segment table buffer.put(segmentTable, 0, segmentTableSize); // segment size length += segmentTableSize; clearSegmentTable(); // clear segment table for next header int checksumCrc32 = calcCrc32(0x00, buffer.array(), length); if (immediatePage != null) { checksumCrc32 = calcCrc32(checksumCrc32, immediatePage, immediatePage.length); buffer.putInt(HEADER_CHECKSUM_OFFSET, checksumCrc32); segmentTableNextTimestamp -= TIME_SCALE_NS; } return checksumCrc32; } @Nullable private byte[] makeMetadata() { if (DEBUG) { Log.d("OggFromWebMWriter", "Downloading media with codec ID " + webmTrack.codecId); } if ("A_OPUS".equals(webmTrack.codecId)) { final var metadata = new ArrayList>(); if (streamInfo != null) { metadata.add(Pair.create("COMMENT", streamInfo.getUrl())); metadata.add(Pair.create("GENRE", streamInfo.getCategory())); metadata.add(Pair.create("ARTIST", streamInfo.getUploaderName())); metadata.add(Pair.create("TITLE", streamInfo.getName())); metadata.add(Pair.create("DATE", streamInfo .getUploadDate() .getLocalDateTime() .format(DateTimeFormatter.ISO_DATE))); } if (DEBUG) { Log.d("OggFromWebMWriter", "Creating metadata header with this data:"); metadata.forEach(p -> { Log.d("OggFromWebMWriter", p.first + "=" + p.second); }); } return makeOpusTagsHeader(metadata); } else if ("A_VORBIS".equals(webmTrack.codecId)) { return new byte[]{ 0x03, // ¿¿¿??? 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, // "vorbis" binary string 0x00, 0x00, 0x00, 0x00, // writing application string size (not present) 0x00, 0x00, 0x00, 0x00 // additional tags count (zero means no tags) }; } // not implemented for the desired codec return null; } /** * This creates a single metadata tag for use in opus metadata headers. It contains the four * byte string length field and includes the string as-is. This cannot be used independently, * but must follow a proper "OpusTags" header. * * @param pair A key-value pair in the format "KEY=some value" * @return The binary data of the encoded metadata tag */ private static byte[] makeOpusMetadataTag(final Pair pair) { final var keyValue = pair.first.toUpperCase() + "=" + pair.second.trim(); final var bytes = keyValue.getBytes(); final var buf = ByteBuffer.allocate(4 + bytes.length); buf.order(ByteOrder.LITTLE_ENDIAN); buf.putInt(bytes.length); buf.put(bytes); return buf.array(); } /** * This returns a complete "OpusTags" header, created from the provided metadata tags. *

* You probably want to use makeOpusMetadata(), which uses this function to create * a header with sensible metadata filled in. * * @param keyValueLines A list of pairs of the tags. This can also be though of as a mapping * from one key to multiple values. * @return The binary header */ private static byte[] makeOpusTagsHeader(final List> keyValueLines) { final var tags = keyValueLines .stream() .filter(p -> !p.second.isBlank()) .map(OggFromWebMWriter::makeOpusMetadataTag) .collect(Collectors.toUnmodifiableList()); final var tagsBytes = tags.stream().collect(Collectors.summingInt(arr -> arr.length)); // Fixed header fields + dynamic fields final var byteCount = 16 + tagsBytes; final var head = ByteBuffer.allocate(byteCount); head.order(ByteOrder.LITTLE_ENDIAN); head.put(new byte[]{ 0x4F, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73, // "OpusTags" binary string 0x00, 0x00, 0x00, 0x00, // vendor (aka. Encoder) string of length 0 }); head.putInt(tags.size()); // 4 bytes for tag count tags.forEach(head::put); // dynamic amount of tag bytes return head.array(); } private void write(final ByteBuffer buffer) throws IOException { output.write(buffer.array(), 0, buffer.position()); buffer.position(0); } @Nullable private SimpleBlock getNextBlock() throws IOException { SimpleBlock res; if (webmBlock != null) { res = webmBlock; webmBlock = null; return res; } if (webmSegment == null) { webmSegment = webm.getNextSegment(); if (webmSegment == null) { return null; // no more blocks in the selected track } } if (webmCluster == null) { webmCluster = webmSegment.getNextCluster(); if (webmCluster == null) { webmSegment = null; return getNextBlock(); } } res = webmCluster.getNextSimpleBlock(); if (res == null) { webmCluster = null; return getNextBlock(); } webmBlockNearDuration = res.absoluteTimeCodeNs - webmBlockLastTimecode; webmBlockLastTimecode = res.absoluteTimeCodeNs; return res; } private float getSampleFrequencyFromTrack(final byte[] bMetadata) { // hardcoded way final ByteBuffer buffer = ByteBuffer.wrap(bMetadata); while (buffer.remaining() >= 6) { final int id = buffer.getShort() & 0xFFFF; if (id == 0x0000B584) { return buffer.getFloat(); } } return 0.0f; } private void clearSegmentTable() { segmentTableNextTimestamp += TIME_SCALE_NS; packetFlag = FLAG_UNSET; segmentTableSize = 0; } private boolean addPacketSegment(final SimpleBlock block) { final long timestamp = block.absoluteTimeCodeNs + webmTrack.codecDelay; if (timestamp >= segmentTableNextTimestamp) { return false; } return addPacketSegment(block.dataSize); } private boolean addPacketSegment(final int size) { if (size > 65025) { throw new UnsupportedOperationException("page size cannot be larger than 65025"); } int available = (segmentTable.length - segmentTableSize) * 255; final boolean extra = (size % 255) == 0; if (extra) { // add a zero byte entry in the table // required to indicate the sample size is multiple of 255 available -= 255; } // check if possible add the segment, without overflow the table if (available < size) { return false; // not enough space on the page } for (int seg = size; seg > 0; seg -= 255) { segmentTable[segmentTableSize++] = (byte) Math.min(seg, 255); } if (extra) { segmentTable[segmentTableSize++] = 0x00; } return true; } private void populateCrc32Table() { for (int i = 0; i < 0x100; i++) { int crc = i << 24; for (int j = 0; j < 8; j++) { final long b = crc >>> 31; crc <<= 1; crc ^= (int) (0x100000000L - b) & 0x04c11db7; } crc32Table[i] = crc; } } private int calcCrc32(final int initialCrc, final byte[] buffer, final int size) { int crc = initialCrc; for (int i = 0; i < size; i++) { final int reg = (crc >>> 24) & 0xff; crc = (crc << 8) ^ crc32Table[reg ^ (buffer[i] & 0xff)]; } return crc; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/streams/SrtFromTtmlWriter.java ================================================ package org.schabi.newpipe.streams; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; import org.jsoup.nodes.Node; import org.jsoup.nodes.TextNode; import org.jsoup.parser.Parser; import org.jsoup.select.Elements; import org.schabi.newpipe.streams.io.SharpStream; import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; /** * Converts TTML subtitles to SRT format. * * References: * - TTML 2.0 (W3C): https://www.w3.org/TR/ttml2/ * - SRT format: https://en.wikipedia.org/wiki/SubRip */ public class SrtFromTtmlWriter { private static final String NEW_LINE = "\r\n"; private final SharpStream out; private final boolean ignoreEmptyFrames; private final Charset charset = StandardCharsets.UTF_8; // According to the SubRip (.srt) specification, subtitle // numbering must start from 1. // Some players accept 0 or even negative indices, // but to ensure compliance we start at 1. private int frameIndex = 1; public SrtFromTtmlWriter(final SharpStream out, final boolean ignoreEmptyFrames) { this.out = out; this.ignoreEmptyFrames = ignoreEmptyFrames; } private static String getTimestamp(final Element frame, final String attr) { return frame .attr(attr) .replace('.', ','); // SRT subtitles uses comma as decimal separator } private void writeFrame(final String begin, final String end, final StringBuilder text) throws IOException { writeString(String.valueOf(frameIndex)); frameIndex += 1; writeString(NEW_LINE); writeString(begin); writeString(" --> "); writeString(end); writeString(NEW_LINE); writeString(text.toString()); writeString(NEW_LINE); writeString(NEW_LINE); } private void writeString(final String text) throws IOException { out.write(text.getBytes(charset)); } /** * Decode XML or HTML entities into their actual (literal) characters. * * TTML is XML-based, so text nodes may contain escaped entities * instead of direct characters. For example: * * "&" → "&" * "<" → "<" * ">" → ">" * " " → "\t" (TAB) * " " ( ) → "\n" (LINE FEED) * * XML files cannot contain characters like "<", ">", "&" directly, * so they must be represented using their entity-encoded forms. * * Jsoup sometimes leaves nested or encoded entities unresolved * (e.g. inside

text nodes in TTML files), so this function * acts as a final “safety net” to ensure all entities are decoded * before further normalization. * * Character representation layers for reference: * - Literal characters: <, >, & * → appear in runtime/output text (e.g. final SRT output) * - Escaped entities: <, >, & * → appear in XML/HTML/TTML source files * - Numeric entities:  , , * → appear mainly in XML/TTML files (also valid in HTML) * for non-printable or special characters * - Unicode escapes: \u00A0 (Java/Unicode internal form) * → appear only in Java source code (NOT valid in XML) * * XML entities include both named (&, <) and numeric * ( ,  ) forms. * * @param encodedEntities The raw text fragment possibly containing * encoded XML entities. * @return A decoded string where all entities are replaced by their * actual (literal) characters. */ private String decodeXmlEntities(final String encodedEntities) { return Parser.unescapeEntities(encodedEntities, true); } /** * Handle rare XML entity characters like LF: (`\n`), * CR: (`\r`) and CRLF: (`\r\n`). * * These are technically valid in TTML (XML allows them) * but unusual in practice, since most TTML line breaks * are represented as
tags instead. * As a defensive approach, we normalize them: * * - Windows (\r\n), macOS (\r), and Unix (\n) → unified SRT NEW_LINE (\r\n) * * Although well-formed TTML normally encodes line breaks * as
tags, some auto-generated or malformed TTML files * may embed literal newline entities ( , ). This * normalization ensures these cases render properly in SRT * players instead of breaking the subtitle structure. * * @param text To be normalized text with actual characters. * @return Unified SRT NEW_LINE converted from all kinds of line breaks. */ private String normalizeLineBreakForSrt(final String text) { String cleaned = text; // NOTE: // The order of newline replacements must NOT change, // or duplicated line breaks (e.g. \r\n → \n\n) will occur. cleaned = cleaned.replace("\r\n", "\n") .replace("\r", "\n"); cleaned = cleaned.replace("\n", NEW_LINE); return cleaned; } private String normalizeForSrt(final String actualText) { String cleaned = actualText; // Replace NBSP "non-breaking space" (\u00A0) with regular space ' '(\u0020). // // Why: // - Some viewers render NBSP(\u00A0) incorrectly: // * MPlayer 1.5: shown as “??” // * Linux command `cat -A`: displayed as control-like markers // (M-BM-) // * Acode (Android editor): displayed as visible replacement // glyphs (red dots) // - Other viewers show it as a normal space (e.g., VS Code 1.104.0, // vlc 3.0.20, mpv 0.37.0, Totem 43.0) // → Mixed rendering creates inconsistency and may confuse users. // // Details: // - YouTube TTML subtitles use both regular spaces (\u0020) // and non-breaking spaces (\u00A0). // - SRT subtitles only support regular spaces (\u0020), // so \u00A0 may cause display issues. // - \u00A0 and \u0020 are visually identical (i.e., they both // appear as spaces ' '), but they differ in Unicode encoding, // and NBSP (\u00A0) renders differently in different viewers. // - SRT is a plain-text format and does not interpret // "non-breaking" behavior. // // Conclusion: // - Ensure uniform behavior, so replace it to a regular space // without "non-breaking" behavior. // // References: // - Unicode U+00A0 NBSP (Latin-1 Supplement): // https://unicode.org/charts/PDF/U0080.pdf cleaned = cleaned.replace('\u00A0', ' ') // Non-breaking space .replace('\u202F', ' ') // Narrow no-break space .replace('\u205F', ' ') // Medium mathematical space .replace('\u3000', ' ') // Ideographic space // \u2000 ~ \u200A are whitespace characters (e.g., // en space, em space), replaced with regular space (\u0020). .replaceAll("[\\u2000-\\u200A]", " "); // Whitespace characters // \u200B ~ \u200F are a range of non-spacing characters // (e.g., zero-width space, zero-width non-joiner, etc.), // which have no effect in *.SRT files and may cause // display issues. // These characters are invisible to the human eye, and // they still exist in the encoding, so they need to be // removed. // After removal, the actual content becomes completely // empty "", meaning there are no characters left, just // an empty space, which helps avoid formatting issues // in subtitles. cleaned = cleaned.replaceAll("[\\u200B-\\u200F]", ""); // Non-spacing characters // Remove control characters (\u0000 ~ \u001F, except // \n, \r, \t). // - These are ASCII C0 control codes (e.g. \u0001 SOH, // \u0008 BS, \u001F US), invisible and irrelevant in // subtitles, may cause square boxes (?) in players. // - Reference: // Unicode Basic Latin (https://unicode.org/charts/PDF/U0000.pdf) // ASCII Control (https://en.wikipedia.org/wiki/ASCII#Control_characters) cleaned = cleaned.replaceAll("[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F]", ""); // Reasoning: // - subtitle files generally don't require tabs for alignment. // - Tabs can be displayed with varying widths across different // editors or platforms, which may cause display issues. // - Replace it with a single space for consistent display // across different editors or platforms. cleaned = cleaned.replace('\t', ' '); cleaned = normalizeLineBreakForSrt(cleaned); return cleaned; } private String sanitizeFragment(final String raw) { if (null == raw) { return ""; } final String actualCharacters = decodeXmlEntities(raw); final String srtSafeText = normalizeForSrt(actualCharacters); return srtSafeText; } // Recursively process all child nodes to ensure text inside // nested tags (e.g., ) is also extracted. private void traverseChildNodesForNestedTags(final Node parent, final StringBuilder text) { for (final Node child : parent.childNodes()) { extractText(child, text); } } // CHECKSTYLE:OFF checkstyle:JavadocStyle // checkstyle does not understand that span tags are inside a code block /** *

Recursive method to extract text from all nodes.

*

* This method processes {@link TextNode}s and {@code
} tags, * recursively extracting text from nested tags * (e.g. extracting text from nested {@code } tags). * Newlines are added for {@code
} tags. *

* @param node the current node to process * @param text the {@link StringBuilder} to append the extracted text to */ // -------------------------------------------------------------------- // [INTERNAL NOTE] TTML text layer explanation // // TTML parsing involves multiple text "layers": // 1. Raw XML entities (e.g., <,  ) are decoded by Jsoup. // 2. extractText() works on DOM TextNodes (already parsed strings). // 3. sanitizeFragment() decodes remaining entities and fixes // Unicode quirks. // 4. normalizeForSrt() ensures literal text is safe for SRT output. // // In short: // Jsoup handles XML-level syntax, // our code handles text-level normalization for subtitles. // -------------------------------------------------------------------- private void extractText(final Node node, final StringBuilder text) { if (node instanceof TextNode textNode) { String rawTtmlFragment = textNode.getWholeText(); String srtContent = sanitizeFragment(rawTtmlFragment); text.append(srtContent); } else if (node instanceof Element element) { //
is a self-closing HTML tag used to insert a line break. if (element.tagName().equalsIgnoreCase("br")) { // Add a newline for
tags text.append(NEW_LINE); } } traverseChildNodesForNestedTags(node, text); } // CHECKSTYLE:ON public void build(final SharpStream ttml) throws IOException { /* * TTML parser with BASIC support * multiple CUE is not supported * styling is not supported * tag timestamps (in auto-generated subtitles) are not supported, maybe in the future * also TimestampTagOption enum is not applicable * Language parsing is not supported */ // parse XML final byte[] buffer = new byte[(int) ttml.available()]; ttml.read(buffer); final Document doc = Jsoup.parse(new ByteArrayInputStream(buffer), "UTF-8", "", Parser.xmlParser()); final StringBuilder text = new StringBuilder(128); final Elements paragraphList = doc.select("body > div > p"); // check if has frames if (paragraphList.isEmpty()) { return; } for (final Element paragraph : paragraphList) { text.setLength(0); // Recursively extract text from all child nodes extractText(paragraph, text); if (ignoreEmptyFrames && text.length() < 1) { continue; } final String begin = getTimestamp(paragraph, "begin"); final String end = getTimestamp(paragraph, "end"); writeFrame(begin, end, text); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/streams/WebMReader.java ================================================ package org.schabi.newpipe.streams; import org.schabi.newpipe.streams.io.SharpStream; import java.io.EOFException; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.NoSuchElementException; /** * * @author kapodamy */ public class WebMReader { private static final int ID_EMBL = 0x0A45DFA3; private static final int ID_EMBL_READ_VERSION = 0x02F7; private static final int ID_EMBL_DOC_TYPE = 0x0282; private static final int ID_EMBL_DOC_TYPE_READ_VERSION = 0x0285; private static final int ID_SEGMENT = 0x08538067; private static final int ID_INFO = 0x0549A966; private static final int ID_TIMECODE_SCALE = 0x0AD7B1; private static final int ID_DURATION = 0x489; private static final int ID_TRACKS = 0x0654AE6B; private static final int ID_TRACK_ENTRY = 0x2E; private static final int ID_TRACK_NUMBER = 0x57; private static final int ID_TRACK_TYPE = 0x03; private static final int ID_CODEC_ID = 0x06; private static final int ID_CODEC_PRIVATE = 0x23A2; private static final int ID_VIDEO = 0x60; private static final int ID_AUDIO = 0x61; private static final int ID_DEFAULT_DURATION = 0x3E383; private static final int ID_FLAG_LACING = 0x1C; private static final int ID_CODEC_DELAY = 0x16AA; private static final int ID_SEEK_PRE_ROLL = 0x16BB; private static final int ID_CLUSTER = 0x0F43B675; private static final int ID_TIMECODE = 0x67; private static final int ID_SIMPLE_BLOCK = 0x23; private static final int ID_BLOCK = 0x21; private static final int ID_GROUP_BLOCK = 0x20; public enum TrackKind { Audio/*2*/, Video/*1*/, Other } private final DataReader stream; private Segment segment; private WebMTrack[] tracks; private int selectedTrack; private boolean done; private boolean firstSegment; public WebMReader(final SharpStream source) { this.stream = new DataReader(source); } public void parse() throws IOException { Element elem = readElement(ID_EMBL); if (!readEbml(elem, 1, 2)) { throw new UnsupportedOperationException("Unsupported EBML data (WebM)"); } ensure(elem); elem = untilElement(null, ID_SEGMENT); if (elem == null) { throw new IOException("Fragment element not found"); } segment = readSegment(elem, 0, true); tracks = segment.tracks; selectedTrack = -1; done = false; firstSegment = true; } public WebMTrack[] getAvailableTracks() { return tracks; } public WebMTrack selectTrack(final int index) { selectedTrack = index; return tracks[index]; } public Segment getNextSegment() throws IOException { if (done) { return null; } if (firstSegment && segment != null) { firstSegment = false; return segment; } ensure(segment.ref); // WARNING: track cannot be the same or have different index in new segments final Element elem = untilElement(null, ID_SEGMENT); if (elem == null) { done = true; return null; } segment = readSegment(elem, 0, false); return segment; } private long readNumber(final Element parent) throws IOException { int length = (int) parent.contentSize; long value = 0; while (length-- > 0) { final int read = stream.read(); if (read == -1) { throw new EOFException(); } value = (value << 8) | read; } return value; } private String readString(final Element parent) throws IOException { return new String(readBlob(parent), StandardCharsets.UTF_8); // or use "utf-8" } private byte[] readBlob(final Element parent) throws IOException { final long length = parent.contentSize; final byte[] buffer = new byte[(int) length]; final int read = stream.read(buffer); if (read < length) { throw new EOFException(); } return buffer; } private long readEncodedNumber() throws IOException { int value = stream.read(); if (value > 0) { byte size = 1; int mask = 0x80; while (size < 9) { if ((value & mask) == mask) { mask = 0xFF; mask >>= size; long number = value & mask; for (int i = 1; i < size; i++) { value = stream.read(); number <<= 8; number |= value; } return number; } mask >>= 1; size++; } } throw new IOException("Invalid encoded length"); } private Element readElement() throws IOException { final Element elem = new Element(); elem.offset = stream.position(); elem.type = (int) readEncodedNumber(); elem.contentSize = readEncodedNumber(); elem.size = elem.contentSize + stream.position() - elem.offset; return elem; } private Element readElement(final int expected) throws IOException { final Element elem = readElement(); if (expected != 0 && elem.type != expected) { throw new NoSuchElementException("expected " + elementID(expected) + " found " + elementID(elem.type)); } return elem; } private Element untilElement(final Element ref, final int... expected) throws IOException { Element elem; while (ref == null ? stream.available() : (stream.position() < (ref.offset + ref.size))) { elem = readElement(); if (expected.length < 1) { return elem; } for (final int type : expected) { if (elem.type == type) { return elem; } } ensure(elem); } return null; } private String elementID(final long type) { return "0x".concat(Long.toHexString(type)); } private void ensure(final Element ref) throws IOException { final long skip = (ref.offset + ref.size) - stream.position(); if (skip == 0) { return; } else if (skip < 0) { throw new EOFException(String.format( "parser go beyond limits of the Element. type=%s offset=%s size=%s position=%s", elementID(ref.type), ref.offset, ref.size, stream.position() )); } stream.skipBytes(skip); } private boolean readEbml(final Element ref, final int minReadVersion, final int minDocTypeVersion) throws IOException { Element elem = untilElement(ref, ID_EMBL_READ_VERSION); if (elem == null) { return false; } if (readNumber(elem) > minReadVersion) { return false; } elem = untilElement(ref, ID_EMBL_DOC_TYPE); if (elem == null) { return false; } if (!readString(elem).equals("webm")) { return false; } elem = untilElement(ref, ID_EMBL_DOC_TYPE_READ_VERSION); return elem != null && readNumber(elem) <= minDocTypeVersion; } private Info readInfo(final Element ref) throws IOException { Element elem; final Info info = new Info(); while ((elem = untilElement(ref, ID_TIMECODE_SCALE, ID_DURATION)) != null) { switch (elem.type) { case ID_TIMECODE_SCALE: info.timecodeScale = readNumber(elem); break; case ID_DURATION: info.duration = readNumber(elem); break; } ensure(elem); } if (info.timecodeScale == 0) { throw new NoSuchElementException("Element Timecode not found"); } return info; } private Segment readSegment(final Element ref, final int trackLacingExpected, final boolean metadataExpected) throws IOException { final Segment obj = new Segment(ref); Element elem; while ((elem = untilElement(ref, ID_INFO, ID_TRACKS, ID_CLUSTER)) != null) { if (elem.type == ID_CLUSTER) { obj.currentCluster = elem; break; } switch (elem.type) { case ID_INFO: obj.info = readInfo(elem); break; case ID_TRACKS: obj.tracks = readTracks(elem, trackLacingExpected); break; } ensure(elem); } if (metadataExpected && (obj.info == null || obj.tracks == null)) { throw new RuntimeException( "Cluster element found without Info and/or Tracks element at position " + ref.offset); } return obj; } private WebMTrack[] readTracks(final Element ref, final int lacingExpected) throws IOException { final ArrayList trackEntries = new ArrayList<>(2); Element elemTrackEntry; while ((elemTrackEntry = untilElement(ref, ID_TRACK_ENTRY)) != null) { final WebMTrack entry = new WebMTrack(); boolean drop = false; Element elem; while ((elem = untilElement(elemTrackEntry)) != null) { switch (elem.type) { case ID_TRACK_NUMBER: entry.trackNumber = readNumber(elem); break; case ID_TRACK_TYPE: entry.trackType = (int) readNumber(elem); break; case ID_CODEC_ID: entry.codecId = readString(elem); break; case ID_CODEC_PRIVATE: entry.codecPrivate = readBlob(elem); break; case ID_AUDIO: case ID_VIDEO: entry.bMetadata = readBlob(elem); break; case ID_DEFAULT_DURATION: entry.defaultDuration = readNumber(elem); break; case ID_FLAG_LACING: drop = readNumber(elem) != lacingExpected; break; case ID_CODEC_DELAY: entry.codecDelay = readNumber(elem); break; case ID_SEEK_PRE_ROLL: entry.seekPreRoll = readNumber(elem); break; default: break; } ensure(elem); } if (!drop) { trackEntries.add(entry); } ensure(elemTrackEntry); } final WebMTrack[] entries = trackEntries.toArray(new WebMTrack[0]); for (final WebMTrack entry : entries) { switch (entry.trackType) { case 1: entry.kind = TrackKind.Video; break; case 2: entry.kind = TrackKind.Audio; break; default: entry.kind = TrackKind.Other; break; } } return entries; } private SimpleBlock readSimpleBlock(final Element ref) throws IOException { final SimpleBlock obj = new SimpleBlock(ref); obj.trackNumber = readEncodedNumber(); obj.relativeTimeCode = stream.readShort(); obj.flags = (byte) stream.read(); obj.dataSize = (int) ((ref.offset + ref.size) - stream.position()); obj.createdFromBlock = ref.type == ID_BLOCK; // NOTE: lacing is not implemented, and will be mixed with the stream data if (obj.dataSize < 0) { throw new IOException(String.format( "Unexpected SimpleBlock element size, missing %s bytes", -obj.dataSize)); } return obj; } private Cluster readCluster(final Element ref) throws IOException { final Cluster obj = new Cluster(ref); final Element elem = untilElement(ref, ID_TIMECODE); if (elem == null) { throw new NoSuchElementException("Cluster at " + ref.offset + " without Timecode element"); } obj.timecode = readNumber(elem); return obj; } static class Element { int type; long offset; long contentSize; long size; } public static class Info { public long timecodeScale; public long duration; } public static class WebMTrack { public long trackNumber; protected int trackType; public String codecId; public byte[] codecPrivate; public byte[] bMetadata; public TrackKind kind; public long defaultDuration = -1; public long codecDelay = -1; public long seekPreRoll = -1; } public class Segment { Segment(final Element ref) { this.ref = ref; this.firstClusterInSegment = true; } public Info info; WebMTrack[] tracks; private Element currentCluster; private final Element ref; boolean firstClusterInSegment; public Cluster getNextCluster() throws IOException { if (done) { return null; } if (firstClusterInSegment && segment.currentCluster != null) { firstClusterInSegment = false; return readCluster(segment.currentCluster); } ensure(segment.currentCluster); final Element elem = untilElement(segment.ref, ID_CLUSTER); if (elem == null) { return null; } segment.currentCluster = elem; return readCluster(segment.currentCluster); } } public static class SimpleBlock { public InputStream data; public boolean createdFromBlock; SimpleBlock(final Element ref) { this.ref = ref; } public long trackNumber; public short relativeTimeCode; public long absoluteTimeCodeNs; public byte flags; public int dataSize; private final Element ref; public boolean isKeyframe() { return (flags & 0x80) == 0x80; } } public class Cluster { Element ref; SimpleBlock currentSimpleBlock = null; Element currentBlockGroup = null; public long timecode; Cluster(final Element ref) { this.ref = ref; } boolean insideClusterBounds() { return stream.position() >= (ref.offset + ref.size); } public SimpleBlock getNextSimpleBlock() throws IOException { if (insideClusterBounds()) { return null; } if (currentBlockGroup != null) { ensure(currentBlockGroup); currentBlockGroup = null; currentSimpleBlock = null; } else if (currentSimpleBlock != null) { ensure(currentSimpleBlock.ref); } while (!insideClusterBounds()) { Element elem = untilElement(ref, ID_SIMPLE_BLOCK, ID_GROUP_BLOCK); if (elem == null) { return null; } if (elem.type == ID_GROUP_BLOCK) { currentBlockGroup = elem; elem = untilElement(currentBlockGroup, ID_BLOCK); if (elem == null) { ensure(currentBlockGroup); currentBlockGroup = null; continue; } } currentSimpleBlock = readSimpleBlock(elem); if (currentSimpleBlock.trackNumber == tracks[selectedTrack].trackNumber) { currentSimpleBlock.data = stream.getView(currentSimpleBlock.dataSize); // calculate the timestamp in nanoseconds currentSimpleBlock.absoluteTimeCodeNs = currentSimpleBlock.relativeTimeCode + this.timecode; currentSimpleBlock.absoluteTimeCodeNs *= segment.info.timecodeScale; return currentSimpleBlock; } ensure(elem); } return null; } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java ================================================ package org.schabi.newpipe.streams; import androidx.annotation.NonNull; import org.schabi.newpipe.streams.WebMReader.Cluster; import org.schabi.newpipe.streams.WebMReader.Segment; import org.schabi.newpipe.streams.WebMReader.SimpleBlock; import org.schabi.newpipe.streams.WebMReader.WebMTrack; import org.schabi.newpipe.streams.io.SharpStream; import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.ArrayList; /** * @author kapodamy */ public class WebMWriter implements Closeable { private static final int BUFFER_SIZE = 8 * 1024; private static final int DEFAULT_TIMECODE_SCALE = 1000000; private static final int INTERV = 100; // 100ms on 1000000us timecode scale private static final int DEFAULT_CUES_EACH_MS = 5000; // 5000ms on 1000000us timecode scale private static final byte CLUSTER_HEADER_SIZE = 8; private static final int CUE_RESERVE_SIZE = 65535; private static final byte MINIMUM_EBML_VOID_SIZE = 4; private WebMReader.WebMTrack[] infoTracks; private SharpStream[] sourceTracks; private WebMReader[] readers; private boolean done = false; private boolean parsed = false; private long written = 0; private Segment[] readersSegment; private Cluster[] readersCluster; private ArrayList clustersOffsetsSizes; private byte[] outBuffer; private ByteBuffer outByteBuffer; public WebMWriter(final SharpStream... source) { sourceTracks = source; readers = new WebMReader[sourceTracks.length]; infoTracks = new WebMTrack[sourceTracks.length]; outBuffer = new byte[BUFFER_SIZE]; outByteBuffer = ByteBuffer.wrap(outBuffer); clustersOffsetsSizes = new ArrayList<>(256); } public WebMTrack[] getTracksFromSource(final int sourceIndex) throws IllegalStateException { if (done) { throw new IllegalStateException("already done"); } if (!parsed) { throw new IllegalStateException("All sources must be parsed first"); } return readers[sourceIndex].getAvailableTracks(); } public void parseSources() throws IOException, IllegalStateException { if (done) { throw new IllegalStateException("already done"); } if (parsed) { throw new IllegalStateException("already parsed"); } try { for (int i = 0; i < readers.length; i++) { readers[i] = new WebMReader(sourceTracks[i]); readers[i].parse(); } } finally { parsed = true; } } public void selectTracks(final int... trackIndex) throws IOException { try { readersSegment = new Segment[readers.length]; readersCluster = new Cluster[readers.length]; for (int i = 0; i < readers.length; i++) { infoTracks[i] = readers[i].selectTrack(trackIndex[i]); readersSegment[i] = readers[i].getNextSegment(); } } finally { parsed = true; } } public boolean isDone() { return done; } @Override public void close() { done = true; parsed = true; for (final SharpStream src : sourceTracks) { src.close(); } sourceTracks = null; readers = null; infoTracks = null; readersSegment = null; readersCluster = null; outBuffer = null; outByteBuffer = null; clustersOffsetsSizes = null; } @SuppressWarnings("MethodLength") public void build(final SharpStream out) throws IOException, RuntimeException { if (!out.canRewind()) { throw new IOException("The output stream must be allow seek"); } makeEBML(out); final long offsetSegmentSizeSet = written + 5; final long offsetInfoDurationSet = written + 94; final long offsetClusterSet = written + 58; final long offsetCuesSet = written + 75; final ArrayList listBuffer = new ArrayList<>(4); /* segment */ listBuffer.add(new byte[]{ 0x18, 0x53, (byte) 0x80, 0x67, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00// segment content size }); final long segmentOffset = written + listBuffer.get(0).length; /* seek head */ listBuffer.add(new byte[]{ 0x11, 0x4d, (byte) 0x9b, 0x74, (byte) 0xbe, 0x4d, (byte) 0xbb, (byte) 0x8b, 0x53, (byte) 0xab, (byte) 0x84, 0x15, 0x49, (byte) 0xa9, 0x66, 0x53, (byte) 0xac, (byte) 0x81, /*info offset*/ 0x43, 0x4d, (byte) 0xbb, (byte) 0x8b, 0x53, (byte) 0xab, (byte) 0x84, 0x16, 0x54, (byte) 0xae, 0x6b, 0x53, (byte) 0xac, (byte) 0x81, /*tracks offset*/ 0x56, 0x4d, (byte) 0xbb, (byte) 0x8e, 0x53, (byte) 0xab, (byte) 0x84, 0x1f, 0x43, (byte) 0xb6, 0x75, 0x53, (byte) 0xac, (byte) 0x84, /*cluster offset [2]*/ 0x00, 0x00, 0x00, 0x00, 0x4d, (byte) 0xbb, (byte) 0x8e, 0x53, (byte) 0xab, (byte) 0x84, 0x1c, 0x53, (byte) 0xbb, 0x6b, 0x53, (byte) 0xac, (byte) 0x84, /*cues offset [7]*/ 0x00, 0x00, 0x00, 0x00 }); /* info */ listBuffer.add(new byte[]{ 0x15, 0x49, (byte) 0xa9, 0x66, (byte) 0x8e, 0x2a, (byte) 0xd7, (byte) 0xb1 }); // the segment duration MUST NOT exceed 4 bytes listBuffer.add(encode(DEFAULT_TIMECODE_SCALE, true)); listBuffer.add(new byte[]{0x44, (byte) 0x89, (byte) 0x84, 0x00, 0x00, 0x00, 0x00, // info.duration }); /* tracks */ listBuffer.addAll(makeTracks()); dump(listBuffer, out); // reserve space for Cues element final long cueOffset = written; makeEbmlVoid(out, CUE_RESERVE_SIZE, true); final int[] defaultSampleDuration = new int[infoTracks.length]; final long[] duration = new long[infoTracks.length]; for (int i = 0; i < infoTracks.length; i++) { if (infoTracks[i].defaultDuration < 0) { defaultSampleDuration[i] = -1; // not available } else { defaultSampleDuration[i] = (int) Math.ceil(infoTracks[i].defaultDuration / (float) DEFAULT_TIMECODE_SCALE); } duration[i] = -1; } // Select a track for the cue final int cuesForTrackId = selectTrackForCue(); long nextCueTime = infoTracks[cuesForTrackId].trackType == 1 ? -1 : 0; final ArrayList keyFrames = new ArrayList<>(32); int firstClusterOffset = (int) written; long currentClusterOffset = makeCluster(out, 0, 0, true); long baseTimecode = 0; long limitTimecode = -1; int limitTimecodeByTrackId = cuesForTrackId; int blockWritten = Integer.MAX_VALUE; int newClusterByTrackId = -1; while (blockWritten > 0) { blockWritten = 0; int i = 0; while (i < readers.length) { final Block bloq = getNextBlockFrom(i); if (bloq == null) { i++; continue; } if (bloq.data == null) { blockWritten = 1; // fake block newClusterByTrackId = i; i++; continue; } if (newClusterByTrackId == i) { limitTimecodeByTrackId = i; newClusterByTrackId = -1; baseTimecode = bloq.absoluteTimecode; limitTimecode = baseTimecode + INTERV; currentClusterOffset = makeCluster(out, baseTimecode, currentClusterOffset, true); } if (cuesForTrackId == i) { if ((nextCueTime > -1 && bloq.absoluteTimecode >= nextCueTime) || (nextCueTime < 0 && bloq.isKeyframe())) { if (nextCueTime > -1) { nextCueTime += DEFAULT_CUES_EACH_MS; } keyFrames.add(new KeyFrame(segmentOffset, currentClusterOffset, written, bloq.absoluteTimecode)); } } writeBlock(out, bloq, baseTimecode); blockWritten++; if (defaultSampleDuration[i] < 0 && duration[i] >= 0) { // if the sample duration in unknown, // calculate using current_duration - previous_duration defaultSampleDuration[i] = (int) (bloq.absoluteTimecode - duration[i]); } duration[i] = bloq.absoluteTimecode; if (limitTimecode < 0) { limitTimecode = bloq.absoluteTimecode + INTERV; continue; } if (bloq.absoluteTimecode >= limitTimecode) { if (limitTimecodeByTrackId != i) { limitTimecode += INTERV - (bloq.absoluteTimecode - limitTimecode); } i++; } } } makeCluster(out, -1, currentClusterOffset, false); final long segmentSize = written - offsetSegmentSizeSet - 7; /* Segment size */ seekTo(out, offsetSegmentSizeSet); outByteBuffer.putLong(0, segmentSize); out.write(outBuffer, 1, DataReader.LONG_SIZE - 1); /* Segment duration */ long longestDuration = 0; for (int i = 0; i < duration.length; i++) { if (defaultSampleDuration[i] > 0) { duration[i] += defaultSampleDuration[i]; } if (duration[i] > longestDuration) { longestDuration = duration[i]; } } seekTo(out, offsetInfoDurationSet); outByteBuffer.putFloat(0, longestDuration); dump(outBuffer, DataReader.FLOAT_SIZE, out); /* first Cluster offset */ firstClusterOffset -= segmentOffset; writeInt(out, offsetClusterSet, firstClusterOffset); seekTo(out, cueOffset); /* Cue */ short cueSize = 0; dump(new byte[]{0x1c, 0x53, (byte) 0xbb, 0x6b, 0x20, 0x00, 0x00}, out); // header size is 7 for (final KeyFrame keyFrame : keyFrames) { final int size = makeCuePoint(cuesForTrackId, keyFrame, outBuffer); if ((cueSize + size + 7 + MINIMUM_EBML_VOID_SIZE) > CUE_RESERVE_SIZE) { break; // no space left } cueSize += size; dump(outBuffer, size, out); } makeEbmlVoid(out, CUE_RESERVE_SIZE - cueSize - 7, false); seekTo(out, cueOffset + 5); outByteBuffer.putShort(0, cueSize); dump(outBuffer, DataReader.SHORT_SIZE, out); /* seek head, seek for cues element */ writeInt(out, offsetCuesSet, (int) (cueOffset - segmentOffset)); for (final ClusterInfo cluster : clustersOffsetsSizes) { writeInt(out, cluster.offset, cluster.size | 0x10000000); } } private Block getNextBlockFrom(final int internalTrackId) throws IOException { if (readersSegment[internalTrackId] == null) { readersSegment[internalTrackId] = readers[internalTrackId].getNextSegment(); if (readersSegment[internalTrackId] == null) { return null; // no more blocks in the selected track } } if (readersCluster[internalTrackId] == null) { readersCluster[internalTrackId] = readersSegment[internalTrackId].getNextCluster(); if (readersCluster[internalTrackId] == null) { readersSegment[internalTrackId] = null; return getNextBlockFrom(internalTrackId); } } final SimpleBlock res = readersCluster[internalTrackId].getNextSimpleBlock(); if (res == null) { readersCluster[internalTrackId] = null; return new Block(); // fake block to indicate the end of the cluster } final Block bloq = new Block(); bloq.data = res.data; bloq.dataSize = res.dataSize; bloq.trackNumber = internalTrackId; bloq.flags = res.flags; bloq.absoluteTimecode = res.absoluteTimeCodeNs / DEFAULT_TIMECODE_SCALE; return bloq; } private void seekTo(final SharpStream stream, final long offset) throws IOException { if (stream.canSeek()) { stream.seek(offset); } else { if (offset > written) { stream.skip(offset - written); } else { stream.rewind(); stream.skip(offset); } } written = offset; } private void writeInt(final SharpStream stream, final long offset, final int number) throws IOException { seekTo(stream, offset); outByteBuffer.putInt(0, number); dump(outBuffer, DataReader.INTEGER_SIZE, stream); } private void writeBlock(final SharpStream stream, final Block bloq, final long clusterTimecode) throws IOException { final long relativeTimeCode = bloq.absoluteTimecode - clusterTimecode; if (relativeTimeCode < Short.MIN_VALUE || relativeTimeCode > Short.MAX_VALUE) { throw new IndexOutOfBoundsException("SimpleBlock timecode overflow."); } final ArrayList listBuffer = new ArrayList<>(5); listBuffer.add(new byte[]{(byte) 0xa3}); listBuffer.add(null); // block size listBuffer.add(encode(bloq.trackNumber + 1, false)); listBuffer.add(ByteBuffer.allocate(DataReader.SHORT_SIZE).putShort((short) relativeTimeCode) .array()); listBuffer.add(new byte[]{bloq.flags}); int blockSize = bloq.dataSize; for (int i = 2; i < listBuffer.size(); i++) { blockSize += listBuffer.get(i).length; } listBuffer.set(1, encode(blockSize, false)); dump(listBuffer, stream); int read; while ((read = bloq.data.read(outBuffer)) > 0) { dump(outBuffer, read, stream); } } private long makeCluster(final SharpStream stream, final long timecode, final long offsetStart, final boolean create) throws IOException { ClusterInfo cluster; long offset = offsetStart; if (offset > 0) { // save the size of the previous cluster (maximum 256 MiB) cluster = clustersOffsetsSizes.get(clustersOffsetsSizes.size() - 1); cluster.size = (int) (written - offset - CLUSTER_HEADER_SIZE); } offset = written; if (create) { /* cluster */ dump(new byte[]{0x1f, 0x43, (byte) 0xb6, 0x75}, stream); cluster = new ClusterInfo(); cluster.offset = written; clustersOffsetsSizes.add(cluster); dump(new byte[]{ 0x10, 0x00, 0x00, 0x00, /* timestamp */ (byte) 0xe7 }, stream); dump(encode(timecode, true), stream); } return offset; } private void makeEBML(final SharpStream stream) throws IOException { // default values dump(new byte[]{ 0x1A, 0x45, (byte) 0xDF, (byte) 0xA3, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0x42, (byte) 0x86, (byte) 0x81, 0x01, 0x42, (byte) 0xF7, (byte) 0x81, 0x01, 0x42, (byte) 0xF2, (byte) 0x81, 0x04, 0x42, (byte) 0xF3, (byte) 0x81, 0x08, 0x42, (byte) 0x82, (byte) 0x84, 0x77, 0x65, 0x62, 0x6D, 0x42, (byte) 0x87, (byte) 0x81, 0x02, 0x42, (byte) 0x85, (byte) 0x81, 0x02 }, stream); } private ArrayList makeTracks() { final ArrayList buffer = new ArrayList<>(1); buffer.add(new byte[]{0x16, 0x54, (byte) 0xae, 0x6b}); buffer.add(null); for (int i = 0; i < infoTracks.length; i++) { buffer.addAll(makeTrackEntry(i, infoTracks[i])); } return lengthFor(buffer); } private ArrayList makeTrackEntry(final int internalTrackId, final WebMTrack track) { final byte[] id = encode(internalTrackId + 1, true); final ArrayList buffer = new ArrayList<>(12); /* track */ buffer.add(new byte[]{(byte) 0xae}); buffer.add(null); /* track number */ buffer.add(new byte[]{(byte) 0xd7}); buffer.add(id); /* track uid */ buffer.add(new byte[]{0x73, (byte) 0xc5}); buffer.add(id); /* flag lacing */ buffer.add(new byte[]{(byte) 0x9c, (byte) 0x81, 0x00}); /* lang */ buffer.add(new byte[]{0x22, (byte) 0xb5, (byte) 0x9c, (byte) 0x83, 0x75, 0x6e, 0x64}); /* codec id */ buffer.add(new byte[]{(byte) 0x86}); buffer.addAll(encode(track.codecId)); /* codec delay*/ if (track.codecDelay >= 0) { buffer.add(new byte[]{0x56, (byte) 0xAA}); buffer.add(encode(track.codecDelay, true)); } /* codec seek pre-roll*/ if (track.seekPreRoll >= 0) { buffer.add(new byte[]{0x56, (byte) 0xBB}); buffer.add(encode(track.seekPreRoll, true)); } /* type */ buffer.add(new byte[]{(byte) 0x83}); buffer.add(encode(track.trackType, true)); /* default duration */ if (track.defaultDuration >= 0) { buffer.add(new byte[]{0x23, (byte) 0xe3, (byte) 0x83}); buffer.add(encode(track.defaultDuration, true)); } /* audio/video */ if ((track.trackType == 1 || track.trackType == 2) && valid(track.bMetadata)) { buffer.add(new byte[]{(byte) (track.trackType == 1 ? 0xe0 : 0xe1)}); buffer.add(encode(track.bMetadata.length, false)); buffer.add(track.bMetadata); } /* codec private*/ if (valid(track.codecPrivate)) { buffer.add(new byte[]{0x63, (byte) 0xa2}); buffer.add(encode(track.codecPrivate.length, false)); buffer.add(track.codecPrivate); } return lengthFor(buffer); } private int makeCuePoint(final int internalTrackId, final KeyFrame keyFrame, final byte[] buffer) { final ArrayList cue = new ArrayList<>(5); /* CuePoint */ cue.add(new byte[]{(byte) 0xbb}); cue.add(null); /* CueTime */ cue.add(new byte[]{(byte) 0xb3}); cue.add(encode(keyFrame.duration, true)); /* CueTrackPosition */ cue.addAll(makeCueTrackPosition(internalTrackId, keyFrame)); int size = 0; lengthFor(cue); for (final byte[] buff : cue) { System.arraycopy(buff, 0, buffer, size, buff.length); size += buff.length; } return size; } private ArrayList makeCueTrackPosition(final int internalTrackId, final KeyFrame keyFrame) { final ArrayList buffer = new ArrayList<>(8); /* CueTrackPositions */ buffer.add(new byte[]{(byte) 0xb7}); buffer.add(null); /* CueTrack */ buffer.add(new byte[]{(byte) 0xf7}); buffer.add(encode(internalTrackId + 1, true)); /* CueClusterPosition */ buffer.add(new byte[]{(byte) 0xf1}); buffer.add(encode(keyFrame.clusterPosition, true)); /* CueRelativePosition */ if (keyFrame.relativePosition > 0) { buffer.add(new byte[]{(byte) 0xf0}); buffer.add(encode(keyFrame.relativePosition, true)); } return lengthFor(buffer); } private void makeEbmlVoid(final SharpStream out, final int amount, final boolean wipe) throws IOException { int size = amount; /* ebml void */ outByteBuffer.putShort(0, (short) 0xec20); outByteBuffer.putShort(2, (short) (size - 4)); dump(outBuffer, 4, out); if (wipe) { size -= 4; while (size > 0) { final int write = Math.min(size, outBuffer.length); dump(outBuffer, write, out); size -= write; } } } private void dump(final byte[] buffer, final SharpStream stream) throws IOException { dump(buffer, buffer.length, stream); } private void dump(final byte[] buffer, final int count, final SharpStream stream) throws IOException { stream.write(buffer, 0, count); written += count; } private void dump(final ArrayList buffers, final SharpStream stream) throws IOException { for (final byte[] buffer : buffers) { stream.write(buffer); written += buffer.length; } } private ArrayList lengthFor(final ArrayList buffer) { long size = 0; for (int i = 2; i < buffer.size(); i++) { size += buffer.get(i).length; } buffer.set(1, encode(size, false)); return buffer; } private byte[] encode(final long number, final boolean withLength) { int length = -1; for (int i = 1; i <= 7; i++) { if (number < Math.pow(2, 7 * i)) { length = i; break; } } if (length < 1) { throw new ArithmeticException("Can't encode a number of bigger than 7 bytes"); } if (number == (Math.pow(2, 7 * length)) - 1) { length++; } final int offset = withLength ? 1 : 0; final byte[] buffer = new byte[offset + length]; final long marker = Math.floorDiv(length - 1, 8); int shift = 0; for (int i = length - 1; i >= 0; i--, shift += 8) { long b = number >>> shift; if (!withLength && i == marker) { b = b | (0x80 >>> (length - 1)); } buffer[offset + i] = (byte) b; } if (withLength) { buffer[0] = (byte) (0x80 | length); } return buffer; } private ArrayList encode(final String value) { final byte[] str = value.getBytes(StandardCharsets.UTF_8); // or use "utf-8" final ArrayList buffer = new ArrayList<>(2); buffer.add(encode(str.length, false)); buffer.add(str); return buffer; } private boolean valid(final byte[] buffer) { return buffer != null && buffer.length > 0; } private int selectTrackForCue() { int i = 0; int videoTracks = 0; int audioTracks = 0; for (; i < infoTracks.length; i++) { switch (infoTracks[i].trackType) { case 1: videoTracks++; break; case 2: audioTracks++; break; } } final int kind; if (audioTracks == infoTracks.length) { kind = 2; } else if (videoTracks == infoTracks.length) { kind = 1; } else if (videoTracks > 0) { kind = 1; } else if (audioTracks > 0) { kind = 2; } else { return 0; } // TODO: in the above code, find and select the shortest track for the desired kind for (i = 0; i < infoTracks.length; i++) { if (kind == infoTracks[i].trackType) { return i; } } return 0; } static class KeyFrame { KeyFrame(final long segment, final long cluster, final long block, final long timecode) { clusterPosition = cluster - segment; relativePosition = (int) (block - cluster - CLUSTER_HEADER_SIZE); duration = timecode; } final long clusterPosition; final int relativePosition; final long duration; } static class Block { InputStream data; int trackNumber; byte flags; int dataSize; long absoluteTimecode; boolean isKeyframe() { return (flags & 0x80) == 0x80; } @NonNull @Override public String toString() { return String.format("trackNumber=%s isKeyFrame=%S absoluteTimecode=%s", trackNumber, isKeyframe(), absoluteTimecode); } } static class ClusterInfo { long offset; int size; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/streams/io/NoFileManagerSafeGuard.java ================================================ package org.schabi.newpipe.streams.io; import android.content.ActivityNotFoundException; import android.content.Context; import android.os.Build; import android.util.Log; import androidx.activity.result.ActivityResultLauncher; import androidx.appcompat.app.AlertDialog; import org.schabi.newpipe.R; /** * Helper for when no file-manager/activity was found. */ public final class NoFileManagerSafeGuard { private NoFileManagerSafeGuard() { // No impl } /** * Shows an alert dialog when no file-manager is found. * @param context Context */ private static void showActivityNotFoundAlert(final Context context) { if (context == null) { throw new IllegalArgumentException( "Unable to open no file manager alert dialog: Context is null"); } final String message; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // Android 10+ only allows SAF message = context.getString(R.string.no_appropriate_file_manager_message_android_10); } else { message = context.getString( R.string.no_appropriate_file_manager_message, context.getString(R.string.downloads_storage_use_saf_title)); } new AlertDialog.Builder(context) .setTitle(R.string.no_app_to_open_intent) .setMessage(message) .setPositiveButton(R.string.ok, null) .show(); } /** * Launches the file manager safely. * * If no file manager is found (which is normally only the case when the user uninstalled * the default file manager or the OS lacks one) an alert dialog shows up, asking the user * to fix the situation. * * @param activityResultLauncher see {@link ActivityResultLauncher#launch(Object)} * @param input see {@link ActivityResultLauncher#launch(Object)} * @param tag Tag used for logging * @param context Context * @param see {@link ActivityResultLauncher#launch(Object)} */ public static void launchSafe( final ActivityResultLauncher activityResultLauncher, final I input, final String tag, final Context context ) { try { activityResultLauncher.launch(input); } catch (final ActivityNotFoundException aex) { Log.w(tag, "Unable to launch file/directory picker", aex); NoFileManagerSafeGuard.showActivityNotFoundAlert(context); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/streams/io/SharpInputStream.java ================================================ package org.schabi.newpipe.streams.io; import androidx.annotation.NonNull; import java.io.IOException; import java.io.InputStream; /** * Simply wraps a readable {@link SharpStream} allowing it to be used with built-in Java stuff that * supports {@link InputStream}. */ public class SharpInputStream extends InputStream { private final SharpStream stream; public SharpInputStream(final SharpStream stream) throws IOException { if (!stream.canRead()) { throw new IOException("SharpStream is not readable"); } this.stream = stream; } @Override public int read() throws IOException { return stream.read(); } @Override public int read(@NonNull final byte[] b) throws IOException { return stream.read(b); } @Override public int read(@NonNull final byte[] b, final int off, final int len) throws IOException { return stream.read(b, off, len); } @Override public long skip(final long n) throws IOException { return stream.skip(n); } @Override public int available() { final long res = stream.available(); return res > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) res; } @Override public void close() { stream.close(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/streams/io/SharpOutputStream.java ================================================ package org.schabi.newpipe.streams.io; import androidx.annotation.NonNull; import java.io.IOException; import java.io.OutputStream; /** * Simply wraps a writable {@link SharpStream} allowing it to be used with built-in Java stuff that * supports {@link OutputStream}. */ public class SharpOutputStream extends OutputStream { private final SharpStream stream; public SharpOutputStream(final SharpStream stream) throws IOException { if (!stream.canWrite()) { throw new IOException("SharpStream is not writable"); } this.stream = stream; } @Override public void write(final int b) throws IOException { stream.write((byte) b); } @Override public void write(@NonNull final byte[] b) throws IOException { stream.write(b); } @Override public void write(@NonNull final byte[] b, final int off, final int len) throws IOException { stream.write(b, off, len); } @Override public void flush() throws IOException { stream.flush(); } @Override public void close() { stream.close(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java ================================================ package org.schabi.newpipe.streams.io; import java.io.Closeable; import java.io.Flushable; import java.io.IOException; /** * Based on C#'s Stream class. SharpStream is a wrapper around the 2 different APIs for SAF * ({@link us.shandian.giga.io.FileStreamSAF}) and non-SAF ({@link us.shandian.giga.io.FileStream}). * It has both input and output like in C#, while in Java those are usually different classes. * {@link SharpInputStream} and {@link SharpOutputStream} are simple classes that wrap * {@link SharpStream} and extend respectively {@link java.io.InputStream} and * {@link java.io.OutputStream}, since unfortunately a class can only extend one class, so that a * sharp stream can be used with built-in Java stuff that supports {@link java.io.InputStream} * or {@link java.io.OutputStream}. */ public abstract class SharpStream implements Closeable, Flushable { public abstract int read() throws IOException; public abstract int read(byte[] buffer) throws IOException; public abstract int read(byte[] buffer, int offset, int count) throws IOException; public abstract long skip(long amount) throws IOException; public abstract long available(); public abstract void rewind() throws IOException; public abstract boolean isClosed(); @Override public abstract void close(); public abstract boolean canRewind(); public abstract boolean canRead(); public abstract boolean canWrite(); public boolean canSetLength() { return false; } public boolean canSeek() { return false; } public abstract void write(byte value) throws IOException; public abstract void write(byte[] buffer) throws IOException; public abstract void write(byte[] buffer, int offset, int count) throws IOException; public void flush() throws IOException { // STUB } public void setLength(final long length) throws IOException { throw new IOException("Not implemented"); } public void seek(final long offset) throws IOException { throw new IOException("Not implemented"); } public long length() throws IOException { throw new UnsupportedOperationException("Unsupported operation"); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java ================================================ package org.schabi.newpipe.streams.io; import static android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME; import static android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.database.Cursor; import android.net.Uri; import android.os.ParcelFileDescriptor; import android.provider.DocumentsContract; import android.system.Os; import android.system.StructStatVfs; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.documentfile.provider.DocumentFile; import org.schabi.newpipe.settings.NewPipeSettings; import org.schabi.newpipe.util.FilePickerActivityHelper; import java.io.FileDescriptor; import java.io.IOException; import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.stream.Collectors; import java.util.stream.Stream; public class StoredDirectoryHelper { private static final String TAG = StoredDirectoryHelper.class.getSimpleName(); public static final int PERMISSION_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; private Path ioTree; private DocumentFile docTree; /** * Context is `null` for non-SAF files, i.e. files that use `ioTree`. */ @Nullable private Context context; private final String tag; public StoredDirectoryHelper(@NonNull final Context context, @NonNull final Uri path, final String tag) throws IOException { this.tag = tag; if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(path.getScheme())) { ioTree = Paths.get(URI.create(path.toString())); return; } this.context = context; try { this.context.getContentResolver().takePersistableUriPermission(path, PERMISSION_FLAGS); } catch (final Exception e) { throw new IOException(e); } this.docTree = DocumentFile.fromTreeUri(context, path); if (this.docTree == null) { throw new IOException("Failed to create the tree from Uri"); } } public StoredFileHelper createFile(final String filename, final String mime) { return createFile(filename, mime, false); } public StoredFileHelper createUniqueFile(final String name, final String mime) { final List matches = new ArrayList<>(); final String[] filename = splitFilename(name); final String lcFileName = filename[0].toLowerCase(); if (docTree == null) { try (Stream stream = Files.list(ioTree)) { matches.addAll(stream.map(path -> path.getFileName().toString().toLowerCase()) .filter(fileName -> fileName.startsWith(lcFileName)) .collect(Collectors.toList())); } catch (final IOException e) { Log.e(TAG, "Exception while traversing " + ioTree, e); } } else { // warning: SAF file listing is very slow final Uri docTreeChildren = DocumentsContract.buildChildDocumentsUriUsingTree( docTree.getUri(), DocumentsContract.getDocumentId(docTree.getUri())); final String[] projection = new String[]{COLUMN_DISPLAY_NAME}; final String selection = "(LOWER(" + COLUMN_DISPLAY_NAME + ") LIKE ?%"; final ContentResolver cr = context.getContentResolver(); try (Cursor cursor = cr.query(docTreeChildren, projection, selection, new String[]{lcFileName}, null)) { if (cursor != null) { while (cursor.moveToNext()) { addIfStartWith(matches, lcFileName, cursor.getString(0)); } } } } if (matches.isEmpty()) { return createFile(name, mime, true); } // check if the filename is in use String lcName = name.toLowerCase(); for (final String testName : matches) { if (testName.equals(lcName)) { lcName = null; break; } } // create file if filename not in use if (lcName != null) { return createFile(name, mime, true); } Collections.sort(matches, String::compareTo); for (int i = 1; i < 1000; i++) { if (Collections.binarySearch(matches, makeFileName(lcFileName, i, filename[1])) < 0) { return createFile(makeFileName(filename[0], i, filename[1]), mime, true); } } return createFile(String.valueOf(System.currentTimeMillis()).concat(filename[1]), mime, false); } private StoredFileHelper createFile(final String filename, final String mime, final boolean safe) { final StoredFileHelper storage; try { if (docTree == null) { storage = new StoredFileHelper(ioTree, filename, mime); } else { storage = new StoredFileHelper(context, docTree, filename, mime, safe); } } catch (final IOException e) { return null; } storage.tag = tag; return storage; } public Uri getUri() { return docTree == null ? Uri.fromFile(ioTree.toFile()) : docTree.getUri(); } public boolean exists() { return docTree == null ? Files.exists(ioTree) : docTree.exists(); } /** * Indicates whether it's using the {@code java.io} API. * * @return {@code true} for Java I/O API, otherwise, {@code false} for Storage Access Framework */ public boolean isDirect() { return docTree == null; } /** * Get free memory of the storage partition this file belongs to (root of the directory). * See StackOverflow and * * {@code statvfs()} and {@code fstatvfs()} docs * * @return amount of free memory in the volume of current directory (bytes), or {@link * Long#MAX_VALUE} if an error occurred */ public long getFreeStorageSpace() { try { final StructStatVfs stat; if (ioTree != null) { // non-SAF file, use statvfs with the path directly (also, `context` would be null // for non-SAF files, so we wouldn't be able to call `getContentResolver` anyway) stat = Os.statvfs(ioTree.toString()); } else { // SAF file, we can't get a path directly, so obtain a file descriptor first // and then use fstatvfs with the file descriptor try (ParcelFileDescriptor parcelFileDescriptor = context.getContentResolver().openFileDescriptor(getUri(), "r")) { if (parcelFileDescriptor == null) { return Long.MAX_VALUE; } final FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor(); stat = Os.fstatvfs(fileDescriptor); } } // this is the same formula used inside the FsStat class return stat.f_bavail * stat.f_frsize; } catch (final Throwable e) { // ignore any error Log.e(TAG, "Could not get free storage space", e); return Long.MAX_VALUE; } } /** * Only using Java I/O. Creates the directory named by this abstract pathname, including any * necessary but nonexistent parent directories. * Note that if this operation fails it may have succeeded in creating some of the necessary * parent directories. * * @return true if and only if the directory was created, * along with all necessary parent directories or already exists; false * otherwise */ public boolean mkdirs() { if (docTree == null) { try { Files.createDirectories(ioTree); } catch (final IOException e) { Log.e(TAG, "Error while creating directories at " + ioTree, e); } return Files.exists(ioTree); } if (docTree.exists()) { return true; } try { DocumentFile parent; String child = docTree.getName(); while (true) { parent = docTree.getParentFile(); if (parent == null || child == null) { break; } if (parent.exists()) { return true; } parent.createDirectory(child); child = parent.getName(); // for the next iteration } } catch (final Exception ignored) { // no more parent directories or unsupported by the storage provider } return false; } public String getTag() { return tag; } public Uri findFile(final String filename) { if (docTree == null) { final Path res = ioTree.resolve(filename); return Files.exists(res) ? Uri.fromFile(res.toFile()) : null; } final DocumentFile res = findFileSAFHelper(context, docTree, filename); return res == null ? null : res.getUri(); } public boolean canWrite() { return docTree == null ? Files.isWritable(ioTree) : docTree.canWrite(); } /** * @return {@code false} if the storage is direct, or the SAF storage is valid; {@code true} if * SAF access to this SAF storage is denied (e.g. the user clicked on {@code Android settings -> * Apps & notifications -> NewPipe -> Storage & cache -> Clear access}); */ public boolean isInvalidSafStorage() { return docTree != null && docTree.getName() == null; } @NonNull @Override public String toString() { return (docTree == null ? Uri.fromFile(ioTree.toFile()) : docTree.getUri()).toString(); } //////////////////// // Utils /////////////////// private static void addIfStartWith(final List list, @NonNull final String base, final String str) { if (isNullOrEmpty(str)) { return; } final String lowerStr = str.toLowerCase(); if (lowerStr.startsWith(base)) { list.add(lowerStr); } } /** * Splits the filename into the name and extension. * * @param filename The filename to split * @return A String array with the name at index 0 and extension at index 1 */ private static String[] splitFilename(@NonNull final String filename) { final int dotIndex = filename.lastIndexOf('.'); if (dotIndex < 0 || (dotIndex == filename.length() - 1)) { return new String[]{filename, ""}; } return new String[]{filename.substring(0, dotIndex), filename.substring(dotIndex)}; } private static String makeFileName(final String name, final int idx, final String ext) { return name + "(" + idx + ")" + ext; } /** * Fast (but not enough) file/directory finder under the storage access framework. * * @param context The context * @param tree Directory where search * @param filename Target filename * @return A {@link DocumentFile} contain the reference, otherwise, null */ static DocumentFile findFileSAFHelper(@Nullable final Context context, final DocumentFile tree, final String filename) { if (context == null) { return tree.findFile(filename); // warning: this is very slow } if (!tree.canRead()) { return null; // missing read permission } final int name = 0; final int documentId = 1; // LOWER() SQL function is not supported final String selection = COLUMN_DISPLAY_NAME + " = ?"; //final String selection = COLUMN_DISPLAY_NAME + " LIKE ?%"; final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(tree.getUri(), DocumentsContract.getDocumentId(tree.getUri())); final String[] projection = {COLUMN_DISPLAY_NAME, COLUMN_DOCUMENT_ID}; final ContentResolver contentResolver = context.getContentResolver(); final String lowerFilename = filename.toLowerCase(); try (Cursor cursor = contentResolver.query(childrenUri, projection, selection, new String[]{lowerFilename}, null)) { if (cursor == null) { return null; } while (cursor.moveToNext()) { if (cursor.isNull(name) || !cursor.getString(name).toLowerCase().startsWith(lowerFilename)) { continue; } return DocumentFile.fromSingleUri(context, DocumentsContract.buildDocumentUriUsingTree(tree.getUri(), cursor.getString(documentId))); } } return null; } public static Intent getPicker(final Context ctx) { if (NewPipeSettings.useStorageAccessFramework(ctx)) { return new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) .putExtra("android.content.extra.SHOW_ADVANCED", true) .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | StoredDirectoryHelper.PERMISSION_FLAGS); } else { return new Intent(ctx, FilePickerActivityHelper.class) .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) .putExtra(FilePickerActivityHelper.EXTRA_MODE, FilePickerActivityHelper.MODE_DIR); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.java ================================================ package org.schabi.newpipe.streams.io; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Build; import android.os.Environment; import android.provider.DocumentsContract; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.documentfile.provider.DocumentFile; import com.nononsenseapps.filepicker.Utils; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.settings.NewPipeSettings; import org.schabi.newpipe.util.FilePickerActivityHelper; import java.io.File; import java.io.IOException; import java.io.Serializable; import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import us.shandian.giga.io.FileStream; import us.shandian.giga.io.FileStreamSAF; public class StoredFileHelper implements Serializable { private static final boolean DEBUG = MainActivity.DEBUG; private static final String TAG = StoredFileHelper.class.getSimpleName(); private static final long serialVersionUID = 0L; public static final String DEFAULT_MIME = "application/octet-stream"; private transient DocumentFile docFile; private transient DocumentFile docTree; private transient Path ioPath; private transient Context context; protected String source; private String sourceTree; protected String tag; private String srcName; private String srcType; public StoredFileHelper(final Context context, final Uri uri, final String mime) { if (FilePickerActivityHelper.isOwnFileUri(context, uri)) { final File ioFile = Utils.getFileForUri(uri); ioPath = ioFile.toPath(); source = Uri.fromFile(ioFile).toString(); } else { docFile = DocumentFile.fromSingleUri(context, uri); source = uri.toString(); } this.context = context; this.srcType = mime; } public StoredFileHelper(@Nullable final Uri parent, final String filename, final String mime, final String tag) { this.source = null; // this instance will be "invalid" see invalidate()/isInvalid() methods this.srcName = filename; this.srcType = mime == null ? DEFAULT_MIME : mime; if (parent != null) { this.sourceTree = parent.toString(); } this.tag = tag; } StoredFileHelper(@Nullable final Context context, final DocumentFile tree, final String filename, final String mime, final boolean safe) throws IOException { this.docTree = tree; this.context = context; final DocumentFile res; if (safe) { // no conflicts (the filename is not in use) res = this.docTree.createFile(mime, filename); if (res == null) { throw new IOException("Cannot create the file"); } } else { res = createSAF(context, mime, filename); } this.docFile = res; this.source = docFile.getUri().toString(); this.sourceTree = docTree.getUri().toString(); this.srcName = this.docFile.getName(); this.srcType = this.docFile.getType(); } StoredFileHelper(final Path location, final String filename, final String mime) throws IOException { ioPath = location.resolve(filename); Files.deleteIfExists(ioPath); Files.createFile(ioPath); source = Uri.fromFile(ioPath.toFile()).toString(); sourceTree = Uri.fromFile(location.toFile()).toString(); srcName = ioPath.getFileName().toString(); srcType = mime; } public StoredFileHelper(final Context context, @Nullable final Uri parent, @NonNull final Uri path, final String tag) throws IOException { this.tag = tag; this.source = path.toString(); if (path.getScheme() == null || path.getScheme().equalsIgnoreCase(ContentResolver.SCHEME_FILE)) { this.ioPath = Paths.get(URI.create(this.source)); } else { final DocumentFile file = DocumentFile.fromSingleUri(context, path); if (file == null) { throw new IOException("SAF not available"); } this.context = context; if (file.getName() == null) { this.source = null; return; } else { this.docFile = file; takePermissionSAF(); } } if (parent != null) { if (!ContentResolver.SCHEME_FILE.equals(parent.getScheme())) { this.docTree = DocumentFile.fromTreeUri(context, parent); } this.sourceTree = parent.toString(); } this.srcName = getName(); this.srcType = getType(); } public static StoredFileHelper deserialize(@NonNull final StoredFileHelper storage, final Context context) throws IOException { final Uri treeUri = storage.sourceTree == null ? null : Uri.parse(storage.sourceTree); if (storage.isInvalid()) { return new StoredFileHelper(treeUri, storage.srcName, storage.srcType, storage.tag); } final StoredFileHelper instance = new StoredFileHelper(context, treeUri, Uri.parse(storage.source), storage.tag); // under SAF, if the target document is deleted, conserve the filename and mime if (instance.srcName == null) { instance.srcName = storage.srcName; } if (instance.srcType == null) { instance.srcType = storage.srcType; } return instance; } public SharpStream getStream() throws IOException { assertValid(); if (docFile == null) { return new FileStream(ioPath.toFile()); } else { return new FileStreamSAF(context.getContentResolver(), docFile.getUri()); } } public SharpStream openAndTruncateStream() throws IOException { final SharpStream sharpStream = getStream(); try { sharpStream.setLength(0); } catch (final Throwable e) { // we can't use try-with-resources here, since we only want to close the stream if an // exception occurs, but leave it open if everything goes well sharpStream.close(); throw e; } return sharpStream; } /** * Indicates whether it's using the {@code java.io} API. * * @return {@code true} for Java I/O API, otherwise, {@code false} for Storage Access Framework */ public boolean isDirect() { assertValid(); return docFile == null; } public boolean isInvalid() { return source == null; } public Uri getUri() { assertValid(); return docFile == null ? Uri.fromFile(ioPath.toFile()) : docFile.getUri(); } public Uri getParentUri() { assertValid(); return sourceTree == null ? null : Uri.parse(sourceTree); } public void truncate() throws IOException { assertValid(); try (SharpStream fs = getStream()) { fs.setLength(0); } } public boolean delete() { if (source == null) { return true; } if (docFile == null) { try { return Files.deleteIfExists(ioPath); } catch (final IOException e) { Log.e(TAG, "Exception while deleting " + ioPath, e); return false; } } final boolean res = docFile.delete(); try { final int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; context.getContentResolver().releasePersistableUriPermission(docFile.getUri(), flags); } catch (final Exception ex) { // nothing to do } return res; } public long length() { assertValid(); if (docFile == null) { try { return Files.size(ioPath); } catch (final IOException e) { Log.e(TAG, "Exception while getting the size of " + ioPath, e); return 0; } } else { return docFile.length(); } } public boolean canWrite() { if (source == null) { return false; } return docFile == null ? Files.isWritable(ioPath) : docFile.canWrite(); } public String getName() { if (source == null) { return srcName; } else if (docFile == null) { return ioPath.getFileName().toString(); } final String name = docFile.getName(); return name == null ? srcName : name; } public String getType() { if (source == null || docFile == null) { return srcType; } final String type = docFile.getType(); return type == null ? srcType : type; } public String getTag() { return tag; } public boolean existsAsFile() { if (source == null || (docFile == null && ioPath == null)) { if (DEBUG) { Log.d(TAG, "existsAsFile called but something is null: source = [" + (source == null ? "null => storage is invalid" : source) + "], docFile = [" + docFile + "], ioPath = [" + ioPath + "]"); } return false; } // WARNING: DocumentFile.exists() and DocumentFile.isFile() methods are slow // docFile.isVirtual() means it is non-physical? return docFile == null ? Files.isRegularFile(ioPath) : (docFile.exists() && docFile.isFile()); } public boolean create() { assertValid(); final boolean result; if (docFile == null) { try { Files.createFile(ioPath); result = true; } catch (final IOException e) { Log.e(TAG, "Exception while creating " + ioPath, e); return false; } } else if (docTree == null) { result = false; } else { if (!docTree.canRead() || !docTree.canWrite()) { return false; } try { docFile = createSAF(context, srcType, srcName); if (docFile.getName() == null) { return false; } result = true; } catch (final IOException e) { return false; } } if (result) { source = (docFile == null ? Uri.fromFile(ioPath.toFile()) : docFile.getUri()) .toString(); srcName = getName(); srcType = getType(); } return result; } public void invalidate() { if (source == null) { return; } srcName = getName(); srcType = getType(); source = null; docTree = null; docFile = null; ioPath = null; context = null; } public boolean equals(final StoredFileHelper storage) { if (this == storage) { return true; } // note: do not compare tags, files can have the same parent folder //if (stringMismatch(this.tag, storage.tag)) return false; if (stringMismatch(getLowerCase(this.sourceTree), getLowerCase(this.sourceTree))) { return false; } if (this.isInvalid() || storage.isInvalid()) { if (this.srcName == null || storage.srcName == null || this.srcType == null || storage.srcType == null) { return false; } return this.srcName.equalsIgnoreCase(storage.srcName) && this.srcType.equalsIgnoreCase(storage.srcType); } if (this.isDirect() != storage.isDirect()) { return false; } if (this.isDirect()) { return this.ioPath.equals(storage.ioPath); } return DocumentsContract.getDocumentId(this.docFile.getUri()) .equalsIgnoreCase(DocumentsContract.getDocumentId(storage.docFile.getUri())); } @NonNull @Override public String toString() { if (source == null) { return "[Invalid state] name=" + srcName + " type=" + srcType + " tag=" + tag; } else { return "sourceFile=" + source + " treeSource=" + (sourceTree == null ? "" : sourceTree) + " tag=" + tag; } } private void assertValid() { if (source == null) { throw new IllegalStateException("In invalid state"); } } private void takePermissionSAF() throws IOException { try { context.getContentResolver().takePersistableUriPermission(docFile.getUri(), StoredDirectoryHelper.PERMISSION_FLAGS); } catch (final Exception e) { if (docFile.getName() == null) { throw new IOException(e); } } } @NonNull private DocumentFile createSAF(@Nullable final Context ctx, final String mime, final String filename) throws IOException { DocumentFile res = StoredDirectoryHelper.findFileSAFHelper(ctx, docTree, filename); if (res != null && res.exists() && res.isDirectory()) { if (!res.delete()) { throw new IOException("Directory with the same name found but cannot delete"); } res = null; } if (res == null) { res = this.docTree.createFile(srcType == null ? DEFAULT_MIME : mime, filename); if (res == null) { throw new IOException("Cannot create the file"); } } return res; } private String getLowerCase(final String str) { return str == null ? null : str.toLowerCase(); } private boolean stringMismatch(final String str1, final String str2) { if (str1 == null && str2 == null) { return false; } if ((str1 == null) != (str2 == null)) { return true; } return !str1.equals(str2); } public static Intent getPicker(@NonNull final Context ctx, @NonNull final String mimeType) { if (NewPipeSettings.useStorageAccessFramework(ctx)) { return new Intent(Intent.ACTION_OPEN_DOCUMENT) .putExtra("android.content.extra.SHOW_ADVANCED", true) .setType(mimeType) .addCategory(Intent.CATEGORY_OPENABLE) .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | StoredDirectoryHelper.PERMISSION_FLAGS); } else { return new Intent(ctx, FilePickerActivityHelper.class) .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) .putExtra(FilePickerActivityHelper.EXTRA_SINGLE_CLICK, true) .putExtra(FilePickerActivityHelper.EXTRA_MODE, FilePickerActivityHelper.MODE_FILE); } } public static Intent getPicker(@NonNull final Context ctx, @NonNull final String mimeType, @Nullable final Uri initialPath) { return applyInitialPathToPickerIntent(ctx, getPicker(ctx, mimeType), initialPath, null); } public static Intent getNewPicker(@NonNull final Context ctx, @Nullable final String filename, @NonNull final String mimeType, @Nullable final Uri initialPath) { final Intent i; if (NewPipeSettings.useStorageAccessFramework(ctx)) { i = new Intent(Intent.ACTION_CREATE_DOCUMENT) .putExtra("android.content.extra.SHOW_ADVANCED", true) .setType(mimeType) .addCategory(Intent.CATEGORY_OPENABLE) .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | StoredDirectoryHelper.PERMISSION_FLAGS); if (filename != null) { i.putExtra(Intent.EXTRA_TITLE, filename); } } else { i = new Intent(ctx, FilePickerActivityHelper.class) .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_EXISTING_FILE, true) .putExtra(FilePickerActivityHelper.EXTRA_MODE, FilePickerActivityHelper.MODE_NEW_FILE); } return applyInitialPathToPickerIntent(ctx, i, initialPath, filename); } private static Intent applyInitialPathToPickerIntent(@NonNull final Context ctx, @NonNull final Intent intent, @Nullable final Uri initialPath, @Nullable final String filename) { if (NewPipeSettings.useStorageAccessFramework(ctx)) { if (initialPath == null) { return intent; // nothing to do, no initial path provided } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { return intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialPath); } else { return intent; // can't set initial path on API < 26 } } else { if (initialPath == null && filename == null) { return intent; // nothing to do, no initial path and no file name provided } File file; if (initialPath == null) { // The only way to set the previewed filename in non-SAF FilePicker is to set a // starting path ending with that filename. So when the initialPath is null but // filename isn't just default to the external storage directory. file = Environment.getExternalStorageDirectory(); } else { try { file = Utils.getFileForUri(initialPath); } catch (final Throwable ignored) { // getFileForUri() can't decode paths to 'storage', fallback to this file = new File(initialPath.toString()); } } // remove any filename at the end of the path (get the parent directory in that case) if (!file.exists() || !file.isDirectory()) { file = file.getParentFile(); if (file == null || !file.exists()) { // default to the external storage directory in case of an invalid path file = Environment.getExternalStorageDirectory(); } // else: file is surely a directory } if (filename != null) { // append a filename so that the non-SAF FilePicker shows it as the preview file = new File(file, filename); } return intent .putExtra(FilePickerActivityHelper.EXTRA_START_PATH, file.getAbsolutePath()); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java ================================================ package org.schabi.newpipe.util; import android.content.Context; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.BaseAdapter; import android.widget.ImageView; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper; import java.io.Serializable; import java.util.List; import java.util.stream.Collectors; /** * A list adapter for groups of {@link AudioStream}s (audio tracks). */ public class AudioTrackAdapter extends BaseAdapter { private final AudioTracksWrapper tracksWrapper; public AudioTrackAdapter(final AudioTracksWrapper tracksWrapper) { this.tracksWrapper = tracksWrapper; } @Override public int getCount() { return tracksWrapper.size(); } @Override public List getItem(final int position) { return tracksWrapper.getTracksList().get(position).getStreamsList(); } @Override public long getItemId(final int position) { return position; } @Override public View getView(final int position, final View convertView, final ViewGroup parent) { final var context = parent.getContext(); final View view; if (convertView == null) { view = LayoutInflater.from(context).inflate( R.layout.stream_quality_item, parent, false); } else { view = convertView; } final ImageView woSoundIconView = view.findViewById(R.id.wo_sound_icon); final TextView formatNameView = view.findViewById(R.id.stream_format_name); final TextView qualityView = view.findViewById(R.id.stream_quality); final TextView sizeView = view.findViewById(R.id.stream_size); final List streams = getItem(position); final AudioStream stream = streams.get(0); woSoundIconView.setVisibility(View.GONE); sizeView.setVisibility(View.VISIBLE); if (stream.getAudioTrackId() != null) { formatNameView.setText(stream.getAudioTrackId()); } qualityView.setText(Localization.audioTrackName(context, stream)); return view; } public static class AudioTracksWrapper implements Serializable { private final List> tracksList; public AudioTracksWrapper(@NonNull final List> groupedAudioStreams, @Nullable final Context context) { this.tracksList = groupedAudioStreams.stream().map(streams -> new StreamInfoWrapper<>(streams, context)).collect(Collectors.toList()); } public List> getTracksList() { return tracksList; } public int size() { return tracksList.size(); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/BridgeStateSaverInitializer.java ================================================ package org.schabi.newpipe.util; import android.content.Context; import android.os.Bundle; import android.os.Parcelable; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.evernote.android.state.StateSaver; import com.livefront.bridge.Bridge; import com.livefront.bridge.SavedStateHandler; import com.livefront.bridge.ViewSavedStateHandler; /** * Configures Bridge's state saver. */ public final class BridgeStateSaverInitializer { public static void init(final Context context) { Bridge.initialize( context, new SavedStateHandler() { @Override public void saveInstanceState( @NonNull final Object target, @NonNull final Bundle state) { StateSaver.saveInstanceState(target, state); } @Override public void restoreInstanceState( @NonNull final Object target, @Nullable final Bundle state) { StateSaver.restoreInstanceState(target, state); } }, new ViewSavedStateHandler() { @NonNull @Override public Parcelable saveInstanceState( @NonNull final T target, @Nullable final Parcelable parentState) { return StateSaver.saveInstanceState(target, parentState); } @Nullable @Override public Parcelable restoreInstanceState( @NonNull final T target, @Nullable final Parcelable state) { return StateSaver.restoreInstanceState(target, state); } } ); } private BridgeStateSaverInitializer() { } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java ================================================ package org.schabi.newpipe.util; import android.content.Context; import android.content.SharedPreferences; import androidx.annotation.StringRes; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; import java.util.List; import java.util.Set; public final class ChannelTabHelper { private ChannelTabHelper() { } /** * @param tab the channel tab to check * @return whether the tab should contain (playable) streams or not */ public static boolean isStreamsTab(final String tab) { switch (tab) { case ChannelTabs.VIDEOS: case ChannelTabs.TRACKS: case ChannelTabs.LIKES: case ChannelTabs.SHORTS: case ChannelTabs.LIVESTREAMS: return true; default: return false; } } /** * @param tab the channel tab link handler to check * @return whether the tab should contain (playable) streams or not */ public static boolean isStreamsTab(final ListLinkHandler tab) { final List contentFilters = tab.getContentFilters(); if (contentFilters.isEmpty()) { return false; // this should never happen, but check just to be sure } else { return isStreamsTab(contentFilters.get(0)); } } @StringRes private static int getShowTabKey(final String tab) { switch (tab) { case ChannelTabs.VIDEOS: return R.string.show_channel_tabs_videos; case ChannelTabs.TRACKS: return R.string.show_channel_tabs_tracks; case ChannelTabs.SHORTS: return R.string.show_channel_tabs_shorts; case ChannelTabs.LIVESTREAMS: return R.string.show_channel_tabs_livestreams; case ChannelTabs.CHANNELS: return R.string.show_channel_tabs_channels; case ChannelTabs.PLAYLISTS: return R.string.show_channel_tabs_playlists; case ChannelTabs.ALBUMS: return R.string.show_channel_tabs_albums; case ChannelTabs.LIKES: return R.string.show_channel_tabs_likes; default: return -1; } } @StringRes private static int getFetchFeedTabKey(final String tab) { switch (tab) { case ChannelTabs.VIDEOS: return R.string.fetch_channel_tabs_videos; case ChannelTabs.TRACKS: return R.string.fetch_channel_tabs_tracks; case ChannelTabs.SHORTS: return R.string.fetch_channel_tabs_shorts; case ChannelTabs.LIVESTREAMS: return R.string.fetch_channel_tabs_livestreams; case ChannelTabs.LIKES: return R.string.fetch_channel_tabs_likes; default: return -1; } } @StringRes public static int getTranslationKey(final String tab) { switch (tab) { case ChannelTabs.VIDEOS: return R.string.channel_tab_videos; case ChannelTabs.TRACKS: return R.string.channel_tab_tracks; case ChannelTabs.SHORTS: return R.string.channel_tab_shorts; case ChannelTabs.LIVESTREAMS: return R.string.channel_tab_livestreams; case ChannelTabs.CHANNELS: return R.string.channel_tab_channels; case ChannelTabs.PLAYLISTS: return R.string.channel_tab_playlists; case ChannelTabs.ALBUMS: return R.string.channel_tab_albums; case ChannelTabs.LIKES: return R.string.channel_tab_likes; default: return R.string.unknown_content; } } public static boolean showChannelTab(final Context context, final SharedPreferences sharedPreferences, @StringRes final int key) { final Set enabledTabs = sharedPreferences.getStringSet( context.getString(R.string.show_channel_tabs_key), null); if (enabledTabs == null) { return true; // default to true } else { return enabledTabs.contains(context.getString(key)); } } public static boolean showChannelTab(final Context context, final SharedPreferences sharedPreferences, final String tab) { final int key = ChannelTabHelper.getShowTabKey(tab); if (key == -1) { return false; } return showChannelTab(context, sharedPreferences, key); } public static boolean fetchFeedChannelTab(final Context context, final SharedPreferences sharedPreferences, final ListLinkHandler tab) { final List contentFilters = tab.getContentFilters(); if (contentFilters.isEmpty()) { return false; // this should never happen, but check just to be sure } final int key = ChannelTabHelper.getFetchFeedTabKey(contentFilters.get(0)); if (key == -1) { return false; } final Set enabledTabs = sharedPreferences.getStringSet( context.getString(R.string.feed_fetch_channel_tabs_key), null); if (enabledTabs == null) { return true; // default to true } else { return enabledTabs.contains(context.getString(key)); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/Constants.kt ================================================ @file:JvmName("Constants") package org.schabi.newpipe.util /** * Default duration when using throttle functions across the app, in milliseconds. */ const val DEFAULT_THROTTLE_TIMEOUT = 120L const val KEY_SERVICE_ID = "key_service_id" const val KEY_URL = "key_url" const val KEY_TITLE = "key_title" const val KEY_LINK_TYPE = "key_link_type" const val KEY_OPEN_SEARCH = "key_open_search" const val KEY_SEARCH_STRING = "key_search_string" const val KEY_THEME_CHANGE = "key_theme_change" const val KEY_MAIN_PAGE_CHANGE = "key_main_page_change" const val NO_SERVICE_ID = -1 ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/DependentPreferenceHelper.kt ================================================ /* * SPDX-FileCopyrightText: 2023-2026 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.util import android.content.Context import androidx.preference.PreferenceManager import org.schabi.newpipe.R /** * For preferences with dependencies and multiple use case, * this class can be used to reduce the lines of code. */ object DependentPreferenceHelper { /** * Option `Resume playback` depends on `Watch history`, this method can be used to retrieve if * `Resume playback` and its dependencies are all enabled. * * @param context the Android context * @return returns true if `Resume playback` and `Watch history` are both enabled */ @JvmStatic fun getResumePlaybackEnabled(context: Context): Boolean { val prefs = PreferenceManager.getDefaultSharedPreferences(context) return prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true) && prefs.getBoolean(context.getString(R.string.enable_playback_resume_key), true) } /** * Option `Position in lists` depends on `Watch history`, this method can be used to retrieve if * `Position in lists` and its dependencies are all enabled. * * @param context the Android context * @return returns true if `Positions in lists` and `Watch history` are both enabled */ @JvmStatic fun getPositionsInListsEnabled(context: Context): Boolean { val prefs = PreferenceManager.getDefaultSharedPreferences(context) return prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true) && prefs.getBoolean(context.getString(R.string.enable_playback_state_lists_key), true) } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java ================================================ package org.schabi.newpipe.util; import static android.content.Context.INPUT_SERVICE; import android.annotation.SuppressLint; import android.app.UiModeManager; import android.content.Context; import android.content.pm.PackageManager; import android.content.res.Configuration; import android.graphics.Point; import android.hardware.input.InputManager; import android.os.BatteryManager; import android.os.Build; import android.provider.Settings; import android.util.TypedValue; import android.view.InputDevice; import android.view.KeyEvent; import android.view.WindowInsets; import android.view.WindowManager; import android.webkit.CookieManager; import androidx.annotation.Dimension; import androidx.annotation.NonNull; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; import androidx.preference.PreferenceManager; import org.schabi.newpipe.App; import org.schabi.newpipe.R; import java.lang.reflect.Method; public final class DeviceUtils { private static final String AMAZON_FEATURE_FIRE_TV = "amazon.hardware.fire_tv"; private static final boolean SAMSUNG = Build.MANUFACTURER.equals("samsung"); private static Boolean isTV = null; private static Boolean isFireTV = null; /** *

The app version code that corresponds to the last update * of the media tunneling device blacklist.

*

The value of this variable needs to be updated everytime a new device that does not * support media tunneling to match the upcoming version code.

* @see #shouldSupportMediaTunneling() */ public static final int MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION = 994; // region: devices not supporting media tunneling / media tunneling blacklist /** *

Formuler Z8 Pro, Z8, CC, Z Alpha, Z+ Neo.

*

Blacklist reason: black screen

*

Board: HiSilicon Hi3798MV200

*/ private static final boolean HI3798MV200 = Build.VERSION.SDK_INT == 24 && Build.DEVICE.equals("Hi3798MV200"); /** *

Zephir TS43UHD-2.

*

Blacklist reason: black screen

*/ private static final boolean CVT_MT5886_EU_1G = Build.VERSION.SDK_INT == 24 && Build.DEVICE.equals("cvt_mt5886_eu_1g"); /** * Hilife TV. *

Blacklist reason: black screen

*/ private static final boolean REALTEKATV = Build.VERSION.SDK_INT == 25 && Build.DEVICE.equals("RealtekATV"); /** *

Phillips 4K (O)LED TV.

* Supports custom ROMs with different API levels */ private static final boolean PH7M_EU_5596 = Build.VERSION.SDK_INT >= 26 && Build.DEVICE.equals("PH7M_EU_5596"); /** *

Philips QM16XE.

*

Blacklist reason: black screen

*/ private static final boolean QM16XE_U = Build.VERSION.SDK_INT == 23 && Build.DEVICE.equals("QM16XE_U"); /** *

Sony Bravia VH1.

*

Processor: MT5895

*

Blacklist reason: fullscreen crash / stuttering

*/ private static final boolean BRAVIA_VH1 = Build.VERSION.SDK_INT == 29 && Build.DEVICE.equals("BRAVIA_VH1"); /** *

Sony Bravia VH2.

*

Blacklist reason: fullscreen crash; this includes model A90J as reported in * * #9023

*/ private static final boolean BRAVIA_VH2 = Build.VERSION.SDK_INT == 29 && Build.DEVICE.equals("BRAVIA_VH2"); /** *

Sony Bravia Android TV platform 2.

* Uses a MediaTek MT5891 (MT5596) SoC. * @see * https://github.com/CiNcH83/bravia_atv2 */ private static final boolean BRAVIA_ATV2 = Build.DEVICE.equals("BRAVIA_ATV2"); /** *

Sony Bravia Android TV platform 3 4K.

*

Uses ARM MT5891 and a {@link #BRAVIA_ATV2} motherboard.

* * @see * https://browser.geekbench.com/v4/cpu/9101105 */ private static final boolean BRAVIA_ATV3_4K = Build.DEVICE.equals("BRAVIA_ATV3_4K"); /** *

Panasonic 4KTV-JUP.

*

Blacklist reason: fullscreen crash

*/ private static final boolean TX_50JXW834 = Build.DEVICE.equals("TX_50JXW834"); /** *

Bouygtel4K / Bouygues Telecom Bbox 4K.

*

Blacklist reason: black screen; reported at * * #10122

*/ private static final boolean HMB9213NW = Build.DEVICE.equals("HMB9213NW"); // endregion private DeviceUtils() { } public static boolean isFireTv() { if (isFireTV != null) { return isFireTV; } isFireTV = App.getInstance().getPackageManager().hasSystemFeature(AMAZON_FEATURE_FIRE_TV); return isFireTV; } public static boolean isTv(final Context context) { if (isTV != null) { return isTV; } final PackageManager pm = App.getInstance().getPackageManager(); // from doc: https://developer.android.com/training/tv/start/hardware.html#runtime-check boolean isTv = ContextCompat.getSystemService(context, UiModeManager.class) .getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION || isFireTv() || pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK); // from https://stackoverflow.com/a/58932366 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { final boolean isBatteryAbsent = context.getSystemService(BatteryManager.class) .getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) == 0; isTv = isTv || (isBatteryAbsent && !pm.hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN) && pm.hasSystemFeature(PackageManager.FEATURE_USB_HOST) && pm.hasSystemFeature(PackageManager.FEATURE_ETHERNET)); } DeviceUtils.isTV = isTv; return DeviceUtils.isTV; } /** * Checks if the device is in desktop or DeX mode. This function should only * be invoked once on view load as it is using reflection for the DeX checks. * @param context the context to use for services and config. * @return true if the Android device is in desktop mode or using DeX. */ @SuppressWarnings("JavaReflectionMemberAccess") public static boolean isDesktopMode(@NonNull final Context context) { // Adapted from https://stackoverflow.com/a/64615568 // to check for all input devices that have an active cursor final InputManager im = (InputManager) context.getSystemService(INPUT_SERVICE); for (final int id : im.getInputDeviceIds()) { final InputDevice inputDevice = im.getInputDevice(id); if (inputDevice.supportsSource(InputDevice.SOURCE_BLUETOOTH_STYLUS) || inputDevice.supportsSource(InputDevice.SOURCE_MOUSE) || inputDevice.supportsSource(InputDevice.SOURCE_STYLUS) || inputDevice.supportsSource(InputDevice.SOURCE_TOUCHPAD) || inputDevice.supportsSource(InputDevice.SOURCE_TRACKBALL)) { return true; } } final UiModeManager uiModeManager = ContextCompat.getSystemService(context, UiModeManager.class); if (uiModeManager != null && uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_DESK) { return true; } if (!SAMSUNG) { return false; // DeX is Samsung-specific, skip the checks below on non-Samsung devices } // DeX check for standalone and multi-window mode, from: // https://developer.samsung.com/samsung-dex/modify-optimizing.html try { final Configuration config = context.getResources().getConfiguration(); final Class configClass = config.getClass(); final int semDesktopModeEnabledConst = configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass); final int currentMode = configClass.getField("semDesktopModeEnabled").getInt(config); if (semDesktopModeEnabledConst == currentMode) { return true; } } catch (final NoSuchFieldException | IllegalAccessException ignored) { // Device doesn't seem to support DeX } @SuppressLint("WrongConstant") final Object desktopModeManager = context .getApplicationContext() .getSystemService("desktopmode"); if (desktopModeManager != null) { try { final Method getDesktopModeStateMethod = desktopModeManager.getClass() .getDeclaredMethod("getDesktopModeState"); final Object desktopModeState = getDesktopModeStateMethod .invoke(desktopModeManager); final Class desktopModeStateClass = desktopModeState.getClass(); final Method getEnabledMethod = desktopModeStateClass .getDeclaredMethod("getEnabled"); final int enabledStatus = (int) getEnabledMethod.invoke(desktopModeState); if (enabledStatus == desktopModeStateClass .getDeclaredField("ENABLED").getInt(desktopModeStateClass)) { return true; } } catch (final Exception ignored) { // Device does not support DeX 3.0 or something went wrong when trying to determine // if it supports this feature } } return false; } public static boolean isTablet(@NonNull final Context context) { final String tabletModeSetting = PreferenceManager.getDefaultSharedPreferences(context) .getString(context.getString(R.string.tablet_mode_key), ""); if (tabletModeSetting.equals(context.getString(R.string.tablet_mode_on_key))) { return true; } else if (tabletModeSetting.equals(context.getString(R.string.tablet_mode_off_key))) { return false; } // else automatically determine whether we are in a tablet or not return (context.getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE; } public static boolean isConfirmKey(final int keyCode) { switch (keyCode) { case KeyEvent.KEYCODE_DPAD_CENTER: case KeyEvent.KEYCODE_ENTER: case KeyEvent.KEYCODE_SPACE: case KeyEvent.KEYCODE_NUMPAD_ENTER: return true; default: return false; } } public static int dpToPx(@Dimension(unit = Dimension.DP) final int dp, @NonNull final Context context) { return (int) TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, dp, context.getResources().getDisplayMetrics()); } public static int spToPx(@Dimension(unit = Dimension.SP) final int sp, @NonNull final Context context) { return (int) TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_SP, sp, context.getResources().getDisplayMetrics()); } public static boolean isLandscape(final Context context) { return context.getResources().getDisplayMetrics().heightPixels < context.getResources() .getDisplayMetrics().widthPixels; } public static boolean isInMultiWindow(final AppCompatActivity activity) { return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity.isInMultiWindowMode(); } public static boolean hasAnimationsAnimatorDurationEnabled(final Context context) { return Settings.System.getFloat( context.getContentResolver(), Settings.Global.ANIMATOR_DURATION_SCALE, 1F) != 0F; } public static int getWindowHeight(@NonNull final WindowManager windowManager) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { final var windowMetrics = windowManager.getCurrentWindowMetrics(); final var windowInsets = windowMetrics.getWindowInsets(); final var insets = windowInsets.getInsetsIgnoringVisibility( WindowInsets.Type.navigationBars() | WindowInsets.Type.displayCutout()); return windowMetrics.getBounds().height() - (insets.top + insets.bottom); } else { final Point point = new Point(); windowManager.getDefaultDisplay().getSize(point); return point.y; } } /** *

Some devices have broken tunneled video playback but claim to support it.

*

This can cause a black video player surface while attempting to play a video or * crashes while entering or exiting the full screen player. * The issue effects Android TVs most commonly. * See #5911 and * #9023 for more info.

* @Note Update {@link #MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION} * when adding a new device to the method. * @return {@code false} if affected device; {@code true} otherwise */ public static boolean shouldSupportMediaTunneling() { // Maintainers note: update MEDIA_TUNNELING_DEVICES_UPDATE_APP_VERSION_CODE return !HI3798MV200 && !CVT_MT5886_EU_1G && !REALTEKATV && !QM16XE_U && !BRAVIA_VH1 && !BRAVIA_VH2 && !BRAVIA_ATV2 && !BRAVIA_ATV3_4K && !PH7M_EU_5596 && !TX_50JXW834 && !HMB9213NW; } /** * @return whether the device has support for WebView, see * https://stackoverflow.com/a/69626735 */ public static boolean supportsWebView() { try { CookieManager.getInstance(); return true; } catch (final Throwable ignored) { return false; } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java ================================================ /* * Copyright 2017 Mauricio Colli * ExtractorHelper.java is part of NewPipe * * License: GPL-3.0+ * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package org.schabi.newpipe.util; 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.util.Log; import android.view.View; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.text.HtmlCompat; import androidx.preference.PreferenceManager; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.Info; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage; import org.schabi.newpipe.extractor.MetaInfo; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; import org.schabi.newpipe.extractor.comments.CommentsInfo; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; 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.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor; import org.schabi.newpipe.util.text.TextLinkifier; import java.util.Collections; import java.util.List; import io.reactivex.rxjava3.core.Maybe; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; public final class ExtractorHelper { private static final String TAG = ExtractorHelper.class.getSimpleName(); private static final InfoCache CACHE = InfoCache.getInstance(); private ExtractorHelper() { //no instance } private static void checkServiceId(final int serviceId) { if (serviceId == Constants.NO_SERVICE_ID) { throw new IllegalArgumentException("serviceId is NO_SERVICE_ID"); } } public static Single searchFor(final int serviceId, final String searchString, final List contentFilter, final String sortFilter) { checkServiceId(serviceId); return Single.fromCallable(() -> SearchInfo.getInfo(NewPipe.getService(serviceId), NewPipe.getService(serviceId) .getSearchQHFactory() .fromQuery(searchString, contentFilter, sortFilter))); } public static Single> getMoreSearchItems( final int serviceId, final String searchString, final List contentFilter, final String sortFilter, final Page page) { checkServiceId(serviceId); return Single.fromCallable(() -> SearchInfo.getMoreItems(NewPipe.getService(serviceId), NewPipe.getService(serviceId) .getSearchQHFactory() .fromQuery(searchString, contentFilter, sortFilter), page)); } public static Single> suggestionsFor(final int serviceId, final String query) { checkServiceId(serviceId); return Single.fromCallable(() -> { final SuggestionExtractor extractor = NewPipe.getService(serviceId) .getSuggestionExtractor(); return extractor != null ? extractor.suggestionList(query) : Collections.emptyList(); }); } public static Single getStreamInfo(final int serviceId, final String url, final boolean forceLoad) { checkServiceId(serviceId); return checkCache(forceLoad, serviceId, url, InfoCache.Type.STREAM, Single.fromCallable(() -> StreamInfo.getInfo(NewPipe.getService(serviceId), url))); } public static Single getChannelInfo(final int serviceId, final String url, final boolean forceLoad) { checkServiceId(serviceId); return checkCache(forceLoad, serviceId, url, InfoCache.Type.CHANNEL, Single.fromCallable(() -> ChannelInfo.getInfo(NewPipe.getService(serviceId), url))); } public static Single getChannelTab(final int serviceId, final ListLinkHandler listLinkHandler, final boolean forceLoad) { checkServiceId(serviceId); return checkCache(forceLoad, serviceId, listLinkHandler.getUrl(), InfoCache.Type.CHANNEL_TAB, Single.fromCallable(() -> ChannelTabInfo.getInfo(NewPipe.getService(serviceId), listLinkHandler))); } public static Single> getMoreChannelTabItems( final int serviceId, final ListLinkHandler listLinkHandler, final Page nextPage) { checkServiceId(serviceId); return Single.fromCallable(() -> ChannelTabInfo.getMoreItems(NewPipe.getService(serviceId), listLinkHandler, nextPage)); } public static Single getCommentsInfo(final int serviceId, final String url, final boolean forceLoad) { checkServiceId(serviceId); return checkCache(forceLoad, serviceId, url, InfoCache.Type.COMMENTS, Single.fromCallable(() -> CommentsInfo.getInfo(NewPipe.getService(serviceId), url))); } public static Single> getMoreCommentItems( final int serviceId, final CommentsInfo info, final Page nextPage) { checkServiceId(serviceId); return Single.fromCallable(() -> CommentsInfo.getMoreItems(NewPipe.getService(serviceId), info, nextPage)); } public static Single> getMoreCommentItems( final int serviceId, final String url, final Page nextPage) { checkServiceId(serviceId); return Single.fromCallable(() -> CommentsInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage)); } public static Single getPlaylistInfo(final int serviceId, final String url, final boolean forceLoad) { checkServiceId(serviceId); return checkCache(forceLoad, serviceId, url, InfoCache.Type.PLAYLIST, Single.fromCallable(() -> PlaylistInfo.getInfo(NewPipe.getService(serviceId), url))); } public static Single> getMorePlaylistItems(final int serviceId, final String url, final Page nextPage) { checkServiceId(serviceId); return Single.fromCallable(() -> PlaylistInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage)); } public static Single getKioskInfo(final int serviceId, final String url, final boolean forceLoad) { return checkCache(forceLoad, serviceId, url, InfoCache.Type.KIOSK, Single.fromCallable(() -> KioskInfo.getInfo(NewPipe.getService(serviceId), url))); } public static Single> getMoreKioskItems(final int serviceId, final String url, final Page nextPage) { return Single.fromCallable(() -> KioskInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage)); } /*////////////////////////////////////////////////////////////////////////// // Cache //////////////////////////////////////////////////////////////////////////*/ /** * Check if we can load it from the cache (forceLoad parameter), if we can't, * load from the network (Single loadFromNetwork) * and put the results in the cache. * * @param the item type's class that extends {@link Info} * @param forceLoad whether to force loading from the network instead of from the cache * @param serviceId the service to load from * @param url the URL to load * @param cacheType the {@link InfoCache.Type} of the item * @param loadFromNetwork the {@link Single} to load the item from the network * @return a {@link Single} that loads the item */ private static Single checkCache(final boolean forceLoad, final int serviceId, @NonNull final String url, @NonNull final InfoCache.Type cacheType, @NonNull final Single loadFromNetwork) { checkServiceId(serviceId); final Single actualLoadFromNetwork = loadFromNetwork .doOnSuccess(info -> CACHE.putInfo(serviceId, url, info, cacheType)); final Single load; if (forceLoad) { CACHE.removeInfo(serviceId, url, cacheType); load = actualLoadFromNetwork; } else { load = Maybe.concat(ExtractorHelper.loadFromCache(serviceId, url, cacheType), actualLoadFromNetwork.toMaybe()) .firstElement() // Take the first valid .toSingle(); } return load; } /** * Default implementation uses the {@link InfoCache} to get cached results. * * @param the item type's class that extends {@link Info} * @param serviceId the service to load from * @param url the URL to load * @param cacheType the {@link InfoCache.Type} of the item * @return a {@link Single} that loads the item */ private static Maybe loadFromCache( final int serviceId, @NonNull final String url, @NonNull final InfoCache.Type cacheType) { checkServiceId(serviceId); return Maybe.defer(() -> { //noinspection unchecked final I info = (I) CACHE.getFromKey(serviceId, url, cacheType); if (MainActivity.DEBUG) { Log.d(TAG, "loadFromCache() called, info > " + info); } // Only return info if it's not null (it is cached) if (info != null) { return Maybe.just(info); } return Maybe.empty(); }); } public static boolean isCached(final int serviceId, @NonNull final String url, @NonNull final InfoCache.Type cacheType) { return null != loadFromCache(serviceId, url, cacheType).blockingGet(); } /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ /** * Formats the text contained in the meta info list as HTML and puts it into the text view, * while also making the separator visible. If the list is null or empty, or the user chose not * to see meta information, both the text view and the separator are hidden * * @param metaInfos a list of meta information, can be null or empty * @param metaInfoTextView the text view in which to show the formatted HTML * @param metaInfoSeparator another view to be shown or hidden accordingly to the text view * @param disposables disposables created by the method are added here and their lifecycle * should be handled by the calling class */ public static void showMetaInfoInTextView(@Nullable final List metaInfos, final TextView metaInfoTextView, final View metaInfoSeparator, final CompositeDisposable disposables) { final Context context = metaInfoTextView.getContext(); if (metaInfos == null || metaInfos.isEmpty() || !PreferenceManager.getDefaultSharedPreferences(context).getBoolean( context.getString(R.string.show_meta_info_key), true)) { metaInfoTextView.setVisibility(View.GONE); metaInfoSeparator.setVisibility(View.GONE); } else { final StringBuilder stringBuilder = new StringBuilder(); for (final MetaInfo metaInfo : metaInfos) { if (!isNullOrEmpty(metaInfo.getTitle())) { stringBuilder.append("").append(metaInfo.getTitle()).append("") .append(Localization.DOT_SEPARATOR); } String content = metaInfo.getContent().getContent().trim(); if (content.endsWith(".")) { content = content.substring(0, content.length() - 1); // remove . at end } stringBuilder.append(content); for (int i = 0; i < metaInfo.getUrls().size(); i++) { if (i == 0) { stringBuilder.append(Localization.DOT_SEPARATOR); } else { stringBuilder.append("

"); } stringBuilder .append("") .append(capitalizeIfAllUppercase(metaInfo.getUrlTexts().get(i).trim())) .append(""); } } metaInfoSeparator.setVisibility(View.VISIBLE); TextLinkifier.fromHtml(metaInfoTextView, stringBuilder.toString(), HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, null, disposables, SET_LINK_MOVEMENT_METHOD); } } private static String capitalizeIfAllUppercase(final String text) { for (int i = 0; i < text.length(); i++) { if (Character.isLowerCase(text.charAt(i))) { return text; // there is at least a lowercase letter -> not all uppercase } } if (text.isEmpty()) { return text; } else { return text.substring(0, 1).toUpperCase() + text.substring(1).toLowerCase(); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/FallbackViewHolder.java ================================================ package org.schabi.newpipe.util; import android.view.View; import androidx.recyclerview.widget.RecyclerView; public class FallbackViewHolder extends RecyclerView.ViewHolder { public FallbackViewHolder(final View itemView) { super(itemView); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java ================================================ package org.schabi.newpipe.util; import android.content.Context; import android.net.Uri; import android.os.Bundle; import android.os.Environment; import android.util.TypedValue; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.loader.content.Loader; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.SortedList; import com.nononsenseapps.filepicker.AbstractFilePickerFragment; import com.nononsenseapps.filepicker.FilePickerFragment; import org.schabi.newpipe.R; import java.io.File; public class FilePickerActivityHelper extends com.nononsenseapps.filepicker.FilePickerActivity { private CustomFilePickerFragment currentFragment; public static boolean isOwnFileUri(@NonNull final Context context, @NonNull final Uri uri) { if (uri.getAuthority() == null) { return false; } return uri.getAuthority().startsWith(context.getPackageName()); } @Override public void onCreate(final Bundle savedInstanceState) { if (ThemeHelper.isLightThemeSelected(this)) { this.setTheme(R.style.FilePickerThemeLight); } else { this.setTheme(R.style.FilePickerThemeDark); } super.onCreate(savedInstanceState); } @Override public void onBackPressed() { // If at top most level, normal behaviour if (currentFragment.isBackTop()) { super.onBackPressed(); } else { // Else go up currentFragment.goUp(); } } @Override protected AbstractFilePickerFragment getFragment(@Nullable final String startPath, final int mode, final boolean allowMultiple, final boolean allowCreateDir, final boolean allowExistingFile, final boolean singleClick) { final CustomFilePickerFragment fragment = new CustomFilePickerFragment(); fragment.setArgs(startPath != null ? startPath : Environment.getExternalStorageDirectory().getPath(), mode, allowMultiple, allowCreateDir, allowExistingFile, singleClick); currentFragment = fragment; return currentFragment; } /*////////////////////////////////////////////////////////////////////////// // Internal //////////////////////////////////////////////////////////////////////////*/ public static class CustomFilePickerFragment extends FilePickerFragment { @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { return super.onCreateView(inflater, container, savedInstanceState); } @NonNull @Override public RecyclerView.ViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { final RecyclerView.ViewHolder viewHolder = super.onCreateViewHolder(parent, viewType); final View view = viewHolder.itemView.findViewById(android.R.id.text1); if (view instanceof TextView) { ((TextView) view).setTextSize(TypedValue.COMPLEX_UNIT_PX, getResources().getDimension(R.dimen.file_picker_items_text_size)); } return viewHolder; } @Override public void onClickOk(@NonNull final View view) { if (mode == MODE_NEW_FILE && getNewFileName().isEmpty()) { if (mToast != null) { mToast.cancel(); } mToast = Toast.makeText(getActivity(), R.string.file_name_empty_error, Toast.LENGTH_SHORT); mToast.show(); return; } super.onClickOk(view); } @Override protected boolean isItemVisible(@NonNull final File file) { if (file.isDirectory() && file.isHidden()) { return true; } return super.isItemVisible(file); } public File getBackTop() { if (getArguments() == null) { return Environment.getExternalStorageDirectory(); } final String path = getArguments().getString(KEY_START_PATH, "/"); if (path.contains(Environment.getExternalStorageDirectory().getPath())) { return Environment.getExternalStorageDirectory(); } return getPath(path); } public boolean isBackTop() { return compareFiles(mCurrentPath, getBackTop()) == 0 || compareFiles(mCurrentPath, new File("/")) == 0; } @Override public void onLoadFinished(@NonNull final Loader> loader, final SortedList data) { super.onLoadFinished(loader, data); layoutManager.scrollToPosition(0); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/FilenameUtils.kt ================================================ /* * SPDX-FileCopyrightText: 2017-2025 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.util import android.content.Context import androidx.preference.PreferenceManager import java.util.regex.Matcher import org.schabi.newpipe.R import org.schabi.newpipe.ktx.getStringSafe object FilenameUtils { private const val CHARSET_MOST_SPECIAL = "[\\n\\r|?*<\":\\\\>/']+" private const val CHARSET_ONLY_LETTERS_AND_DIGITS = "[^\\w\\d]+" /** * #143 #44 #42 #22: make sure that the filename does not contain illegal chars. * * @param context the context to retrieve strings and preferences from * @param title the title to create a filename from * @return the filename */ @JvmStatic fun createFilename(context: Context, title: String): String { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) val charsetLd = context.getString(R.string.charset_letters_and_digits_value) val charsetMs = context.getString(R.string.charset_most_special_value) val defaultCharset = context.getString(R.string.default_file_charset_value) val replacementChar = sharedPreferences.getStringSafe( context.getString(R.string.settings_file_replacement_character_key), "_" ) val selectedCharset = sharedPreferences.getStringSafe( context.getString(R.string.settings_file_charset_key), "" ).ifEmpty { defaultCharset } val charset = when (selectedCharset) { charsetLd -> CHARSET_ONLY_LETTERS_AND_DIGITS charsetMs -> CHARSET_MOST_SPECIAL else -> selectedCharset // Is the user using a custom charset? } return createFilename(title, charset, Matcher.quoteReplacement(replacementChar)) } /** * Create a valid filename. * * @param title the title to create a filename from * @param invalidCharacters patter matching invalid characters * @param replacementChar the replacement * @return the filename */ private fun createFilename( title: String, invalidCharacters: String, replacementChar: String ): String { return title.replace(invalidCharacters.toRegex(), replacementChar) } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/InfoCache.java ================================================ /* * Copyright 2017 Mauricio Colli * InfoCache.java is part of NewPipe * * License: GPL-3.0+ * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package org.schabi.newpipe.util; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.collection.LruCache; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.extractor.Info; import java.util.Map; public final class InfoCache { private final String TAG = getClass().getSimpleName(); private static final boolean DEBUG = MainActivity.DEBUG; private static final InfoCache INSTANCE = new InfoCache(); private static final int MAX_ITEMS_ON_CACHE = 60; /** * Trim the cache to this size. */ private static final int TRIM_CACHE_TO = 30; private static final LruCache LRU_CACHE = new LruCache<>(MAX_ITEMS_ON_CACHE); private InfoCache() { // no instance } /** * Identifies the type of {@link Info} to put into the cache. */ public enum Type { STREAM, CHANNEL, CHANNEL_TAB, COMMENTS, PLAYLIST, KIOSK, } public static InfoCache getInstance() { return INSTANCE; } @NonNull private static String keyOf(final int serviceId, @NonNull final String url, @NonNull final Type cacheType) { return serviceId + ":" + cacheType.ordinal() + ":" + url; } private static void removeStaleCache() { for (final Map.Entry entry : InfoCache.LRU_CACHE.snapshot().entrySet()) { final CacheData data = entry.getValue(); if (data != null && data.isExpired()) { InfoCache.LRU_CACHE.remove(entry.getKey()); } } } @Nullable private static Info getInfo(@NonNull final String key) { final CacheData data = InfoCache.LRU_CACHE.get(key); if (data == null) { return null; } if (data.isExpired()) { InfoCache.LRU_CACHE.remove(key); return null; } return data.info; } @Nullable public Info getFromKey(final int serviceId, @NonNull final String url, @NonNull final Type cacheType) { if (DEBUG) { Log.d(TAG, "getFromKey() called with: " + "serviceId = [" + serviceId + "], url = [" + url + "]"); } synchronized (LRU_CACHE) { return getInfo(keyOf(serviceId, url, cacheType)); } } public void putInfo(final int serviceId, @NonNull final String url, @NonNull final Info info, @NonNull final Type cacheType) { if (DEBUG) { Log.d(TAG, "putInfo() called with: info = [" + info + "]"); } final long expirationMillis = ServiceHelper.getCacheExpirationMillis(info.getServiceId()); synchronized (LRU_CACHE) { final CacheData data = new CacheData(info, expirationMillis); LRU_CACHE.put(keyOf(serviceId, url, cacheType), data); } } public void removeInfo(final int serviceId, @NonNull final String url, @NonNull final Type cacheType) { if (DEBUG) { Log.d(TAG, "removeInfo() called with: " + "serviceId = [" + serviceId + "], url = [" + url + "]"); } synchronized (LRU_CACHE) { LRU_CACHE.remove(keyOf(serviceId, url, cacheType)); } } public void clearCache() { if (DEBUG) { Log.d(TAG, "clearCache() called"); } synchronized (LRU_CACHE) { LRU_CACHE.evictAll(); } } public void trimCache() { if (DEBUG) { Log.d(TAG, "trimCache() called"); } synchronized (LRU_CACHE) { removeStaleCache(); LRU_CACHE.trimToSize(TRIM_CACHE_TO); } } public long getSize() { synchronized (LRU_CACHE) { return LRU_CACHE.size(); } } private static final class CacheData { private final long expireTimestamp; private final Info info; private CacheData(@NonNull final Info info, final long timeoutMillis) { this.expireTimestamp = System.currentTimeMillis() + timeoutMillis; this.info = info; } private boolean isExpired() { return System.currentTimeMillis() > expireTimestamp; } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java ================================================ package org.schabi.newpipe.util; import android.app.Activity; import android.view.inputmethod.InputMethodManager; import android.widget.EditText; import androidx.core.content.ContextCompat; /** * Utility class for the Android keyboard. *

* See also https://stackoverflow.com/q/1109022 *

*/ public final class KeyboardUtil { private KeyboardUtil() { } public static void showKeyboard(final Activity activity, final EditText editText) { if (activity == null || editText == null) { return; } if (editText.requestFocus()) { final InputMethodManager imm = ContextCompat.getSystemService(activity, InputMethodManager.class); if (!imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED)) { /* * Sometimes the keyboard can't be shown because Android's ImeFocusController is in * a incorrect state e.g. when animations are disabled or the unfocus event of the * previous view arrives in the wrong moment (see #7647 for details). * The invalid state can be fixed by to re-focusing the editText. */ editText.clearFocus(); editText.requestFocus(); // Try again imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED); } } } public static void hideKeyboard(final Activity activity, final EditText editText) { if (activity == null || editText == null) { return; } final InputMethodManager imm = ContextCompat.getSystemService(activity, InputMethodManager.class); imm.hideSoftInputFromWindow(editText.getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS); editText.clearFocus(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/KioskTranslator.kt ================================================ /* * SPDX-FileCopyrightText: 2017-2025 NewPipe contributors * SPDX-FileCopyrightText: 2025 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.util import android.content.Context import org.schabi.newpipe.R object KioskTranslator { @JvmStatic fun getTranslatedKioskName(kioskId: String, context: Context): String { return when (kioskId) { "Trending" -> context.getString(R.string.trending) "Top 50" -> context.getString(R.string.top_50) "New & hot" -> context.getString(R.string.new_and_hot) "Local" -> context.getString(R.string.local) "Recently added" -> context.getString(R.string.recently_added) "Most liked" -> context.getString(R.string.most_liked) "conferences" -> context.getString(R.string.conferences) "recent" -> context.getString(R.string.recent) "live" -> context.getString(R.string.duration_live) "Featured" -> context.getString(R.string.featured) "Radio" -> context.getString(R.string.radio) "trending_gaming" -> context.getString(R.string.trending_gaming) "trending_music" -> context.getString(R.string.trending_music) "trending_movies_and_shows" -> context.getString(R.string.trending_movies) "trending_podcasts_episodes" -> context.getString(R.string.trending_podcasts) else -> kioskId } } @JvmStatic fun getKioskIcon(kioskId: String): Int { return when (kioskId) { "Trending", "Top 50", "New & hot", "conferences" -> R.drawable.ic_whatshot "Local" -> R.drawable.ic_home "Recently added", "recent" -> R.drawable.ic_add_circle_outline "Most liked" -> R.drawable.ic_thumb_up "live" -> R.drawable.ic_live_tv "Featured" -> R.drawable.ic_stars "Radio" -> R.drawable.ic_radio "trending_gaming" -> R.drawable.ic_videogame_asset "trending_music" -> R.drawable.ic_music_note "trending_movies_and_shows" -> R.drawable.ic_movie "trending_podcasts_episodes" -> R.drawable.ic_podcasts else -> 0 } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/ListHelper.java ================================================ package org.schabi.newpipe.util; import static org.schabi.newpipe.extractor.ServiceList.YouTube; import android.content.Context; import android.content.SharedPreferences; import android.content.res.Resources; import android.net.ConnectivityManager; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.core.content.ContextCompat; import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.AudioTrackType; import org.schabi.newpipe.extractor.stream.DeliveryMethod; import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.VideoStream; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.List; import java.util.Locale; import java.util.Objects; import java.util.Set; import java.util.function.Predicate; import java.util.stream.Collectors; public final class ListHelper { // Video format in order of quality. 0=lowest quality, n=highest quality private static final List VIDEO_FORMAT_QUALITY_RANKING = List.of(MediaFormat.v3GPP, MediaFormat.WEBM, MediaFormat.MPEG_4); // Audio format in order of quality. 0=lowest quality, n=highest quality private static final List AUDIO_FORMAT_QUALITY_RANKING = List.of(MediaFormat.MP3, MediaFormat.WEBMA, MediaFormat.M4A); // Audio format in order of efficiency. 0=least efficient, n=most efficient private static final List AUDIO_FORMAT_EFFICIENCY_RANKING = List.of(MediaFormat.MP3, MediaFormat.M4A, MediaFormat.WEBMA); // Use a Set for better performance private static final Set HIGH_RESOLUTION_LIST = Set.of("1440p", "2160p"); // Audio track types in order of priority. 0=lowest, n=highest private static final List AUDIO_TRACK_TYPE_RANKING = List.of(AudioTrackType.DESCRIPTIVE, AudioTrackType.SECONDARY, AudioTrackType.DUBBED, AudioTrackType.ORIGINAL); // Audio track types in order of priority when descriptive audio is preferred. private static final List AUDIO_TRACK_TYPE_RANKING_DESCRIPTIVE = List.of(AudioTrackType.SECONDARY, AudioTrackType.DUBBED, AudioTrackType.ORIGINAL, AudioTrackType.DESCRIPTIVE); /** * List of supported YouTube Itag ids. * The original order is kept. * @see {@link org.schabi.newpipe.extractor.services.youtube.ItagItem#ITAG_LIST} */ private static final List SUPPORTED_ITAG_IDS = List.of( 17, 36, // video v3GPP 18, 34, 35, 59, 78, 22, 37, 38, // video MPEG4 43, 44, 45, 46, // video webm 171, 172, 139, 140, 141, 249, 250, 251, // audio 160, 133, 134, 135, 212, 136, 298, 137, 299, 266, // video only 278, 242, 243, 244, 245, 246, 247, 248, 271, 272, 302, 303, 308, 313, 315 ); private ListHelper() { } /** * @param context Android app context * @param videoStreams list of the video streams to check * @return index of the video stream with the default index * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) */ public static int getDefaultResolutionIndex(final Context context, final List videoStreams) { final String defaultResolution = computeDefaultResolution(context, R.string.default_resolution_key, R.string.default_resolution_value); return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams); } /** * @param context Android app context * @param videoStreams list of the video streams to check * @param defaultResolution the default resolution to look for * @return index of the video stream with the default index * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) */ public static int getResolutionIndex(final Context context, final List videoStreams, final String defaultResolution) { return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams); } /** * @param context Android app context * @param videoStreams list of the video streams to check * @return index of the video stream with the default index * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) */ public static int getPopupDefaultResolutionIndex(final Context context, final List videoStreams) { final String defaultResolution = computeDefaultResolution(context, R.string.default_popup_resolution_key, R.string.default_popup_resolution_value); return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams); } /** * @param context Android app context * @param videoStreams list of the video streams to check * @param defaultResolution the default resolution to look for * @return index of the video stream with the default index * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) */ public static int getPopupResolutionIndex(final Context context, final List videoStreams, final String defaultResolution) { return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams); } public static int getDefaultAudioFormat(final Context context, final List audioStreams) { return getAudioIndexByHighestRank(audioStreams, getAudioTrackComparator(context).thenComparing(getAudioFormatComparator(context))); } public static int getDefaultAudioTrackGroup(final Context context, final List> groupedAudioStreams) { if (groupedAudioStreams == null || groupedAudioStreams.isEmpty()) { return -1; } final Comparator cmp = getAudioTrackComparator(context); final List highestRanked = groupedAudioStreams.stream() .max((o1, o2) -> cmp.compare(o1.get(0), o2.get(0))) .orElse(null); return groupedAudioStreams.indexOf(highestRanked); } public static int getAudioFormatIndex(final Context context, final List audioStreams, @Nullable final String trackId) { if (trackId != null) { for (int i = 0; i < audioStreams.size(); i++) { final AudioStream s = audioStreams.get(i); if (s.getAudioTrackId() != null && s.getAudioTrackId().equals(trackId)) { return i; } } } return getDefaultAudioFormat(context, audioStreams); } /** * Return a {@link Stream} list which uses the given delivery method from a {@link Stream} * list. * * @param streamList the original {@link Stream stream} list * @param deliveryMethod the {@link DeliveryMethod delivery method} * @param the item type's class that extends {@link Stream} * @return a {@link Stream stream} list which uses the given delivery method */ @NonNull public static List getStreamsOfSpecifiedDelivery( @Nullable final List streamList, final DeliveryMethod deliveryMethod) { return getFilteredStreamList(streamList, stream -> stream.getDeliveryMethod() == deliveryMethod); } /** * Return a {@link Stream} list which only contains URL streams and non-torrent streams. * * @param streamList the original stream list * @param the item type's class that extends {@link Stream} * @return a stream list which only contains URL streams and non-torrent streams */ @NonNull public static List getUrlAndNonTorrentStreams( @Nullable final List streamList) { return getFilteredStreamList(streamList, stream -> stream.isUrl() && stream.getDeliveryMethod() != DeliveryMethod.TORRENT); } /** * Return a {@link Stream} list which only contains streams which can be played by the player. * *

* Some formats are not supported, see {@link #SUPPORTED_ITAG_IDS} for more details. * Torrent streams are also removed, because they cannot be retrieved, like OPUS streams using * HLS as their delivery method, since they are not supported by ExoPlayer. *

* * @param the item type's class that extends {@link Stream} * @param streamList the original stream list * @param serviceId the service ID from which the streams' list comes from * @return a stream list which only contains streams that can be played the player */ @NonNull public static List getPlayableStreams( @Nullable final List streamList, final int serviceId) { final int youtubeServiceId = YouTube.getServiceId(); return getFilteredStreamList(streamList, stream -> stream.getDeliveryMethod() != DeliveryMethod.TORRENT && (stream.getDeliveryMethod() != DeliveryMethod.HLS || stream.getFormat() != MediaFormat.OPUS) && (serviceId != youtubeServiceId || stream.getItagItem() == null || SUPPORTED_ITAG_IDS.contains(stream.getItagItem().id))); } /** * Join the two lists of video streams (video_only and normal videos), * and sort them according with default format chosen by the user. * * @param context the context to search for the format to give preference * @param videoStreams the normal videos list * @param videoOnlyStreams the video-only stream list * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest * @param preferVideoOnlyStreams if video-only streams should preferred when both video-only * streams and normal video streams are available * @return the sorted list */ @NonNull public static List getSortedStreamVideosList( @NonNull final Context context, @Nullable final List videoStreams, @Nullable final List videoOnlyStreams, final boolean ascendingOrder, final boolean preferVideoOnlyStreams) { final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); final boolean showHigherResolutions = preferences.getBoolean( context.getString(R.string.show_higher_resolutions_key), false); final MediaFormat defaultFormat = getDefaultFormat(context, R.string.default_video_format_key, R.string.default_video_format_value); return getSortedStreamVideosList(defaultFormat, showHigherResolutions, videoStreams, videoOnlyStreams, ascendingOrder, preferVideoOnlyStreams); } /** * Get a sorted list containing a set of default resolution info * and additional resolution info if showHigherResolutions is true. * * @param resources the resources to get the resolutions from * @param defaultResolutionKey the settings key of the default resolution * @param additionalResolutionKey the settings key of the additional resolutions * @param showHigherResolutions if higher resolutions should be included in the sorted list * @return a sorted list containing the default and maybe additional resolutions */ public static List getSortedResolutionList( final Resources resources, final int defaultResolutionKey, final int additionalResolutionKey, final boolean showHigherResolutions) { final List resolutions = new ArrayList<>(Arrays.asList( resources.getStringArray(defaultResolutionKey))); if (!showHigherResolutions) { return resolutions; } final List additionalResolutions = Arrays.asList( resources.getStringArray(additionalResolutionKey)); // keep "best resolution" at the top resolutions.addAll(1, additionalResolutions); return resolutions; } public static boolean isHighResolutionSelected(final String selectedResolution, final int additionalResolutionKey, final Resources resources) { return Arrays.asList(resources.getStringArray( additionalResolutionKey)) .contains(selectedResolution); } /** * Filter the list of audio streams and return a list with the preferred stream for * each audio track. Streams are sorted with the preferred language in the first position. * * @param context the context to search for the track to give preference * @param audioStreams the list of audio streams * @return the sorted, filtered list */ public static List getFilteredAudioStreams( @NonNull final Context context, @Nullable final List audioStreams) { if (audioStreams == null) { return Collections.emptyList(); } final HashMap collectedStreams = new HashMap<>(); final Comparator cmp = getAudioFormatComparator(context); for (final AudioStream stream : audioStreams) { if (stream.getDeliveryMethod() == DeliveryMethod.TORRENT || (stream.getDeliveryMethod() == DeliveryMethod.HLS && stream.getFormat() == MediaFormat.OPUS)) { continue; } final String trackId = Objects.toString(stream.getAudioTrackId(), ""); final AudioStream presentStream = collectedStreams.get(trackId); if (presentStream == null || cmp.compare(stream, presentStream) > 0) { collectedStreams.put(trackId, stream); } } // Filter unknown audio tracks if there are multiple tracks if (collectedStreams.size() > 1) { collectedStreams.remove(""); } // Sort collected streams by name return collectedStreams.values().stream().sorted(getAudioTrackNameComparator()) .collect(Collectors.toList()); } /** * Group the list of audioStreams by their track ID and sort the resulting list by track name. * * @param context app context to get track names for sorting * @param audioStreams list of audio streams * @return list of audio streams lists representing individual tracks */ public static List> getGroupedAudioStreams( @NonNull final Context context, @Nullable final List audioStreams) { if (audioStreams == null) { return Collections.emptyList(); } final HashMap> collectedStreams = new HashMap<>(); for (final AudioStream stream : audioStreams) { final String trackId = Objects.toString(stream.getAudioTrackId(), ""); if (collectedStreams.containsKey(trackId)) { collectedStreams.get(trackId).add(stream); } else { final List list = new ArrayList<>(); list.add(stream); collectedStreams.put(trackId, list); } } // Filter unknown audio tracks if there are multiple tracks if (collectedStreams.size() > 1) { collectedStreams.remove(""); } // Sort tracks alphabetically, sort track streams by quality final Comparator nameCmp = getAudioTrackNameComparator(); final Comparator formatCmp = getAudioFormatComparator(context); return collectedStreams.values().stream() .sorted((o1, o2) -> nameCmp.compare(o1.get(0), o2.get(0))) .map(streams -> streams.stream().sorted(formatCmp).collect(Collectors.toList())) .collect(Collectors.toList()); } /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ /** * Get a filtered stream list, by using Java 8 Stream's API and the given predicate. * * @param streamList the stream list to filter * @param streamListPredicate the predicate which will be used to filter streams * @param the item type's class that extends {@link Stream} * @return a new stream list filtered using the given predicate */ private static List getFilteredStreamList( @Nullable final List streamList, final Predicate streamListPredicate) { if (streamList == null) { return Collections.emptyList(); } return streamList.stream() .filter(streamListPredicate) .collect(Collectors.toList()); } private static String computeDefaultResolution(@NonNull final Context context, final int key, final int value) { final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); // Load the preferred resolution otherwise the best available String resolution = preferences != null ? preferences.getString(context.getString(key), context.getString(value)) : context.getString(R.string.best_resolution_key); final String maxResolution = getResolutionLimit(context); if (maxResolution != null && (resolution.equals(context.getString(R.string.best_resolution_key)) || compareVideoStreamResolution(maxResolution, resolution) < 1)) { resolution = maxResolution; } return resolution; } /** * Return the index of the default stream in the list, that will be sorted in the process, based * on the parameters defaultResolution and defaultFormat. * * @param defaultResolution the default resolution to look for * @param bestResolutionKey key of the best resolution * @param defaultFormat the default format to look for * @param videoStreams a mutable list of the video streams to check (it will be sorted in * place) * @return index of the default resolution&format in the sorted videoStreams */ static int getDefaultResolutionIndex(final String defaultResolution, final String bestResolutionKey, final MediaFormat defaultFormat, @Nullable final List videoStreams) { if (videoStreams == null || videoStreams.isEmpty()) { return -1; } sortStreamList(videoStreams, false); if (defaultResolution.equals(bestResolutionKey)) { return 0; } final int defaultStreamIndex = getVideoStreamIndex(defaultResolution, defaultFormat, videoStreams); // this is actually an error, // but maybe there is really no stream fitting to the default value. if (defaultStreamIndex == -1) { return 0; } return defaultStreamIndex; } /** * Join the two lists of video streams (video_only and normal videos), * and sort them according with default format chosen by the user. * * @param defaultFormat format to give preference * @param showHigherResolutions show >1080p resolutions * @param videoStreams normal videos list * @param videoOnlyStreams video only stream list * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest * @param preferVideoOnlyStreams if video-only streams should preferred when both video-only * streams and normal video streams are available * @return the sorted list */ @NonNull static List getSortedStreamVideosList( @Nullable final MediaFormat defaultFormat, final boolean showHigherResolutions, @Nullable final List videoStreams, @Nullable final List videoOnlyStreams, final boolean ascendingOrder, final boolean preferVideoOnlyStreams ) { // Determine order of streams // The last added list is preferred final List> videoStreamsOrdered = preferVideoOnlyStreams ? Arrays.asList(videoStreams, videoOnlyStreams) : Arrays.asList(videoOnlyStreams, videoStreams); final List allInitialStreams = videoStreamsOrdered.stream() // Ignore lists that are null .filter(Objects::nonNull) .flatMap(List::stream) // Filter out higher resolutions (or not if high resolutions should always be shown) .filter(stream -> showHigherResolutions || !HIGH_RESOLUTION_LIST.contains(stream.getResolution() // Replace any frame rate with nothing .replaceAll("p\\d+$", "p"))) .collect(Collectors.toList()); final HashMap hashMap = new HashMap<>(); // Add all to the hashmap for (final VideoStream videoStream : allInitialStreams) { hashMap.put(videoStream.getResolution(), videoStream); } // Override the values when the key == resolution, with the defaultFormat for (final VideoStream videoStream : allInitialStreams) { if (videoStream.getFormat() == defaultFormat) { hashMap.put(videoStream.getResolution(), videoStream); } } // Return the sorted list return sortStreamList(new ArrayList<>(hashMap.values()), ascendingOrder); } /** * Sort the streams list depending on the parameter ascendingOrder; *

* It works like that:
* - Take a string resolution, remove the letters, replace "0p60" (for 60fps videos) with "1" * and sort by the greatest:
*

     *      720p     ->  720
     *      720p60   ->  721
     *      360p     ->  360
     *      1080p    ->  1080
     *      1080p60  ->  1081
     * 
* ascendingOrder ? 360 < 720 < 721 < 1080 < 1081 * !ascendingOrder ? 1081 < 1080 < 721 < 720 < 360
* * @param videoStreams list that the sorting will be applied * @param ascendingOrder true -> smallest to greatest | false -> greatest to smallest * @return The sorted list (same reference as parameter videoStreams) */ private static List sortStreamList(final List videoStreams, final boolean ascendingOrder) { // Compares the quality of two video streams. final Comparator comparator = Comparator.nullsLast(Comparator .comparing(VideoStream::getResolution, ListHelper::compareVideoStreamResolution) .thenComparingInt(s -> VIDEO_FORMAT_QUALITY_RANKING.indexOf(s.getFormat()))); Collections.sort(videoStreams, ascendingOrder ? comparator : comparator.reversed()); return videoStreams; } /** * Get the audio-stream from the list with the highest rank, depending on the comparator. * Format will be ignored if it yields no results. * * @param audioStreams List of audio streams * @param comparator The comparator used for determining the max/best/highest ranked value * @return Index of audio stream that produces the highest ranked result or -1 if not found */ static int getAudioIndexByHighestRank(@Nullable final List audioStreams, final Comparator comparator) { if (audioStreams == null || audioStreams.isEmpty()) { return -1; } final AudioStream highestRankedAudioStream = audioStreams.stream() .max(comparator).orElse(null); return audioStreams.indexOf(highestRankedAudioStream); } /** * Locates a possible match for the given resolution and format in the provided list. * *

In this order:

* *
    *
  1. Find a format and resolution match
  2. *
  3. Find a format and resolution match and ignore the refresh
  4. *
  5. Find a resolution match
  6. *
  7. Find a resolution match and ignore the refresh
  8. *
  9. Find a resolution just below the requested resolution and ignore the refresh
  10. *
  11. Give up
  12. *
* * @param targetResolution the resolution to look for * @param targetFormat the format to look for * @param videoStreams the available video streams * @return the index of the preferred video stream */ static int getVideoStreamIndex(@NonNull final String targetResolution, final MediaFormat targetFormat, @NonNull final List videoStreams) { int fullMatchIndex = -1; int fullMatchNoRefreshIndex = -1; int resMatchOnlyIndex = -1; int resMatchOnlyNoRefreshIndex = -1; int lowerResMatchNoRefreshIndex = -1; final String targetResolutionNoRefresh = targetResolution.replaceAll("p\\d+$", "p"); for (int idx = 0; idx < videoStreams.size(); idx++) { final MediaFormat format = targetFormat == null ? null : videoStreams.get(idx).getFormat(); final String resolution = videoStreams.get(idx).getResolution(); final String resolutionNoRefresh = resolution.replaceAll("p\\d+$", "p"); if (format == targetFormat && resolution.equals(targetResolution)) { fullMatchIndex = idx; } if (format == targetFormat && resolutionNoRefresh.equals(targetResolutionNoRefresh)) { fullMatchNoRefreshIndex = idx; } if (resMatchOnlyIndex == -1 && resolution.equals(targetResolution)) { resMatchOnlyIndex = idx; } if (resMatchOnlyNoRefreshIndex == -1 && resolutionNoRefresh.equals(targetResolutionNoRefresh)) { resMatchOnlyNoRefreshIndex = idx; } if (lowerResMatchNoRefreshIndex == -1 && compareVideoStreamResolution( resolutionNoRefresh, targetResolutionNoRefresh) < 0) { lowerResMatchNoRefreshIndex = idx; } } if (fullMatchIndex != -1) { return fullMatchIndex; } if (fullMatchNoRefreshIndex != -1) { return fullMatchNoRefreshIndex; } if (resMatchOnlyIndex != -1) { return resMatchOnlyIndex; } if (resMatchOnlyNoRefreshIndex != -1) { return resMatchOnlyNoRefreshIndex; } return lowerResMatchNoRefreshIndex; } /** * Fetches the desired resolution or returns the default if it is not found. * The resolution will be reduced if video chocking is active. * * @param context Android app context * @param defaultResolution the default resolution * @param videoStreams the list of video streams to check * @return the index of the preferred video stream */ private static int getDefaultResolutionWithDefaultFormat(@NonNull final Context context, final String defaultResolution, final List videoStreams) { final MediaFormat defaultFormat = getDefaultFormat(context, R.string.default_video_format_key, R.string.default_video_format_value); return getDefaultResolutionIndex(defaultResolution, context.getString(R.string.best_resolution_key), defaultFormat, videoStreams); } @Nullable private static MediaFormat getDefaultFormat(@NonNull final Context context, @StringRes final int defaultFormatKey, @StringRes final int defaultFormatValueKey) { final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); final String defaultFormat = context.getString(defaultFormatValueKey); final String defaultFormatString = preferences.getString( context.getString(defaultFormatKey), defaultFormat ); return getMediaFormatFromKey(context, defaultFormatString); } @Nullable private static MediaFormat getMediaFormatFromKey(@NonNull final Context context, @NonNull final String formatKey) { MediaFormat format = null; if (formatKey.equals(context.getString(R.string.video_webm_key))) { format = MediaFormat.WEBM; } else if (formatKey.equals(context.getString(R.string.video_mp4_key))) { format = MediaFormat.MPEG_4; } else if (formatKey.equals(context.getString(R.string.video_3gp_key))) { format = MediaFormat.v3GPP; } else if (formatKey.equals(context.getString(R.string.audio_webm_key))) { format = MediaFormat.WEBMA; } else if (formatKey.equals(context.getString(R.string.audio_m4a_key))) { format = MediaFormat.M4A; } return format; } private static int compareVideoStreamResolution(@NonNull final String r1, @NonNull final String r2) { try { final int res1 = Integer.parseInt(r1.replaceAll("0p\\d+$", "1") .replaceAll("[^\\d.]", "")); final int res2 = Integer.parseInt(r2.replaceAll("0p\\d+$", "1") .replaceAll("[^\\d.]", "")); return res1 - res2; } catch (final NumberFormatException e) { // Consider the first one greater because we don't know if the two streams are // different or not (a NumberFormatException was thrown so we don't know the resolution // of one stream or of all streams) return 1; } } static boolean isLimitingDataUsage(@NonNull final Context context) { return getResolutionLimit(context) != null; } /** * The maximum resolution allowed. * * @param context App context * @return maximum resolution allowed or null if there is no maximum */ private static String getResolutionLimit(@NonNull final Context context) { String resolutionLimit = null; if (isMeteredNetwork(context)) { final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); final String defValue = context.getString(R.string.limit_data_usage_none_key); final String value = preferences.getString( context.getString(R.string.limit_mobile_data_usage_key), defValue); resolutionLimit = defValue.equals(value) ? null : value; } return resolutionLimit; } /** * The current network is metered (like mobile data)? * * @param context App context * @return {@code true} if connected to a metered network */ public static boolean isMeteredNetwork(@NonNull final Context context) { final ConnectivityManager manager = ContextCompat.getSystemService(context, ConnectivityManager.class); if (manager == null || manager.getActiveNetworkInfo() == null) { return false; } return manager.isActiveNetworkMetered(); } /** * Get a {@link Comparator} to compare {@link AudioStream}s by their format and bitrate. * *

The preferred stream will be ordered last.

* * @param context app context * @return Comparator */ private static Comparator getAudioFormatComparator( final @NonNull Context context) { final MediaFormat defaultFormat = getDefaultFormat(context, R.string.default_audio_format_key, R.string.default_audio_format_value); return getAudioFormatComparator(defaultFormat, isLimitingDataUsage(context)); } /** * Get a {@link Comparator} to compare {@link AudioStream}s by their format and bitrate. * *

The preferred stream will be ordered last.

* * @param defaultFormat the default format to look for * @param limitDataUsage choose low bitrate audio stream * @return Comparator */ static Comparator getAudioFormatComparator( @Nullable final MediaFormat defaultFormat, final boolean limitDataUsage) { final List formatRanking = limitDataUsage ? AUDIO_FORMAT_EFFICIENCY_RANKING : AUDIO_FORMAT_QUALITY_RANKING; Comparator bitrateComparator = Comparator.comparingInt(AudioStream::getAverageBitrate); if (limitDataUsage) { bitrateComparator = bitrateComparator.reversed(); } return Comparator.comparing(AudioStream::getFormat, (o1, o2) -> { if (defaultFormat != null) { return Boolean.compare(o1 == defaultFormat, o2 == defaultFormat); } return 0; }).thenComparing(bitrateComparator).thenComparingInt( stream -> formatRanking.indexOf(stream.getFormat())); } /** * Get a {@link Comparator} to compare {@link AudioStream}s by their tracks. * *

Tracks will be compared this order:

*
    *
  1. If {@code preferOriginalAudio}: use original audio
  2. *
  3. Language matches {@code preferredLanguage}
  4. *
  5. * Track type ranks highest in this order: * Original > Dubbed > Descriptive *

    If {@code preferDescriptiveAudio}: * Descriptive > Dubbed > Original

    *
  6. *
  7. Language is English
  8. *
* *

The preferred track will be ordered last.

* * @param context App context * @return Comparator */ private static Comparator getAudioTrackComparator( @NonNull final Context context) { final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(context); final Locale preferredLanguage = Localization.getPreferredLocale(context); final boolean preferOriginalAudio = preferences.getBoolean(context.getString(R.string.prefer_original_audio_key), true); final boolean preferDescriptiveAudio = preferences.getBoolean(context.getString(R.string.prefer_descriptive_audio_key), false); return getAudioTrackComparator(preferredLanguage, preferOriginalAudio, preferDescriptiveAudio); } /** * Get a {@link Comparator} to compare {@link AudioStream}s by their tracks. * *

Tracks will be compared this order:

*
    *
  1. If {@code preferOriginalAudio}: use original audio
  2. *
  3. Language matches {@code preferredLanguage}
  4. *
  5. * Track type ranks highest in this order: * Original > Dubbed > Descriptive *

    If {@code preferDescriptiveAudio}: * Descriptive > Dubbed > Original

    *
  6. *
  7. Language is English
  8. *
* *

The preferred track will be ordered last.

* * @param preferredLanguage Preferred audio stream language * @param preferOriginalAudio Get the original audio track regardless of its language * @param preferDescriptiveAudio Prefer the descriptive audio track if available * @return Comparator */ static Comparator getAudioTrackComparator( final Locale preferredLanguage, final boolean preferOriginalAudio, final boolean preferDescriptiveAudio) { final String langCode = preferredLanguage.getISO3Language(); final List trackTypeRanking = preferDescriptiveAudio ? AUDIO_TRACK_TYPE_RANKING_DESCRIPTIVE : AUDIO_TRACK_TYPE_RANKING; return Comparator.comparing(AudioStream::getAudioTrackType, (o1, o2) -> { if (preferOriginalAudio) { return Boolean.compare( o1 == AudioTrackType.ORIGINAL, o2 == AudioTrackType.ORIGINAL); } return 0; }).thenComparing(AudioStream::getAudioLocale, Comparator.nullsFirst(Comparator.comparing( locale -> locale.getISO3Language().equals(langCode)))) .thenComparing(AudioStream::getAudioTrackType, Comparator.nullsFirst(Comparator.comparingInt(trackTypeRanking::indexOf))) .thenComparing(AudioStream::getAudioLocale, Comparator.nullsFirst(Comparator.comparing( locale -> locale.getISO3Language().equals( Locale.ENGLISH.getISO3Language())))); } /** * Get a {@link Comparator} to compare {@link AudioStream}s by their languages and track types * for alphabetical sorting. * * @return Comparator */ private static Comparator getAudioTrackNameComparator() { final Locale appLoc = Localization.getAppLocale(); return Comparator.comparing(AudioStream::getAudioLocale, Comparator.nullsLast( Comparator.comparing(locale -> locale.getDisplayName(appLoc)))) .thenComparing(AudioStream::getAudioTrackType, Comparator.nullsLast( Comparator.naturalOrder())); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/Localization.java ================================================ package org.schabi.newpipe.util; import static org.schabi.newpipe.MainActivity.DEBUG; import android.annotation.SuppressLint; import android.content.Context; import android.content.SharedPreferences; import android.content.res.Resources; import android.icu.text.CompactDecimalFormat; import android.os.Build; import android.text.BidiFormatter; import android.text.TextUtils; import android.text.format.DateUtils; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.PluralsRes; import androidx.annotation.StringRes; import androidx.appcompat.app.AppCompatDelegate; import androidx.core.math.MathUtils; import androidx.core.os.LocaleListCompat; import androidx.preference.PreferenceManager; import org.ocpsoft.prettytime.PrettyTime; import org.ocpsoft.prettytime.units.Decade; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.localization.ContentCountry; import org.schabi.newpipe.extractor.localization.DateWrapper; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.AudioTrackType; import java.math.BigDecimal; import java.math.RoundingMode; import java.text.NumberFormat; import java.time.OffsetDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.format.FormatStyle; import java.util.Arrays; import java.util.List; import java.util.Locale; import java.util.stream.Collectors; /* * Created by chschtsch on 12/29/15. * * Copyright (C) Gregory Arkhipov 2015 * Localization.java is part of NewPipe. * * NewPipe is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * NewPipe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ public final class Localization { private static final String TAG = Localization.class.toString(); public static final String DOT_SEPARATOR = " • "; private static PrettyTime prettyTime; private Localization() { } @NonNull public static String concatenateStrings(final String... strings) { return concatenateStrings(DOT_SEPARATOR, Arrays.asList(strings)); } @NonNull public static String concatenateStrings(final String delimiter, final List strings) { return strings.stream() .filter(string -> !TextUtils.isEmpty(string)) .collect(Collectors.joining(delimiter)); } /** * Localize a user name like @foobar. * * Will correctly handle right-to-left usernames by using a {@link BidiFormatter}. * For right-to-left usernames, it will put the @ on the right side to read more naturally. * * @param plainName username, with an optional leading @ * @return a usernames that can include RTL-characters */ @NonNull public static String localizeUserName(final String plainName) { return BidiFormatter.getInstance().unicodeWrap(plainName); } public static org.schabi.newpipe.extractor.localization.Localization getPreferredLocalization( final Context context) { return org.schabi.newpipe.extractor.localization.Localization .fromLocale(getPreferredLocale(context)); } public static ContentCountry getPreferredContentCountry(@NonNull final Context context) { final String contentCountry = PreferenceManager.getDefaultSharedPreferences(context) .getString(context.getString(R.string.content_country_key), context.getString(R.string.default_localization_key)); if (contentCountry.equals(context.getString(R.string.default_localization_key))) { return new ContentCountry(Locale.getDefault().getCountry()); } return new ContentCountry(contentCountry); } public static Locale getPreferredLocale(@NonNull final Context context) { return getLocaleFromPrefs(context, R.string.content_language_key); } public static Locale getAppLocale() { final Locale customLocale = AppCompatDelegate.getApplicationLocales().get(0); return customLocale != null ? customLocale : Locale.getDefault(); } public static String localizeNumber(final long number) { return localizeNumber((double) number); } public static String localizeNumber(final double number) { return NumberFormat.getInstance(getAppLocale()).format(number); } public static String formatDate(@NonNull final OffsetDateTime offsetDateTime) { return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) .withLocale(getAppLocale()) .format(offsetDateTime.atZoneSameInstant(ZoneId.systemDefault())); } @SuppressLint("StringFormatInvalid") public static String localizeUploadDate(@NonNull final Context context, @NonNull final OffsetDateTime offsetDateTime) { return context.getString(R.string.upload_date_text, formatDate(offsetDateTime)); } public static String localizeViewCount(@NonNull final Context context, final long viewCount) { return getQuantity(context, R.plurals.views, R.string.no_views, viewCount, localizeNumber(viewCount)); } public static String localizeStreamCount(@NonNull final Context context, final long streamCount) { switch ((int) streamCount) { case (int) ListExtractor.ITEM_COUNT_UNKNOWN: return ""; case (int) ListExtractor.ITEM_COUNT_INFINITE: return context.getString(R.string.infinite_videos); case (int) ListExtractor.ITEM_COUNT_MORE_THAN_100: return context.getString(R.string.more_than_100_videos); default: return getQuantity(context, R.plurals.videos, R.string.no_videos, streamCount, localizeNumber(streamCount)); } } public static String localizeStreamCountMini(@NonNull final Context context, final long streamCount) { switch ((int) streamCount) { case (int) ListExtractor.ITEM_COUNT_UNKNOWN: return ""; case (int) ListExtractor.ITEM_COUNT_INFINITE: return context.getString(R.string.infinite_videos_mini); case (int) ListExtractor.ITEM_COUNT_MORE_THAN_100: return context.getString(R.string.more_than_100_videos_mini); default: return String.valueOf(streamCount); } } public static String localizeWatchingCount(@NonNull final Context context, final long watchingCount) { return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount, localizeNumber(watchingCount)); } public static String shortCount(@NonNull final Context context, final long count) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { return CompactDecimalFormat.getInstance(getAppLocale(), CompactDecimalFormat.CompactStyle.SHORT).format(count); } final double value = (double) count; if (count >= 1000000000) { final double shortenedValue = value / 1000000000; final int scale = shortenedValue >= 100 ? 0 : 1; return context.getString(R.string.short_billion, localizeNumber(round(shortenedValue, scale))); } else if (count >= 1000000) { final double shortenedValue = value / 1000000; final int scale = shortenedValue >= 100 ? 0 : 1; return context.getString(R.string.short_million, localizeNumber(round(shortenedValue, scale))); } else if (count >= 1000) { final double shortenedValue = value / 1000; final int scale = shortenedValue >= 100 ? 0 : 1; return context.getString(R.string.short_thousand, localizeNumber(round(shortenedValue, scale))); } else { return localizeNumber(value); } } public static String listeningCount(@NonNull final Context context, final long listeningCount) { return getQuantity(context, R.plurals.listening, R.string.no_one_listening, listeningCount, shortCount(context, listeningCount)); } public static String shortWatchingCount(@NonNull final Context context, final long watchingCount) { return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount, shortCount(context, watchingCount)); } public static String shortViewCount(@NonNull final Context context, final long viewCount) { return getQuantity(context, R.plurals.views, R.string.no_views, viewCount, shortCount(context, viewCount)); } public static String shortSubscriberCount(@NonNull final Context context, final long subscriberCount) { return getQuantity(context, R.plurals.subscribers, R.string.no_subscribers, subscriberCount, shortCount(context, subscriberCount)); } public static String downloadCount(@NonNull final Context context, final int downloadCount) { return getQuantity(context, R.plurals.download_finished_notification, 0, downloadCount, shortCount(context, downloadCount)); } public static String deletedDownloadCount(@NonNull final Context context, final int deletedCount) { return getQuantity(context, R.plurals.deleted_downloads_toast, 0, deletedCount, shortCount(context, deletedCount)); } public static String replyCount(@NonNull final Context context, final int replyCount) { return getQuantity(context, R.plurals.replies, 0, replyCount, String.valueOf(replyCount)); } /** * @param context the Android context * @param likeCount the like count, possibly negative if unknown * @return if {@code likeCount} is smaller than {@code 0}, the string {@code "-"}, otherwise * the result of calling {@link #shortCount(Context, long)} on the like count */ public static String likeCount(@NonNull final Context context, final int likeCount) { if (likeCount < 0) { return "-"; } else { return shortCount(context, likeCount); } } /** * Get a readable text for a duration in the format {@code hours:minutes:seconds}. * * @param duration the duration in seconds * @return a formatted duration String or {@code 00:00} if the duration is zero. */ public static String getDurationString(final long duration) { return DateUtils.formatElapsedTime(Math.max(duration, 0)); } /** * Get a readable text for a duration in the format {@code hours:minutes:seconds+}. If the given * duration is incomplete, a plus is appended to the duration string. * * @param duration the duration in seconds * @param isDurationComplete whether the given duration is complete or whether info is missing * @param showDurationPrefix whether the duration-prefix shall be shown * @return a formatted duration String or {@code 00:00} if the duration is zero. */ public static String getDurationString(final long duration, final boolean isDurationComplete, final boolean showDurationPrefix) { final String output = getDurationString(duration); final String durationPrefix = showDurationPrefix ? "⏱ " : ""; final String durationPostfix = isDurationComplete ? "" : "+"; return durationPrefix + output + durationPostfix; } /** * Localize an amount of seconds into a human readable string. * *

The seconds will be converted to the closest whole time unit. *

For example, 60 seconds would give "1 minute", 119 would also give "1 minute". * * @param context used to get plurals resources. * @param durationInSecs an amount of seconds. * @return duration in a human readable string. */ @NonNull public static String localizeDuration(@NonNull final Context context, final int durationInSecs) { if (durationInSecs < 0) { throw new IllegalArgumentException("duration can not be negative"); } final int days = (int) (durationInSecs / (24 * 60 * 60L)); final int hours = (int) (durationInSecs % (24 * 60 * 60L) / (60 * 60L)); final int minutes = (int) (durationInSecs % (24 * 60 * 60L) % (60 * 60L) / 60L); final int seconds = (int) (durationInSecs % (24 * 60 * 60L) % (60 * 60L) % 60L); final Resources resources = context.getResources(); if (days > 0) { return resources.getQuantityString(R.plurals.days, days, days); } else if (hours > 0) { return resources.getQuantityString(R.plurals.hours, hours, hours); } else if (minutes > 0) { return resources.getQuantityString(R.plurals.minutes, minutes, minutes); } else { return resources.getQuantityString(R.plurals.seconds, seconds, seconds); } } /** * Get the localized name of an audio track. * *

Examples of results returned by this method:

*
    *
  • English (original)
  • *
  • English (descriptive)
  • *
  • Spanish (Spain) (dubbed)
  • *
* * @param context the context used to get the app language * @param track an {@link AudioStream} of the track * @return the localized name of the audio track */ public static String audioTrackName(@NonNull final Context context, final AudioStream track) { final String name; if (track.getAudioLocale() != null) { name = track.getAudioLocale().getDisplayName(); } else if (track.getAudioTrackName() != null) { name = track.getAudioTrackName(); } else { name = context.getString(R.string.unknown_audio_track); } if (track.getAudioTrackType() != null) { final String trackType = audioTrackType(context, track.getAudioTrackType()); return context.getString(R.string.audio_track_name, name, trackType); } return name; } @NonNull private static String audioTrackType(@NonNull final Context context, @NonNull final AudioTrackType trackType) { return switch (trackType) { case ORIGINAL -> context.getString(R.string.audio_track_type_original); case DUBBED -> context.getString(R.string.audio_track_type_dubbed); case DESCRIPTIVE -> context.getString(R.string.audio_track_type_descriptive); case SECONDARY -> context.getString(R.string.audio_track_type_secondary); }; } /*////////////////////////////////////////////////////////////////////////// // Pretty Time //////////////////////////////////////////////////////////////////////////*/ public static void initPrettyTime(@NonNull final PrettyTime time) { prettyTime = time; // Do not use decades as YouTube doesn't either. prettyTime.removeUnit(Decade.class); } public static PrettyTime resolvePrettyTime() { return new PrettyTime(getAppLocale()); } public static String relativeTime(@NonNull final OffsetDateTime offsetDateTime) { return prettyTime.formatUnrounded(offsetDateTime); } /** * @param context the Android context; if {@code null} then even if in debug mode and the * setting is enabled, {@code textual} will not be shown next to {@code parsed} * @param parsed the textual date or time ago parsed by NewPipeExtractor, or {@code null} if * the extractor could not parse it * @param textual the original textual date or time ago string as provided by services * @return {@link #relativeTime(OffsetDateTime)} is used if {@code parsed != null}, otherwise * {@code textual} is returned. If in debug mode, {@code context != null}, * {@code parsed != null} and the relevant setting is enabled, {@code textual} will * be appended to the returned string for debugging purposes. */ @Nullable public static String relativeTimeOrTextual(@Nullable final Context context, @Nullable final DateWrapper parsed, @Nullable final String textual) { if (parsed == null) { return textual; } else if (DEBUG && context != null && PreferenceManager .getDefaultSharedPreferences(context) .getBoolean(context.getString(R.string.show_original_time_ago_key), false)) { return relativeTime(parsed.offsetDateTime()) + " (" + textual + ")"; } else { return relativeTime(parsed.offsetDateTime()); } } private static Locale getLocaleFromPrefs(@NonNull final Context context, @StringRes final int prefKey) { final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); final String defaultKey = context.getString(R.string.default_localization_key); final String languageCode = sp.getString(context.getString(prefKey), defaultKey); if (languageCode.equals(defaultKey)) { return Locale.getDefault(); } else { return Locale.forLanguageTag(languageCode); } } private static double round(final double value, final int scale) { return new BigDecimal(value).setScale(scale, RoundingMode.HALF_UP).doubleValue(); } /** * A wrapper around {@code context.getResources().getQuantityString()} with some safeguard. * * @param context the Android context * @param pluralId the ID of the plural resource * @param zeroCaseStringId the resource ID of the string to use in case {@code count=0}, * or 0 if the plural resource should be used in the zero case too * @param count the number that should be used to pick the correct plural form * @param formattedCount the formatting parameter to substitute inside the plural resource, * ideally just {@code count} converted to string * @return the formatted string with the correct pluralization */ private static String getQuantity(@NonNull final Context context, @PluralsRes final int pluralId, @StringRes final int zeroCaseStringId, final long count, final String formattedCount) { if (count == 0 && zeroCaseStringId != 0) { return context.getString(zeroCaseStringId); } // As we use the already formatted count // is not the responsibility of this method handle long numbers // (it probably will fall in the "other" category, // or some language have some specific rule... then we have to change it) final int safeCount = (int) MathUtils.clamp(count, Integer.MIN_VALUE, Integer.MAX_VALUE); return context.getResources().getQuantityString(pluralId, safeCount, formattedCount); } // Starting with pull request #12093, NewPipe exclusively uses Android's // public per-app language APIs to read and set the UI language for NewPipe. // The following code will migrate any existing custom app language in SharedPreferences to // use the public per-app language APIs instead. // For reference, see // https://android-developers.googleblog.com/2022/11/per-app-language-preferences-part-1.html public static void migrateAppLanguageSettingIfNecessary(@NonNull final Context context) { final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); final String appLanguageKey = context.getString(R.string.app_language_key); final String appLanguageValue = sp.getString(appLanguageKey, null); if (appLanguageValue != null) { // The app language key is used on Android versions < 33 // for more info, see ContentSettingsFragment if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { sp.edit().remove(appLanguageKey).apply(); } final String appLanguageDefaultValue = context.getString(R.string.default_localization_key); if (!appLanguageValue.equals(appLanguageDefaultValue)) { try { AppCompatDelegate.setApplicationLocales( LocaleListCompat.forLanguageTags(appLanguageValue)); } catch (final RuntimeException e) { Log.e(TAG, "Failed to migrate previous custom app language " + "setting to public per-app language APIs" ); } } } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java ================================================ package org.schabi.newpipe.util; import static android.text.TextUtils.isEmpty; import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams; import android.annotation.SuppressLint; import android.app.Activity; import android.content.Context; import android.content.Intent; import android.net.Uri; import android.os.Build; import android.util.Log; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; import com.jakewharton.processphoenix.ProcessPhoenix; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.RouterActivity; import org.schabi.newpipe.about.AboutActivity; import org.schabi.newpipe.database.feed.model.FeedGroupEntity; import org.schabi.newpipe.download.DownloadActivity; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.DeliveryMethod; import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.fragments.MainFragment; import org.schabi.newpipe.fragments.detail.VideoDetailFragment; import org.schabi.newpipe.fragments.list.channel.ChannelFragment; import org.schabi.newpipe.fragments.list.comments.CommentRepliesFragment; import org.schabi.newpipe.fragments.list.kiosk.KioskFragment; import org.schabi.newpipe.fragments.list.playlist.PlaylistFragment; import org.schabi.newpipe.fragments.list.search.SearchFragment; import org.schabi.newpipe.local.bookmark.BookmarkFragment; import org.schabi.newpipe.local.feed.FeedFragment; import org.schabi.newpipe.local.history.StatisticsPlaylistFragment; import org.schabi.newpipe.local.playlist.LocalPlaylistFragment; import org.schabi.newpipe.local.subscription.SubscriptionFragment; import org.schabi.newpipe.local.subscription.SubscriptionsImportFragment; import org.schabi.newpipe.player.PlayQueueActivity; import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.PlayerIntentType; import org.schabi.newpipe.player.PlayerService; import org.schabi.newpipe.player.PlayerType; import org.schabi.newpipe.player.TimestampChangeData; import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.settings.SettingsActivity; import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.List; import java.util.Optional; public final class NavigationHelper { public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag"; public static final String SEARCH_FRAGMENT_TAG = "search_fragment_tag"; private static final String TAG = NavigationHelper.class.getSimpleName(); private NavigationHelper() { } /*////////////////////////////////////////////////////////////////////////// // Players //////////////////////////////////////////////////////////////////////////*/ /* INTENT */ @NonNull public static Intent getPlayerIntent(@NonNull final Context context, @NonNull final Class targetClazz, @Nullable final PlayQueue playQueue, @NonNull final PlayerIntentType playerIntentType) { final String cacheKey = Optional.ofNullable(playQueue) .map(queue -> SerializedCache.getInstance().put(queue, PlayQueue.class)) .orElse(null); return new Intent(context, targetClazz) .putExtra(Player.PLAY_QUEUE_KEY, cacheKey) .putExtra(Player.PLAYER_TYPE, PlayerType.MAIN) .putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true) .putExtra(Player.PLAYER_INTENT_TYPE, playerIntentType); } @NonNull public static Intent getPlayerTimestampIntent(@NonNull final Context context, @NonNull final TimestampChangeData data) { return new Intent(context, PlayerService.class) .putExtra(Player.PLAYER_INTENT_TYPE, PlayerIntentType.TimestampChange) .putExtra(Player.PLAYER_INTENT_DATA, data); } @NonNull public static Intent getPlayerEnqueueNextIntent(@NonNull final Context context, @NonNull final Class targetClazz, @Nullable final PlayQueue playQueue) { return getPlayerIntent(context, targetClazz, playQueue, PlayerIntentType.EnqueueNext) // see comment in `getPlayerEnqueueIntent` as to why `resumePlayback` is false .putExtra(Player.RESUME_PLAYBACK, false); } /* PLAY */ public static void playOnMainPlayer(final AppCompatActivity activity, @NonNull final PlayQueue playQueue) { final PlayQueueItem item = playQueue.getItem(); if (item != null) { openVideoDetailFragment(activity, activity.getSupportFragmentManager(), item.getServiceId(), item.getUrl(), item.getTitle(), playQueue, false); } } public static void playOnMainPlayer(final Context context, @NonNull final PlayQueue playQueue, final boolean switchingPlayers) { final PlayQueueItem item = playQueue.getItem(); if (item != null) { openVideoDetail(context, item.getServiceId(), item.getUrl(), item.getTitle(), playQueue, switchingPlayers); } } public static void playOnPopupPlayer(final Context context, final PlayQueue queue, final boolean resumePlayback) { if (!PermissionHelper.isPopupEnabledElseAsk(context)) { return; } Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show(); final var intent = getPlayerIntent(context, PlayerService.class, queue, PlayerIntentType.AllOthers) .putExtra(Player.PLAYER_TYPE, PlayerType.POPUP) .putExtra(Player.RESUME_PLAYBACK, resumePlayback); ContextCompat.startForegroundService(context, intent); } public static void playOnBackgroundPlayer(final Context context, final PlayQueue queue, final boolean resumePlayback) { Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT) .show(); final Intent intent = getPlayerIntent(context, PlayerService.class, queue, PlayerIntentType.AllOthers) .putExtra(Player.PLAYER_TYPE, PlayerType.AUDIO) .putExtra(Player.RESUME_PLAYBACK, resumePlayback); ContextCompat.startForegroundService(context, intent); } /* ENQUEUE */ public static void enqueueOnPlayer(final Context context, final PlayQueue queue, final PlayerType playerType) { if (playerType == PlayerType.POPUP && !PermissionHelper.isPopupEnabledElseAsk(context)) { return; } Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show(); // when enqueueing `resumePlayback` is always `false` since: // - if there is a video already playing, the value of `resumePlayback` just doesn't make // any difference. // - if there is nothing already playing, it is useful for the enqueue action to have a // slightly different behaviour than the normal play action: the latter resumes playback, // the former doesn't. (note that enqueue can be triggered when nothing is playing only // by long pressing the video detail fragment, playlist or channel controls final Intent intent = getPlayerIntent(context, PlayerService.class, queue, PlayerIntentType.Enqueue) .putExtra(Player.RESUME_PLAYBACK, false) .putExtra(Player.PLAYER_TYPE, playerType); ContextCompat.startForegroundService(context, intent); } public static void enqueueOnPlayer(final Context context, final PlayQueue queue) { PlayerType playerType = PlayerHolder.getInstance().getType(); if (playerType == null) { Log.e(TAG, "Enqueueing but no player is open; defaulting to background player"); playerType = PlayerType.AUDIO; } enqueueOnPlayer(context, queue, playerType); } /* ENQUEUE NEXT */ public static void enqueueNextOnPlayer(final Context context, final PlayQueue queue) { PlayerType playerType = PlayerHolder.getInstance().getType(); if (playerType == null) { Log.e(TAG, "Enqueueing next but no player is open; defaulting to background player"); playerType = PlayerType.AUDIO; } Toast.makeText(context, R.string.enqueued_next, Toast.LENGTH_SHORT).show(); final Intent intent = getPlayerEnqueueNextIntent(context, PlayerService.class, queue) .putExtra(Player.PLAYER_TYPE, playerType); ContextCompat.startForegroundService(context, intent); } /*////////////////////////////////////////////////////////////////////////// // External Players //////////////////////////////////////////////////////////////////////////*/ public static void playOnExternalAudioPlayer(@NonNull final Context context, @NonNull final StreamInfo info) { final List audioStreams = info.getAudioStreams(); if (audioStreams == null || audioStreams.isEmpty()) { Toast.makeText(context, R.string.audio_streams_empty, Toast.LENGTH_SHORT).show(); return; } final List audioStreamsForExternalPlayers = getUrlAndNonTorrentStreams(audioStreams); if (audioStreamsForExternalPlayers.isEmpty()) { Toast.makeText(context, R.string.no_audio_streams_available_for_external_players, Toast.LENGTH_SHORT).show(); return; } final int index = ListHelper.getDefaultAudioFormat(context, audioStreamsForExternalPlayers); final AudioStream audioStream = audioStreamsForExternalPlayers.get(index); playOnExternalPlayer(context, info.getName(), info.getUploaderName(), audioStream); } public static void playOnExternalVideoPlayer(final Context context, @NonNull final StreamInfo info) { final List videoStreams = info.getVideoStreams(); if (videoStreams == null || videoStreams.isEmpty()) { Toast.makeText(context, R.string.video_streams_empty, Toast.LENGTH_SHORT).show(); return; } final List videoStreamsForExternalPlayers = ListHelper.getSortedStreamVideosList(context, getUrlAndNonTorrentStreams(videoStreams), null, false, false); if (videoStreamsForExternalPlayers.isEmpty()) { Toast.makeText(context, R.string.no_video_streams_available_for_external_players, Toast.LENGTH_SHORT).show(); return; } final int index = ListHelper.getDefaultResolutionIndex(context, videoStreamsForExternalPlayers); final VideoStream videoStream = videoStreamsForExternalPlayers.get(index); playOnExternalPlayer(context, info.getName(), info.getUploaderName(), videoStream); } public static void playOnExternalPlayer(@NonNull final Context context, @Nullable final String name, @Nullable final String artist, @NonNull final Stream stream) { final DeliveryMethod deliveryMethod = stream.getDeliveryMethod(); final String mimeType; if (!stream.isUrl() || deliveryMethod == DeliveryMethod.TORRENT) { Toast.makeText(context, R.string.selected_stream_external_player_not_supported, Toast.LENGTH_SHORT).show(); return; } switch (deliveryMethod) { case PROGRESSIVE_HTTP: if (stream.getFormat() == null) { if (stream instanceof AudioStream) { mimeType = "audio/*"; } else if (stream instanceof VideoStream) { mimeType = "video/*"; } else { // This should never be reached, because subtitles are not opened in // external players return; } } else { mimeType = stream.getFormat().getMimeType(); } break; case HLS: mimeType = "application/x-mpegURL"; break; case DASH: mimeType = "application/dash+xml"; break; case SS: mimeType = "application/vnd.ms-sstr+xml"; break; default: // Torrent streams are not exposed to external players mimeType = ""; } final Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); intent.setDataAndType(Uri.parse(stream.getContent()), mimeType); intent.putExtra(Intent.EXTRA_TITLE, name); intent.putExtra("title", name); intent.putExtra("artist", artist); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); resolveActivityOrAskToInstall(context, intent); } public static void resolveActivityOrAskToInstall(@NonNull final Context context, @NonNull final Intent intent) { if (!ShareUtils.tryOpenIntentInApp(context, intent)) { if (context instanceof Activity) { new AlertDialog.Builder(context) .setMessage(R.string.no_player_found) .setPositiveButton(R.string.install, (dialog, which) -> ShareUtils.installApp(context, context.getString(R.string.vlc_package))) .setNegativeButton(R.string.cancel, (dialog, which) -> Log.i("NavigationHelper", "You unlocked a secret unicorn.")) .show(); } else { Toast.makeText(context, R.string.no_player_found_toast, Toast.LENGTH_LONG).show(); } } } /*////////////////////////////////////////////////////////////////////////// // Through FragmentManager //////////////////////////////////////////////////////////////////////////*/ @SuppressLint("CommitTransaction") private static FragmentTransaction defaultTransaction(final FragmentManager fragmentManager) { return fragmentManager.beginTransaction() .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, R.animator.custom_fade_in, R.animator.custom_fade_out); } public static void gotoMainFragment(final FragmentManager fragmentManager) { final boolean popped = fragmentManager.popBackStackImmediate(MAIN_FRAGMENT_TAG, 0); if (!popped) { openMainFragment(fragmentManager); } } public static void openMainFragment(final FragmentManager fragmentManager) { InfoCache.getInstance().trimCache(); fragmentManager.popBackStackImmediate(null, FragmentManager.POP_BACK_STACK_INCLUSIVE); defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, new MainFragment()) .addToBackStack(MAIN_FRAGMENT_TAG) .commit(); } public static boolean tryGotoSearchFragment(final FragmentManager fragmentManager) { if (MainActivity.DEBUG) { for (int i = 0; i < fragmentManager.getBackStackEntryCount(); i++) { Log.d("NavigationHelper", "tryGoToSearchFragment() [" + i + "]" + " = [" + fragmentManager.getBackStackEntryAt(i) + "]"); } } return fragmentManager.popBackStackImmediate(SEARCH_FRAGMENT_TAG, 0); } public static void openSearchFragment(final FragmentManager fragmentManager, final int serviceId, final String searchString) { defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, SearchFragment.getInstance(serviceId, searchString)) .addToBackStack(SEARCH_FRAGMENT_TAG) .commit(); } public static void expandMainPlayer(final Context context) { context.sendBroadcast(new Intent(VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER)); } public static void sendPlayerStartedEvent(final Context context) { context.sendBroadcast(new Intent(VideoDetailFragment.ACTION_PLAYER_STARTED)); } public static void showMiniPlayer(final FragmentManager fragmentManager) { final VideoDetailFragment instance = VideoDetailFragment.getInstanceInCollapsedState(); defaultTransaction(fragmentManager) .replace(R.id.fragment_player_holder, instance) .runOnCommit(() -> sendPlayerStartedEvent(instance.requireActivity())) .commitAllowingStateLoss(); } private interface RunnableWithVideoDetailFragment { void run(VideoDetailFragment detailFragment); } public static void openVideoDetailFragment(@NonNull final Context context, @NonNull final FragmentManager fragmentManager, final int serviceId, @Nullable final String url, @NonNull final String title, @Nullable final PlayQueue playQueue, final boolean switchingPlayers) { final boolean autoPlay; @Nullable final PlayerType playerType = PlayerHolder.getInstance().getType(); if (playerType == null) { // no player open autoPlay = PlayerHelper.isAutoplayAllowedByUser(context); } else if (switchingPlayers) { // switching player to main player autoPlay = PlayerHolder.getInstance().isPlaying(); // keep play/pause state } else if (playerType == PlayerType.MAIN) { // opening new stream while already playing in main player autoPlay = PlayerHelper.isAutoplayAllowedByUser(context); } else { // opening new stream while already playing in another player autoPlay = false; } final RunnableWithVideoDetailFragment onVideoDetailFragmentReady = detailFragment -> { expandMainPlayer(detailFragment.requireActivity()); detailFragment.setAutoPlay(autoPlay); if (switchingPlayers) { // Situation when user switches from players to main player. All needed data is // here, we can start watching (assuming newQueue equals playQueue). // Starting directly in fullscreen if the previous player type was popup. detailFragment.openVideoPlayer(playerType == PlayerType.POPUP || PlayerHelper.isStartMainPlayerFullscreenEnabled(context)); } else { detailFragment.selectAndLoadVideo(serviceId, url, title, playQueue); } detailFragment.scrollToTop(); }; final Fragment fragment = fragmentManager.findFragmentById(R.id.fragment_player_holder); if (fragment instanceof VideoDetailFragment && fragment.isVisible()) { onVideoDetailFragmentReady.run((VideoDetailFragment) fragment); } else { // Specify no url here, otherwise the VideoDetailFragment will start loading the // stream automatically if it's the first time it is being opened, but then // onVideoDetailFragmentReady will kick in and start another loading process. // See VideoDetailFragment.wasCleared() and its usage in doInitialLoadLogic(). final VideoDetailFragment instance = VideoDetailFragment .getInstance(serviceId, null, title, playQueue); instance.setAutoPlay(autoPlay); defaultTransaction(fragmentManager) .replace(R.id.fragment_player_holder, instance) .runOnCommit(() -> onVideoDetailFragmentReady.run(instance)) .commit(); } } public static void openChannelFragment(final FragmentManager fragmentManager, final int serviceId, final String url, @NonNull final String name) { defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, ChannelFragment.getInstance(serviceId, url, name)) .addToBackStack(null) .commit(); } public static void openChannelFragment(@NonNull final Fragment fragment, @NonNull final StreamInfoItem item, final String uploaderUrl) { // For some reason `getParentFragmentManager()` doesn't work, but this does. openChannelFragment( fragment.requireActivity().getSupportFragmentManager(), item.getServiceId(), uploaderUrl, item.getUploaderName()); } /** * Opens the comment author channel fragment, if the {@link CommentsInfoItem#getUploaderUrl()} * of {@code comment} is non-null. Shows a UI-error snackbar if something goes wrong. * * @param activity the activity with the fragment manager and in which to show the snackbar * @param comment the comment whose uploader/author will be opened */ public static void openCommentAuthorIfPresent(@NonNull final FragmentActivity activity, @NonNull final CommentsInfoItem comment) { if (isEmpty(comment.getUploaderUrl())) { return; } try { openChannelFragment(activity.getSupportFragmentManager(), comment.getServiceId(), comment.getUploaderUrl(), comment.getUploaderName()); } catch (final Exception e) { ErrorUtil.showUiErrorSnackbar(activity, "Opening channel fragment", e); } } public static void openCommentRepliesFragment(@NonNull final FragmentActivity activity, @NonNull final CommentsInfoItem comment) { closeCommentRepliesFragments(activity); defaultTransaction(activity.getSupportFragmentManager()) .replace(R.id.fragment_holder, new CommentRepliesFragment(comment), CommentRepliesFragment.TAG) .addToBackStack(CommentRepliesFragment.TAG) .commit(); } /** * Closes all open {@link CommentRepliesFragment}s in {@code activity}, * including those that are not at the top of the back stack. * This is needed to prevent multiple open CommentRepliesFragments * Ideally there should only be one since we remove existing before opening a new one. * @param activity the activity in which to close the CommentRepliesFragments */ public static void closeCommentRepliesFragments(@NonNull final FragmentActivity activity) { final FragmentManager fm = activity.getSupportFragmentManager(); // Remove all existing fragment instances tagged as CommentRepliesFragment final FragmentTransaction tx = defaultTransaction(fm); boolean removed = false; for (final Fragment fragment : fm.getFragments()) { if (fragment != null && CommentRepliesFragment.TAG.equals(fragment.getTag())) { tx.remove(fragment); removed = true; } } if (removed) { tx.commit(); } // Only pop back stack entries named CommentRepliesFragment.TAG if they are at the top. while (fm.getBackStackEntryCount() > 0 && CommentRepliesFragment.TAG.equals( fm.getBackStackEntryAt(fm.getBackStackEntryCount() - 1).getName() ) ) { fm.popBackStackImmediate(CommentRepliesFragment.TAG, FragmentManager.POP_BACK_STACK_INCLUSIVE); } } public static void openPlaylistFragment(final FragmentManager fragmentManager, final int serviceId, final String url, @NonNull final String name) { defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, PlaylistFragment.getInstance(serviceId, url, name)) .addToBackStack(null) .commit(); } public static void openFeedFragment(final FragmentManager fragmentManager) { openFeedFragment(fragmentManager, FeedGroupEntity.GROUP_ALL_ID, null); } public static void openFeedFragment(final FragmentManager fragmentManager, final long groupId, @Nullable final String groupName) { defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, FeedFragment.newInstance(groupId, groupName)) .addToBackStack(null) .commit(); } public static void openBookmarksFragment(final FragmentManager fragmentManager) { defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, new BookmarkFragment()) .addToBackStack(null) .commit(); } public static void openSubscriptionFragment(final FragmentManager fragmentManager) { defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, new SubscriptionFragment()) .addToBackStack(null) .commit(); } public static void openKioskFragment(final FragmentManager fragmentManager, final int serviceId, final String kioskId) throws ExtractionException { defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, KioskFragment.getInstance(serviceId, kioskId)) .addToBackStack(null) .commit(); } public static void openLocalPlaylistFragment(final FragmentManager fragmentManager, final long playlistId, final String name) { defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, LocalPlaylistFragment.getInstance(playlistId, name == null ? "" : name)) .addToBackStack(null) .commit(); } public static void openStatisticFragment(final FragmentManager fragmentManager) { defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, new StatisticsPlaylistFragment()) .addToBackStack(null) .commit(); } public static void openSubscriptionsImportFragment(final FragmentManager fragmentManager, final int serviceId) { defaultTransaction(fragmentManager) .replace(R.id.fragment_holder, SubscriptionsImportFragment.getInstance(serviceId)) .addToBackStack(null) .commit(); } /*////////////////////////////////////////////////////////////////////////// // Through Intents //////////////////////////////////////////////////////////////////////////*/ public static void openSearch(final Context context, final int serviceId, final String searchString) { final Intent mIntent = new Intent(context, MainActivity.class); mIntent.putExtra(Constants.KEY_SERVICE_ID, serviceId); mIntent.putExtra(Constants.KEY_SEARCH_STRING, searchString); mIntent.putExtra(Constants.KEY_OPEN_SEARCH, true); context.startActivity(mIntent); } public static void openVideoDetail(final Context context, final int serviceId, final String url, @NonNull final String title, @Nullable final PlayQueue playQueue, final boolean switchingPlayers) { final Intent intent = getStreamIntent(context, serviceId, url, title) .putExtra(VideoDetailFragment.KEY_SWITCHING_PLAYERS, switchingPlayers); if (playQueue != null) { final String cacheKey = SerializedCache.getInstance().put(playQueue, PlayQueue.class); if (cacheKey != null) { intent.putExtra(Player.PLAY_QUEUE_KEY, cacheKey); } } context.startActivity(intent); } /** * Opens {@link ChannelFragment}. * Use this instead of {@link #openChannelFragment(FragmentManager, int, String, String)} * when no fragments are used / no FragmentManager is available. * @param context * @param serviceId * @param url * @param title */ public static void openChannelFragmentUsingIntent(final Context context, final int serviceId, final String url, @NonNull final String title) { final Intent intent = getOpenIntent(context, url, serviceId, StreamingService.LinkType.CHANNEL); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.putExtra(Constants.KEY_TITLE, title); context.startActivity(intent); } public static void openMainActivity(final Context context) { final Intent mIntent = new Intent(context, MainActivity.class); mIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); mIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK); context.startActivity(mIntent); } public static void openRouterActivity(final Context context, final String url) { final Intent mIntent = new Intent(context, RouterActivity.class); mIntent.setData(Uri.parse(url)); context.startActivity(mIntent); } public static void openAbout(final Context context) { final Intent intent = new Intent(context, AboutActivity.class); context.startActivity(intent); } public static void openSettings(final Context context) { final Intent intent = new Intent(context, SettingsActivity.class); context.startActivity(intent); } public static void openDownloads(final Activity activity) { if (PermissionHelper.checkStoragePermissions( activity, PermissionHelper.DOWNLOADS_REQUEST_CODE)) { final Intent intent = new Intent(activity, DownloadActivity.class); activity.startActivity(intent); } } public static Intent getPlayQueueActivityIntent(final Context context) { final Intent intent = new Intent(context, PlayQueueActivity.class); if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); } return intent; } public static void openPlayQueue(final Context context) { final Intent intent = new Intent(context, PlayQueueActivity.class); context.startActivity(intent); } /*////////////////////////////////////////////////////////////////////////// // Link handling //////////////////////////////////////////////////////////////////////////*/ private static Intent getOpenIntent(final Context context, final String url, final int serviceId, final StreamingService.LinkType type) { final Intent mIntent = new Intent(context, MainActivity.class); mIntent.putExtra(Constants.KEY_SERVICE_ID, serviceId); mIntent.putExtra(Constants.KEY_URL, url); mIntent.putExtra(Constants.KEY_LINK_TYPE, type); return mIntent; } public static Intent getIntentByLink(final Context context, final String url) throws ExtractionException { return getIntentByLink(context, NewPipe.getServiceByUrl(url), url); } public static Intent getIntentByLink(final Context context, final StreamingService service, final String url) throws ExtractionException { final StreamingService.LinkType linkType = service.getLinkTypeByUrl(url); if (linkType == StreamingService.LinkType.NONE) { throw new ExtractionException("Url not known to service. service=" + service + " url=" + url); } return getOpenIntent(context, url, service.getServiceId(), linkType); } public static Intent getChannelIntent(final Context context, final int serviceId, final String url) { return getOpenIntent(context, url, serviceId, StreamingService.LinkType.CHANNEL); } public static Intent getStreamIntent(final Context context, final int serviceId, final String url, @Nullable final String title) { return getOpenIntent(context, url, serviceId, StreamingService.LinkType.STREAM) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .putExtra(Constants.KEY_TITLE, title); } /** * Finish this Activity as well as all Activities running below it * and then start MainActivity. * * @param activity the activity to finish */ public static void restartApp(final Activity activity) { NewPipeDatabase.close(); ProcessPhoenix.triggerRebirth(activity.getApplicationContext()); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/NewPipeTextViewHelper.kt ================================================ /* * SPDX-FileCopyrightText: 2021-2026 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.util import android.text.Selection import android.text.Spannable import android.widget.TextView import org.schabi.newpipe.util.external_communication.ShareUtils object NewPipeTextViewHelper { /** * Share the selected text of [NewPipeTextViews][org.schabi.newpipe.views.NewPipeTextView] and * [NewPipeEditTexts][org.schabi.newpipe.views.NewPipeEditText] with * [ShareUtils.shareText]. * * * * This allows EMUI users to get the Android share sheet instead of the EMUI share sheet when * using the `Share` command of the popup menu which appears when selecting text. * * * @param textView the [TextView] on which sharing the selected text. It should be a * [org.schabi.newpipe.views.NewPipeTextView] or a [org.schabi.newpipe.views.NewPipeEditText] * (even if [standard TextViews][TextView] are supported). */ @JvmStatic fun shareSelectedTextWithShareUtils(textView: TextView) { val textViewText = textView.getText() shareSelectedTextIfNotNullAndNotEmpty(textView, getSelectedText(textView, textViewText)) if (textViewText is Spannable) { Selection.setSelection(textViewText, textView.selectionEnd) } } private fun getSelectedText(textView: TextView, text: CharSequence?): CharSequence? { if (!textView.hasSelection() || text == null) { return null } val start = textView.selectionStart val end = textView.selectionEnd return if (start > end) { text.subSequence(end, start) } else { text.subSequence(start, end) } } private fun shareSelectedTextIfNotNullAndNotEmpty( textView: TextView, selectedText: CharSequence? ) { if (!selectedText.isNullOrEmpty()) { ShareUtils.shareText(textView.context, "", selectedText.toString()) } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/OnClickGesture.java ================================================ package org.schabi.newpipe.util; import androidx.recyclerview.widget.RecyclerView; public interface OnClickGesture { void selected(T selectedItem); default void held(final T selectedItem) { // Optional gesture } default void drag(final T selectedItem, final RecyclerView.ViewHolder viewHolder) { // Optional gesture } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/PeertubeHelper.kt ================================================ /* * SPDX-FileCopyrightText: 2019-2026 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.util import android.content.Context import androidx.core.content.edit import androidx.preference.PreferenceManager import com.grack.nanojson.JsonObject import com.grack.nanojson.JsonParser import com.grack.nanojson.JsonWriter import org.schabi.newpipe.R import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance object PeertubeHelper { @JvmStatic val currentInstance: PeertubeInstance get() = ServiceList.PeerTube.instance @JvmStatic fun getInstanceList(context: Context): List { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) val savedInstanceListKey = context.getString(R.string.peertube_instance_list_key) val savedJson = sharedPreferences.getString(savedInstanceListKey, null) ?: return listOf(currentInstance) return runCatching { JsonParser.`object`().from(savedJson).getArray("instances") .filterIsInstance() .map { PeertubeInstance(it.getString("url"), it.getString("name")) } }.getOrDefault(listOf(currentInstance)) } @JvmStatic fun selectInstance(instance: PeertubeInstance, context: Context): PeertubeInstance { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) val selectedInstanceKey = context.getString(R.string.peertube_selected_instance_key) val jsonWriter = JsonWriter.string().`object`() jsonWriter.value("name", instance.name) jsonWriter.value("url", instance.url) val jsonToSave = jsonWriter.end().done() sharedPreferences.edit { putString(selectedInstanceKey, jsonToSave) } ServiceList.PeerTube.instance = instance return instance } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java ================================================ package org.schabi.newpipe.util; import android.Manifest; import android.app.Activity; import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; import android.os.Build; import android.provider.Settings; import android.text.Html; import android.widget.Toast; import androidx.annotation.RequiresApi; import androidx.appcompat.app.AlertDialog; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import org.schabi.newpipe.App; import org.schabi.newpipe.R; import org.schabi.newpipe.settings.NewPipeSettings; public final class PermissionHelper { public static final int POST_NOTIFICATIONS_REQUEST_CODE = 779; public static final int DOWNLOAD_DIALOG_REQUEST_CODE = 778; public static final int DOWNLOADS_REQUEST_CODE = 777; private PermissionHelper() { } public static boolean checkStoragePermissions(final Activity activity, final int requestCode) { if (NewPipeSettings.useStorageAccessFramework(activity)) { return true; // Storage permissions are not needed for SAF } if (!checkReadStoragePermissions(activity, requestCode)) { return false; } return checkWriteStoragePermissions(activity, requestCode); } public static boolean checkReadStoragePermissions(final Activity activity, final int requestCode) { if (ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(activity, new String[]{ Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE}, requestCode); return false; } return true; } public static boolean checkWriteStoragePermissions(final Activity activity, final int requestCode) { // Here, thisActivity is the current activity if (ContextCompat.checkSelfPermission(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { // Should we show an explanation? /*if (ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.WRITE_EXTERNAL_STORAGE)) { // Show an explanation to the user *asynchronously* -- don't block // this thread waiting for the user's response! After the user // sees the explanation, try again to request the permission. } else {*/ // No explanation needed, we can request the permission. ActivityCompat.requestPermissions(activity, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, requestCode); // PERMISSION_WRITE_STORAGE is an // app-defined int constant. The callback method gets the // result of the request. /*}*/ return false; } return true; } public static boolean checkPostNotificationsPermission(final Activity activity, final int requestCode) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission(activity, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { if (!App.getInstance().getNotificationsRequested()) { ActivityCompat.requestPermissions(activity, new String[]{Manifest.permission.POST_NOTIFICATIONS}, requestCode); App.getInstance().setNotificationsRequested(); return false; } } return true; } /** * In order to be able to draw over other apps, * the permission android.permission.SYSTEM_ALERT_WINDOW have to be granted. *

* On < API 23 (MarshMallow) the permission was granted * when the user installed the application (via AndroidManifest), * on > 23, however, it have to start a activity asking the user if he agrees. *

*

* This method just return if the app has permission to draw over other apps, * and if it doesn't, it will try to get the permission. *

* * @param context {@link Context} * @return {@link Settings#canDrawOverlays(Context)} **/ @RequiresApi(api = Build.VERSION_CODES.M) public static boolean checkSystemAlertWindowPermission(final Context context) { if (!Settings.canDrawOverlays(context)) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { final Intent i = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + context.getPackageName())); i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); try { context.startActivity(i); } catch (final ActivityNotFoundException ignored) { } return false; // from Android R the ACTION_MANAGE_OVERLAY_PERMISSION will only point to the menu, // so let’s add a dialog that points the user to the right setting. } else { final String appName = context.getApplicationInfo() .loadLabel(context.getPackageManager()).toString(); final String title = context.getString(R.string.permission_display_over_apps); final String permissionName = context.getString(R.string.permission_display_over_apps_permission_name); final String appNameItalic = "" + appName + ""; final String permissionNameItalic = "" + permissionName + ""; final String message = context.getString(R.string.permission_display_over_apps_message, appNameItalic, permissionNameItalic ); new AlertDialog.Builder(context) .setTitle(title) .setMessage(Html.fromHtml(message, Html.FROM_HTML_MODE_COMPACT)) .setPositiveButton("OK", (dialog, which) -> { // we don’t need the package name here, since it won’t do anything on >R final Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION); try { context.startActivity(intent); } catch (final ActivityNotFoundException ignored) { } }) .setCancelable(true) .show(); return false; } } else { return true; } } /** * Determines whether the popup is enabled, and if it is not, starts the system activity to * request the permission with {@link #checkSystemAlertWindowPermission(Context)} and shows a * toast to the user explaining why the permission is needed. * * @param context the Android context * @return whether the popup is enabled */ public static boolean isPopupEnabledElseAsk(final Context context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || checkSystemAlertWindowPermission(context)) { return true; } else { Toast.makeText(context, R.string.msg_popup_permission, Toast.LENGTH_LONG).show(); return false; } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.kt ================================================ /* * SPDX-FileCopyrightText: 2023-2026 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.util import android.content.Context import android.view.View import android.view.View.OnLongClickListener import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.preference.PreferenceManager import org.schabi.newpipe.R import org.schabi.newpipe.databinding.PlaylistControlBinding import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder import org.schabi.newpipe.player.PlayerType /** * Utility class for play buttons and their respective click listeners. */ object PlayButtonHelper { /** * Initialize [OnClickListener][View.OnClickListener] * and [OnLongClickListener][OnLongClickListener] for playlist control * buttons defined in [R.layout.playlist_control]. * * @param activity The activity to use for the [Toast][Toast]. * @param playlistControlBinding The binding of the * [playlist control layout][R.layout.playlist_control]. * @param fragment The fragment to get the play queue from. */ @JvmStatic fun initPlaylistControlClickListener( activity: AppCompatActivity, playlistControlBinding: PlaylistControlBinding, fragment: PlaylistControlViewHolder ) { // click listener playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener { NavigationHelper.playOnMainPlayer(activity, fragment.getPlayQueue()) showHoldToAppendToastIfNeeded(activity) } playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener { NavigationHelper.playOnPopupPlayer(activity, fragment.getPlayQueue(), false) showHoldToAppendToastIfNeeded(activity) } playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener { NavigationHelper.playOnBackgroundPlayer(activity, fragment.getPlayQueue(), false) showHoldToAppendToastIfNeeded(activity) } // long click listener playlistControlBinding.playlistCtrlPlayAllButton.setOnLongClickListener { NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.MAIN) true } playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener { NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.POPUP) true } playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener { NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.AUDIO) true } } /** * Show the "hold to append" toast if the corresponding preference is enabled. * * @param context The context to show the toast. */ private fun showHoldToAppendToastIfNeeded(context: Context) { if (shouldShowHoldToAppendTip(context)) { Toast.makeText(context, R.string.hold_to_append, Toast.LENGTH_SHORT).show() } } /** * Check if the "hold to append" toast should be shown. * * * * The tip is shown if the corresponding preference is enabled. * This is the default behaviour. * * * @param context The context to get the preference. * @return `true` if the tip should be shown, `false` otherwise. */ @JvmStatic fun shouldShowHoldToAppendTip(context: Context): Boolean { return PreferenceManager.getDefaultSharedPreferences(context) .getBoolean(context.getString(R.string.show_hold_to_append_key), true) } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt ================================================ package org.schabi.newpipe.util import android.content.pm.PackageManager import androidx.core.content.pm.PackageInfoCompat import java.time.Instant import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import org.schabi.newpipe.App import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.ErrorUtil.Companion.createNotification import org.schabi.newpipe.error.UserAction object ReleaseVersionUtil { // Public key of the certificate that is used in NewPipe release versions private const val RELEASE_CERT_PUBLIC_KEY_SHA256 = "cb84069bd68116bafae5ee4ee5b08a567aa6d898404e7cb12f9e756df5cf5cab" @OptIn(ExperimentalStdlibApi::class) val isReleaseApk by lazy { @Suppress("NewApi") val certificates = mapOf( RELEASE_CERT_PUBLIC_KEY_SHA256.hexToByteArray() to PackageManager.CERT_INPUT_SHA256 ) val app = App.instance try { PackageInfoCompat.hasSignatures(app.packageManager, app.packageName, certificates, false) } catch (e: PackageManager.NameNotFoundException) { createNotification( app, ErrorInfo(e, UserAction.CHECK_FOR_NEW_APP_VERSION, "Could not find package info") ) false } } fun isLastUpdateCheckExpired(expiry: Long): Boolean { return Instant.ofEpochSecond(expiry) < Instant.now() } /** * Coerce expiry date time in between 6 hours and 72 hours from now * * @return Epoch second of expiry date time */ fun coerceUpdateCheckExpiry(expiryString: String?): Long { val nowPlus6Hours = ZonedDateTime.now().plusHours(6) val expiry = expiryString?.let { ZonedDateTime.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(it)) .coerceIn(nowPlus6Hours, nowPlus6Hours.plusHours(66)) } ?: nowPlus6Hours return expiry.toEpochSecond() } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/SavedState.kt ================================================ package org.schabi.newpipe.util import android.os.Parcelable import kotlinx.parcelize.Parcelize /** * Information about the saved state on the disk. */ @Parcelize class SavedState( /** * Get the prefix of the saved file. * * @return the file prefix */ val prefixFileSaved: String, /** * Get the path to the saved file. * * @return the path to the saved file */ val pathFileSaved: String ) : Parcelable { override fun toString() = "$prefixFileSaved > $pathFileSaved" } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java ================================================ package org.schabi.newpipe.util; import android.content.Context; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper; import java.util.List; public class SecondaryStreamHelper { private final int position; private final StreamInfoWrapper streams; public SecondaryStreamHelper(@NonNull final StreamInfoWrapper streams, final T selectedStream) { this.streams = streams; this.position = streams.getStreamsList().indexOf(selectedStream); if (this.position < 0) { throw new RuntimeException("selected stream not found"); } } /** * Finds an audio stream compatible with the provided video-only stream, so that the two streams * can be combined in a single file by the downloader. If there are multiple available audio * streams, chooses either the highest or the lowest quality one based on * {@link ListHelper#isLimitingDataUsage(Context)}. * * @param context Android context * @param audioStreams list of audio streams * @param videoStream desired video-ONLY stream * @return the selected audio stream or null if a candidate was not found */ @Nullable public static AudioStream getAudioStreamFor(@NonNull final Context context, @NonNull final List audioStreams, @NonNull final VideoStream videoStream) { final MediaFormat mediaFormat = videoStream.getFormat(); if (mediaFormat == MediaFormat.WEBM) { return audioStreams .stream() .filter(audioStream -> audioStream.getFormat() == MediaFormat.WEBMA || audioStream.getFormat() == MediaFormat.WEBMA_OPUS) .max(ListHelper.getAudioFormatComparator(MediaFormat.WEBMA, ListHelper.isLimitingDataUsage(context))) .orElse(null); } else if (mediaFormat == MediaFormat.MPEG_4) { return audioStreams .stream() .filter(audioStream -> audioStream.getFormat() == MediaFormat.M4A) .max(ListHelper.getAudioFormatComparator(MediaFormat.M4A, ListHelper.isLimitingDataUsage(context))) .orElse(null); } else { return null; } } public T getStream() { return streams.getStreamsList().get(position); } public long getSizeInBytes() { return streams.getSizeInBytes(position); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/SerializedCache.java ================================================ package org.schabi.newpipe.util; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.collection.LruCache; import org.schabi.newpipe.MainActivity; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; import java.util.UUID; public final class SerializedCache { private static final boolean DEBUG = MainActivity.DEBUG; private static final SerializedCache INSTANCE = new SerializedCache(); private static final int MAX_ITEMS_ON_CACHE = 5; private static final LruCache> LRU_CACHE = new LruCache<>(MAX_ITEMS_ON_CACHE); private static final String TAG = "SerializedCache"; private SerializedCache() { //no instance } public static SerializedCache getInstance() { return INSTANCE; } @Nullable public T take(@NonNull final String key, @NonNull final Class type) { if (DEBUG) { Log.d(TAG, "take() called with: key = [" + key + "]"); } synchronized (LRU_CACHE) { return LRU_CACHE.get(key) != null ? getItem(LRU_CACHE.remove(key), type) : null; } } @Nullable public T get(@NonNull final String key, @NonNull final Class type) { if (DEBUG) { Log.d(TAG, "get() called with: key = [" + key + "]"); } synchronized (LRU_CACHE) { final CacheData data = LRU_CACHE.get(key); return data != null ? getItem(data, type) : null; } } @Nullable public String put(@NonNull final T item, @NonNull final Class type) { final String key = UUID.randomUUID().toString(); return put(key, item, type) ? key : null; } public boolean put(@NonNull final String key, @NonNull final T item, @NonNull final Class type) { if (DEBUG) { Log.d(TAG, "put() called with: key = [" + key + "], item = [" + item + "]"); } synchronized (LRU_CACHE) { try { LRU_CACHE.put(key, new CacheData<>(clone(item, type), type)); return true; } catch (final Exception error) { Log.e(TAG, "Serialization failed for: ", error); } } return false; } public void clear() { if (DEBUG) { Log.d(TAG, "clear() called"); } synchronized (LRU_CACHE) { LRU_CACHE.evictAll(); } } public long size() { synchronized (LRU_CACHE) { return LRU_CACHE.size(); } } @Nullable private T getItem(@NonNull final CacheData data, @NonNull final Class type) { return type.isAssignableFrom(data.type) ? type.cast(data.item) : null; } @NonNull private T clone(@NonNull final T item, @NonNull final Class type) throws Exception { final ByteArrayOutputStream bytesOutput = new ByteArrayOutputStream(); try (ObjectOutputStream objectOutput = new ObjectOutputStream(bytesOutput)) { objectOutput.writeObject(item); objectOutput.flush(); } final Object clone = new ObjectInputStream( new ByteArrayInputStream(bytesOutput.toByteArray())).readObject(); return type.cast(clone); } private static final class CacheData { private final T item; private final Class type; private CacheData(@NonNull final T item, @NonNull final Class type) { this.item = item; this.type = type; } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/ServiceHelper.kt ================================================ /* * SPDX-FileCopyrightText: 2018-2026 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.util import android.content.Context import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.core.content.edit import androidx.preference.PreferenceManager import com.grack.nanojson.JsonParser import java.util.concurrent.TimeUnit import org.schabi.newpipe.R import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.StreamingService import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance import org.schabi.newpipe.ktx.getStringSafe object ServiceHelper { private val DEFAULT_FALLBACK_SERVICE: StreamingService = ServiceList.YouTube @JvmStatic @DrawableRes fun getIcon(serviceId: Int): Int { return when (serviceId) { 0 -> R.drawable.ic_smart_display 1 -> R.drawable.ic_cloud 2 -> R.drawable.ic_placeholder_media_ccc 3 -> R.drawable.ic_placeholder_peertube 4 -> R.drawable.ic_placeholder_bandcamp else -> R.drawable.ic_circle } } @JvmStatic fun getTranslatedFilterString(filter: String, context: Context): String { return when (filter) { "all" -> context.getString(R.string.all) "videos", "sepia_videos", "music_videos" -> context.getString(R.string.videos_string) "channels" -> context.getString(R.string.channels) "playlists", "music_playlists" -> context.getString(R.string.playlists) "tracks" -> context.getString(R.string.tracks) "users" -> context.getString(R.string.users) "conferences" -> context.getString(R.string.conferences) "events" -> context.getString(R.string.events) "music_songs" -> context.getString(R.string.songs) "music_albums" -> context.getString(R.string.albums) "music_artists" -> context.getString(R.string.artists) else -> filter } } /** * Get a resource string with instructions for importing subscriptions for each service. * * @param serviceId service to get the instructions for * @return the string resource containing the instructions or -1 if the service don't support it */ @JvmStatic @StringRes fun getImportInstructions(serviceId: Int): Int { return when (serviceId) { 0 -> R.string.import_youtube_instructions 1 -> R.string.import_soundcloud_instructions else -> -1 } } /** * For services that support importing from a channel url, return a hint that will * be used in the EditText that the user will type in his channel url. * * @param serviceId service to get the hint for * @return the hint's string resource or -1 if the service don't support it */ @JvmStatic @StringRes fun getImportInstructionsHint(serviceId: Int): Int { return when (serviceId) { 1 -> R.string.import_soundcloud_instructions_hint else -> -1 } } @JvmStatic fun getSelectedServiceId(context: Context): Int { return (getSelectedService(context) ?: DEFAULT_FALLBACK_SERVICE).serviceId } @JvmStatic fun getSelectedService(context: Context): StreamingService? { val serviceName: String = PreferenceManager.getDefaultSharedPreferences(context) .getStringSafe( context.getString(R.string.current_service_key), context.getString(R.string.default_service_value) ) return runCatching { NewPipe.getService(serviceName) }.getOrNull() } @JvmStatic fun getNameOfServiceById(serviceId: Int): String { return ServiceList.all().stream() .filter { it.serviceId == serviceId } .findFirst() .map(StreamingService::getServiceInfo) .map(StreamingService.ServiceInfo::getName) .orElse("") } /** * @param serviceId the id of the service * @return the service corresponding to the provided id * @throws java.util.NoSuchElementException if there is no service with the provided id */ @JvmStatic fun getServiceById(serviceId: Int): StreamingService { return ServiceList.all().firstNotNullOf { it.takeIf { it.serviceId == serviceId } } } @JvmStatic fun setSelectedServiceId(context: Context, serviceId: Int) { val serviceName = runCatching { NewPipe.getService(serviceId).serviceInfo.name } .getOrDefault(DEFAULT_FALLBACK_SERVICE.serviceInfo.name) setSelectedServicePreferences(context, serviceName) } private fun setSelectedServicePreferences(context: Context, serviceName: String?) { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) sharedPreferences.edit { putString(context.getString(R.string.current_service_key), serviceName) } } @JvmStatic fun getCacheExpirationMillis(serviceId: Int): Long { return if (serviceId == ServiceList.SoundCloud.serviceId) { TimeUnit.MILLISECONDS.convert(5, TimeUnit.MINUTES) } else { TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS) } } fun initService(context: Context, serviceId: Int) { if (serviceId == ServiceList.PeerTube.serviceId) { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) val json = sharedPreferences.getString( context.getString(R.string.peertube_selected_instance_key), null ) ?: return val jsonObject = runCatching { JsonParser.`object`().from(json) } .getOrElse { return@initService } ServiceList.PeerTube.instance = PeertubeInstance( jsonObject.getString("url"), jsonObject.getString("name") ) } } @JvmStatic fun initServices(context: Context) { ServiceList.all().forEach { initService(context, it.serviceId) } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/SimpleOnSeekBarChangeListener.kt ================================================ package org.schabi.newpipe.util import android.widget.SeekBar /** * Why the hell didn't they make a stub implementation for this? */ abstract class SimpleOnSeekBarChangeListener : SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {} override fun onStartTrackingTouch(seekBar: SeekBar) {} override fun onStopTrackingTouch(seekBar: SeekBar) {} } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/SliderStrategy.java ================================================ package org.schabi.newpipe.util; public interface SliderStrategy { /** * Converts from zeroed double with a minimum offset to the nearest rounded slider * equivalent integer. * * @param value the value to convert * @return the converted value */ int progressOf(double value); /** * Converts from slider integer value to an equivalent double value with a given * minimum offset. * * @param progress the value to convert * @return the converted value */ double valueOf(int progress); // TODO: also implement linear strategy when needed final class Quadratic implements SliderStrategy { private final double leftGap; private final double rightGap; private final double center; private final int centerProgress; /** * Quadratic slider strategy that scales the value of a slider given how far the slider * progress is from the center of the slider. The further away from the center, * the faster the interpreted value changes, and vice versa. * * @param minimum the minimum value of the interpreted value of the slider. * @param maximum the maximum value of the interpreted value of the slider. * @param center center of the interpreted value between the minimum and maximum, which * will be used as the center value on the slider progress. Doesn't need * to be the average of the minimum and maximum values, but must be in * between the two. * @param maxProgress the maximum possible progress of the slider, this is the * value that is shown for the UI and controls the granularity of * the slider. Should be as large as possible to avoid floating * point round-off error. Using odd number is recommended. */ public Quadratic(final double minimum, final double maximum, final double center, final int maxProgress) { if (center < minimum || center > maximum) { throw new IllegalArgumentException("Center must be in between minimum and maximum"); } this.leftGap = minimum - center; this.rightGap = maximum - center; this.center = center; this.centerProgress = maxProgress / 2; } @Override public int progressOf(final double value) { final double difference = value - center; final double root = difference >= 0 ? Math.sqrt(difference / rightGap) : -Math.sqrt(Math.abs(difference / leftGap)); final double offset = Math.round(root * centerProgress); return (int) (centerProgress + offset); } @Override public double valueOf(final int progress) { final int offset = progress - centerProgress; final double square = Math.pow(((double) offset) / ((double) centerProgress), 2); final double difference = square * (offset >= 0 ? rightGap : leftGap); return difference + center; } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java ================================================ package org.schabi.newpipe.util; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; import android.content.Context; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import java.util.function.Consumer; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Completable; import io.reactivex.rxjava3.schedulers.Schedulers; /** * Utility class for fetching additional data for stream items when needed. */ public final class SparseItemUtil { private SparseItemUtil() { } /** * Use this to certainly obtain an single play queue with all of the data filled in when the * stream info item you are handling might be sparse, e.g. because it was fetched via a {@link * org.schabi.newpipe.extractor.feed.FeedExtractor}. FeedExtractors provide a fast and * lightweight method to fetch info, but the info might be incomplete (see * {@link org.schabi.newpipe.local.feed.service.FeedLoadService} for more details). * * @param context Android context * @param item item which is checked and eventually loaded completely * @param callback callback to call with the single play queue built from the original item if * all info was available, otherwise from the fetched {@link * org.schabi.newpipe.extractor.stream.StreamInfo} */ public static void fetchItemInfoIfSparse(@NonNull final Context context, @NonNull final StreamInfoItem item, @NonNull final Consumer callback) { if ((StreamTypeUtil.isLiveStream(item.getStreamType()) || item.getDuration() >= 0) && !isNullOrEmpty(item.getUploaderUrl())) { // if the duration is >= 0 (provided that the item is not a livestream) and there is an // uploader url, probably all info is already there, so there is no need to fetch it callback.accept(new SinglePlayQueue(item)); return; } // either the duration or the uploader url are not available, so fetch more info fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(), streamInfo -> callback.accept(new SinglePlayQueue(streamInfo))); } /** * Use this to certainly obtain an uploader url when the stream info item or play queue item you * are handling might not have the uploader url (e.g. because it was fetched with {@link * org.schabi.newpipe.extractor.feed.FeedExtractor}). A toast is shown if loading details is * required. * * @param context Android context * @param serviceId serviceId of the item * @param url item url * @param uploaderUrl uploaderUrl of the item; if null or empty will be fetched * @param callback callback to be called with either the original uploaderUrl, if it was a * valid url, otherwise with the uploader url obtained by fetching the {@link * org.schabi.newpipe.extractor.stream.StreamInfo} corresponding to the item */ public static void fetchUploaderUrlIfSparse(@NonNull final Context context, final int serviceId, @NonNull final String url, @Nullable final String uploaderUrl, @NonNull final Consumer callback) { if (!isNullOrEmpty(uploaderUrl)) { callback.accept(uploaderUrl); return; } fetchStreamInfoAndSaveToDatabase(context, serviceId, url, streamInfo -> callback.accept(streamInfo.getUploaderUrl())); } /** * Loads the stream info corresponding to the given data on an I/O thread, stores the result in * the database and calls the callback on the main thread with the result. A toast will be shown * to the user about loading stream details, so this needs to be called on the main thread. * * @param context Android context * @param serviceId service id of the stream to load * @param url url of the stream to load * @param callback callback to be called with the result */ public static void fetchStreamInfoAndSaveToDatabase(@NonNull final Context context, final int serviceId, @NonNull final String url, final Consumer callback) { Toast.makeText(context, R.string.loading_stream_details, Toast.LENGTH_SHORT).show(); ExtractorHelper.getStreamInfo(serviceId, url, false) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(result -> { // save to database in the background (not on main thread) Completable.fromAction(() -> NewPipeDatabase.getInstance(context) .streamDAO().upsert(new StreamEntity(result))) .subscribeOn(Schedulers.io()) .observeOn(Schedulers.io()) .doOnError(throwable -> ErrorUtil.createNotification(context, new ErrorInfo(throwable, UserAction.REQUESTED_STREAM, "Saving stream info to database", result))) .subscribe(); // call callback on main thread with the obtained result callback.accept(result); }, throwable -> ErrorUtil.createNotification(context, new ErrorInfo(throwable, UserAction.REQUESTED_STREAM, "Loading stream info: " + url, serviceId, url) )); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/StateSaver.java ================================================ /* * Copyright 2017 Mauricio Colli * StateSaver.java is part of NewPipe * * License: GPL-3.0+ * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package org.schabi.newpipe.util; import android.content.Context; import android.os.Bundle; import android.text.TextUtils; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.os.BundleCompat; import org.schabi.newpipe.BuildConfig; import org.schabi.newpipe.MainActivity; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.util.LinkedList; import java.util.Queue; import java.util.concurrent.ConcurrentHashMap; /** * A way to save state to disk or in a in-memory map * if it's just changing configurations (i.e. rotating the phone). */ public final class StateSaver { public static final String KEY_SAVED_STATE = "key_saved_state"; private static final ConcurrentHashMap> STATE_OBJECTS_HOLDER = new ConcurrentHashMap<>(); private static final String TAG = "StateSaver"; private static final String CACHE_DIR_NAME = "state_cache"; private static String cacheDirPath; private StateSaver() { //no instance } /** * Initialize the StateSaver, usually you want to call this in the Application class. * * @param context used to get the available cache dir */ public static void init(final Context context) { final File externalCacheDir = context.getExternalCacheDir(); if (externalCacheDir != null) { cacheDirPath = externalCacheDir.getAbsolutePath(); } if (TextUtils.isEmpty(cacheDirPath)) { cacheDirPath = context.getCacheDir().getAbsolutePath(); } } /** * @param outState * @param writeRead * @return the saved state * @see #tryToRestore(SavedState, WriteRead) */ public static SavedState tryToRestore(final Bundle outState, final WriteRead writeRead) { if (outState == null || writeRead == null) { return null; } final SavedState savedState = BundleCompat.getParcelable( outState, KEY_SAVED_STATE, SavedState.class); if (savedState == null) { return null; } return tryToRestore(savedState, writeRead); } /** * Try to restore the state from memory and disk, * using the {@link StateSaver.WriteRead#readFrom(Queue)} from the writeRead. * * @param savedState * @param writeRead * @return the saved state */ @Nullable private static SavedState tryToRestore(@NonNull final SavedState savedState, @NonNull final WriteRead writeRead) { if (MainActivity.DEBUG) { Log.d(TAG, "tryToRestore() called with: savedState = [" + savedState + "], " + "writeRead = [" + writeRead + "]"); } try { Queue savedObjects = STATE_OBJECTS_HOLDER.remove(savedState.getPrefixFileSaved()); if (savedObjects != null) { writeRead.readFrom(savedObjects); if (MainActivity.DEBUG) { Log.d(TAG, "tryToSave: reading objects from holder > " + savedObjects + ", stateObjectsHolder > " + STATE_OBJECTS_HOLDER); } return savedState; } final File file = new File(savedState.getPathFileSaved()); if (!file.exists()) { if (MainActivity.DEBUG) { Log.d(TAG, "Cache file doesn't exist: " + file.getAbsolutePath()); } return null; } try (FileInputStream fileInputStream = new FileInputStream(file); ObjectInputStream inputStream = new ObjectInputStream(fileInputStream)) { //noinspection unchecked savedObjects = (Queue) inputStream.readObject(); } if (savedObjects != null) { writeRead.readFrom(savedObjects); } return savedState; } catch (final Exception e) { Log.e(TAG, "Failed to restore state", e); } return null; } /** * @param isChangingConfig * @param savedState * @param outState * @param writeRead * @return the saved state or {@code null} * @see #tryToSave(boolean, String, String, WriteRead) */ @Nullable public static SavedState tryToSave(final boolean isChangingConfig, @Nullable final SavedState savedState, final Bundle outState, final WriteRead writeRead) { @NonNull final String currentSavedPrefix; if (savedState == null || TextUtils.isEmpty(savedState.getPrefixFileSaved())) { // Generate unique prefix currentSavedPrefix = System.nanoTime() - writeRead.hashCode() + ""; } else { // Reuse prefix currentSavedPrefix = savedState.getPrefixFileSaved(); } final SavedState newSavedState = tryToSave(isChangingConfig, currentSavedPrefix, writeRead.generateSuffix(), writeRead); if (newSavedState != null) { outState.putParcelable(StateSaver.KEY_SAVED_STATE, newSavedState); return newSavedState; } return null; } /** * If it's not changing configuration (i.e. rotating screen), * try to write the state from {@link StateSaver.WriteRead#writeTo(Queue)} * to the file with the name of prefixFileName + suffixFileName, * in a cache folder got from the {@link #init(Context)}. *

* It checks if the file already exists and if it does, just return the path, * so a good way to save is: *

*
    *
  • A fixed prefix for the file
  • *
  • A changing suffix
  • *
* * @param isChangingConfig * @param prefixFileName * @param suffixFileName * @param writeRead * @return the saved state or {@code null} */ @Nullable private static SavedState tryToSave(final boolean isChangingConfig, final String prefixFileName, final String suffixFileName, final WriteRead writeRead) { if (MainActivity.DEBUG) { Log.d(TAG, "tryToSave() called with: " + "isChangingConfig = [" + isChangingConfig + "], " + "prefixFileName = [" + prefixFileName + "], " + "suffixFileName = [" + suffixFileName + "], " + "writeRead = [" + writeRead + "]"); } final LinkedList savedObjects = new LinkedList<>(); writeRead.writeTo(savedObjects); if (isChangingConfig) { if (savedObjects.size() > 0) { STATE_OBJECTS_HOLDER.put(prefixFileName, savedObjects); return new SavedState(prefixFileName, ""); } else { if (MainActivity.DEBUG) { Log.d(TAG, "Nothing to save"); } return null; } } try { File cacheDir = new File(cacheDirPath); if (!cacheDir.exists()) { throw new RuntimeException("Cache dir does not exist > " + cacheDirPath); } cacheDir = new File(cacheDir, CACHE_DIR_NAME); if (!cacheDir.exists()) { if (!cacheDir.mkdir()) { if (BuildConfig.DEBUG) { Log.e(TAG, "Failed to create cache directory " + cacheDir.getAbsolutePath()); } return null; } } final File file = new File(cacheDir, prefixFileName + (TextUtils.isEmpty(suffixFileName) ? ".cache" : suffixFileName)); if (file.exists() && file.length() > 0) { // If the file already exists, just return it return new SavedState(prefixFileName, file.getAbsolutePath()); } else { // Delete any file that contains the prefix final File[] files = cacheDir.listFiles((dir, name) -> name.contains(prefixFileName)); for (final File fileToDelete : files) { fileToDelete.delete(); } } try (FileOutputStream fileOutputStream = new FileOutputStream(file); ObjectOutputStream outputStream = new ObjectOutputStream(fileOutputStream)) { outputStream.writeObject(savedObjects); } return new SavedState(prefixFileName, file.getAbsolutePath()); } catch (final Exception e) { Log.e(TAG, "Failed to save state", e); } return null; } /** * Delete the cache file contained in the savedState. * Also remove any possible-existing value in the memory-cache. * * @param savedState the saved state to delete */ public static void onDestroy(final SavedState savedState) { if (MainActivity.DEBUG) { Log.d(TAG, "onDestroy() called with: savedState = [" + savedState + "]"); } if (savedState != null && !savedState.getPathFileSaved().isEmpty()) { STATE_OBJECTS_HOLDER.remove(savedState.getPrefixFileSaved()); try { //noinspection ResultOfMethodCallIgnored new File(savedState.getPathFileSaved()).delete(); } catch (final Exception ignored) { } } } /** * Clear all the files in cache (in memory and disk). */ public static void clearStateFiles() { if (MainActivity.DEBUG) { Log.d(TAG, "clearStateFiles() called"); } STATE_OBJECTS_HOLDER.clear(); File cacheDir = new File(cacheDirPath); if (!cacheDir.exists()) { return; } cacheDir = new File(cacheDir, CACHE_DIR_NAME); if (cacheDir.exists()) { final File[] list = cacheDir.listFiles(); if (list != null) { for (final File file : list) { file.delete(); } } } } /** * Used for describing how to save/read the objects. *

* Queue was chosen by its FIFO property. */ public interface WriteRead { /** * Generate a changing suffix that will name the cache file, * and be used to identify if it changed (thus reducing useless reading/saving). * * @return a unique value */ String generateSuffix(); /** * Add to this queue objects that you want to save. * * @param objectsToSave the objects to save */ void writeTo(Queue objectsToSave); /** * Poll saved objects from the queue in the order they were written. * * @param savedObjects queue of objects returned by {@link #writeTo(Queue)} */ void readFrom(@NonNull Queue savedObjects) throws Exception; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java ================================================ package org.schabi.newpipe.util; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; import android.content.Context; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.BaseAdapter; import android.widget.ImageView; import android.widget.Spinner; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import androidx.collection.SparseArrayCompat; import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.downloader.Response; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.extractor.utils.Utils; import java.io.Serializable; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.Callable; import java.util.stream.Collectors; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.schedulers.Schedulers; import us.shandian.giga.util.Utility; /** * A list adapter for a list of {@link Stream streams}. * It currently supports {@link VideoStream}, {@link AudioStream} and {@link SubtitlesStream}. * * @param the primary stream type's class extending {@link Stream} * @param the secondary stream type's class extending {@link Stream} */ public class StreamItemAdapter extends BaseAdapter { @NonNull private final StreamInfoWrapper streamsWrapper; @NonNull private final SparseArrayCompat> secondaryStreams; /** * Indicates that at least one of the primary streams is an instance of {@link VideoStream}, * has no audio ({@link VideoStream#isVideoOnly()} returns true) and has no secondary stream * associated with it. */ private final boolean hasAnyVideoOnlyStreamWithNoSecondaryStream; public StreamItemAdapter( @NonNull final StreamInfoWrapper streamsWrapper, @NonNull final SparseArrayCompat> secondaryStreams ) { this.streamsWrapper = streamsWrapper; this.secondaryStreams = secondaryStreams; this.hasAnyVideoOnlyStreamWithNoSecondaryStream = checkHasAnyVideoOnlyStreamWithNoSecondaryStream(); } public StreamItemAdapter(final StreamInfoWrapper streamsWrapper) { this(streamsWrapper, new SparseArrayCompat<>(0)); } public List getAll() { return streamsWrapper.getStreamsList(); } public SparseArrayCompat> getAllSecondary() { return secondaryStreams; } @Override public int getCount() { return streamsWrapper.getStreamsList().size(); } @Override public T getItem(final int position) { return streamsWrapper.getStreamsList().get(position); } @Override public long getItemId(final int position) { return position; } @Override public View getDropDownView(final int position, final View convertView, final ViewGroup parent) { return getCustomView(position, convertView, parent, true); } @Override public View getView(final int position, final View convertView, final ViewGroup parent) { return getCustomView(((Spinner) parent).getSelectedItemPosition(), convertView, parent, false); } @NonNull private View getCustomView(final int position, final View view, final ViewGroup parent, final boolean isDropdownItem) { final var context = parent.getContext(); View convertView = view; if (convertView == null) { convertView = LayoutInflater.from(context).inflate( R.layout.stream_quality_item, parent, false); } final ImageView woSoundIconView = convertView.findViewById(R.id.wo_sound_icon); final TextView formatNameView = convertView.findViewById(R.id.stream_format_name); final TextView qualityView = convertView.findViewById(R.id.stream_quality); final TextView sizeView = convertView.findViewById(R.id.stream_size); final T stream = getItem(position); final MediaFormat mediaFormat = streamsWrapper.getFormat(position); int woSoundIconVisibility = View.GONE; String qualityString; if (stream instanceof VideoStream) { final VideoStream videoStream = ((VideoStream) stream); qualityString = videoStream.getResolution(); if (hasAnyVideoOnlyStreamWithNoSecondaryStream) { if (videoStream.isVideoOnly()) { woSoundIconVisibility = secondaryStreams.get(position) != null // It has a secondary stream associated with it, so check if it's a // dropdown view so it doesn't look out of place (missing margin) // compared to those that don't. ? (isDropdownItem ? View.INVISIBLE : View.GONE) // It doesn't have a secondary stream, icon is visible no matter what. : View.VISIBLE; } else if (isDropdownItem) { woSoundIconVisibility = View.INVISIBLE; } } } else if (stream instanceof AudioStream) { final AudioStream audioStream = ((AudioStream) stream); if (audioStream.getAverageBitrate() > 0) { qualityString = audioStream.getAverageBitrate() + "kbps"; } else { qualityString = context.getString(R.string.unknown_quality); } } else if (stream instanceof SubtitlesStream) { qualityString = ((SubtitlesStream) stream).getDisplayLanguageName(); if (((SubtitlesStream) stream).isAutoGenerated()) { qualityString += " (" + context.getString(R.string.caption_auto_generated) + ")"; } } else { if (mediaFormat == null) { qualityString = context.getString(R.string.unknown_quality); } else { qualityString = mediaFormat.getSuffix(); } } if (streamsWrapper.getSizeInBytes(position) > 0) { final var secondary = secondaryStreams.get(position); if (secondary != null) { final long size = secondary.getSizeInBytes() + streamsWrapper.getSizeInBytes(position); sizeView.setText(Utility.formatBytes(size)); } else { sizeView.setText(streamsWrapper.getFormattedSize(position)); } sizeView.setVisibility(View.VISIBLE); } else { sizeView.setVisibility(View.GONE); } if (stream instanceof SubtitlesStream) { formatNameView.setText(((SubtitlesStream) stream).getLanguageTag()); } else { if (mediaFormat == null) { formatNameView.setText(context.getString(R.string.unknown_format)); } else if (mediaFormat == MediaFormat.WEBMA_OPUS) { // noinspection AndroidLintSetTextI18n formatNameView.setText("opus"); } else { formatNameView.setText(mediaFormat.getName()); } } qualityView.setText(qualityString); woSoundIconView.setVisibility(woSoundIconVisibility); return convertView; } /** * @return if there are any video-only streams with no secondary stream associated with them. * @see #hasAnyVideoOnlyStreamWithNoSecondaryStream */ private boolean checkHasAnyVideoOnlyStreamWithNoSecondaryStream() { for (int i = 0; i < streamsWrapper.getStreamsList().size(); i++) { final T stream = streamsWrapper.getStreamsList().get(i); if (stream instanceof VideoStream) { final boolean videoOnly = ((VideoStream) stream).isVideoOnly(); if (videoOnly && secondaryStreams.get(i) == null) { return true; } } } return false; } /** * A wrapper class that includes a way of storing the stream sizes. * * @param the stream type's class extending {@link Stream} */ public static class StreamInfoWrapper implements Serializable { private static final StreamInfoWrapper EMPTY = new StreamInfoWrapper<>(Collections.emptyList(), null); private static final int SIZE_UNSET = -2; private final List streamsList; private final long[] streamSizes; private final MediaFormat[] streamFormats; private final String unknownSize; public StreamInfoWrapper(@NonNull final List streamList, @Nullable final Context context) { this.streamsList = streamList; this.streamSizes = new long[streamsList.size()]; this.unknownSize = context == null ? "--.-" : context.getString(R.string.unknown_content); this.streamFormats = new MediaFormat[streamsList.size()]; resetInfo(); } /** * Helper method to fetch the sizes and missing media formats * of all the streams in a wrapper. * * @param the stream type's class extending {@link Stream} * @param streamsWrapper the wrapper * @return a {@link Single} that returns a boolean indicating if any elements were changed */ @NonNull public static Single fetchMoreInfoForWrapper( final StreamInfoWrapper streamsWrapper) { final Callable fetchAndSet = () -> { boolean hasChanged = false; for (final X stream : streamsWrapper.getStreamsList()) { final boolean changeSize = streamsWrapper.getSizeInBytes(stream) <= SIZE_UNSET; final boolean changeFormat = stream.getFormat() == null; if (!changeSize && !changeFormat) { continue; } final Response response = DownloaderImpl.getInstance() .head(stream.getContent()); if (changeSize) { final String contentLength = response.getHeader("Content-Length"); if (!isNullOrEmpty(contentLength)) { streamsWrapper.setSize(stream, Long.parseLong(contentLength)); hasChanged = true; } } if (changeFormat) { hasChanged = retrieveMediaFormat(stream, streamsWrapper, response) || hasChanged; } } return hasChanged; }; return Single.fromCallable(fetchAndSet) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .onErrorReturnItem(true); } /** * Try to retrieve the {@link MediaFormat} for a stream from the request headers. * * @param the stream type to get the {@link MediaFormat} for * @param stream the stream to find the {@link MediaFormat} for * @param streamsWrapper the wrapper to store the found {@link MediaFormat} in * @param response the response of the head request for the given stream * @return {@code true} if the media format could be retrieved; {@code false} otherwise */ @VisibleForTesting public static boolean retrieveMediaFormat( @NonNull final X stream, @NonNull final StreamInfoWrapper streamsWrapper, @NonNull final Response response) { return retrieveMediaFormatFromFileTypeHeaders(stream, streamsWrapper, response) || retrieveMediaFormatFromContentDispositionHeader( stream, streamsWrapper, response) || retrieveMediaFormatFromContentTypeHeader(stream, streamsWrapper, response); } @VisibleForTesting public static boolean retrieveMediaFormatFromFileTypeHeaders( @NonNull final X stream, @NonNull final StreamInfoWrapper streamsWrapper, @NonNull final Response response) { // try to use additional headers from CDNs or servers, // e.g. x-amz-meta-file-type (e.g. for SoundCloud) final List keys = response.responseHeaders().keySet().stream() .filter(k -> k.endsWith("file-type")).collect(Collectors.toList()); if (!keys.isEmpty()) { for (final String key : keys) { final String suffix = response.getHeader(key); final MediaFormat format = MediaFormat.getFromSuffix(suffix); if (format != null) { streamsWrapper.setFormat(stream, format); return true; } } } return false; } /** *

Retrieve a {@link MediaFormat} from a HTTP Content-Disposition header * for a stream and store the info in a wrapper.

* @see * * mdn Web Docs for the HTTP Content-Disposition Header * @param stream the stream to get the {@link MediaFormat} for * @param streamsWrapper the wrapper to store the {@link MediaFormat} in * @param response the response to get the Content-Disposition header from * @return {@code true} if the {@link MediaFormat} could be retrieved from the response; * otherwise {@code false} * @param */ @VisibleForTesting public static boolean retrieveMediaFormatFromContentDispositionHeader( @NonNull final X stream, @NonNull final StreamInfoWrapper streamsWrapper, @NonNull final Response response) { // parse the Content-Disposition header, // see // there can be two filename directives String contentDisposition = response.getHeader("Content-Disposition"); if (contentDisposition == null) { return false; } try { contentDisposition = Utils.decodeUrlUtf8(contentDisposition); final String[] parts = contentDisposition.split(";"); for (String part : parts) { final String fileName; part = part.trim(); // extract the filename if (part.startsWith("filename=")) { // remove directive and decode fileName = Utils.decodeUrlUtf8(part.substring(9)); } else if (part.startsWith("filename*=")) { fileName = Utils.decodeUrlUtf8(part.substring(10)); } else { continue; } // extract the file extension / suffix final String[] p = fileName.split("\\."); String suffix = p[p.length - 1]; if (suffix.endsWith("\"") || suffix.endsWith("'")) { // remove trailing quotes if present, end index is exclusive suffix = suffix.substring(0, suffix.length() - 1); } // get the corresponding media format final MediaFormat format = MediaFormat.getFromSuffix(suffix); if (format != null) { streamsWrapper.setFormat(stream, format); return true; } } } catch (final Exception ignored) { // fail silently } return false; } @VisibleForTesting public static boolean retrieveMediaFormatFromContentTypeHeader( @NonNull final X stream, @NonNull final StreamInfoWrapper streamsWrapper, @NonNull final Response response) { // try to get the format by content type // some mime types are not unique for every format, those are omitted final String contentTypeHeader = response.getHeader("Content-Type"); if (contentTypeHeader == null) { return false; } @Nullable MediaFormat foundFormat = null; for (final MediaFormat format : MediaFormat.getAllFromMimeType(contentTypeHeader)) { if (foundFormat == null) { foundFormat = format; } else if (foundFormat.id != format.id) { return false; } } if (foundFormat != null) { streamsWrapper.setFormat(stream, foundFormat); return true; } return false; } public void resetInfo() { Arrays.fill(streamSizes, SIZE_UNSET); for (int i = 0; i < streamsList.size(); i++) { streamFormats[i] = streamsList.get(i) == null // test for invalid streams ? null : streamsList.get(i).getFormat(); } } public static StreamInfoWrapper empty() { //noinspection unchecked return (StreamInfoWrapper) EMPTY; } public List getStreamsList() { return streamsList; } public long getSizeInBytes(final int streamIndex) { return streamSizes[streamIndex]; } public long getSizeInBytes(final T stream) { return streamSizes[streamsList.indexOf(stream)]; } public String getFormattedSize(final int streamIndex) { return formatSize(getSizeInBytes(streamIndex)); } private String formatSize(final long size) { if (size > -1) { return Utility.formatBytes(size); } return unknownSize; } public void setSize(final T stream, final long sizeInBytes) { streamSizes[streamsList.indexOf(stream)] = sizeInBytes; } public MediaFormat getFormat(final int streamIndex) { return streamFormats[streamIndex]; } public void setFormat(final T stream, final MediaFormat format) { streamFormats[streamsList.indexOf(stream)] = format; } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.kt ================================================ /* * SPDX-FileCopyrightText: 2021-2026 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.util import org.schabi.newpipe.extractor.stream.StreamType /** * Utility class for [StreamType]. */ object StreamTypeUtil { /** * Check if the [StreamType] of a stream is a livestream. * * @param streamType the stream type of the stream * @return whether the stream type is [StreamType.AUDIO_STREAM], * [StreamType.AUDIO_LIVE_STREAM] or [StreamType.POST_LIVE_AUDIO_STREAM] */ @JvmStatic fun isAudio(streamType: StreamType): Boolean { return streamType == StreamType.AUDIO_STREAM || streamType == StreamType.AUDIO_LIVE_STREAM || streamType == StreamType.POST_LIVE_AUDIO_STREAM } /** * Check if the [StreamType] of a stream is a livestream. * * @param streamType the stream type of the stream * @return whether the stream type is [StreamType.VIDEO_STREAM], * [StreamType.LIVE_STREAM] or [StreamType.POST_LIVE_STREAM] */ @JvmStatic fun isVideo(streamType: StreamType): Boolean { return streamType == StreamType.VIDEO_STREAM || streamType == StreamType.LIVE_STREAM || streamType == StreamType.POST_LIVE_STREAM } /** * Check if the [StreamType] of a stream is a livestream. * * @param streamType the stream type of the stream * @return whether the stream type is [StreamType.LIVE_STREAM] or * [StreamType.AUDIO_LIVE_STREAM] */ @JvmStatic fun isLiveStream(streamType: StreamType): Boolean { return streamType == StreamType.LIVE_STREAM || streamType == StreamType.AUDIO_LIVE_STREAM } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java ================================================ /* * Copyright 2018 Mauricio Colli * ThemeHelper.java is part of NewPipe * * License: GPL-3.0+ * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package org.schabi.newpipe.util; import android.app.Activity; import android.content.Context; import android.content.res.Configuration; import android.content.res.Resources; import android.graphics.drawable.Drawable; import android.util.TypedValue; import androidx.annotation.AttrRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StyleRes; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatDelegate; import androidx.appcompat.content.res.AppCompatResources; import androidx.core.content.ContextCompat; import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.info_list.ItemViewMode; public final class ThemeHelper { private ThemeHelper() { } /** * Apply the selected theme (on NewPipe settings) in the context * with the default style (see {@link #setTheme(Context, int)}). * * ThemeHelper.setDayNightMode should be called before * the applying theme for the first time in session * * @param context context that the theme will be applied */ public static void setTheme(final Context context) { setTheme(context, -1); } /** * Apply the selected theme (on NewPipe settings) in the context, * themed according with the styles defined for the service . * * ThemeHelper.setDayNightMode should be called before * the applying theme for the first time in session * * @param context context that the theme will be applied * @param serviceId the theme will be styled to the service with this id, * pass -1 to get the default style */ public static void setTheme(final Context context, final int serviceId) { context.setTheme(getThemeForService(context, serviceId)); } /** * Return true if the selected theme (on NewPipe settings) is the Light theme. * * @param context context to get the preference * @return whether the light theme is selected */ public static boolean isLightThemeSelected(final Context context) { final String selectedThemeKey = getSelectedThemeKey(context); final Resources res = context.getResources(); return selectedThemeKey.equals(res.getString(R.string.light_theme_key)) || (selectedThemeKey.equals(res.getString(R.string.auto_device_theme_key)) && !isDeviceDarkThemeEnabled(context)); } /** * Return a dialog theme styled according to the (default) selected theme. * * @param context context to get the selected theme * @return the dialog style (the default one) */ @StyleRes public static int getDialogTheme(final Context context) { return isLightThemeSelected(context) ? R.style.LightDialogTheme : R.style.DarkDialogTheme; } /** * Return a min-width dialog theme styled according to the (default) selected theme. * * @param context context to get the selected theme * @return the dialog style (the default one) */ @StyleRes public static int getMinWidthDialogTheme(final Context context) { return isLightThemeSelected(context) ? R.style.LightDialogMinWidthTheme : R.style.DarkDialogMinWidthTheme; } /** * Return the selected theme styled according to the serviceId. * * @param context context to get the selected theme * @param serviceId return a theme styled to this service, * -1 to get the default * @return the selected style (styled) */ @StyleRes public static int getThemeForService(final Context context, final int serviceId) { final Resources res = context.getResources(); final String lightThemeKey = res.getString(R.string.light_theme_key); final String blackThemeKey = res.getString(R.string.black_theme_key); final String automaticDeviceThemeKey = res.getString(R.string.auto_device_theme_key); final String selectedThemeKey = getSelectedThemeKey(context); int baseTheme = R.style.DarkTheme; // default to dark theme if (selectedThemeKey.equals(lightThemeKey)) { baseTheme = R.style.LightTheme; } else if (selectedThemeKey.equals(blackThemeKey)) { baseTheme = R.style.BlackTheme; } else if (selectedThemeKey.equals(automaticDeviceThemeKey)) { if (isDeviceDarkThemeEnabled(context)) { // use the dark theme variant preferred by the user final String selectedNightThemeKey = getSelectedNightThemeKey(context); if (selectedNightThemeKey.equals(blackThemeKey)) { baseTheme = R.style.BlackTheme; } else { baseTheme = R.style.DarkTheme; } } else { // there is only one day theme baseTheme = R.style.LightTheme; } } if (serviceId <= -1) { return baseTheme; } final StreamingService service; try { service = NewPipe.getService(serviceId); } catch (final ExtractionException ignored) { return baseTheme; } String themeName = "DarkTheme"; // default if (baseTheme == R.style.LightTheme) { themeName = "LightTheme"; } else if (baseTheme == R.style.BlackTheme) { themeName = "BlackTheme"; } themeName += "." + service.getServiceInfo().getName(); final int resourceId = context.getResources() .getIdentifier(themeName, "style", context.getPackageName()); if (resourceId > 0) { return resourceId; } return baseTheme; } @StyleRes public static int getSettingsThemeStyle(final Context context) { final Resources res = context.getResources(); final String lightTheme = res.getString(R.string.light_theme_key); final String blackTheme = res.getString(R.string.black_theme_key); final String automaticDeviceTheme = res.getString(R.string.auto_device_theme_key); final String selectedTheme = getSelectedThemeKey(context); if (selectedTheme.equals(lightTheme)) { return R.style.LightSettingsTheme; } else if (selectedTheme.equals(blackTheme)) { return R.style.BlackSettingsTheme; } else if (selectedTheme.equals(automaticDeviceTheme)) { if (isDeviceDarkThemeEnabled(context)) { // use the dark theme variant preferred by the user final String selectedNightTheme = getSelectedNightThemeKey(context); if (selectedNightTheme.equals(blackTheme)) { return R.style.BlackSettingsTheme; } else { return R.style.DarkSettingsTheme; } } else { // there is only one day theme return R.style.LightSettingsTheme; } } else { // default to dark theme return R.style.DarkSettingsTheme; } } /** * Get a color from an attr styled according to the context's theme. * * @param context Android app context * @param attrColor attribute reference of the resource * @return the color */ public static int resolveColorFromAttr(final Context context, @AttrRes final int attrColor) { final TypedValue value = new TypedValue(); context.getTheme().resolveAttribute(attrColor, value, true); if (value.resourceId != 0) { return ContextCompat.getColor(context, value.resourceId); } return value.data; } /** * Resolves a {@link Drawable} by it's id. * * @param context Context * @param attrResId Resource id * @return the {@link Drawable} */ public static Drawable resolveDrawable(@NonNull final Context context, @AttrRes final int attrResId) { final TypedValue typedValue = new TypedValue(); context.getTheme().resolveAttribute(attrResId, typedValue, true); return AppCompatResources.getDrawable(context, typedValue.resourceId); } /** * Gets a runtime dimen from the {@code android} package. Should be used for dimens for which * normal accessing with {@code R.dimen.} is not available. * * @param context context * @param name dimen resource name (e.g. navigation_bar_height) * @return the obtained dimension, in pixels, or 0 if the resource could not be resolved */ public static int getAndroidDimenPx(@NonNull final Context context, final String name) { final int resId = context.getResources().getIdentifier(name, "dimen", "android"); if (resId <= 0) { return 0; } return context.getResources().getDimensionPixelSize(resId); } private static String getSelectedThemeKey(final Context context) { final String themeKey = context.getString(R.string.theme_key); final String defaultTheme = context.getString(R.string.default_theme_value); return PreferenceManager.getDefaultSharedPreferences(context) .getString(themeKey, defaultTheme); } private static String getSelectedNightThemeKey(final Context context) { final String nightThemeKey = context.getString(R.string.night_theme_key); final String defaultNightTheme = context.getResources() .getString(R.string.default_night_theme_value); return PreferenceManager.getDefaultSharedPreferences(context) .getString(nightThemeKey, defaultNightTheme); } /** * Sets the title to the activity, if the activity is an {@link AppCompatActivity} and has an * action bar. * * @param activity the activity to set the title of * @param title the title to set to the activity */ public static void setTitleToAppCompatActivity(@Nullable final Activity activity, final CharSequence title) { if (activity instanceof AppCompatActivity) { final ActionBar actionBar = ((AppCompatActivity) activity).getSupportActionBar(); if (actionBar != null) { actionBar.setTitle(title); } } } /** * Get the device theme *

* It will return true if the device 's theme is dark, false otherwise. *

* From https://developer.android.com/guide/topics/ui/look-and-feel/darktheme#java * * @param context the context to use * @return true:dark theme, false:light or unknown */ public static boolean isDeviceDarkThemeEnabled(final Context context) { final int deviceTheme = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; switch (deviceTheme) { case Configuration.UI_MODE_NIGHT_YES: return true; case Configuration.UI_MODE_NIGHT_UNDEFINED: case Configuration.UI_MODE_NIGHT_NO: default: return false; } } public static void setDayNightMode(final Context context) { setDayNightMode(context, ThemeHelper.getSelectedThemeKey(context)); } public static void setDayNightMode(final Context context, final String selectedThemeKey) { final Resources res = context.getResources(); if (selectedThemeKey.equals(res.getString(R.string.light_theme_key))) { AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); } else if (selectedThemeKey.equals(res.getString(R.string.dark_theme_key)) || selectedThemeKey.equals(res.getString(R.string.black_theme_key))) { AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); } else { AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); } } /** * Returns whether the grid layout or the list layout should be used. If the user set "auto" * mode in settings, decides based on screen orientation (landscape) and size. * * @param context the context to use * @return true:use grid layout, false:use list layout */ public static boolean shouldUseGridLayout(final Context context) { final ItemViewMode mode = getItemViewMode(context); return mode == ItemViewMode.GRID; } /** * Calculates the number of grid channel info items that can fit horizontally on the screen. * * @param context the context to use * @return the span count of grid channel info items */ public static int getGridSpanCountChannels(final Context context) { return getGridSpanCount(context, context.getResources().getDimensionPixelSize(R.dimen.channel_item_grid_min_width)); } /** * Returns item view mode. * @param context to read preference and parse string * @return Returns one of ItemViewMode */ public static ItemViewMode getItemViewMode(final Context context) { final String listMode = PreferenceManager.getDefaultSharedPreferences(context) .getString(context.getString(R.string.list_view_mode_key), context.getString(R.string.list_view_mode_value)); final ItemViewMode result; if (listMode.equals(context.getString(R.string.list_view_mode_list_key))) { result = ItemViewMode.LIST; } else if (listMode.equals(context.getString(R.string.list_view_mode_grid_key))) { result = ItemViewMode.GRID; } else if (listMode.equals(context.getString(R.string.list_view_mode_card_key))) { result = ItemViewMode.CARD; } else { // Auto mode - evaluate whether to use Grid based on screen real estate. final Configuration configuration = context.getResources().getConfiguration(); final boolean useGrid = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE && configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE); if (useGrid) { result = ItemViewMode.GRID; } else { result = ItemViewMode.LIST; } } return result; } /** * Calculates the number of grid stream info items that can fit horizontally on the screen. The * width of a grid stream info item is obtained from the thumbnail width plus the right and left * paddings. * * @param context the context to use * @return the span count of grid stream info items */ public static int getGridSpanCountStreams(final Context context) { final Resources res = context.getResources(); return getGridSpanCount(context, res.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width) + res.getDimensionPixelSize(R.dimen.video_item_search_padding) * 2); } /** * Calculates the number of grid items that can fit horizontally on the screen based on the * minimum width. * * @param context the context to use * @param minWidth the minimum width of items in the grid * @return the span count of grid list items */ public static int getGridSpanCount(final Context context, final int minWidth) { return Math.max(1, context.getResources().getDisplayMetrics().widthPixels / minWidth); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/ZipHelper.java ================================================ package org.schabi.newpipe.util; import org.schabi.newpipe.streams.io.SharpInputStream; import org.schabi.newpipe.streams.io.StoredFileHelper; import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; /** * Created by Christian Schabesberger on 28.01.18. * Copyright 2018 Christian Schabesberger * ZipHelper.java is part of NewPipe *

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

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

* You should have received a copy of the GNU General Public License * along with this program. If not, see . */ public final class ZipHelper { @FunctionalInterface public interface InputStreamConsumer { void acceptStream(InputStream inputStream) throws IOException; } @FunctionalInterface public interface OutputStreamConsumer { void acceptStream(OutputStream outputStream) throws IOException; } private ZipHelper() { } /** * This function helps to create zip files. Caution, this will overwrite the original file. * * @param outZip the ZipOutputStream where the data should be stored in * @param nameInZip the path of the file inside the zip * @param path the path of the file on the disk that should be added to zip */ public static void addFileToZip(final ZipOutputStream outZip, final String nameInZip, final Path path) throws IOException { try (var inputStream = Files.newInputStream(path)) { addFileToZip(outZip, nameInZip, inputStream); } } /** * This function helps to create zip files. Caution this will overwrite the original file. * * @param outZip the ZipOutputStream where the data should be stored in * @param nameInZip the path of the file inside the zip * @param streamConsumer will be called with an output stream that will go to the output file */ public static void addFileToZip(final ZipOutputStream outZip, final String nameInZip, final OutputStreamConsumer streamConsumer) throws IOException { final byte[] bytes; try (var byteOutput = new ByteArrayOutputStream()) { streamConsumer.acceptStream(byteOutput); bytes = byteOutput.toByteArray(); } try (var byteInput = new ByteArrayInputStream(bytes)) { addFileToZip(outZip, nameInZip, byteInput); } } /** * This function helps to create zip files. Caution this will overwrite the original file. * * @param outZip the ZipOutputStream where the data should be stored in * @param nameInZip the path of the file inside the zip * @param inputStream the content to put inside the file */ private static void addFileToZip(final ZipOutputStream outZip, final String nameInZip, final InputStream inputStream) throws IOException { outZip.putNextEntry(new ZipEntry(nameInZip)); inputStream.transferTo(outZip); } /** * This will extract data from ZipInputStream. Caution, this will overwrite the original file. * * @param zipFile the zip file to extract from * @param nameInZip the path of the file inside the zip * @param path the path of the file on the disk where the data should be extracted to * @return will return true if the file was found within the zip file */ public static boolean extractFileFromZip(final StoredFileHelper zipFile, final String nameInZip, final Path path) throws IOException { return extractFileFromZip(zipFile, nameInZip, input -> Files.copy(input, path, StandardCopyOption.REPLACE_EXISTING)); } /** * This will extract data from ZipInputStream. * * @param zipFile the zip file to extract from * @param nameInZip the path of the file inside the zip * @param streamConsumer will be called with the input stream from the file inside the zip * @return will return true if the file was found within the zip file */ public static boolean extractFileFromZip(final StoredFileHelper zipFile, final String nameInZip, final InputStreamConsumer streamConsumer) throws IOException { try (ZipInputStream inZip = new ZipInputStream(new BufferedInputStream( new SharpInputStream(zipFile.getStream())))) { ZipEntry ze; while ((ze = inZip.getNextEntry()) != null) { if (ze.getName().equals(nameInZip)) { streamConsumer.acceptStream(inZip); return true; } } return false; } } /** * @param zipFile the zip file * @param fileInZip the filename to check * @return whether the provided filename is in the zip; only the first level is checked */ public static boolean zipContainsFile(final StoredFileHelper zipFile, final String fileInZip) throws Exception { try (ZipInputStream inZip = new ZipInputStream(new BufferedInputStream( new SharpInputStream(zipFile.getStream())))) { ZipEntry ze; while ((ze = inZip.getNextEntry()) != null) { if (ze.getName().equals(fileInZip)) { return true; } } return false; } } public static boolean isValidZipFile(final StoredFileHelper file) { try (ZipInputStream ignored = new ZipInputStream(new BufferedInputStream( new SharpInputStream(file.getStream())))) { return true; } catch (final IOException ioe) { return false; } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.java ================================================ package org.schabi.newpipe.util.debounce; import org.schabi.newpipe.error.ErrorInfo; public interface DebounceSavable { /** * Execute operations to save the data.
* Must set {@link DebounceSaver#setIsModified(boolean)} false in this method manually * after the data has been saved. */ void saveImmediate(); void showError(ErrorInfo errorInfo); } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.java ================================================ package org.schabi.newpipe.util.debounce; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.subjects.PublishSubject; public class DebounceSaver { private final long saveDebounceMillis; private final PublishSubject debouncedSaveSignal; private final DebounceSavable debounceSavable; // Has the object been modified private final AtomicBoolean isModified; // Default 10 seconds private static final long DEFAULT_SAVE_DEBOUNCE_MILLIS = 10000; /** * Creates a new {@code DebounceSaver}. * * @param saveDebounceMillis Save the object milliseconds later after the last change * occurred. * @param debounceSavable The object containing data to be saved. */ public DebounceSaver(final long saveDebounceMillis, final DebounceSavable debounceSavable) { this.saveDebounceMillis = saveDebounceMillis; debouncedSaveSignal = PublishSubject.create(); this.debounceSavable = debounceSavable; this.isModified = new AtomicBoolean(); } /** * Creates a new {@code DebounceSaver}. Save the object 10 seconds later after the last change * occurred. * * @param debounceSavable The object containing data to be saved. */ public DebounceSaver(final DebounceSavable debounceSavable) { this(DEFAULT_SAVE_DEBOUNCE_MILLIS, debounceSavable); } public boolean getIsModified() { return isModified.get(); } public void setNoChangesToSave() { isModified.set(false); } public PublishSubject getDebouncedSaveSignal() { return debouncedSaveSignal; } public Disposable getDebouncedSaver() { return debouncedSaveSignal .debounce(saveDebounceMillis, TimeUnit.MILLISECONDS) .observeOn(AndroidSchedulers.mainThread()) .subscribe(ignored -> debounceSavable.saveImmediate(), throwable -> debounceSavable.showError(new ErrorInfo(throwable, UserAction.SOMETHING_ELSE, "Debounced saver"))); } public void setHasChangesToSave() { if (isModified == null || debouncedSaveSignal == null) { return; } isModified.set(true); debouncedSaveSignal.onNext(System.currentTimeMillis()); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java ================================================ package org.schabi.newpipe.util.external_communication; import static org.schabi.newpipe.util.external_communication.ShareUtils.installApp; import static org.schabi.newpipe.util.external_communication.ShareUtils.tryOpenIntentInApp; import android.content.Context; import android.content.Intent; import android.net.Uri; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.ServiceList; /** * Util class that provides methods which are related to the Kodi Media Center and its Kore app. * @see Kodi website */ public final class KoreUtils { private KoreUtils() { } public static boolean isServiceSupportedByKore(final int serviceId) { return (serviceId == ServiceList.YouTube.getServiceId() || serviceId == ServiceList.SoundCloud.getServiceId()); } public static boolean shouldShowPlayWithKodi(@NonNull final Context context, final int serviceId) { return isServiceSupportedByKore(serviceId) && PreferenceManager.getDefaultSharedPreferences(context) .getBoolean(context.getString(R.string.show_play_with_kodi_key), false); } /** * Start an activity to install Kore. * * @param context the context to use */ public static void installKore(final Context context) { installApp(context, context.getString(R.string.kore_package)); } /** * Start Kore app to show a video on Kodi, and if the app is not installed ask the user to * install it. *

* For a list of supported urls see the * * Kore source code * . * * @param context the context to use * @param streamUrl the url to the stream to play */ public static void playWithKore(final Context context, final Uri streamUrl) { final Intent intent = new Intent(Intent.ACTION_VIEW) .setPackage(context.getString(R.string.kore_package)) .setData(streamUrl) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); if (!tryOpenIntentInApp(context, intent)) { new AlertDialog.Builder(context) .setMessage(R.string.kore_not_found) .setPositiveButton(R.string.install, (dialog, which) -> installKore(context)) .setNegativeButton(R.string.cancel, null) .show(); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java ================================================ package org.schabi.newpipe.util.external_communication; import static org.schabi.newpipe.MainActivity.DEBUG; import static coil3.Image_androidKt.toBitmap; import android.content.ActivityNotFoundException; import android.content.ClipData; import android.content.ClipboardManager; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.net.Uri; import android.os.Build; import android.text.TextUtils; import android.util.Log; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; import androidx.core.content.FileProvider; import org.schabi.newpipe.BuildConfig; import org.schabi.newpipe.R; import org.schabi.newpipe.RouterActivity; import org.schabi.newpipe.extractor.Image; import org.schabi.newpipe.util.image.ImageStrategy; import java.nio.file.Files; import java.util.Collections; import java.util.List; import coil3.SingletonImageLoader; import coil3.disk.DiskCache; import coil3.memory.MemoryCache; public final class ShareUtils { private static final String TAG = ShareUtils.class.getSimpleName(); private ShareUtils() { } /** * Open an Intent to install an app. *

* This method tries to open the default app market with the package id passed as the * second param (a system chooser will be opened if there are multiple markets and no default) * and falls back to Google Play Store web URL if no app to handle the market scheme was found. *

* It uses {@link #openIntentInApp(Context, Intent)} to open market scheme and {@link * #openUrlInBrowser(Context, String)} to open Google Play Store web URL. * * @param context the context to use * @param packageId the package id of the app to be installed */ public static void installApp(@NonNull final Context context, final String packageId) { // Try market scheme final Intent marketSchemeIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=" + packageId)) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); if (!tryOpenIntentInApp(context, marketSchemeIntent)) { // Fall back to Google Play Store Web URL (F-Droid can handle it) openUrlInApp(context, "https://play.google.com/store/apps/details?id=" + packageId); } } /** * Open the url with the system default browser. If no browser is installed, falls back to * {@link #openAppChooser(Context, Intent, boolean)} (for displaying that no apps are available * to handle the action, or possible OEM-related edge cases). *

* This function selects the package to open based on which apps respond to the {@code http://} * schema alone, which should exclude special non-browser apps that are can handle the url (e.g. * the official YouTube app). *

* Therefore please prefer {@link #openUrlInApp(Context, String)}, that handles package * resolution in a standard way, unless this is the action of an explicit "Open in browser" * button. * * @param context the context to use * @param url the url to browse **/ public static void openUrlInBrowser(@NonNull final Context context, final String url) { // Target a generic http://, so we are sure to get a browser and not e.g. the yt app. // Note that this requires the `http` schema to be added to `` in the manifest. final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("http://")); final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // See https://stackoverflow.com/a/58801285 and `setSelector` documentation intent.setSelector(browserIntent); try { context.startActivity(intent); } catch (final ActivityNotFoundException e) { // No browser is available. This should, in the end, yield a nice AOSP error message // indicating that no app is available to handle this action. // // Note: there are some situations where modified OEM ROMs have apps that appear // to be browsers but are actually app choosers. If starting the Activity fails // related to this, opening the system app chooser is still the correct behavior. intent.setSelector(null); openAppChooser(context, intent, true); } } /** * Open a url with the system default app using {@link Intent#ACTION_VIEW}, showing a toast in * case of failure. * * @param context the context to use * @param url the url to open */ public static void openUrlInApp(@NonNull final Context context, final String url) { openIntentInApp(context, new Intent(Intent.ACTION_VIEW, Uri.parse(url)) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); } /** * Open an intent with the system default app. *

* Use {@link #openIntentInApp(Context, Intent)} to show a toast in case of failure. * * @param context the context to use * @param intent the intent to open * @return true if the intent could be opened successfully, false otherwise */ public static boolean tryOpenIntentInApp(@NonNull final Context context, @NonNull final Intent intent) { try { context.startActivity(intent); } catch (final ActivityNotFoundException e) { return false; } return true; } /** * Open an intent with the system default app, showing a toast in case of failure. *

* Use {@link #tryOpenIntentInApp(Context, Intent)} if you don't want the toast. Use {@link * #openUrlInApp(Context, String)} as a shorthand for {@link Intent#ACTION_VIEW} with urls. * * @param context the context to use * @param intent the intent to */ public static void openIntentInApp(@NonNull final Context context, @NonNull final Intent intent) { if (!tryOpenIntentInApp(context, intent)) { Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG) .show(); } } /** * Open the system chooser to launch an intent. *

* This method opens an {@link android.content.Intent#ACTION_CHOOSER} of the intent putted * as the intent param. If the setTitleChooser boolean is true, the string "Open with" will be * set as the title of the system chooser. * For Android P and higher, title for {@link android.content.Intent#ACTION_SEND} system * choosers must be set on this intent, not on the * {@link android.content.Intent#ACTION_CHOOSER} intent. * * @param context the context to use * @param intent the intent to open * @param setTitleChooser set the title "Open with" to the chooser if true, else not */ private static void openAppChooser(@NonNull final Context context, @NonNull final Intent intent, final boolean setTitleChooser) { final Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER); chooserIntent.putExtra(Intent.EXTRA_INTENT, intent); chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); if (setTitleChooser) { chooserIntent.putExtra(Intent.EXTRA_TITLE, context.getString(R.string.open_with)); } // Avoid opening in NewPipe // (Implementation note: if the URL is one for which NewPipe itself // is set as handler on Android >= 12, we actually remove the only eligible app // for this link, and browsers will not be offered to the user. For that, use // `openUrlInBrowser`.) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { chooserIntent.putExtra( Intent.EXTRA_EXCLUDE_COMPONENTS, new ComponentName[]{new ComponentName(context, RouterActivity.class)} ); } // Migrate any clip data and flags from the original intent. final int permFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION); if (permFlags != 0) { ClipData targetClipData = intent.getClipData(); if (targetClipData == null && intent.getData() != null) { final ClipData.Item item = new ClipData.Item(intent.getData()); final String[] mimeTypes; if (intent.getType() != null) { mimeTypes = new String[] {intent.getType()}; } else { mimeTypes = new String[] {}; } targetClipData = new ClipData(null, mimeTypes, item); } if (targetClipData != null) { chooserIntent.setClipData(targetClipData); chooserIntent.addFlags(permFlags); } } try { context.startActivity(chooserIntent); } catch (final ActivityNotFoundException e) { Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show(); } } /** * Open the android share sheet to share a content. * *

* For Android 10+ users, a content preview is shown, which includes the title of the shared * content and an image preview the content, if its URL is not null or empty and its * corresponding image is in the image cache. *

* * @param context the context to use * @param title the title of the content * @param content the content to share * @param imagePreviewUrl the image of the subject */ public static void shareText(@NonNull final Context context, @NonNull final String title, final String content, final String imagePreviewUrl) { final Intent shareIntent = new Intent(Intent.ACTION_SEND); shareIntent.setType("text/plain"); shareIntent.putExtra(Intent.EXTRA_TEXT, content); if (!TextUtils.isEmpty(title)) { shareIntent.putExtra(Intent.EXTRA_TITLE, title); shareIntent.putExtra(Intent.EXTRA_SUBJECT, title); } // Content preview in the share sheet has been added in Android 10, so it's not needed to // set a content preview which will be never displayed // See https://developer.android.com/training/sharing/send#adding-rich-content-previews // If loading of images has been disabled, don't try to generate a content preview if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !TextUtils.isEmpty(imagePreviewUrl) && ImageStrategy.shouldLoadImages()) { final ClipData clipData = generateClipDataForImagePreview(context, imagePreviewUrl); if (clipData != null) { shareIntent.setClipData(clipData); shareIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); } } openAppChooser(context, shareIntent, false); } /** * Open the android share sheet to share a content. * *

* For Android 10+ users, a content preview is shown, which includes the title of the shared * content and an image preview the content, if the preferred image chosen by {@link * ImageStrategy#choosePreferredImage(List)} is in the image cache. *

* * @param context the context to use * @param title the title of the content * @param content the content to share * @param images a set of possible {@link Image}s of the subject, among which to choose with * {@link ImageStrategy#choosePreferredImage(List)} since that's likely to * provide an image that is in Coil's cache */ public static void shareText(@NonNull final Context context, @NonNull final String title, final String content, final List images) { shareText(context, title, content, ImageStrategy.choosePreferredImage(images)); } /** * Open the android share sheet to share a content. * *

* This calls {@link #shareText(Context, String, String, String)} with an empty string for the * {@code imagePreviewUrl} parameter. This method should be used when the shared content has no * preview thumbnail. *

* * @param context the context to use * @param title the title of the content * @param content the content to share */ public static void shareText(@NonNull final Context context, @NonNull final String title, final String content) { shareText(context, title, content, ""); } /** * Copy the text to clipboard, and indicate to the user whether the operation was completed * successfully using a Toast. * * @param context the context to use * @param text the text to copy */ public static void copyToClipboard(@NonNull final Context context, final String text) { final ClipboardManager clipboardManager = ContextCompat.getSystemService(context, ClipboardManager.class); if (clipboardManager == null) { Toast.makeText(context, R.string.permission_denied, Toast.LENGTH_LONG).show(); return; } try { clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text)); if (Build.VERSION.SDK_INT < 33) { // Android 13 has its own "copied to clipboard" dialog Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); } } catch (final Exception e) { Log.e(TAG, "Error when trying to copy text to clipboard", e); Toast.makeText(context, R.string.msg_failed_to_copy, Toast.LENGTH_SHORT).show(); } } /** * Generate a {@link ClipData} with the image of the content shared, if it's in the app cache. * *

* In order not to worry about network issues (timeouts, DNS issues, low connection speed, ...) * when sharing a content, only images in the {@link MemoryCache} or {@link DiskCache} * used by the Coil library are used as preview images. If the thumbnail image is not in the * cache, no {@link ClipData} will be generated and {@code null} will be returned. * *

* In order to display the image in the content preview of the Android share sheet, an URI of * the content, accessible and readable by other apps has to be generated, so a new file inside * the application cache will be generated, named {@code android_share_sheet_image_preview.jpg} * (if a file under this name already exists, it will be overwritten). The thumbnail will be * compressed in JPEG format, with a {@code 90} compression level. *

* *

* Note that if an exception occurs when generating the {@link ClipData}, {@code null} is * returned. *

* *

* Using the result of this method when sharing has only an effect on the system share sheet (if * OEMs didn't change Android system standard behavior) on Android API 29 and higher. *

* * @param context the context to use * @param thumbnailUrl the URL of the content thumbnail * @return a {@link ClipData} of the content thumbnail, or {@code null} */ @Nullable private static ClipData generateClipDataForImagePreview( @NonNull final Context context, @NonNull final String thumbnailUrl) { try { // Save the image in memory to the application's cache because we need a URI to the // image to generate a ClipData which will show the share sheet, and so an image file final Context applicationContext = context.getApplicationContext(); final var loader = SingletonImageLoader.get(context); final var value = loader.getMemoryCache() .get(new MemoryCache.Key(thumbnailUrl, Collections.emptyMap())); final Bitmap cachedBitmap; if (value != null) { cachedBitmap = toBitmap(value.getImage()); } else { try (var snapshot = loader.getDiskCache().openSnapshot(thumbnailUrl)) { if (snapshot != null) { cachedBitmap = BitmapFactory.decodeFile(snapshot.getData().toString()); } else { cachedBitmap = null; } } } if (cachedBitmap == null) { return null; } final var path = applicationContext.getCacheDir().toPath() .resolve("android_share_sheet_image_preview.jpg"); // Any existing file will be overwritten try (var outputStream = Files.newOutputStream(path)) { cachedBitmap.compress(Bitmap.CompressFormat.JPEG, 90, outputStream); } final ClipData clipData = ClipData.newUri(applicationContext.getContentResolver(), "", FileProvider.getUriForFile(applicationContext, BuildConfig.APPLICATION_ID + ".provider", path.toFile())); if (DEBUG) { Log.d(TAG, "ClipData successfully generated for Android share sheet: " + clipData); } return clipData; } catch (final Exception e) { Log.w(TAG, "Error when setting preview image for share sheet", e); return null; } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/image/CoilHelper.kt ================================================ package org.schabi.newpipe.util.image import android.content.Context import android.graphics.Bitmap import android.util.Log import android.widget.ImageView import androidx.annotation.DrawableRes import coil3.executeBlocking import coil3.imageLoader import coil3.request.Disposable import coil3.request.ImageRequest import coil3.request.error import coil3.request.placeholder import coil3.request.target import coil3.request.transformations import coil3.size.Size import coil3.target.Target import coil3.toBitmap import coil3.transform.Transformation import kotlin.math.min import org.schabi.newpipe.MainActivity import org.schabi.newpipe.R import org.schabi.newpipe.extractor.Image import org.schabi.newpipe.ktx.scale object CoilHelper { private val TAG = CoilHelper::class.java.simpleName @JvmOverloads fun loadBitmapBlocking( context: Context, url: String?, @DrawableRes placeholderResId: Int = 0 ): Bitmap? = context.imageLoader .executeBlocking(getImageRequest(context, url, placeholderResId).build()) .image ?.toBitmap() fun loadAvatar( target: ImageView, images: List ) { loadImageDefault(target, images, R.drawable.placeholder_person) } fun loadAvatar( target: ImageView, url: String? ) { loadImageDefault(target, url, R.drawable.placeholder_person) } fun loadThumbnail( target: ImageView, images: List ) { loadImageDefault(target, images, R.drawable.placeholder_thumbnail_video) } fun loadThumbnail( target: ImageView, url: String? ) { loadImageDefault(target, url, R.drawable.placeholder_thumbnail_video) } fun loadScaledDownThumbnail( context: Context, images: List, target: Target ): Disposable { val url = ImageStrategy.choosePreferredImage(images) val request = getImageRequest(context, url, R.drawable.placeholder_thumbnail_video) .target(target) .transformations( object : Transformation() { override val cacheKey = "COIL_PLAYER_THUMBNAIL_TRANSFORMATION_KEY" override suspend fun transform( input: Bitmap, size: Size ): Bitmap { if (MainActivity.DEBUG) { Log.d(TAG, "Thumbnail - transform() called") } val notificationThumbnailWidth = min( context.resources.getDimension(R.dimen.player_notification_thumbnail_width), input.width.toFloat() ).toInt() var newHeight = input.height / (input.width / notificationThumbnailWidth) val result = input.scale(notificationThumbnailWidth, newHeight) return if (result == input || !result.isMutable) { // create a new mutable bitmap to prevent strange crashes on some // devices (see #4638) newHeight = input.height / (input.width / (notificationThumbnailWidth - 1)) input.scale(notificationThumbnailWidth, newHeight) } else { result } } } ).build() return context.imageLoader.enqueue(request) } fun loadDetailsThumbnail( target: ImageView, images: List ) { val url = ImageStrategy.choosePreferredImage(images) loadImageDefault(target, url, R.drawable.placeholder_thumbnail_video, false) } fun loadBanner( target: ImageView, images: List ) { loadImageDefault(target, images, R.drawable.placeholder_channel_banner) } fun loadPlaylistThumbnail( target: ImageView, images: List ) { loadImageDefault(target, images, R.drawable.placeholder_thumbnail_playlist) } fun loadPlaylistThumbnail( target: ImageView, url: String? ) { loadImageDefault(target, url, R.drawable.placeholder_thumbnail_playlist) } private fun loadImageDefault( target: ImageView, images: List, @DrawableRes placeholderResId: Int ) { loadImageDefault(target, ImageStrategy.choosePreferredImage(images), placeholderResId) } private fun loadImageDefault( target: ImageView, url: String?, @DrawableRes placeholderResId: Int, showPlaceholder: Boolean = true ) { val request = getImageRequest(target.context, url, placeholderResId, showPlaceholder) .target(target) .build() target.context.imageLoader.enqueue(request) } private fun getImageRequest( context: Context, url: String?, @DrawableRes placeholderResId: Int, showPlaceholderWhileLoading: Boolean = true ): ImageRequest.Builder { // if the URL was chosen with `choosePreferredImage` it will be null, but check again // `shouldLoadImages` in case the URL was chosen with `imageListToDbUrl` (which is the case // for URLs stored in the database) val takenUrl = url?.takeIf { it.isNotEmpty() && ImageStrategy.shouldLoadImages() } return ImageRequest .Builder(context) .data(takenUrl) .error(placeholderResId) .memoryCacheKey(takenUrl) .diskCacheKey(takenUrl) .apply { if (takenUrl != null || showPlaceholderWhileLoading) { placeholder(placeholderResId) } } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt ================================================ /* * SPDX-FileCopyrightText: 2023-2025 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.util.image import kotlin.math.abs import org.schabi.newpipe.extractor.Image import org.schabi.newpipe.extractor.Image.ResolutionLevel object ImageStrategy { // when preferredImageQuality is LOW or MEDIUM, images are sorted by how close their preferred // image quality is to these values (H stands for "Height") private const val BEST_LOW_H = 75 private const val BEST_MEDIUM_H = 250 private var preferredImageQuality = PreferredImageQuality.MEDIUM @JvmStatic fun setPreferredImageQuality(preferredImageQuality: PreferredImageQuality) { ImageStrategy.preferredImageQuality = preferredImageQuality } @JvmStatic fun shouldLoadImages(): Boolean { return preferredImageQuality != PreferredImageQuality.NONE } @JvmStatic fun estimatePixelCount(image: Image, widthOverHeight: Double): Double { if (image.height == Image.HEIGHT_UNKNOWN) { if (image.width == Image.WIDTH_UNKNOWN) { // images whose size is completely unknown will be in their own subgroups, so // any one of them will do, hence returning the same value for all of them return 0.0 } else { return image.width * image.width / widthOverHeight } } else if (image.width == Image.WIDTH_UNKNOWN) { return image.height * image.height * widthOverHeight } else { return (image.height * image.width).toDouble() } } /** * [choosePreferredImage] contains the description for this function's logic. * * @param images the images from which to choose * @param nonNoneQuality the preferred quality (must NOT be [PreferredImageQuality.NONE]) * @return the chosen preferred image, or `null` if the list is empty * @see [choosePreferredImage] */ @JvmStatic fun choosePreferredImage(images: List, nonNoneQuality: PreferredImageQuality): String? { // this will be used to estimate the pixel count for images where only one of height or // width are known val widthOverHeight = images .filter { image -> image.height != Image.HEIGHT_UNKNOWN && image.width != Image.WIDTH_UNKNOWN } .map { image -> (image.width.toDouble()) / image.height } .elementAtOrNull(0) ?: 1.0 val preferredLevel = nonNoneQuality.toResolutionLevel() // TODO: rewrite using kotlin collections API `groupBy` will be handy val initialComparator = Comparator // the first step splits the images into groups of resolution levels .comparingInt { i: Image -> return@comparingInt when (i.estimatedResolutionLevel) { // avoid unknowns as much as possible ResolutionLevel.UNKNOWN -> 3 // prefer a matching resolution level preferredLevel -> 0 // the preferredLevel is only 1 "step" away (either HIGH or LOW) ResolutionLevel.MEDIUM -> 1 // the preferredLevel is the furthest away possible (2 "steps") else -> 2 } } // then each level's group is further split into two subgroups, one with known image // size (which is also the preferred subgroup) and the other without .thenComparing { image -> image.height == Image.HEIGHT_UNKNOWN && image.width == Image.WIDTH_UNKNOWN } // The third step chooses, within each subgroup with known image size, the best image based // on how close its size is to BEST_LOW_H or BEST_MEDIUM_H (with proper units). Subgroups // without known image size will be left untouched since estimatePixelCount always returns // the same number for those. val finalComparator = when (nonNoneQuality) { PreferredImageQuality.NONE -> initialComparator PreferredImageQuality.LOW -> initialComparator.thenComparingDouble { image -> val pixelCount = estimatePixelCount(image, widthOverHeight) abs(pixelCount - BEST_LOW_H * BEST_LOW_H * widthOverHeight) } PreferredImageQuality.MEDIUM -> initialComparator.thenComparingDouble { image -> val pixelCount = estimatePixelCount(image, widthOverHeight) abs(pixelCount - BEST_MEDIUM_H * BEST_MEDIUM_H * widthOverHeight) } PreferredImageQuality.HIGH -> initialComparator.thenComparingDouble { image -> // this is reversed with a - so that the highest resolution is chosen -estimatePixelCount(image, widthOverHeight) } } return images.stream() // using "min" basically means "take the first group, then take the first subgroup, // then choose the best image, while ignoring all other groups and subgroups" .min(finalComparator) .map(Image::getUrl) .orElse(null) } /** * Chooses an image amongst the provided list based on the user preference previously set with * [setPreferredImageQuality]. `null` will be returned in * case the list is empty or the user preference is to not show images. *
* These properties will be preferred, from most to least important: * * 1. The image's [Image.estimatedResolutionLevel] is not unknown and is close to [preferredImageQuality] * 2. At least one of the image's width or height are known * 3. The highest resolution image is finally chosen if the user's preference is * [PreferredImageQuality.HIGH], otherwise the chosen image is the one that has the height * closest to [BEST_LOW_H] or [BEST_MEDIUM_H] * *
* Use [imageListToDbUrl] if the URL is going to be saved to the database, to avoid * saving nothing in case at the moment of saving the user preference is to not show images. * * @param images the images from which to choose * @return the chosen preferred image, or `null` if the list is empty or the user disabled * images * @see [imageListToDbUrl] */ @JvmStatic fun choosePreferredImage(images: List): String? { if (preferredImageQuality == PreferredImageQuality.NONE) { return null // do not load images } return choosePreferredImage(images, preferredImageQuality) } /** * Like [choosePreferredImage], except that if [preferredImageQuality] is * [PreferredImageQuality.NONE] an image will be chosen anyway (with preferred quality * [PreferredImageQuality.MEDIUM]. *

* To go back to a list of images (obviously with just the one chosen image) from a URL saved in * the database use [dbUrlToImageList]. * * @param images the images from which to choose * @return the chosen preferred image, or `null` if the list is empty * @see [choosePreferredImage] * @see [dbUrlToImageList] */ @JvmStatic fun imageListToDbUrl(images: List): String? { val quality = when (preferredImageQuality) { PreferredImageQuality.NONE -> PreferredImageQuality.MEDIUM else -> preferredImageQuality } return choosePreferredImage(images, quality) } /** * Wraps the URL (coming from the database) in a `List` so that it is usable * seamlessly in all of the places where the extractor would return a list of images, including * allowing to build info objects based on database objects. *

* To obtain a url to save to the database from a list of images use [imageListToDbUrl]. * * @param url the URL to wrap coming from the database, or `null` to get an empty list * @return a list containing just one [Image] wrapping the provided URL, with unknown * image size fields, or an empty list if the URL is `null` * @see [imageListToDbUrl] */ @JvmStatic fun dbUrlToImageList(url: String?): List { return when (url) { null -> listOf() else -> listOf( Image( url, Image.HEIGHT_UNKNOWN, Image.WIDTH_UNKNOWN, ResolutionLevel.UNKNOWN ) ) } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.kt ================================================ /* * SPDX-FileCopyrightText: 2023-2025 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.util.image import android.content.Context import org.schabi.newpipe.R import org.schabi.newpipe.extractor.Image.ResolutionLevel enum class PreferredImageQuality { NONE, LOW, MEDIUM, HIGH; fun toResolutionLevel(): ResolutionLevel { return when (this) { LOW -> ResolutionLevel.LOW MEDIUM -> ResolutionLevel.MEDIUM HIGH -> ResolutionLevel.HIGH NONE -> ResolutionLevel.UNKNOWN } } companion object { @JvmStatic fun fromPreferenceKey(context: Context, key: String?): PreferredImageQuality { return when (key) { context.getString(R.string.image_quality_none_key) -> NONE context.getString(R.string.image_quality_low_key) -> LOW context.getString(R.string.image_quality_high_key) -> HIGH else -> MEDIUM // default to medium } } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/potoken/JavaScriptUtil.kt ================================================ package org.schabi.newpipe.util.potoken import com.grack.nanojson.JsonObject import com.grack.nanojson.JsonParser import com.grack.nanojson.JsonWriter import okio.ByteString.Companion.decodeBase64 import okio.ByteString.Companion.toByteString /** * Parses the raw challenge data obtained from the Create endpoint and returns an object that can be * embedded in a JavaScript snippet. */ fun parseChallengeData(rawChallengeData: String): String { val scrambled = JsonParser.array().from(rawChallengeData) val challengeData = if (scrambled.size > 1 && scrambled.isString(1)) { val descrambled = descramble(scrambled.getString(1)) JsonParser.array().from(descrambled) } else { scrambled.getArray(0) } val messageId = challengeData.getString(0) val interpreterHash = challengeData.getString(3) val program = challengeData.getString(4) val globalName = challengeData.getString(5) val clientExperimentsStateBlob = challengeData.getString(7) val privateDoNotAccessOrElseSafeScriptWrappedValue = challengeData.getArray(1, null)?.find { it is String } val privateDoNotAccessOrElseTrustedResourceUrlWrappedValue = challengeData.getArray(2, null)?.find { it is String } return JsonWriter.string( JsonObject.builder() .value("messageId", messageId) .`object`("interpreterJavascript") .value("privateDoNotAccessOrElseSafeScriptWrappedValue", privateDoNotAccessOrElseSafeScriptWrappedValue) .value("privateDoNotAccessOrElseTrustedResourceUrlWrappedValue", privateDoNotAccessOrElseTrustedResourceUrlWrappedValue) .end() .value("interpreterHash", interpreterHash) .value("program", program) .value("globalName", globalName) .value("clientExperimentsStateBlob", clientExperimentsStateBlob) .done() ) } /** * Parses the raw integrity token data obtained from the GenerateIT endpoint to a JavaScript * `Uint8Array` that can be embedded directly in JavaScript code, and an [Int] representing the * duration of this token in seconds. */ fun parseIntegrityTokenData(rawIntegrityTokenData: String): Pair { val integrityTokenData = JsonParser.array().from(rawIntegrityTokenData) return base64ToU8(integrityTokenData.getString(0)) to integrityTokenData.getLong(1) } /** * Converts a string (usually the identifier used as input to `obtainPoToken`) to a JavaScript * `Uint8Array` that can be embedded directly in JavaScript code. */ fun stringToU8(identifier: String): String { return newUint8Array(identifier.toByteArray()) } /** * Takes a poToken encoded as a sequence of bytes represented as integers separated by commas * (e.g. "97,98,99" would be "abc"), which is the output of `Uint8Array::toString()` in JavaScript, * and converts it to the specific base64 representation for poTokens. */ fun u8ToBase64(poToken: String): String { return poToken.split(",") .map { it.toUByte().toByte() } .toByteArray() .toByteString() .base64() .replace("+", "-") .replace("/", "_") } /** * Takes the scrambled challenge, decodes it from base64, adds 97 to each byte. */ private fun descramble(scrambledChallenge: String): String { return base64ToByteString(scrambledChallenge) .map { (it + 97).toByte() } .toByteArray() .decodeToString() } /** * Decodes a base64 string encoded in the specific base64 representation used by YouTube, and * returns a JavaScript `Uint8Array` that can be embedded directly in JavaScript code. */ private fun base64ToU8(base64: String): String { return newUint8Array(base64ToByteString(base64)) } private fun newUint8Array(contents: ByteArray): String { return "new Uint8Array([" + contents.joinToString(separator = ",") { it.toUByte().toString() } + "])" } /** * Decodes a base64 string encoded in the specific base64 representation used by YouTube. */ private fun base64ToByteString(base64: String): ByteArray { val base64Mod = base64 .replace('-', '+') .replace('_', '/') .replace('.', '=') return (base64Mod.decodeBase64() ?: throw PoTokenException("Cannot base64 decode")) .toByteArray() } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/potoken/PoTokenException.kt ================================================ package org.schabi.newpipe.util.potoken class PoTokenException(message: String) : Exception(message) // to be thrown if the WebView provided by the system is broken class BadWebViewException(message: String) : Exception(message) fun buildExceptionForJsError(error: String): Exception { return if (error.contains("SyntaxError")) { BadWebViewException(error) } else { PoTokenException(error) } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/potoken/PoTokenGenerator.kt ================================================ package org.schabi.newpipe.util.potoken import android.content.Context import io.reactivex.rxjava3.core.Single import java.io.Closeable /** * This interface was created to allow for multiple methods to generate poTokens in the future (e.g. * via WebView and via a local DOM implementation) */ interface PoTokenGenerator : Closeable { /** * Generates a poToken for the provided identifier, using the `integrityToken` and * `webPoSignalOutput` previously obtained in the initialization of [PoTokenWebView]. Can be * called multiple times. */ fun generatePoToken(identifier: String): Single /** * @return whether the `integrityToken` is expired, in which case all tokens generated by * [generatePoToken] will be invalid */ fun isExpired(): Boolean interface Factory { /** * Initializes a [PoTokenGenerator] by loading the BotGuard VM, running it, and obtaining * an `integrityToken`. Can then be used multiple times to generate multiple poTokens with * [generatePoToken]. * * @param context used e.g. to load the HTML asset or to instantiate a WebView */ fun newPoTokenGenerator(context: Context): Single } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/potoken/PoTokenProviderImpl.kt ================================================ package org.schabi.newpipe.util.potoken import android.os.Handler import android.os.Looper import android.util.Log import org.schabi.newpipe.App import org.schabi.newpipe.BuildConfig import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.services.youtube.InnertubeClientRequestInfo import org.schabi.newpipe.extractor.services.youtube.PoTokenProvider import org.schabi.newpipe.extractor.services.youtube.PoTokenResult import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper import org.schabi.newpipe.util.DeviceUtils object PoTokenProviderImpl : PoTokenProvider { val TAG = PoTokenProviderImpl::class.simpleName private val webViewSupported by lazy { DeviceUtils.supportsWebView() } private var webViewBadImpl = false // whether the system has a bad WebView implementation private object WebPoTokenGenLock private var webPoTokenVisitorData: String? = null private var webPoTokenStreamingPot: String? = null private var webPoTokenGenerator: PoTokenGenerator? = null override fun getWebClientPoToken(videoId: String): PoTokenResult? { if (!webViewSupported || webViewBadImpl) { return null } try { return getWebClientPoToken(videoId = videoId, forceRecreate = false) } catch (e: RuntimeException) { // RxJava's Single wraps exceptions into RuntimeErrors, so we need to unwrap them here when (val cause = e.cause) { is BadWebViewException -> { Log.e(TAG, "Could not obtain poToken because WebView is broken", e) webViewBadImpl = true return null } null -> throw e else -> throw cause // includes PoTokenException } } } /** * @param forceRecreate whether to force the recreation of [webPoTokenGenerator], to be used in * case the current [webPoTokenGenerator] threw an error last time * [PoTokenGenerator.generatePoToken] was called */ private fun getWebClientPoToken(videoId: String, forceRecreate: Boolean): PoTokenResult { // just a helper class since Kotlin does not have builtin support for 4-tuples data class Quadruple(val t1: T1, val t2: T2, val t3: T3, val t4: T4) val (poTokenGenerator, visitorData, streamingPot, hasBeenRecreated) = synchronized(WebPoTokenGenLock) { val shouldRecreate = webPoTokenGenerator == null || forceRecreate || webPoTokenGenerator!!.isExpired() if (shouldRecreate) { val innertubeClientRequestInfo = InnertubeClientRequestInfo.ofWebClient() innertubeClientRequestInfo.clientInfo.clientVersion = YoutubeParsingHelper.getClientVersion() webPoTokenVisitorData = YoutubeParsingHelper.getVisitorDataFromInnertube( innertubeClientRequestInfo, NewPipe.getPreferredLocalization(), NewPipe.getPreferredContentCountry(), YoutubeParsingHelper.getYouTubeHeaders(), YoutubeParsingHelper.YOUTUBEI_V1_URL, null, false ) // close the current webPoTokenGenerator on the main thread webPoTokenGenerator?.let { Handler(Looper.getMainLooper()).post { it.close() } } // create a new webPoTokenGenerator webPoTokenGenerator = PoTokenWebView .newPoTokenGenerator(App.instance).blockingGet() // The streaming poToken needs to be generated exactly once before generating // any other (player) tokens. webPoTokenStreamingPot = webPoTokenGenerator!! .generatePoToken(webPoTokenVisitorData!!).blockingGet() } return@synchronized Quadruple( webPoTokenGenerator!!, webPoTokenVisitorData!!, webPoTokenStreamingPot!!, shouldRecreate ) } val playerPot = try { // Not using synchronized here, since poTokenGenerator would be able to generate // multiple poTokens in parallel if needed. The only important thing is for exactly one // visitorData/streaming poToken to be generated before anything else. poTokenGenerator.generatePoToken(videoId).blockingGet() } catch (throwable: Throwable) { if (hasBeenRecreated) { // the poTokenGenerator has just been recreated (and possibly this is already the // second time we try), so there is likely nothing we can do throw throwable } else { // retry, this time recreating the [webPoTokenGenerator] from scratch; // this might happen for example if NewPipe goes in the background and the WebView // content is lost Log.e(TAG, "Failed to obtain poToken, retrying", throwable) return getWebClientPoToken(videoId = videoId, forceRecreate = true) } } if (BuildConfig.DEBUG) { Log.d( TAG, "poToken for $videoId: playerPot=$playerPot, " + "streamingPot=$streamingPot, visitor_data=$visitorData" ) } return PoTokenResult(visitorData, playerPot, streamingPot) } override fun getWebEmbedClientPoToken(videoId: String): PoTokenResult? = null override fun getAndroidClientPoToken(videoId: String): PoTokenResult? = null override fun getIosClientPoToken(videoId: String): PoTokenResult? = null } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/potoken/PoTokenWebView.kt ================================================ package org.schabi.newpipe.util.potoken import android.content.Context import android.os.Handler import android.os.Looper import android.util.Log import android.webkit.ConsoleMessage import android.webkit.JavascriptInterface import android.webkit.WebChromeClient import android.webkit.WebView import androidx.annotation.MainThread import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewFeature import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.SingleEmitter import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.schedulers.Schedulers import java.time.Instant import org.schabi.newpipe.BuildConfig import org.schabi.newpipe.DownloaderImpl class PoTokenWebView private constructor( context: Context, // to be used exactly once only during initialization! private val generatorEmitter: SingleEmitter ) : PoTokenGenerator { private val webView = WebView(context) private val disposables = CompositeDisposable() // used only during initialization private val poTokenEmitters = mutableListOf>>() private lateinit var expirationInstant: Instant //region Initialization init { val webViewSettings = webView.settings //noinspection SetJavaScriptEnabled we want to use JavaScript! webViewSettings.javaScriptEnabled = true if (WebViewFeature.isFeatureSupported(WebViewFeature.SAFE_BROWSING_ENABLE)) { WebSettingsCompat.setSafeBrowsingEnabled(webViewSettings, false) } webViewSettings.userAgentString = USER_AGENT webViewSettings.blockNetworkLoads = true // the WebView does not need internet access // so that we can run async functions and get back the result webView.addJavascriptInterface(this, JS_INTERFACE) webView.webChromeClient = object : WebChromeClient() { override fun onConsoleMessage(m: ConsoleMessage): Boolean { if (m.message().contains("Uncaught")) { // There should not be any uncaught errors while executing the code, because // everything that can fail is guarded by try-catch. Therefore, this likely // indicates that there was a syntax error in the code, i.e. the WebView only // supports a really old version of JS. val fmt = "\"${m.message()}\", source: ${m.sourceId()} (${m.lineNumber()})" val exception = BadWebViewException(fmt) Log.e(TAG, "This WebView implementation is broken: $fmt") onInitializationErrorCloseAndCancel(exception) popAllPoTokenEmitters().forEach { (_, emitter) -> emitter.onError(exception) } } return super.onConsoleMessage(m) } } } /** * Must be called right after instantiating [PoTokenWebView] to perform the actual * initialization. This will asynchronously go through all the steps needed to load BotGuard, * run it, and obtain an `integrityToken`. */ private fun loadHtmlAndObtainBotguard(context: Context) { if (BuildConfig.DEBUG) { Log.d(TAG, "loadHtmlAndObtainBotguard() called") } disposables.add( Single.fromCallable { val html = context.assets.open("po_token.html").bufferedReader() .use { it.readText() } return@fromCallable html } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( { html -> webView.loadDataWithBaseURL( "https://www.youtube.com", html.replaceFirst( "", // calls downloadAndRunBotguard() when the page has finished loading "\n$JS_INTERFACE.downloadAndRunBotguard()" ), "text/html", "utf-8", null ) }, this::onInitializationErrorCloseAndCancel ) ) } /** * Called during initialization by the JavaScript snippet appended to the HTML page content in * [loadHtmlAndObtainBotguard] after the WebView content has been loaded. */ @JavascriptInterface fun downloadAndRunBotguard() { if (BuildConfig.DEBUG) { Log.d(TAG, "downloadAndRunBotguard() called") } makeBotguardServiceRequest( "https://www.youtube.com/api/jnn/v1/Create", "[ \"$REQUEST_KEY\" ]" ) { responseBody -> val parsedChallengeData = parseChallengeData(responseBody) webView.evaluateJavascript( """try { data = $parsedChallengeData runBotGuard(data).then(function (result) { this.webPoSignalOutput = result.webPoSignalOutput $JS_INTERFACE.onRunBotguardResult(result.botguardResponse) }, function (error) { $JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack) }) } catch (error) { $JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack) }""", null ) } } /** * Called during initialization by the JavaScript snippets from either * [downloadAndRunBotguard] or [onRunBotguardResult]. */ @JavascriptInterface fun onJsInitializationError(error: String) { if (BuildConfig.DEBUG) { Log.e(TAG, "Initialization error from JavaScript: $error") } onInitializationErrorCloseAndCancel(buildExceptionForJsError(error)) } /** * Called during initialization by the JavaScript snippet from [downloadAndRunBotguard] after * obtaining the BotGuard execution output [botguardResponse]. */ @JavascriptInterface fun onRunBotguardResult(botguardResponse: String) { if (BuildConfig.DEBUG) { Log.d(TAG, "botguardResponse: $botguardResponse") } makeBotguardServiceRequest( "https://www.youtube.com/api/jnn/v1/GenerateIT", "[ \"$REQUEST_KEY\", \"$botguardResponse\" ]" ) { responseBody -> if (BuildConfig.DEBUG) { Log.d(TAG, "GenerateIT response: $responseBody") } val (integrityToken, expirationTimeInSeconds) = parseIntegrityTokenData(responseBody) // leave 10 minutes of margin just to be sure expirationInstant = Instant.now().plusSeconds(expirationTimeInSeconds - 600) webView.evaluateJavascript( "this.integrityToken = $integrityToken" ) { if (BuildConfig.DEBUG) { Log.d(TAG, "initialization finished, expiration=${expirationTimeInSeconds}s") } generatorEmitter.onSuccess(this) } } } //endregion //region Obtaining poTokens override fun generatePoToken(identifier: String): Single = Single.create { emitter -> if (BuildConfig.DEBUG) { Log.d(TAG, "generatePoToken() called with identifier $identifier") } runOnMainThread(emitter) { addPoTokenEmitter(identifier, emitter) val u8Identifier = stringToU8(identifier) webView.evaluateJavascript( """try { identifier = "$identifier" u8Identifier = $u8Identifier poTokenU8 = obtainPoToken(webPoSignalOutput, integrityToken, u8Identifier) poTokenU8String = "" for (i = 0; i < poTokenU8.length; i++) { if (i != 0) poTokenU8String += "," poTokenU8String += poTokenU8[i] } $JS_INTERFACE.onObtainPoTokenResult(identifier, poTokenU8String) } catch (error) { $JS_INTERFACE.onObtainPoTokenError(identifier, error + "\n" + error.stack) }""" ) {} } } /** * Called by the JavaScript snippet from [generatePoToken] when an error occurs in calling the * JavaScript `obtainPoToken()` function. */ @JavascriptInterface fun onObtainPoTokenError(identifier: String, error: String) { if (BuildConfig.DEBUG) { Log.e(TAG, "obtainPoToken error from JavaScript: $error") } popPoTokenEmitter(identifier)?.onError(buildExceptionForJsError(error)) } /** * Called by the JavaScript snippet from [generatePoToken] with the original identifier and the * result of the JavaScript `obtainPoToken()` function. */ @JavascriptInterface fun onObtainPoTokenResult(identifier: String, poTokenU8: String) { if (BuildConfig.DEBUG) { Log.d(TAG, "Generated poToken (before decoding): identifier=$identifier poTokenU8=$poTokenU8") } val poToken = try { u8ToBase64(poTokenU8) } catch (t: Throwable) { popPoTokenEmitter(identifier)?.onError(t) return } if (BuildConfig.DEBUG) { Log.d(TAG, "Generated poToken: identifier=$identifier poToken=$poToken") } popPoTokenEmitter(identifier)?.onSuccess(poToken) } override fun isExpired(): Boolean { return Instant.now().isAfter(expirationInstant) } //endregion //region Handling multiple emitters /** * Adds the ([identifier], [emitter]) pair to the [poTokenEmitters] list. This makes it so that * multiple poToken requests can be generated invparallel, and the results will be notified to * the right emitters. */ private fun addPoTokenEmitter(identifier: String, emitter: SingleEmitter) { synchronized(poTokenEmitters) { poTokenEmitters.add(Pair(identifier, emitter)) } } /** * Extracts and removes from the [poTokenEmitters] list a [SingleEmitter] based on its * [identifier]. The emitter is supposed to be used immediately after to either signal a success * or an error. */ private fun popPoTokenEmitter(identifier: String): SingleEmitter? { return synchronized(poTokenEmitters) { poTokenEmitters.indexOfFirst { it.first == identifier }.takeIf { it >= 0 }?.let { poTokenEmitters.removeAt(it).second } } } /** * Clears [poTokenEmitters] and returns its previous contents. The emitters are supposed to be * used immediately after to either signal a success or an error. */ private fun popAllPoTokenEmitters(): List>> { return synchronized(poTokenEmitters) { val result = poTokenEmitters.toList() poTokenEmitters.clear() result } } //endregion //region Utils /** * Makes a POST request to [url] with the given [data] by setting the correct headers. Calls * [onInitializationErrorCloseAndCancel] in case of any network errors and also if the response * does not have HTTP code 200, therefore this is supposed to be used only during * initialization. Calls [handleResponseBody] with the response body if the response is * successful. The request is performed in the background and a disposable is added to * [disposables]. */ private fun makeBotguardServiceRequest( url: String, data: String, handleResponseBody: (String) -> Unit ) { disposables.add( Single.fromCallable { return@fromCallable DownloaderImpl.getInstance().post( url, mapOf( // replace the downloader user agent "User-Agent" to listOf(USER_AGENT), "Accept" to listOf("application/json"), "Content-Type" to listOf("application/json+protobuf"), "x-goog-api-key" to listOf(GOOGLE_API_KEY), "x-user-agent" to listOf("grpc-web-javascript/0.1") ), data.toByteArray() ) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( { response -> val httpCode = response.responseCode() if (httpCode != 200) { onInitializationErrorCloseAndCancel( PoTokenException("Invalid response code: $httpCode") ) return@subscribe } val responseBody = response.responseBody() handleResponseBody(responseBody) }, this::onInitializationErrorCloseAndCancel ) ) } /** * Handles any error happening during initialization, releasing resources and sending the error * to [generatorEmitter]. */ private fun onInitializationErrorCloseAndCancel(error: Throwable) { runOnMainThread(generatorEmitter) { close() generatorEmitter.onError(error) } } /** * Releases all [webView] and [disposables] resources. */ @MainThread override fun close() { disposables.dispose() webView.clearHistory() // clears RAM cache and disk cache (globally for all WebViews) webView.clearCache(true) // ensures that the WebView isn't doing anything when destroying it webView.loadUrl("about:blank") webView.onPause() webView.removeAllViews() webView.destroy() } //endregion companion object : PoTokenGenerator.Factory { private val TAG = PoTokenWebView::class.simpleName // Public API key used by BotGuard, which has been got by looking at BotGuard requests private const val GOOGLE_API_KEY = "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw" // NOSONAR private const val REQUEST_KEY = "O43z0dpjhgX20SCx4KAo" private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.3" private const val JS_INTERFACE = "PoTokenWebView" override fun newPoTokenGenerator(context: Context): Single = Single.create { emitter -> runOnMainThread(emitter) { val potWv = PoTokenWebView(context, emitter) potWv.loadHtmlAndObtainBotguard(context) emitter.setDisposable(potWv.disposables) } } /** * Runs [runnable] on the main thread using `Handler(Looper.getMainLooper()).post()`, and * if the `post` fails emits an error on [emitterIfPostFails]. */ private fun runOnMainThread( emitterIfPostFails: SingleEmitter, runnable: Runnable ) { if (!Handler(Looper.getMainLooper()).post(runnable)) { emitterIfPostFails.onError(PoTokenException("Could not run on main thread")) } } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.java ================================================ package org.schabi.newpipe.util.text; import android.content.Context; import android.view.View; import androidx.annotation.NonNull; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; final class HashtagLongPressClickableSpan extends LongPressClickableSpan { @NonNull private final Context context; @NonNull private final String parsedHashtag; private final int relatedInfoServiceId; HashtagLongPressClickableSpan(@NonNull final Context context, @NonNull final String parsedHashtag, final int relatedInfoServiceId) { this.context = context; this.parsedHashtag = parsedHashtag; this.relatedInfoServiceId = relatedInfoServiceId; } @Override public void onClick(@NonNull final View view) { NavigationHelper.openSearch(context, relatedInfoServiceId, parsedHashtag); } @Override public void onLongClick(@NonNull final View view) { ShareUtils.copyToClipboard(context, parsedHashtag); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java ================================================ package org.schabi.newpipe.util.text; import android.content.Context; import android.content.Intent; import androidx.core.content.ContextCompat; import androidx.annotation.NonNull; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.exceptions.ParsingException; import org.schabi.newpipe.extractor.linkhandler.LinkHandlerFactory; import org.schabi.newpipe.player.TimestampChangeData; import org.schabi.newpipe.util.NavigationHelper; import java.util.regex.Matcher; import java.util.regex.Pattern; public final class InternalUrlsHandler { private static final Pattern AMPERSAND_TIMESTAMP_PATTERN = Pattern.compile("(.*)&t=(\\d+)"); private InternalUrlsHandler() { } /** * Handle a YouTube timestamp description URL in NewPipe. *

* This method will check if the provided url is a YouTube timestamp description URL ({@code * https://www.youtube.com/watch?v=}video_id{@code &t=}time_in_seconds). If yes, the popup * player will be opened when the user will click on the timestamp in the video description, * at the time and for the video indicated in the timestamp. * * @param context the context to use * @param url the URL to check if it can be handled * @return true if the URL can be handled by NewPipe, false if it cannot */ public static boolean handleUrlDescriptionTimestamp(final Context context, @NonNull final String url) { final Matcher matcher = AMPERSAND_TIMESTAMP_PATTERN.matcher(url); if (!matcher.matches()) { return false; } final String matchedUrl = matcher.group(1); final int seconds; if (matcher.group(2) == null) { seconds = -1; } else { seconds = Integer.parseInt(matcher.group(2)); } final StreamingService service; final StreamingService.LinkType linkType; try { service = NewPipe.getServiceByUrl(matchedUrl); linkType = service.getLinkTypeByUrl(matchedUrl); if (linkType == StreamingService.LinkType.NONE) { return false; } } catch (final ExtractionException e) { return false; } if (linkType == StreamingService.LinkType.STREAM && seconds != -1) { return playOnPopup(context, matchedUrl, service, seconds); } else { NavigationHelper.openRouterActivity(context, matchedUrl); return true; } } /** * Play a content in the floating player. * * @param context the context to be used * @param url the URL of the content * @param service the service of the content * @param seconds the position in seconds at which the floating player will start * @return true if the playback of the content has successfully started or false if not */ public static boolean playOnPopup(final Context context, final String url, @NonNull final StreamingService service, final int seconds) { final LinkHandlerFactory factory = service.getStreamLHFactory(); final String cleanUrl; try { cleanUrl = factory.getUrl(factory.getId(url)); } catch (final ParsingException e) { return false; } final Intent intent = NavigationHelper.getPlayerTimestampIntent(context, new TimestampChangeData( service.getServiceId(), cleanUrl, seconds )); ContextCompat.startForegroundService(context, intent); return true; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/text/LongPressClickableSpan.java ================================================ package org.schabi.newpipe.util.text; import android.text.style.ClickableSpan; import android.view.View; import androidx.annotation.NonNull; public abstract class LongPressClickableSpan extends ClickableSpan { public abstract void onLongClick(@NonNull View view); } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/text/LongPressLinkMovementMethod.java ================================================ package org.schabi.newpipe.util.text; import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine; import android.os.Handler; import android.os.Looper; import android.text.Selection; import android.text.Spannable; import android.text.method.LinkMovementMethod; import android.text.method.MovementMethod; import android.view.MotionEvent; import android.view.ViewConfiguration; import android.widget.TextView; import androidx.annotation.NonNull; // Class adapted from https://stackoverflow.com/a/31786969 public class LongPressLinkMovementMethod extends LinkMovementMethod { private static final int LONG_PRESS_TIME = ViewConfiguration.getLongPressTimeout(); private static LongPressLinkMovementMethod instance; private Handler longClickHandler; private boolean isLongPressed = false; @Override public boolean onTouchEvent(@NonNull final TextView widget, @NonNull final Spannable buffer, @NonNull final MotionEvent event) { final int action = event.getAction(); if (action == MotionEvent.ACTION_CANCEL && longClickHandler != null) { longClickHandler.removeCallbacksAndMessages(null); } if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { final int offset = getOffsetForHorizontalLine(widget, event); final LongPressClickableSpan[] link = buffer.getSpans(offset, offset, LongPressClickableSpan.class); if (link.length != 0) { if (action == MotionEvent.ACTION_UP) { if (longClickHandler != null) { longClickHandler.removeCallbacksAndMessages(null); } if (!isLongPressed) { link[0].onClick(widget); } isLongPressed = false; } else { Selection.setSelection(buffer, buffer.getSpanStart(link[0]), buffer.getSpanEnd(link[0])); if (longClickHandler != null) { longClickHandler.postDelayed(() -> { link[0].onLongClick(widget); isLongPressed = true; }, LONG_PRESS_TIME); } } return true; } } return super.onTouchEvent(widget, buffer, event); } public static MovementMethod getInstance() { if (instance == null) { instance = new LongPressLinkMovementMethod(); instance.longClickHandler = new Handler(Looper.myLooper()); } return instance; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java ================================================ package org.schabi.newpipe.util.text; import android.graphics.Paint; import android.text.Layout; import android.view.View; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.text.HtmlCompat; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.stream.Description; import java.util.function.Consumer; import io.reactivex.rxjava3.disposables.CompositeDisposable; /** *

Class to ellipsize text inside a {@link TextView}.

* This class provides all utils to automatically ellipsize and expand a text */ public final class TextEllipsizer { private static final int EXPANDED_LINES = Integer.MAX_VALUE; private static final String ELLIPSIS = "…"; @NonNull private final CompositeDisposable disposable = new CompositeDisposable(); @NonNull private final TextView view; private final int maxLines; @NonNull private Description content; @Nullable private StreamingService streamingService; @Nullable private String streamUrl; private boolean isEllipsized = false; @Nullable private Boolean canBeEllipsized = null; @NonNull private final Paint paintAtContentSize = new Paint(); private final float ellipsisWidthPx; @Nullable private Consumer stateChangeListener = null; @Nullable private Consumer onContentChanged; public TextEllipsizer(@NonNull final TextView view, final int maxLines, @Nullable final StreamingService streamingService) { this.view = view; this.maxLines = maxLines; this.content = Description.EMPTY_DESCRIPTION; this.streamingService = streamingService; paintAtContentSize.setTextSize(view.getTextSize()); ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS); } public void setOnContentChanged(@Nullable final Consumer onContentChanged) { this.onContentChanged = onContentChanged; } public void setContent(@NonNull final Description content) { this.content = content; canBeEllipsized = null; linkifyContentView(v -> { final int currentMaxLines = view.getMaxLines(); view.setMaxLines(EXPANDED_LINES); canBeEllipsized = view.getLineCount() > maxLines; view.setMaxLines(currentMaxLines); if (onContentChanged != null) { onContentChanged.accept(canBeEllipsized); } }); } public void setStreamUrl(@Nullable final String streamUrl) { this.streamUrl = streamUrl; } public void setStreamingService(@NonNull final StreamingService streamingService) { this.streamingService = streamingService; } /** * Expand the {@link TextEllipsizer#content} to its full length. */ public void expand() { view.setMaxLines(EXPANDED_LINES); linkifyContentView(v -> isEllipsized = false); } /** * Shorten the {@link TextEllipsizer#content} to the given number of * {@link TextEllipsizer#maxLines maximum lines} and add trailing '{@code …}' * if the text was shorted. */ public void ellipsize() { // expand text to see whether it is necessary to ellipsize the text view.setMaxLines(EXPANDED_LINES); linkifyContentView(v -> { final CharSequence charSeqText = view.getText(); if (charSeqText != null && view.getLineCount() > maxLines) { // Note that converting to String removes spans (i.e. links), but that's something // we actually want since when the text is ellipsized we want all clicks on the // comment to expand the comment, not to open links. final String text = charSeqText.toString(); final Layout layout = view.getLayout(); final float lineWidth = layout.getLineWidth(maxLines - 1); final float layoutWidth = layout.getWidth(); final int lineStart = layout.getLineStart(maxLines - 1); final int lineEnd = layout.getLineEnd(maxLines - 1); // remove characters up until there is enough space for the ellipsis // (also summing 2 more pixels, just to be sure to avoid float rounding errors) int end = lineEnd; float removedCharactersWidth = 0.0f; while (lineWidth - removedCharactersWidth + ellipsisWidthPx + 2.0f > layoutWidth && end >= lineStart) { end -= 1; // recalculate each time to account for ligatures or other similar things removedCharactersWidth = paintAtContentSize.measureText( text.substring(end, lineEnd)); } // remove trailing spaces and newlines while (end > 0 && Character.isWhitespace(text.charAt(end - 1))) { end -= 1; } final String newVal = text.substring(0, end) + ELLIPSIS; view.setText(newVal); isEllipsized = true; } else { isEllipsized = false; } view.setMaxLines(maxLines); }); } /** * Toggle the view between the ellipsized and expanded state. */ public void toggle() { if (isEllipsized) { expand(); } else { ellipsize(); } } /** * Whether the {@link #view} can be ellipsized. * This is only the case when the {@link #content} has more lines * than allowed via {@link #maxLines}. * @return {@code true} if the {@link #content} has more lines than allowed via * {@link #maxLines} and thus can be shortened, {@code false} if the {@code content} fits into * the {@link #view} without being shortened and {@code null} if the initialization is not * completed yet. */ @Nullable public Boolean canBeEllipsized() { return canBeEllipsized; } private void linkifyContentView(final Consumer consumer) { final boolean oldState = isEllipsized; disposable.clear(); TextLinkifier.fromDescription(view, content, HtmlCompat.FROM_HTML_MODE_LEGACY, streamingService, streamUrl, disposable, v -> { consumer.accept(v); notifyStateChangeListener(oldState); }); } /** * Add a listener which is called when the given content is changed, * either from ellipsized to full or vice versa. * @param listener The listener to be called, or {@code null} to remove it. * The Boolean parameter is the new state. * Ellipsized content is represented as {@code true}, * normal or full content by {@code false}. */ public void setStateChangeListener(@Nullable final Consumer listener) { this.stateChangeListener = listener; } private void notifyStateChangeListener(final boolean oldState) { if (oldState != isEllipsized && stateChangeListener != null) { stateChangeListener.accept(isEllipsized); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java ================================================ package org.schabi.newpipe.util.text; import android.content.Context; import android.text.SpannableStringBuilder; import android.text.style.URLSpan; import android.text.util.Linkify; import android.util.Log; import android.view.View; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.text.HtmlCompat; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.stream.Description; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; import io.noties.markwon.Markwon; import io.noties.markwon.linkify.LinkifyPlugin; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.schedulers.Schedulers; public final class TextLinkifier { public static final String TAG = TextLinkifier.class.getSimpleName(); // Looks for hashtags with characters from any language (\p{L}), numbers, or underscores private static final Pattern HASHTAGS_PATTERN = Pattern.compile("(#[\\p{L}0-9_]+)"); public static final Consumer SET_LINK_MOVEMENT_METHOD = v -> v.setMovementMethod(LongPressLinkMovementMethod.getInstance()); private TextLinkifier() { } /** * Create links for contents with an {@link Description} in the various possible formats. *

* This will call one of these three functions based on the format: {@link #fromHtml}, * {@link #fromMarkdown} or {@link #fromPlainText}. * * @param textView the TextView to set the htmlBlock linked * @param description the htmlBlock to be linked * @param htmlCompatFlag the int flag to be set if {@link HtmlCompat#fromHtml(String, int)} * will be called (not used for formats different than HTML) * @param relatedInfoService if given, handle hashtags to search for the term in the correct * service * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle * timestamps to open the stream in the popup player at the specific * time * @param disposables disposables created by the method are added here and their * lifecycle should be handled by the calling class * @param onCompletion will be run when setting text to the textView completes; use {@link * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable */ public static void fromDescription(@NonNull final TextView textView, @NonNull final Description description, final int htmlCompatFlag, @Nullable final StreamingService relatedInfoService, @Nullable final String relatedStreamUrl, @NonNull final CompositeDisposable disposables, @Nullable final Consumer onCompletion) { switch (description.getType()) { case Description.HTML: TextLinkifier.fromHtml(textView, description.getContent(), htmlCompatFlag, relatedInfoService, relatedStreamUrl, disposables, onCompletion); break; case Description.MARKDOWN: TextLinkifier.fromMarkdown(textView, description.getContent(), relatedInfoService, relatedStreamUrl, disposables, onCompletion); break; case Description.PLAIN_TEXT: default: TextLinkifier.fromPlainText(textView, description.getContent(), relatedInfoService, relatedStreamUrl, disposables, onCompletion); break; } } /** * Create links for contents with an HTML description. * *

* This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService, * String, CompositeDisposable, Consumer)} after having linked the URLs with * {@link HtmlCompat#fromHtml(String, int)}. *

* * @param textView the {@link TextView} to set the HTML string block linked * @param htmlBlock the HTML string block to be linked * @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, * int)} will be called * @param relatedInfoService if given, handle hashtags to search for the term in the correct * service * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle * timestamps to open the stream in the popup player at the specific * time * @param disposables disposables created by the method are added here and their * lifecycle should be handled by the calling class * @param onCompletion will be run when setting text to the textView completes; use {@link * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable */ public static void fromHtml(@NonNull final TextView textView, @NonNull final String htmlBlock, final int htmlCompatFlag, @Nullable final StreamingService relatedInfoService, @Nullable final String relatedStreamUrl, @NonNull final CompositeDisposable disposables, @Nullable final Consumer onCompletion) { changeLinkIntents( textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfoService, relatedStreamUrl, disposables, onCompletion); } /** * Create links for contents with a plain text description. * *

* This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService, * String, CompositeDisposable, Consumer)} after having linked the URLs with * {@link TextView#setAutoLinkMask(int)} and * {@link TextView#setText(CharSequence, TextView.BufferType)}. *

* * @param textView the {@link TextView} to set the plain text block linked * @param plainTextBlock the block of plain text to be linked * @param relatedInfoService if given, handle hashtags to search for the term in the correct * service * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle * timestamps to open the stream in the popup player at the specific * time * @param disposables disposables created by the method are added here and their * lifecycle should be handled by the calling class * @param onCompletion will be run when setting text to the textView completes; use {@link * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable */ public static void fromPlainText(@NonNull final TextView textView, @NonNull final String plainTextBlock, @Nullable final StreamingService relatedInfoService, @Nullable final String relatedStreamUrl, @NonNull final CompositeDisposable disposables, @Nullable final Consumer onCompletion) { textView.setAutoLinkMask(Linkify.WEB_URLS); textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE); changeLinkIntents(textView, textView.getText(), relatedInfoService, relatedStreamUrl, disposables, onCompletion); } /** * Create links for contents with a markdown description. * *

* This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService, * String, CompositeDisposable, Consumer)} after creating a {@link Markwon} object and using * {@link Markwon#setMarkdown(TextView, String)}. *

* * @param textView the {@link TextView} to set the plain text block linked * @param markdownBlock the block of markdown text to be linked * @param relatedInfoService if given, handle hashtags to search for the term in the correct * service * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle * timestamps to open the stream in the popup player at the specific * time * @param disposables disposables created by the method are added here and their * lifecycle should be handled by the calling class * @param onCompletion will be run when setting text to the textView completes; use {@link * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable */ public static void fromMarkdown(@NonNull final TextView textView, @NonNull final String markdownBlock, @Nullable final StreamingService relatedInfoService, @Nullable final String relatedStreamUrl, @NonNull final CompositeDisposable disposables, @Nullable final Consumer onCompletion) { final Markwon markwon = Markwon.builder(textView.getContext()) .usePlugin(LinkifyPlugin.create()).build(); changeLinkIntents(textView, markwon.toMarkdown(markdownBlock), relatedInfoService, relatedStreamUrl, disposables, onCompletion); } /** * Change links generated by libraries in the description of a content to a custom link action * and add click listeners on timestamps in this description. * *

* Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of * a content, this method will parse the {@link CharSequence} and replace all current web links * with {@link ShareUtils#openUrlInBrowser(Context, String)}. *

* *

* This method will also add click listeners on timestamps in this description, which will play * the content in the popup player at the time indicated in the timestamp, by using * {@link TextLinkifier#addClickListenersOnTimestamps(Context, SpannableStringBuilder, * StreamingService, String, CompositeDisposable)} method and click listeners on hashtags, by * using {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder, * StreamingService)}, which will open a search on the current service with the hashtag. *

* *

* This method is required in order to intercept links and e.g. show a confirmation dialog * before opening a web link. *

* * @param textView the {@link TextView} to which the converted {@link CharSequence} * will be applied * @param chars the {@link CharSequence} to be parsed * @param relatedInfoService if given, handle hashtags to search for the term in the correct * service * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle * timestamps to open the stream in the popup player at the specific * time * @param disposables disposables created by the method are added here and their * lifecycle should be handled by the calling class * @param onCompletion will be run when setting text to the textView completes; use {@link * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable */ private static void changeLinkIntents(@NonNull final TextView textView, @NonNull final CharSequence chars, @Nullable final StreamingService relatedInfoService, @Nullable final String relatedStreamUrl, @NonNull final CompositeDisposable disposables, @Nullable final Consumer onCompletion) { disposables.add(Single.fromCallable(() -> { final Context context = textView.getContext(); // add custom click actions on web links final SpannableStringBuilder textBlockLinked = new SpannableStringBuilder(chars); final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(), URLSpan.class); for (final URLSpan span : urls) { final String url = span.getURL(); final LongPressClickableSpan longPressClickableSpan = new UrlLongPressClickableSpan(context, url); textBlockLinked.setSpan(longPressClickableSpan, textBlockLinked.getSpanStart(span), textBlockLinked.getSpanEnd(span), textBlockLinked.getSpanFlags(span)); textBlockLinked.removeSpan(span); } // add click actions on plain text timestamps only for description of contents, // unneeded for meta-info or other TextViews if (relatedInfoService != null) { if (relatedStreamUrl != null) { addClickListenersOnTimestamps(context, textBlockLinked, relatedInfoService, relatedStreamUrl, disposables); } addClickListenersOnHashtags(context, textBlockLinked, relatedInfoService); } return textBlockLinked; }).subscribeOn(Schedulers.computation()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( textBlockLinked -> setTextViewCharSequence(textView, textBlockLinked, onCompletion), throwable -> { Log.e(TAG, "Unable to linkify text", throwable); // this should never happen, but if it does, just fallback to it setTextViewCharSequence(textView, chars, onCompletion); })); } /** * Add click listeners which opens a search on hashtags in a plain text. * *

* This method finds all timestamps in the {@link SpannableStringBuilder} of the description * using a regular expression, adds for each a {@link LongPressClickableSpan} which opens * {@link NavigationHelper#openSearch(Context, int, String)} and makes a search on the hashtag, * in the service of the content when pressed, and copy the hashtag to clipboard when * long-pressed, if allowed by the caller method (parameter {@code addLongClickCopyListener}). *

* * @param context the {@link Context} to use * @param spannableDescription the {@link SpannableStringBuilder} with the text of the * content description * @param relatedInfoService used to search for the term in the correct service */ private static void addClickListenersOnHashtags( @NonNull final Context context, @NonNull final SpannableStringBuilder spannableDescription, @NonNull final StreamingService relatedInfoService) { final String descriptionText = spannableDescription.toString(); final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText); while (hashtagsMatches.find()) { final int hashtagStart = hashtagsMatches.start(1); final int hashtagEnd = hashtagsMatches.end(1); final String parsedHashtag = descriptionText.substring(hashtagStart, hashtagEnd); // Don't add a LongPressClickableSpan if there is already one, which should be a part // of an URL, already parsed before if (spannableDescription.getSpans(hashtagStart, hashtagEnd, LongPressClickableSpan.class).length == 0) { final int serviceId = relatedInfoService.getServiceId(); spannableDescription.setSpan( new HashtagLongPressClickableSpan(context, parsedHashtag, serviceId), hashtagStart, hashtagEnd, 0); } } } /** * Add click listeners which opens the popup player on timestamps in a plain text. * *

* This method finds all timestamps in the {@link SpannableStringBuilder} of the description * using a regular expression, adds for each a {@link LongPressClickableSpan} which opens the * popup player at the time indicated in the timestamps and copy the timestamp in clipboard * when long-pressed. *

* * @param context the {@link Context} to use * @param spannableDescription the {@link SpannableStringBuilder} with the text of the * content description * @param relatedInfoService the service of the {@code relatedStreamUrl} * @param relatedStreamUrl what to open in the popup player when timestamps are clicked * @param disposables disposables created by the method are added here and their * lifecycle should be handled by the calling class */ private static void addClickListenersOnTimestamps( @NonNull final Context context, @NonNull final SpannableStringBuilder spannableDescription, @NonNull final StreamingService relatedInfoService, @NonNull final String relatedStreamUrl, @NonNull final CompositeDisposable disposables) { final String descriptionText = spannableDescription.toString(); final Matcher timestampsMatches = TimestampExtractor.TIMESTAMPS_PATTERN.matcher( descriptionText); while (timestampsMatches.find()) { final TimestampExtractor.TimestampMatchDTO timestampMatchDTO = TimestampExtractor.getTimestampFromMatcher(timestampsMatches, descriptionText); if (timestampMatchDTO == null) { continue; } spannableDescription.setSpan( new TimestampLongPressClickableSpan(context, descriptionText, disposables, relatedInfoService, relatedStreamUrl, timestampMatchDTO), timestampMatchDTO.timestampStart(), timestampMatchDTO.timestampEnd(), 0); } } private static void setTextViewCharSequence(@NonNull final TextView textView, @Nullable final CharSequence charSequence, @Nullable final Consumer onCompletion) { textView.setText(charSequence); textView.setVisibility(View.VISIBLE); if (onCompletion != null) { onCompletion.accept(textView); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/text/TextViewExtensions.kt ================================================ package org.schabi.newpipe.util.text import android.content.res.Resources import android.text.SpannableString import android.text.method.LinkMovementMethod import android.text.util.Linkify import android.util.Patterns import android.widget.TextView import androidx.annotation.StringRes import androidx.core.text.parseAsHtml import androidx.core.text.toHtml import androidx.core.text.toSpanned /** * Takes in a CharSequence [text] * and makes raw HTTP URLs and HTML anchor tags clickable */ fun TextView.setTextWithLinks(text: CharSequence) { val spanned = SpannableString(text) // Using the pattern overload of addLinks since the one with the int masks strips all spans from the text before applying new ones Linkify.addLinks(spanned, Patterns.WEB_URL, null) this.text = spanned this.movementMethod = LinkMovementMethod.getInstance() } /** * Gets text from string resource with [id] while preserving styling and allowing string format value substitution of [formatArgs] */ fun Resources.getText(@StringRes id: Int, vararg formatArgs: Any?): CharSequence = getText(id).toSpanned().toHtml().format(*formatArgs).parseAsHtml() ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.java ================================================ package org.schabi.newpipe.util.text; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.regex.Matcher; import java.util.regex.Pattern; /** * Extracts timestamps. */ public final class TimestampExtractor { public static final Pattern TIMESTAMPS_PATTERN = Pattern.compile( "(?:^|(?!:)\\W)(?:([0-5]?[0-9]):)?([0-5]?[0-9]):([0-5][0-9])(?=$|(?!:)\\W)"); private TimestampExtractor() { // No impl pls } /** * Gets a single timestamp from a matcher. * * @param timestampMatches the matcher which was created using {@link #TIMESTAMPS_PATTERN} * @param baseText the text where the pattern was applied to / where the matcher is * based upon * @return if a match occurred, a {@link TimestampMatchDTO} filled with information, otherwise * {@code null}. */ @Nullable public static TimestampMatchDTO getTimestampFromMatcher( @NonNull final Matcher timestampMatches, @NonNull final String baseText) { int timestampStart = timestampMatches.start(1); if (timestampStart == -1) { timestampStart = timestampMatches.start(2); } final int timestampEnd = timestampMatches.end(3); final String parsedTimestamp = baseText.substring(timestampStart, timestampEnd); final String[] timestampParts = parsedTimestamp.split(":"); final int seconds; if (timestampParts.length == 3) { // timestamp format: XX:XX:XX seconds = Integer.parseInt(timestampParts[0]) * 3600 // hours + Integer.parseInt(timestampParts[1]) * 60 // minutes + Integer.parseInt(timestampParts[2]); // seconds } else if (timestampParts.length == 2) { // timestamp format: XX:XX seconds = Integer.parseInt(timestampParts[0]) * 60 // minutes + Integer.parseInt(timestampParts[1]); // seconds } else { return null; } return new TimestampMatchDTO(timestampStart, timestampEnd, seconds); } public record TimestampMatchDTO(int timestampStart, int timestampEnd, int seconds) { } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.kt ================================================ /* * SPDX-FileCopyrightText: 2023-2025 NewPipe contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.util.text import android.content.Context import android.view.View import io.reactivex.rxjava3.disposables.CompositeDisposable import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.StreamingService import org.schabi.newpipe.util.external_communication.ShareUtils import org.schabi.newpipe.util.text.TimestampExtractor.TimestampMatchDTO class TimestampLongPressClickableSpan( private val context: Context, private val descriptionText: String, private val disposables: CompositeDisposable, private val relatedInfoService: StreamingService, private val relatedStreamUrl: String, private val timestampMatchDTO: TimestampMatchDTO ) : LongPressClickableSpan() { override fun onClick(view: View) { InternalUrlsHandler.playOnPopup( context, relatedStreamUrl, relatedInfoService, timestampMatchDTO.seconds() ) } override fun onLongClick(view: View) { ShareUtils.copyToClipboard( context, getTimestampTextToCopy( relatedInfoService, relatedStreamUrl, descriptionText, timestampMatchDTO ) ) } companion object { private fun getTimestampTextToCopy( relatedInfoService: StreamingService, relatedStreamUrl: String, descriptionText: String, timestampMatchDTO: TimestampMatchDTO ): String { // TODO: use extractor methods to get timestamps when this feature will be implemented in it when (relatedInfoService) { ServiceList.YouTube -> return relatedStreamUrl + "&t=" + timestampMatchDTO.seconds() ServiceList.SoundCloud, ServiceList.MediaCCC -> return relatedStreamUrl + "#t=" + timestampMatchDTO.seconds() ServiceList.PeerTube -> return relatedStreamUrl + "?start=" + timestampMatchDTO.seconds() } // Return timestamp text for other services return descriptionText.substring( timestampMatchDTO.timestampStart(), timestampMatchDTO.timestampEnd() ) } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/text/TouchUtils.java ================================================ package org.schabi.newpipe.util.text; import android.text.Layout; import android.view.MotionEvent; import android.widget.TextView; import androidx.annotation.NonNull; public final class TouchUtils { private TouchUtils() { } /** * Get the character offset on the closest line to the position pressed by the user of a * {@link TextView} from a {@link MotionEvent} which was fired on this {@link TextView}. * * @param textView the {@link TextView} on which the {@link MotionEvent} was fired * @param event the {@link MotionEvent} which was fired * @return the character offset on the closest line to the position pressed by the user */ public static int getOffsetForHorizontalLine(@NonNull final TextView textView, @NonNull final MotionEvent event) { int x = (int) event.getX(); int y = (int) event.getY(); x -= textView.getTotalPaddingLeft(); y -= textView.getTotalPaddingTop(); x += textView.getScrollX(); y += textView.getScrollY(); final Layout layout = textView.getLayout(); final int line = layout.getLineForVertical(y); return layout.getOffsetForHorizontal(line, x); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java ================================================ package org.schabi.newpipe.util.text; import android.content.Context; import android.view.View; import androidx.annotation.NonNull; import org.schabi.newpipe.util.external_communication.ShareUtils; final class UrlLongPressClickableSpan extends LongPressClickableSpan { @NonNull private final Context context; @NonNull private final String url; UrlLongPressClickableSpan(@NonNull final Context context, @NonNull final String url) { this.context = context; this.url = url; } @Override public void onClick(@NonNull final View view) { if (!InternalUrlsHandler.handleUrlDescriptionTimestamp(context, url)) { ShareUtils.openUrlInApp(context, url); } } @Override public void onLongClick(@NonNull final View view) { ShareUtils.copyToClipboard(context, url); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/urlfinder/PatternsCompat.java ================================================ /* THIS FILE WAS MODIFIED, CHANGES ARE DOCUMENTED. */ /* * Copyright (C) 2016 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package org.schabi.newpipe.util.urlfinder; import java.util.regex.Pattern; /** * Commonly used regular expression patterns. */ public final class PatternsCompat { //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! // CHANGED: Removed unused code // //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! public static final Pattern IP_ADDRESS = Pattern.compile( "((25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9])\\.(25[0-5]|2[0-4]" + "[0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1]" + "[0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}" + "|[1-9][0-9]|[0-9]))"); /** * Valid UCS characters defined in RFC 3987. Excludes space characters. */ private static final String UCS_CHAR = "[" + "\u00A0-\uD7FF" + "\uF900-\uFDCF" + "\uFDF0-\uFFEF" + "\uD800\uDC00-\uD83F\uDFFD" + "\uD840\uDC00-\uD87F\uDFFD" + "\uD880\uDC00-\uD8BF\uDFFD" + "\uD8C0\uDC00-\uD8FF\uDFFD" + "\uD900\uDC00-\uD93F\uDFFD" + "\uD940\uDC00-\uD97F\uDFFD" + "\uD980\uDC00-\uD9BF\uDFFD" + "\uD9C0\uDC00-\uD9FF\uDFFD" + "\uDA00\uDC00-\uDA3F\uDFFD" + "\uDA40\uDC00-\uDA7F\uDFFD" + "\uDA80\uDC00-\uDABF\uDFFD" + "\uDAC0\uDC00-\uDAFF\uDFFD" + "\uDB00\uDC00-\uDB3F\uDFFD" + "\uDB44\uDC00-\uDB7F\uDFFD" + "&&[^\u00A0[\u2000-\u200A]\u2028\u2029\u202F\u3000]]"; /** * Valid characters for IRI label defined in RFC 3987. */ private static final String LABEL_CHAR = "a-zA-Z0-9" + UCS_CHAR; /** * RFC 1035 Section 2.3.4 limits the labels to a maximum 63 octets. */ private static final String IRI_LABEL = "[" + LABEL_CHAR + "](?:[" + LABEL_CHAR + "_\\-]{0,61}[" + LABEL_CHAR + "]){0,1}"; //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! // CHANGED: Removed rtsp from supported protocols // //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! private static final String PROTOCOL = "(?i:http|https)://"; /* A word boundary or end of input. This is to stop foo.sure from matching as foo.su */ private static final String WORD_BOUNDARY = "(?:\\b|$|^)"; private static final String USER_INFO = "(?:[a-zA-Z0-9\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)" + "\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,64}(?:\\:(?:[a-zA-Z0-9\\$\\-\\_" + "\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,25})?\\@"; private static final String PORT_NUMBER = "\\:\\d{1,5}"; private static final String PATH_AND_QUERY = "[/\\?](?:(?:[" + LABEL_CHAR + ";/\\?:@&=#~" // plus optional query params + "\\-\\.\\+!\\*'\\(\\),_\\$])|(?:%[a-fA-F0-9]{2}))*"; /** * Regular expression that matches domain names without a TLD. */ private static final String RELAXED_DOMAIN_NAME = "(?:" + "(?:" + IRI_LABEL + "(?:\\.(?=\\S))" + "?)+" + "|" + IP_ADDRESS + ")"; //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! // CHANGED: Field visibility was modified // //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! /** * Regular expression to match strings that start with a supported protocol. Rules for domain * names and TLDs are more relaxed. TLDs are optional. */ /*package*/ static final String WEB_URL_WITH_PROTOCOL = "(" + WORD_BOUNDARY + "(?:" + "(?:" + PROTOCOL + "(?:" + USER_INFO + ")?" + ")" + "(?:" + RELAXED_DOMAIN_NAME + ")?" + "(?:" + PORT_NUMBER + ")?" + ")" + "(?:" + PATH_AND_QUERY + ")?" + WORD_BOUNDARY + ")"; /** * Do not create this static utility class. */ private PatternsCompat() { } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/util/urlfinder/UrlFinder.kt ================================================ package org.schabi.newpipe.util.urlfinder import java.util.regex.Pattern class UrlFinder { companion object { private val WEB_URL_WITH_PROTOCOL = Pattern.compile(PatternsCompat.WEB_URL_WITH_PROTOCOL) /** * @return the first url found in the input, null otherwise. */ @JvmStatic fun firstUrlFromInput(input: String?): String? { if (input.isNullOrEmpty()) { return null } val matcher = WEB_URL_WITH_PROTOCOL.matcher(input) if (matcher.find()) { return matcher.group() } return null } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/views/AnimatedProgressBar.java ================================================ package org.schabi.newpipe.views; import android.content.Context; import android.util.AttributeSet; import android.view.animation.AccelerateDecelerateInterpolator; import android.view.animation.Animation; import android.view.animation.Transformation; import android.widget.ProgressBar; import androidx.annotation.Nullable; public final class AnimatedProgressBar extends ProgressBar { @Nullable private ProgressBarAnimation animation = null; public AnimatedProgressBar(final Context context) { super(context); } public AnimatedProgressBar(final Context context, final AttributeSet attrs) { super(context, attrs); } public AnimatedProgressBar(final Context context, final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); } public synchronized void setProgressAnimated(final int progress) { cancelAnimation(); animation = new ProgressBarAnimation(this, getProgress(), progress); startAnimation(animation); } private void cancelAnimation() { if (animation != null) { animation.cancel(); animation = null; } clearAnimation(); } private static class ProgressBarAnimation extends Animation { private final AnimatedProgressBar progressBar; private final float from; private final float to; ProgressBarAnimation(final AnimatedProgressBar progressBar, final float from, final float to) { super(); this.progressBar = progressBar; this.from = from; this.to = to; setDuration(500); setInterpolator(new AccelerateDecelerateInterpolator()); } @Override protected void applyTransformation(final float interpolatedTime, final Transformation t) { super.applyTransformation(interpolatedTime, t); final float value = from + (to - from) * interpolatedTime; progressBar.setProgress((int) value); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/views/CustomCollapsingToolbarLayout.java ================================================ package org.schabi.newpipe.views; import android.content.Context; import android.util.AttributeSet; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.view.ViewCompat; import androidx.core.view.WindowInsetsCompat; import com.google.android.material.appbar.CollapsingToolbarLayout; public class CustomCollapsingToolbarLayout extends CollapsingToolbarLayout { public CustomCollapsingToolbarLayout(@NonNull final Context context) { super(context); overrideListener(); } public CustomCollapsingToolbarLayout(@NonNull final Context context, @Nullable final AttributeSet attrs) { super(context, attrs); overrideListener(); } public CustomCollapsingToolbarLayout(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); overrideListener(); } /** * CollapsingToolbarLayout sets it's own setOnApplyInsetsListener which consumes * system insets {@link CollapsingToolbarLayout#onWindowInsetChanged(WindowInsetsCompat)} * so we will not receive them in subviews with fitsSystemWindows = true. * Override Google's behavior * */ public void overrideListener() { ViewCompat.setOnApplyWindowInsetsListener(this, (v, insets) -> insets); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java ================================================ package org.schabi.newpipe.views; import android.content.Context; import android.util.AttributeSet; import android.view.SurfaceView; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT; import static com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM; public class ExpandableSurfaceView extends SurfaceView { private int resizeMode = RESIZE_MODE_FIT; private int baseHeight = 0; private int maxHeight = 0; private float videoAspectRatio = 0.0f; private float scaleX = 1.0f; private float scaleY = 1.0f; public ExpandableSurfaceView(final Context context, final AttributeSet attrs) { super(context, attrs); } @Override protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); if (videoAspectRatio == 0.0f) { return; } int width = MeasureSpec.getSize(widthMeasureSpec); final boolean verticalVideo = videoAspectRatio < 1; // Use maxHeight only on non-fit resize mode and in vertical videos int height = maxHeight != 0 && resizeMode != RESIZE_MODE_FIT && verticalVideo ? maxHeight : baseHeight; if (width == 0 || height == 0) { return; } final float viewAspectRatio = width / ((float) height); final float aspectDeformation = (videoAspectRatio / viewAspectRatio) - 1; scaleX = 1.0f; scaleY = 1.0f; if (resizeMode == RESIZE_MODE_FIT) { if (aspectDeformation > 0) { height = (int) (width / videoAspectRatio); } else { width = (int) (height * videoAspectRatio); } } else if (resizeMode == RESIZE_MODE_ZOOM) { if (aspectDeformation < 0) { scaleY = viewAspectRatio / videoAspectRatio; } else { scaleX = videoAspectRatio / viewAspectRatio; } } super.onMeasure(MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)); } /** * Scale view only in {@link #onLayout} to make transition for ZOOM mode as smooth as possible. */ @Override protected void onLayout(final boolean changed, final int left, final int top, final int right, final int bottom) { setScaleX(scaleX); setScaleY(scaleY); } /** * @param base The height that will be used in every resize mode as a minimum height * @param max The max height for vertical videos in non-FIT resize modes */ public void setHeights(final int base, final int max) { if (baseHeight == base && maxHeight == max) { return; } baseHeight = base; maxHeight = max; requestLayout(); } public void setResizeMode(@AspectRatioFrameLayout.ResizeMode final int newResizeMode) { if (resizeMode == newResizeMode) { return; } resizeMode = newResizeMode; requestLayout(); } @AspectRatioFrameLayout.ResizeMode public int getResizeMode() { return resizeMode; } public void setAspectRatio(final float aspectRatio) { if (videoAspectRatio == aspectRatio || aspectRatio == 0 || !Float.isFinite(aspectRatio)) { return; } videoAspectRatio = aspectRatio; requestLayout(); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.java ================================================ /* * Copyright (C) Eltex ltd 2019 * FocusAwareCoordinator.java is part of NewPipe. * * NewPipe is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * NewPipe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ package org.schabi.newpipe.views; import android.content.Context; import android.graphics.Rect; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; import android.view.WindowInsets; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.view.WindowInsetsCompat; import org.schabi.newpipe.R; public final class FocusAwareCoordinator extends CoordinatorLayout { private final Rect childFocus = new Rect(); public FocusAwareCoordinator(@NonNull final Context context) { super(context); } public FocusAwareCoordinator(@NonNull final Context context, @Nullable final AttributeSet attrs) { super(context, attrs); } public FocusAwareCoordinator(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override public void requestChildFocus(final View child, final View focused) { super.requestChildFocus(child, focused); if (!isInTouchMode()) { if (focused.getHeight() >= getHeight()) { focused.getFocusedRect(childFocus); ((ViewGroup) child).offsetDescendantRectToMyCoords(focused, childFocus); } else { focused.getHitRect(childFocus); ((ViewGroup) child).offsetDescendantRectToMyCoords((View) focused.getParent(), childFocus); } requestChildRectangleOnScreen(child, childFocus, false); } } /** * Applies window insets to all children, not just for the first who consume the insets. * Makes possible for multiple fragments to co-exist. Without this code * the first ViewGroup who consumes will be the last who receive the insets */ @Override public WindowInsets dispatchApplyWindowInsets(final WindowInsets insets) { boolean consumed = false; for (int i = 0; i < getChildCount(); i++) { final View child = getChildAt(i); final WindowInsets res = child.dispatchApplyWindowInsets(insets); if (res.isConsumed()) { consumed = true; } } return consumed ? WindowInsetsCompat.CONSUMED.toWindowInsets() : insets; } /** * Adjusts player's controls manually because onApplyWindowInsets doesn't work when multiple * receivers adjust its bounds. So when two listeners are present (like in profile page) * the player's controls will not receive insets. This method fixes it */ @Override public WindowInsets onApplyWindowInsets(final WindowInsets windowInsets) { final var windowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(windowInsets, this); final var insets = windowInsetsCompat.getInsets(WindowInsetsCompat.Type.systemBars()); final ViewGroup controls = findViewById(R.id.playbackControlRoot); if (controls != null) { controls.setPadding(insets.left, insets.top, insets.right, insets.bottom); } return super.onApplyWindowInsets(windowInsets); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/views/FocusAwareDrawerLayout.java ================================================ /* * Copyright (C) Eltex ltd 2019 * FocusAwareDrawerLayout.java is part of NewPipe. * * NewPipe is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * NewPipe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ package org.schabi.newpipe.views; import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Rect; import android.util.AttributeSet; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.drawerlayout.widget.DrawerLayout; import java.util.ArrayList; public final class FocusAwareDrawerLayout extends DrawerLayout { public FocusAwareDrawerLayout(@NonNull final Context context) { super(context); } public FocusAwareDrawerLayout(@NonNull final Context context, @Nullable final AttributeSet attrs) { super(context, attrs); } public FocusAwareDrawerLayout(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyle) { super(context, attrs, defStyle); } @Override protected boolean onRequestFocusInDescendants(final int direction, final Rect previouslyFocusedRect) { // SDK implementation of this method picks whatever visible View takes the focus first // without regard to addFocusables. If the open drawer is temporarily empty, the focus // escapes outside of it, which can be confusing boolean hasOpenPanels = false; for (int i = 0; i < getChildCount(); ++i) { final View child = getChildAt(i); final DrawerLayout.LayoutParams lp = (DrawerLayout.LayoutParams) child.getLayoutParams(); if (lp.gravity != 0 && isDrawerVisible(child)) { hasOpenPanels = true; if (child.requestFocus(direction, previouslyFocusedRect)) { return true; } } } if (hasOpenPanels) { return false; } return super.onRequestFocusInDescendants(direction, previouslyFocusedRect); } @Override public void addFocusables(final ArrayList views, final int direction, final int focusableMode) { boolean hasOpenPanels = false; View content = null; for (int i = 0; i < getChildCount(); ++i) { final View child = getChildAt(i); final DrawerLayout.LayoutParams lp = (DrawerLayout.LayoutParams) child.getLayoutParams(); if (lp.gravity == 0) { content = child; } else { if (isDrawerVisible(child)) { hasOpenPanels = true; child.addFocusables(views, direction, focusableMode); } } } if (content != null && !hasOpenPanels) { content.addFocusables(views, direction, focusableMode); } } // this override isn't strictly necessary, but it is helpful when DrawerLayout isn't // the topmost view in hierarchy (such as when system or builtin appcompat ActionBar is used) @Override @SuppressLint("RtlHardcoded") public void openDrawer(@NonNull final View drawerView, final boolean animate) { super.openDrawer(drawerView, animate); drawerView.requestFocus(FOCUS_FORWARD); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java ================================================ /* * Copyright (C) Eltex ltd 2019 * FocusAwareDrawerLayout.java is part of NewPipe. * * NewPipe is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * NewPipe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ package org.schabi.newpipe.views; import android.content.Context; import android.graphics.Rect; import android.util.AttributeSet; import android.view.KeyEvent; import android.view.ViewTreeObserver; import android.widget.SeekBar; import androidx.appcompat.widget.AppCompatSeekBar; import org.schabi.newpipe.util.DeviceUtils; /** * SeekBar, adapted for directional navigation. It emulates touch-related callbacks * (onStartTrackingTouch/onStopTrackingTouch), so existing code does not need to be changed to * work with it. */ public final class FocusAwareSeekBar extends AppCompatSeekBar { private NestedListener listener; private ViewTreeObserver treeObserver; public FocusAwareSeekBar(final Context context) { super(context); } public FocusAwareSeekBar(final Context context, final AttributeSet attrs) { super(context, attrs); } public FocusAwareSeekBar(final Context context, final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override public void setOnSeekBarChangeListener(final OnSeekBarChangeListener l) { this.listener = l == null ? null : new NestedListener(l); super.setOnSeekBarChangeListener(listener); } @Override public boolean onKeyDown(final int keyCode, final KeyEvent event) { if (!isInTouchMode() && DeviceUtils.isConfirmKey(keyCode)) { releaseTrack(); } return super.onKeyDown(keyCode, event); } @Override protected void onFocusChanged(final boolean gainFocus, final int direction, final Rect previouslyFocusedRect) { super.onFocusChanged(gainFocus, direction, previouslyFocusedRect); if (!isInTouchMode() && !gainFocus) { releaseTrack(); } } private final ViewTreeObserver.OnTouchModeChangeListener touchModeListener = isInTouchMode -> { if (isInTouchMode) { releaseTrack(); } }; @Override protected void onAttachedToWindow() { super.onAttachedToWindow(); treeObserver = getViewTreeObserver(); treeObserver.addOnTouchModeChangeListener(touchModeListener); } @Override protected void onDetachedFromWindow() { if (treeObserver == null || !treeObserver.isAlive()) { treeObserver = getViewTreeObserver(); } treeObserver.removeOnTouchModeChangeListener(touchModeListener); treeObserver = null; super.onDetachedFromWindow(); } private void releaseTrack() { if (listener != null && listener.isSeeking) { listener.onStopTrackingTouch(this); } } private static final class NestedListener implements OnSeekBarChangeListener { private final OnSeekBarChangeListener delegate; boolean isSeeking; private NestedListener(final OnSeekBarChangeListener delegate) { this.delegate = delegate; } @Override public void onProgressChanged(final SeekBar seekBar, final int progress, final boolean fromUser) { if (!seekBar.isInTouchMode() && !isSeeking && fromUser) { isSeeking = true; onStartTrackingTouch(seekBar); } delegate.onProgressChanged(seekBar, progress, fromUser); } @Override public void onStartTrackingTouch(final SeekBar seekBar) { isSeeking = true; delegate.onStartTrackingTouch(seekBar); } @Override public void onStopTrackingTouch(final SeekBar seekBar) { isSeeking = false; delegate.onStopTrackingTouch(seekBar); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/views/FocusOverlayView.java ================================================ /* * Copyright 2019 Alexander Rvachev * FocusOverlayView.java is part of NewPipe * * License: GPL-3.0+ * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package org.schabi.newpipe.views; import android.app.Activity; import android.app.Dialog; import android.content.Context; import android.graphics.Canvas; import android.graphics.ColorFilter; import android.graphics.Paint; import android.graphics.PixelFormat; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.os.Build; import android.os.Handler; import android.os.Looper; import android.os.Message; import android.view.KeyEvent; import android.view.View; import android.view.ViewGroup; import android.view.ViewTreeObserver; import android.view.Window; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import org.schabi.newpipe.R; import java.lang.ref.WeakReference; public final class FocusOverlayView extends Drawable implements ViewTreeObserver.OnGlobalFocusChangeListener, ViewTreeObserver.OnDrawListener, ViewTreeObserver.OnGlobalLayoutListener, ViewTreeObserver.OnScrollChangedListener, ViewTreeObserver.OnTouchModeChangeListener { private boolean isInTouchMode; private final Rect focusRect = new Rect(); private final Paint rectPaint = new Paint(); private final Handler animator = new Handler(Looper.getMainLooper()) { @Override public void handleMessage(final Message msg) { updateRect(); } }; private WeakReference focused; public FocusOverlayView(final Context context) { rectPaint.setStyle(Paint.Style.STROKE); rectPaint.setStrokeWidth(2); rectPaint.setColor(context.getResources().getColor(R.color.white)); } @Override public void onGlobalFocusChanged(final View oldFocus, final View newFocus) { if (newFocus != null) { focused = new WeakReference<>(newFocus); } else { focused = null; } updateRect(); animator.sendEmptyMessageDelayed(0, 1000); } private void updateRect() { final View focusedView = focused == null ? null : this.focused.get(); final int l = focusRect.left; final int r = focusRect.right; final int t = focusRect.top; final int b = focusRect.bottom; if (focusedView != null && isShown(focusedView)) { focusedView.getGlobalVisibleRect(focusRect); } if (shouldClearFocusRect(focusedView, focusRect)) { focusRect.setEmpty(); } if (l != focusRect.left || r != focusRect.right || t != focusRect.top || b != focusRect.bottom) { invalidateSelf(); } } private boolean isShown(@NonNull final View view) { return view.getWidth() != 0 && view.getHeight() != 0 && view.isShown(); } @Override public void onDraw() { updateRect(); } @Override public void onScrollChanged() { updateRect(); animator.removeMessages(0); animator.sendEmptyMessageDelayed(0, 1000); } @Override public void onGlobalLayout() { updateRect(); animator.sendEmptyMessageDelayed(0, 1000); } @Override public void onTouchModeChanged(final boolean inTouchMode) { this.isInTouchMode = inTouchMode; if (inTouchMode) { updateRect(); } else { invalidateSelf(); } } public void setCurrentFocus(final View newFocus) { if (newFocus == null) { return; } this.isInTouchMode = newFocus.isInTouchMode(); onGlobalFocusChanged(null, newFocus); } @Override public void draw(@NonNull final Canvas canvas) { if (!isInTouchMode && focusRect.width() != 0) { canvas.drawRect(focusRect, rectPaint); } } @Override public int getOpacity() { return PixelFormat.TRANSPARENT; } @Override public void setAlpha(final int alpha) { } @Override public void setColorFilter(final ColorFilter colorFilter) { } /* * When any view in the player looses it's focus (after setVisibility(GONE)) the focus gets * added to the whole fragment which has a width and height equal to the window frame. * The easiest way to avoid the unneeded frame is to skip highlighting of rect that is * equal to the overlayView bounds * */ private boolean shouldClearFocusRect(@Nullable final View focusedView, final Rect focusedRect) { return focusedView == null || focusedRect.equals(getBounds()); } public static void setupFocusObserver(final Dialog dialog) { final Rect displayRect = new Rect(); final Window window = dialog.getWindow(); assert window != null; final View decor = window.getDecorView(); decor.getWindowVisibleDisplayFrame(displayRect); final FocusOverlayView overlay = new FocusOverlayView(dialog.getContext()); overlay.setBounds(0, 0, displayRect.width(), displayRect.height()); setupOverlay(window, overlay); } public static void setupFocusObserver(final Activity activity) { final Rect displayRect = new Rect(); final Window window = activity.getWindow(); final View decor = window.getDecorView(); decor.getWindowVisibleDisplayFrame(displayRect); final FocusOverlayView overlay = new FocusOverlayView(activity); overlay.setBounds(0, 0, displayRect.width(), displayRect.height()); setupOverlay(window, overlay); } private static void setupOverlay(final Window window, final FocusOverlayView overlay) { final ViewGroup decor = (ViewGroup) window.getDecorView(); decor.getOverlay().add(overlay); fixFocusHierarchy(decor); final ViewTreeObserver observer = decor.getViewTreeObserver(); observer.addOnScrollChangedListener(overlay); observer.addOnGlobalFocusChangeListener(overlay); observer.addOnGlobalLayoutListener(overlay); observer.addOnTouchModeChangeListener(overlay); observer.addOnDrawListener(overlay); overlay.setCurrentFocus(decor.getFocusedChild()); // Some key presses don't actually move focus, but still result in movement on screen. // For example, MovementMethod of TextView may cause requestRectangleOnScreen() due to // some "focusable" spans, which in turn causes CoordinatorLayout to "scroll" it's children. // Unfortunately many such forms of "scrolling" do not count as scrolling for purpose // of dispatching ViewTreeObserver callbacks, so we have to intercept them by directly // receiving keys from Window. window.setCallback(new SimpleWindowCallback(window.getCallback()) { @Override public boolean dispatchKeyEvent(final KeyEvent event) { final boolean res = super.dispatchKeyEvent(event); overlay.onKey(event); return res; } }); } private void onKey(final KeyEvent event) { if (event.getAction() != KeyEvent.ACTION_DOWN) { return; } updateRect(); animator.sendEmptyMessageDelayed(0, 100); } private static void fixFocusHierarchy(final View decor) { // During Android 8 development some dumb ass decided, that action bar has to be // a keyboard focus cluster. Unfortunately, keyboard clusters do not work for primary // auditory of key navigation — Android TV users (Android TV remotes do not have // keyboard META key for moving between clusters). We have to fix this unfortunate accident // While we are at it, let's deal with touchscreenBlocksFocus too. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { return; } if (!(decor instanceof ViewGroup)) { return; } clearFocusObstacles((ViewGroup) decor); } @RequiresApi(api = Build.VERSION_CODES.O) private static void clearFocusObstacles(final ViewGroup viewGroup) { viewGroup.setTouchscreenBlocksFocus(false); if (viewGroup.isKeyboardNavigationCluster()) { viewGroup.setKeyboardNavigationCluster(false); return; // clusters aren't supposed to nest } final int childCount = viewGroup.getChildCount(); for (int i = 0; i < childCount; ++i) { final View view = viewGroup.getChildAt(i); if (view instanceof ViewGroup) { clearFocusObstacles((ViewGroup) view); } } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/views/NewPipeEditText.java ================================================ package org.schabi.newpipe.views; import android.content.Context; import android.util.AttributeSet; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.AppCompatEditText; import org.schabi.newpipe.util.NewPipeTextViewHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; /** * An {@link AppCompatEditText} which uses {@link ShareUtils#shareText(Context, String, String)} * when sharing selected text by using the {@code Share} command of the floating actions. * *

* This class allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing * text from {@link AppCompatEditText} on EMUI devices. *

*/ public class NewPipeEditText extends AppCompatEditText { public NewPipeEditText(@NonNull final Context context) { super(context); } public NewPipeEditText(@NonNull final Context context, @Nullable final AttributeSet attrs) { super(context, attrs); } public NewPipeEditText(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override public boolean onTextContextMenuItem(final int id) { if (id == android.R.id.shareText) { NewPipeTextViewHelper.shareSelectedTextWithShareUtils(this); return true; } return super.onTextContextMenuItem(id); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/views/NewPipeRecyclerView.java ================================================ /* * Copyright (C) Eltex ltd 2019 * NewPipeRecyclerView.java is part of NewPipe. * * NewPipe is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * NewPipe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ package org.schabi.newpipe.views; import android.content.Context; import android.graphics.Rect; import android.os.Build; import android.util.AttributeSet; import android.util.Log; import android.view.FocusFinder; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; public class NewPipeRecyclerView extends RecyclerView { private static final String TAG = "NewPipeRecyclerView"; private final Rect focusRect = new Rect(); private final Rect tempFocus = new Rect(); private boolean allowDpadScroll = true; public NewPipeRecyclerView(@NonNull final Context context) { super(context); init(); } public NewPipeRecyclerView(@NonNull final Context context, @Nullable final AttributeSet attrs) { super(context, attrs); init(); } public NewPipeRecyclerView(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyle) { super(context, attrs, defStyle); init(); } private void init() { setFocusable(true); setDescendantFocusability(FOCUS_AFTER_DESCENDANTS); } public void setFocusScrollAllowed(final boolean allowed) { this.allowDpadScroll = allowed; } @Override public View focusSearch(final View focused, final int direction) { // RecyclerView has buggy focusSearch(), that calls into Adapter several times, // but ultimately fails to produce correct results in many cases. To add insult to injury, // it's focusSearch() hard-codes several behaviors, incompatible with widely accepted focus // handling practices: RecyclerView does not allow Adapter to give focus to itself (!!) and // always checks, that returned View is located in "correct" direction (which prevents us // from temporarily giving focus to special hidden View). return null; } @Override protected void removeDetachedView(final View child, final boolean animate) { if (child.hasFocus()) { // If the focused child is being removed (can happen during very fast scrolling), // temporarily give focus to ourselves. This will usually result in another child // gaining focus (which one does not really matter, because at that point scrolling // is FAST, and that child will soon be off-screen too) requestFocus(); } super.removeDetachedView(child, animate); } // we override focusSearch to always return null, so all moves moves lead to // dispatchUnhandledMove(). As added advantage, we can fully swallow some kinds of moves // (such as downward movement, that happens when loading additional contents is in progress @Override public boolean dispatchUnhandledMove(final View focused, final int direction) { tempFocus.setEmpty(); // save focus rect before further manipulation (both focusSearch() and scrollBy() // can mess with focused View by moving it off-screen and detaching) if (focused != null) { final View focusedItem = findContainingItemView(focused); if (focusedItem != null) { focusedItem.getHitRect(focusRect); } } // call focusSearch() to initiate layout, but disregard returned View for now final View adapterResult = super.focusSearch(focused, direction); if (adapterResult != null && !isOutside(adapterResult)) { adapterResult.requestFocus(direction); return true; } if (arrowScroll(direction)) { // if RecyclerView can not yield focus, but there is still some scrolling space in // indicated, direction, scroll some fixed amount in that direction // (the same logic in ScrollView) return true; } if (focused != this && direction == FOCUS_DOWN && !allowDpadScroll) { Log.i(TAG, "Consuming downward scroll: content load in progress"); return true; } if (tryFocusFinder(direction)) { return true; } if (adapterResult != null) { adapterResult.requestFocus(direction); return true; } return super.dispatchUnhandledMove(focused, direction); } private boolean tryFocusFinder(final int direction) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { // Android 9 implemented bunch of handy changes to focus, that render code below less // useful, and also broke findNextFocusFromRect in way, that render this hack useless return false; } final FocusFinder finder = FocusFinder.getInstance(); // try to use FocusFinder instead of adapter final ViewGroup root = (ViewGroup) getRootView(); tempFocus.set(focusRect); root.offsetDescendantRectToMyCoords(this, tempFocus); final View focusFinderResult = finder.findNextFocusFromRect(root, tempFocus, direction); if (focusFinderResult != null && !isOutside(focusFinderResult)) { focusFinderResult.requestFocus(direction); return true; } // look for focus in our ancestors, increasing search scope with each failure // this provides much better locality than using FocusFinder with root ViewGroup parent = (ViewGroup) getParent(); while (parent != root) { tempFocus.set(focusRect); parent.offsetDescendantRectToMyCoords(this, tempFocus); final View candidate = finder.findNextFocusFromRect(parent, tempFocus, direction); if (candidate != null && candidate.requestFocus(direction)) { return true; } parent = (ViewGroup) parent.getParent(); } return false; } private boolean arrowScroll(final int direction) { switch (direction) { case FOCUS_DOWN: if (!canScrollVertically(1)) { return false; } scrollBy(0, 100); break; case FOCUS_UP: if (!canScrollVertically(-1)) { return false; } scrollBy(0, -100); break; case FOCUS_LEFT: if (!canScrollHorizontally(-1)) { return false; } scrollBy(-100, 0); break; case FOCUS_RIGHT: if (!canScrollHorizontally(-1)) { return false; } scrollBy(100, 0); break; default: return false; } return true; } private boolean isOutside(final View view) { return findContainingItemView(view) == null; } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/views/NewPipeTextView.java ================================================ package org.schabi.newpipe.views; import android.content.Context; import android.text.method.MovementMethod; import android.util.AttributeSet; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.widget.AppCompatTextView; import org.schabi.newpipe.util.NewPipeTextViewHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; /** * An {@link AppCompatTextView} which uses {@link ShareUtils#shareText(Context, String, String)} * when sharing selected text by using the {@code Share} command of the floating actions. * *

* This class allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing * text from {@link AppCompatTextView} on EMUI devices and also to keep movement method set when a * text change occurs, if the text cannot be selected and text links are clickable. *

*/ public class NewPipeTextView extends AppCompatTextView { public NewPipeTextView(@NonNull final Context context) { super(context); } public NewPipeTextView(@NonNull final Context context, @Nullable final AttributeSet attrs) { super(context, attrs); } public NewPipeTextView(@NonNull final Context context, @Nullable final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override public void setText(final CharSequence text, final BufferType type) { // We need to set again the movement method after a text change because Android resets the // movement method to the default one in the case where the text cannot be selected and // text links are clickable (which is the default case in NewPipe). final MovementMethod movementMethod = this.getMovementMethod(); super.setText(text, type); setMovementMethod(movementMethod); } @Override public boolean onTextContextMenuItem(final int id) { if (id == android.R.id.shareText) { NewPipeTextViewHelper.shareSelectedTextWithShareUtils(this); return true; } return super.onTextContextMenuItem(id); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/views/ScrollableTabLayout.java ================================================ package org.schabi.newpipe.views; import android.content.Context; import android.util.AttributeSet; import android.view.View; import androidx.annotation.NonNull; import com.google.android.material.tabs.TabLayout; /** * A TabLayout that is scrollable when tabs exceed its width. * Hides when there are less than 2 tabs. */ public class ScrollableTabLayout extends TabLayout { private static final String TAG = ScrollableTabLayout.class.getSimpleName(); private int layoutWidth = 0; private int prevVisibility = View.GONE; public ScrollableTabLayout(final Context context) { super(context); } public ScrollableTabLayout(final Context context, final AttributeSet attrs) { super(context, attrs); } public ScrollableTabLayout(final Context context, final AttributeSet attrs, final int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override protected void onLayout(final boolean changed, final int l, final int t, final int r, final int b) { super.onLayout(changed, l, t, r, b); remeasureTabs(); } @Override protected void onSizeChanged(final int w, final int h, final int oldw, final int oldh) { super.onSizeChanged(w, h, oldw, oldh); layoutWidth = w; } @Override public void addTab(@NonNull final Tab tab, final int position, final boolean setSelected) { super.addTab(tab, position, setSelected); hasMultipleTabs(); // Adding a tab won't decrease total tabs' width so tabMode won't have to change to FIXED if (getTabMode() != MODE_SCROLLABLE) { remeasureTabs(); } } @Override public void removeTabAt(final int position) { super.removeTabAt(position); hasMultipleTabs(); // Removing a tab won't increase total tabs' width // so tabMode won't have to change to SCROLLABLE if (getTabMode() != MODE_FIXED) { remeasureTabs(); } } @Override protected void onVisibilityChanged(final View changedView, final int visibility) { super.onVisibilityChanged(changedView, visibility); // Check width if some tabs have been added/removed while ScrollableTabLayout was invisible // We don't have to check if it was GONE because then requestLayout() will be called if (changedView == this) { if (prevVisibility == View.INVISIBLE) { remeasureTabs(); } prevVisibility = visibility; } } private void setMode(final int mode) { if (mode == getTabMode()) { return; } setTabMode(mode); } /** * Make ScrollableTabLayout not visible if there are less than two tabs. */ private void hasMultipleTabs() { if (getTabCount() > 1) { setVisibility(View.VISIBLE); } else { setVisibility(View.GONE); } } /** * Calculate minimal width required by tabs and set tabMode accordingly. */ private void remeasureTabs() { if (prevVisibility != View.VISIBLE) { return; } if (layoutWidth == 0) { return; } final int count = getTabCount(); int contentWidth = 0; for (int i = 0; i < count; i++) { final View child = getTabAt(i).view; if (child.getVisibility() == View.VISIBLE) { // Use tab's minimum requested width should actual content be too small contentWidth += Math.max(child.getMinimumWidth(), child.getMeasuredWidth()); } } if (contentWidth > layoutWidth) { setMode(TabLayout.MODE_SCROLLABLE); } else { setMode(TabLayout.MODE_FIXED); } } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/views/SimpleWindowCallback.kt ================================================ /* * SPDX-FileCopyrightText: 2026 NewPipe e.V. * SPDX-License-Identifier: GPL-3.0-or-later */ package org.schabi.newpipe.views import android.os.Build import android.view.KeyEvent import android.view.KeyboardShortcutGroup import android.view.Menu import android.view.Window import androidx.annotation.RequiresApi /** * Simple window callback class to allow intercepting key events * @see FocusOverlayView.setupOverlay */ open class SimpleWindowCallback(private val baseCallback: Window.Callback) : Window.Callback by baseCallback { override fun dispatchKeyEvent(event: KeyEvent?): Boolean { return baseCallback.dispatchKeyEvent(event) } @RequiresApi(Build.VERSION_CODES.O) override fun onPointerCaptureChanged(hasCapture: Boolean) { baseCallback.onPointerCaptureChanged(hasCapture) } @RequiresApi(Build.VERSION_CODES.N) override fun onProvideKeyboardShortcuts( data: List?, menu: Menu?, deviceId: Int ) { baseCallback.onProvideKeyboardShortcuts(data, menu, deviceId) } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/views/SuperScrollLayoutManager.java ================================================ /* * Copyright (C) Eltex ltd 2019 * SuperScrollLayoutManager.java is part of NewPipe. * * NewPipe is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * NewPipe is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ package org.schabi.newpipe.views; import android.content.Context; import android.graphics.Rect; import android.view.View; import android.view.ViewGroup; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import java.util.ArrayList; public final class SuperScrollLayoutManager extends LinearLayoutManager { private final Rect handy = new Rect(); private final ArrayList focusables = new ArrayList<>(); public SuperScrollLayoutManager(final Context context) { super(context); } @Override public boolean requestChildRectangleOnScreen(@NonNull final RecyclerView parent, @NonNull final View child, @NonNull final Rect rect, final boolean immediate, final boolean focusedChildVisible) { if (!parent.isInTouchMode()) { // only activate when in directional navigation mode (Android TV etc) — fine grained // touch scrolling is better served by nested scroll system if (!focusedChildVisible || getFocusedChild() == child) { handy.set(rect); parent.offsetDescendantRectToMyCoords(child, handy); parent.requestRectangleOnScreen(handy, immediate); } } return super.requestChildRectangleOnScreen(parent, child, rect, immediate, focusedChildVisible); } @Nullable @Override public View onInterceptFocusSearch(@NonNull final View focused, final int direction) { final View focusedItem = findContainingItemView(focused); if (focusedItem == null) { return super.onInterceptFocusSearch(focused, direction); } final int listDirection = getAbsoluteDirection(direction); if (listDirection == 0) { return super.onInterceptFocusSearch(focused, direction); } // FocusFinder has an oddity: it considers size of Views more important // than closeness to source View. This means, that big Views far away from current item // are preferred to smaller sub-View of closer item. Setting focusability of closer item // to FOCUS_AFTER_DESCENDANTS does not solve this, because ViewGroup#addFocusables omits // such parent itself from list, if any of children are focusable. // Fortunately we can intercept focus search and implement our own logic, based purely // on position along the LinearLayoutManager axis final ViewGroup recycler = (ViewGroup) focusedItem.getParent(); final int sourcePosition = getPosition(focusedItem); if (sourcePosition == 0 && listDirection < 0) { return super.onInterceptFocusSearch(focused, direction); } View preferred = null; int distance = Integer.MAX_VALUE; focusables.clear(); recycler.addFocusables(focusables, direction, recycler.isInTouchMode() ? View.FOCUSABLES_TOUCH_MODE : View.FOCUSABLES_ALL); try { for (final View view : focusables) { if (view == focused || view == recycler) { continue; } if (view == focusedItem) { // do not pass focus back to the item View itself - it makes no sense // (we can still pass focus to it's children however) continue; } final int candidate = getDistance(sourcePosition, view, listDirection); if (candidate < 0) { continue; } if (candidate < distance) { distance = candidate; preferred = view; } } } finally { focusables.clear(); } return preferred; } private int getAbsoluteDirection(final int direction) { switch (direction) { default: break; case View.FOCUS_FORWARD: return 1; case View.FOCUS_BACKWARD: return -1; } if (getOrientation() == RecyclerView.HORIZONTAL) { switch (direction) { default: break; case View.FOCUS_LEFT: return getReverseLayout() ? 1 : -1; case View.FOCUS_RIGHT: return getReverseLayout() ? -1 : 1; } } else { switch (direction) { default: break; case View.FOCUS_UP: return getReverseLayout() ? 1 : -1; case View.FOCUS_DOWN: return getReverseLayout() ? -1 : 1; } } return 0; } private int getDistance(final int sourcePosition, final View candidate, final int direction) { final View itemView = findContainingItemView(candidate); if (itemView == null) { return -1; } final int position = getPosition(itemView); return direction * (position - sourcePosition); } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/views/player/CircleClipTapView.kt ================================================ package org.schabi.newpipe.views.player import android.content.Context import android.graphics.Canvas import android.graphics.Paint import android.graphics.Path import android.util.AttributeSet import android.view.View class CircleClipTapView(context: Context?, attrs: AttributeSet) : View(context, attrs) { private var backgroundPaint = Paint() private var widthPx = 0 private var heightPx = 0 // Background private var shapePath = Path() private var arcSize: Float = 80f private var isLeft = true init { requireNotNull(context) { "Context is null." } backgroundPaint.apply { style = Paint.Style.FILL isAntiAlias = true color = 0x30000000 } val dm = context.resources.displayMetrics widthPx = dm.widthPixels heightPx = dm.heightPixels updatePathShape() } fun updateArcSize(baseView: View) { val newArcSize = baseView.height / 11.4f if (arcSize != newArcSize) { arcSize = newArcSize updatePathShape() } } fun updatePosition(newIsLeft: Boolean) { if (isLeft != newIsLeft) { isLeft = newIsLeft updatePathShape() } } private fun updatePathShape() { val halfWidth = widthPx * 0.5f shapePath.reset() val w = if (isLeft) 0f else widthPx.toFloat() val f = if (isLeft) 1 else -1 shapePath.moveTo(w, 0f) shapePath.lineTo(f * (halfWidth - arcSize) + w, 0f) shapePath.quadTo( f * (halfWidth + arcSize) + w, heightPx.toFloat() / 2, f * (halfWidth - arcSize) + w, heightPx.toFloat() ) shapePath.lineTo(w, heightPx.toFloat()) shapePath.close() invalidate() } override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { super.onSizeChanged(w, h, oldw, oldh) widthPx = w heightPx = h updatePathShape() } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) canvas.clipPath(shapePath) canvas.drawPath(shapePath, backgroundPaint) } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/views/player/PlayerFastSeekOverlay.kt ================================================ package org.schabi.newpipe.views.player import android.content.Context import android.util.AttributeSet import android.util.Log import android.view.LayoutInflater import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.END import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID import androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.START import androidx.constraintlayout.widget.ConstraintSet import org.schabi.newpipe.MainActivity import org.schabi.newpipe.R import org.schabi.newpipe.player.gesture.DisplayPortion import org.schabi.newpipe.player.gesture.DoubleTapListener class PlayerFastSeekOverlay(context: Context, attrs: AttributeSet?) : ConstraintLayout(context, attrs), DoubleTapListener { private var secondsView: SecondsView private var circleClipTapView: CircleClipTapView private var rootConstraintLayout: ConstraintLayout private var wasForwarding: Boolean = false init { LayoutInflater.from(context).inflate(R.layout.player_fast_seek_overlay, this, true) secondsView = findViewById(R.id.seconds_view) circleClipTapView = findViewById(R.id.circle_clip_tap_view) rootConstraintLayout = findViewById(R.id.root_constraint_layout) addOnLayoutChangeListener { view, _, _, _, _, _, _, _, _ -> circleClipTapView.updateArcSize(view) } } private var performListener: PerformListener? = null fun performListener(listener: PerformListener?) = apply { performListener = listener } private var seekSecondsSupplier: () -> Int = { 0 } fun seekSecondsSupplier(supplier: (() -> Int)?) = apply { seekSecondsSupplier = supplier ?: { 0 } } // Indicates whether this (double) tap is the first of a series // Decides whether to call performListener.onAnimationStart or not private var initTap: Boolean = false override fun onDoubleTapStarted(portion: DisplayPortion) { if (DEBUG) { Log.d(TAG, "onDoubleTapStarted called with portion = [$portion]") } initTap = false secondsView.stopAnimation() } override fun onDoubleTapProgressDown(portion: DisplayPortion) { val shouldForward: Boolean = performListener?.getFastSeekDirection(portion)?.directionAsBoolean ?: return if (DEBUG) { Log.d( TAG, "onDoubleTapProgressDown called with " + "shouldForward = [$shouldForward], " + "wasForwarding = [$wasForwarding], " + "initTap = [$initTap], " ) } /* * Check if a initial tap occurred or if direction was switched */ if (!initTap || wasForwarding != shouldForward) { // Reset seconds and update position secondsView.seconds = 0 changeConstraints(shouldForward) circleClipTapView.updatePosition(!shouldForward) secondsView.setForwarding(shouldForward) wasForwarding = shouldForward if (!initTap) { initTap = true } } performListener?.onDoubleTap() secondsView.seconds += seekSecondsSupplier.invoke() performListener?.seek(forward = shouldForward) } override fun onDoubleTapFinished() { if (DEBUG) { Log.d(TAG, "onDoubleTapFinished called with initTap = [$initTap]") } if (initTap) performListener?.onDoubleTapEnd() initTap = false secondsView.stopAnimation() } private fun changeConstraints(forward: Boolean) { val constraintSet = ConstraintSet() with(constraintSet) { clone(rootConstraintLayout) clear(secondsView.id, if (forward) START else END) connect( secondsView.id, if (forward) END else START, PARENT_ID, if (forward) END else START ) secondsView.startAnimation() applyTo(rootConstraintLayout) } } interface PerformListener { fun onDoubleTap() fun onDoubleTapEnd() /** * Determines if the playback should forward/rewind or do nothing. */ fun getFastSeekDirection(portion: DisplayPortion): FastSeekDirection fun seek(forward: Boolean) enum class FastSeekDirection(val directionAsBoolean: Boolean?) { NONE(null), FORWARD(true), BACKWARD(false) } } companion object { private const val TAG = "PlayerFastSeekOverlay" private val DEBUG = MainActivity.DEBUG } } ================================================ FILE: app/src/main/java/org/schabi/newpipe/views/player/SecondsView.kt ================================================ package org.schabi.newpipe.views.player import android.animation.ValueAnimator import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.widget.LinearLayout import androidx.core.animation.addListener import org.schabi.newpipe.R import org.schabi.newpipe.databinding.PlayerFastSeekSecondsViewBinding import org.schabi.newpipe.util.DeviceUtils class SecondsView(context: Context, attrs: AttributeSet?) : LinearLayout(context, attrs) { companion object { const val ICON_ANIMATION_DURATION = 750L } var cycleDuration: Long = ICON_ANIMATION_DURATION set(value) { firstAnimator.duration = value / 5 secondAnimator.duration = value / 5 thirdAnimator.duration = value / 5 fourthAnimator.duration = value / 5 fifthAnimator.duration = value / 5 field = value } var seconds: Int = 0 set(value) { binding.tvSeconds.text = context.resources.getQuantityString( R.plurals.seconds, value, value ) field = value } // Done as a field so that we don't have to compute on each tab if animations are enabled private val animationsEnabled = DeviceUtils.hasAnimationsAnimatorDurationEnabled(context) val binding = PlayerFastSeekSecondsViewBinding.inflate(LayoutInflater.from(context), this) init { orientation = VERTICAL layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT) } fun setForwarding(isForward: Boolean) { binding.triangleContainer.rotation = if (isForward) 0f else 180f } fun startAnimation() { stopAnimation() if (animationsEnabled) { firstAnimator.start() } else { // If no animations are enable show the arrow(s) without animation showWithoutAnimation() } } fun stopAnimation() { firstAnimator.cancel() secondAnimator.cancel() thirdAnimator.cancel() fourthAnimator.cancel() fifthAnimator.cancel() reset() } private fun reset() { binding.icon1.alpha = 0f binding.icon2.alpha = 0f binding.icon3.alpha = 0f } private fun showWithoutAnimation() { binding.icon1.alpha = 1f binding.icon2.alpha = 1f binding.icon3.alpha = 1f } private val firstAnimator: ValueAnimator = CustomValueAnimator( { binding.icon1.alpha = 0f binding.icon2.alpha = 0f binding.icon3.alpha = 0f }, { binding.icon1.alpha = it }, { secondAnimator.start() } ) private val secondAnimator: ValueAnimator = CustomValueAnimator( { binding.icon1.alpha = 1f binding.icon2.alpha = 0f binding.icon3.alpha = 0f }, { binding.icon2.alpha = it }, { thirdAnimator.start() } ) private val thirdAnimator: ValueAnimator = CustomValueAnimator( { binding.icon1.alpha = 1f binding.icon2.alpha = 1f binding.icon3.alpha = 0f }, { binding.icon1.alpha = 1f - binding.icon3.alpha binding.icon3.alpha = it }, { fourthAnimator.start() } ) private val fourthAnimator: ValueAnimator = CustomValueAnimator( { binding.icon1.alpha = 0f binding.icon2.alpha = 1f binding.icon3.alpha = 1f }, { binding.icon2.alpha = 1f - it }, { fifthAnimator.start() } ) private val fifthAnimator: ValueAnimator = CustomValueAnimator( { binding.icon1.alpha = 0f binding.icon2.alpha = 0f binding.icon3.alpha = 1f }, { binding.icon3.alpha = 1f - it }, { firstAnimator.start() } ) private inner class CustomValueAnimator( start: () -> Unit, update: (value: Float) -> Unit, end: () -> Unit ) : ValueAnimator() { init { duration = cycleDuration / 5 setFloatValues(0f, 1f) addUpdateListener { update(it.animatedValue as Float) } addListener( onStart = { start() }, onEnd = { end() } ) } } } ================================================ FILE: app/src/main/java/us/shandian/giga/get/DownloadInitializer.java ================================================ package us.shandian.giga.get; import android.text.TextUtils; import android.util.Log; import androidx.annotation.NonNull; import org.schabi.newpipe.streams.io.SharpStream; import java.io.IOException; import java.io.InterruptedIOException; import java.net.HttpURLConnection; import java.nio.channels.ClosedByInterruptException; import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_FORBIDDEN; public class DownloadInitializer extends Thread { private static final String TAG = "DownloadInitializer"; static final int mId = 0; private static final int RESERVE_SPACE_DEFAULT = 5 * 1024 * 1024;// 5 MiB private static final int RESERVE_SPACE_MAXIMUM = 150 * 1024 * 1024;// 150 MiB private final DownloadMission mMission; private HttpURLConnection mConn; DownloadInitializer(@NonNull DownloadMission mission) { mMission = mission; mConn = null; } private void dispose() { try { mConn.getInputStream().close(); } catch (Exception e) { // nothing to do } } @Override public void run() { if (mMission.current > 0) mMission.resetState(false, true, DownloadMission.ERROR_NOTHING); int retryCount = 0; int httpCode = 204; while (true) { try { if (mMission.blocks == null && mMission.current == 0) { // calculate the whole size of the mission long finalLength = 0; long lowestSize = Long.MAX_VALUE; for (int i = 0; i < mMission.urls.length && mMission.running; i++) { mConn = mMission.openConnection(mMission.urls[i], true, 0, 0); mMission.establishConnection(mId, mConn); dispose(); if (Thread.interrupted()) return; long length = Utility.getTotalContentLength(mConn); if (i == 0) { httpCode = mConn.getResponseCode(); mMission.length = length; } if (length > 0) finalLength += length; if (length < lowestSize) lowestSize = length; } mMission.nearLength = finalLength; // reserve space at the start of the file if (mMission.psAlgorithm != null && mMission.psAlgorithm.reserveSpace) { if (lowestSize < 1) { // the length is unknown use the default size mMission.offsets[0] = RESERVE_SPACE_DEFAULT; } else { // use the smallest resource size to download, otherwise, use the maximum mMission.offsets[0] = lowestSize < RESERVE_SPACE_MAXIMUM ? lowestSize : RESERVE_SPACE_MAXIMUM; } } } else { // ask for the current resource length mConn = mMission.openConnection(true, 0, 0); mMission.establishConnection(mId, mConn); dispose(); if (!mMission.running || Thread.interrupted()) return; httpCode = mConn.getResponseCode(); mMission.length = Utility.getTotalContentLength(mConn); } if (mMission.length == 0 || httpCode == 204) { mMission.notifyError(DownloadMission.ERROR_HTTP_NO_CONTENT, null); return; } // check for dynamic generated content if (mMission.length == -1 && mConn.getResponseCode() == 200) { mMission.blocks = new int[0]; mMission.length = 0; mMission.unknownLength = true; if (DEBUG) { Log.d(TAG, "falling back (unknown length)"); } } else { // Open again mConn = mMission.openConnection(true, mMission.length - 10, mMission.length); mMission.establishConnection(mId, mConn); dispose(); if (!mMission.running || Thread.interrupted()) return; synchronized (mMission.LOCK) { if (mConn.getResponseCode() == 206) { if (mMission.threadCount > 1) { int count = (int) (mMission.length / DownloadMission.BLOCK_SIZE); if ((count * DownloadMission.BLOCK_SIZE) < mMission.length) count++; mMission.blocks = new int[count]; } else { // if one thread is required don't calculate blocks, is useless mMission.blocks = new int[0]; mMission.unknownLength = false; } if (DEBUG) { Log.d(TAG, "http response code = " + mConn.getResponseCode()); } } else { // Fallback to single thread mMission.blocks = new int[0]; mMission.unknownLength = false; if (DEBUG) { Log.d(TAG, "falling back due http response code = " + mConn.getResponseCode()); } } } if (!mMission.running || Thread.interrupted()) return; } try (SharpStream fs = mMission.storage.getStream()) { fs.setLength(mMission.offsets[mMission.current] + mMission.length); fs.seek(mMission.offsets[mMission.current]); } if (!mMission.running || Thread.interrupted()) return; if (!mMission.unknownLength && mMission.recoveryInfo != null) { String entityTag = mConn.getHeaderField("ETAG"); String lastModified = mConn.getHeaderField("Last-Modified"); MissionRecoveryInfo recovery = mMission.recoveryInfo[mMission.current]; if (!TextUtils.isEmpty(entityTag)) { recovery.setValidateCondition(entityTag); } else if (!TextUtils.isEmpty(lastModified)) { recovery.setValidateCondition(lastModified);// Note: this is less precise } else { recovery.setValidateCondition(null); } } mMission.running = false; break; } catch (InterruptedIOException | ClosedByInterruptException e) { return; } catch (Exception e) { if (!mMission.running || super.isInterrupted()) return; if (e instanceof DownloadMission.HttpError && ((DownloadMission.HttpError) e).statusCode == ERROR_HTTP_FORBIDDEN) { // for youtube streams. The url has expired interrupt(); mMission.doRecover(ERROR_HTTP_FORBIDDEN); return; } if (e instanceof IOException && e.getMessage().contains("Permission denied")) { mMission.notifyError(DownloadMission.ERROR_PERMISSION_DENIED, e); return; } if (retryCount++ > mMission.maxRetry) { Log.e(TAG, "initializer failed", e); mMission.notifyError(e); return; } Log.e(TAG, "initializer failed, retrying", e); } } mMission.start(); } @Override public void interrupt() { super.interrupt(); if (mConn != null) dispose(); } } ================================================ FILE: app/src/main/java/us/shandian/giga/get/DownloadMission.java ================================================ package us.shandian.giga.get; import android.os.Handler; import android.system.ErrnoException; import android.system.OsConstants; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import org.schabi.newpipe.DownloaderImpl; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InterruptedIOException; import java.io.Serializable; import java.net.ConnectException; import java.net.HttpURLConnection; import java.net.SocketTimeoutException; import java.net.URL; import java.net.UnknownHostException; import java.nio.channels.ClosedByInterruptException; import java.util.Objects; import javax.net.ssl.SSLException; import org.schabi.newpipe.streams.io.StoredFileHelper; import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; public class DownloadMission extends Mission { private static final long serialVersionUID = 6L;// last bump: 07 october 2019 static final int BUFFER_SIZE = 64 * 1024; static final int BLOCK_SIZE = 512 * 1024; private static final String TAG = "DownloadMission"; public static final int ERROR_NOTHING = -1; public static final int ERROR_PATH_CREATION = 1000; public static final int ERROR_FILE_CREATION = 1001; public static final int ERROR_UNKNOWN_EXCEPTION = 1002; public static final int ERROR_PERMISSION_DENIED = 1003; public static final int ERROR_SSL_EXCEPTION = 1004; public static final int ERROR_UNKNOWN_HOST = 1005; public static final int ERROR_CONNECT_HOST = 1006; public static final int ERROR_POSTPROCESSING = 1007; public static final int ERROR_POSTPROCESSING_STOPPED = 1008; public static final int ERROR_POSTPROCESSING_HOLD = 1009; public static final int ERROR_INSUFFICIENT_STORAGE = 1010; public static final int ERROR_PROGRESS_LOST = 1011; public static final int ERROR_TIMEOUT = 1012; public static final int ERROR_RESOURCE_GONE = 1013; public static final int ERROR_HTTP_NO_CONTENT = 204; static final int ERROR_HTTP_FORBIDDEN = 403; /** * The urls of the file to download */ public String[] urls; /** * Number of bytes downloaded and written */ public volatile long done; /** * Indicates a file generated dynamically on the web server */ public boolean unknownLength; /** * offset in the file where the data should be written */ public long[] offsets; /** * Indicates if the post-processing state: * 0: ready * 1: running * 2: completed * 3: hold */ public volatile int psState; /** * the post-processing algorithm instance */ public Postprocessing psAlgorithm; /** * The current resource to download, {@code urls[current]} and {@code offsets[current]} */ public int current; /** * Metadata where the mission state is saved */ public transient File metadata; /** * maximum attempts */ public transient int maxRetry; /** * Approximated final length, this represent the sum of all resources sizes */ public long nearLength; /** * Download blocks, the size is multiple of {@link DownloadMission#BLOCK_SIZE}. * Every entry (block) in this array holds an offset, used to resume the download. * An block offset can be -1 if the block was downloaded successfully. */ int[] blocks; /** * Download/File resume offset in fallback mode (if applicable) {@link DownloadRunnableFallback} */ volatile long fallbackResumeOffset; /** * Maximum of download threads running, chosen by the user */ public int threadCount = 3; /** * information required to recover a download */ public MissionRecoveryInfo[] recoveryInfo; private transient int finishCount; public transient volatile boolean running; public boolean enqueued; public int errCode = ERROR_NOTHING; public Exception errObject = null; public transient Handler mHandler; private transient boolean[] blockAcquired; private transient long writingToFileNext; private transient volatile boolean writingToFile; final Object LOCK = new Lock(); @NonNull public transient Thread[] threads = new Thread[0]; public transient Thread init = null; public DownloadMission(String[] urls, StoredFileHelper storage, char kind, Postprocessing psInstance) { if (Objects.requireNonNull(urls).length < 1) throw new IllegalArgumentException("urls array is empty"); this.urls = urls; this.kind = kind; this.offsets = new long[urls.length]; this.enqueued = true; this.maxRetry = 3; this.storage = storage; this.psAlgorithm = psInstance; if (DEBUG && psInstance == null && urls.length > 1) { Log.w(TAG, "mission created with multiple urls ¿missing post-processing algorithm?"); } } /** * Acquire a block * * @return the block or {@code null} if no more blocks left */ @Nullable Block acquireBlock() { synchronized (LOCK) { for (int i = 0; i < blockAcquired.length; i++) { if (!blockAcquired[i] && blocks[i] >= 0) { Block block = new Block(); block.position = i; block.done = blocks[i]; blockAcquired[i] = true; return block; } } } return null; } /** * Release an block * * @param position the index of the block * @param done amount of bytes downloaded */ void releaseBlock(int position, int done) { synchronized (LOCK) { blockAcquired[position] = false; blocks[position] = done; } } /** * Opens a connection * * @param headRequest {@code true} for use {@code HEAD} request method, otherwise, {@code GET} is used * @param rangeStart range start * @param rangeEnd range end * @return a {@link java.net.URLConnection URLConnection} linking to the URL. * @throws IOException if an I/O exception occurs. */ HttpURLConnection openConnection(boolean headRequest, long rangeStart, long rangeEnd) throws IOException { return openConnection(urls[current], headRequest, rangeStart, rangeEnd); } HttpURLConnection openConnection(String url, boolean headRequest, long rangeStart, long rangeEnd) throws IOException { HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); conn.setInstanceFollowRedirects(true); conn.setRequestProperty("User-Agent", DownloaderImpl.USER_AGENT); conn.setRequestProperty("Accept", "*/*"); conn.setRequestProperty("Accept-Encoding", "*"); if (headRequest) conn.setRequestMethod("HEAD"); // BUG workaround: switching between networks can freeze the download forever conn.setConnectTimeout(30000); if (rangeStart >= 0) { String req = "bytes=" + rangeStart + "-"; if (rangeEnd > 0) req += rangeEnd; conn.setRequestProperty("Range", req); } return conn; } /** * @param threadId id of the calling thread * @param conn Opens and establish the communication * @throws IOException if an error occurred connecting to the server. * @throws HttpError if the HTTP Status-Code is not satisfiable */ void establishConnection(int threadId, HttpURLConnection conn) throws IOException, HttpError { int statusCode = conn.getResponseCode(); if (DEBUG) { Log.d(TAG, threadId + ":[request] Range=" + conn.getRequestProperty("Range")); Log.d(TAG, threadId + ":[response] Code=" + statusCode); Log.d(TAG, threadId + ":[response] Content-Length=" + conn.getContentLength()); Log.d(TAG, threadId + ":[response] Content-Range=" + conn.getHeaderField("Content-Range")); } switch (statusCode) { case 204: case 205: case 207: throw new HttpError(statusCode); case 416: return;// let the download thread handle this error default: if (statusCode < 200 || statusCode > 299) { throw new HttpError(statusCode); } } } private void notify(int what) { mHandler.obtainMessage(what, this).sendToTarget(); } synchronized void notifyProgress(long deltaLen) { if (unknownLength) { length += deltaLen;// Update length before proceeding } done += deltaLen; if (metadata == null) return; if (!writingToFile && (done > writingToFileNext || deltaLen < 0)) { writingToFile = true; writingToFileNext = done + BLOCK_SIZE; writeThisToFileAsync(); } } synchronized void notifyError(Exception err) { Log.e(TAG, "notifyError()", err); if (err instanceof FileNotFoundException) { notifyError(ERROR_FILE_CREATION, null); } else if (err instanceof SSLException) { notifyError(ERROR_SSL_EXCEPTION, null); } else if (err instanceof HttpError) { notifyError(((HttpError) err).statusCode, null); } else if (err instanceof ConnectException) { notifyError(ERROR_CONNECT_HOST, null); } else if (err instanceof UnknownHostException) { notifyError(ERROR_UNKNOWN_HOST, null); } else if (err instanceof SocketTimeoutException) { notifyError(ERROR_TIMEOUT, null); } else { notifyError(ERROR_UNKNOWN_EXCEPTION, err); } } public synchronized void notifyError(int code, Exception err) { Log.e(TAG, "notifyError() code = " + code, err); if (err != null && err.getCause() instanceof ErrnoException) { int errno = ((ErrnoException) err.getCause()).errno; if (errno == OsConstants.ENOSPC) { code = ERROR_INSUFFICIENT_STORAGE; err = null; } else if (errno == OsConstants.EACCES) { code = ERROR_PERMISSION_DENIED; err = null; } } if (err instanceof IOException) { if (err.getMessage().contains("Permission denied")) { code = ERROR_PERMISSION_DENIED; err = null; } else if (err.getMessage().contains("ENOSPC")) { code = ERROR_INSUFFICIENT_STORAGE; err = null; } else if (!storage.canWrite()) { code = ERROR_FILE_CREATION; err = null; } } errCode = code; errObject = err; switch (code) { case ERROR_SSL_EXCEPTION: case ERROR_UNKNOWN_HOST: case ERROR_CONNECT_HOST: case ERROR_TIMEOUT: // do not change the queue flag for network errors, can be // recovered silently without the user interaction break; default: // also checks for server errors if (code < 500 || code > 599) enqueued = false; } notify(DownloadManagerService.MESSAGE_ERROR); if (running) pauseThreads(); } synchronized void notifyFinished() { if (current < urls.length) { if (++finishCount < threads.length) return; if (DEBUG) { Log.d(TAG, "onFinish: downloaded " + (current + 1) + "/" + urls.length); } current++; if (current < urls.length) { // prepare next sub-mission offsets[current] = offsets[current - 1] + length; initializer(); return; } } if (psAlgorithm != null && psState == 0) { threads = new Thread[]{ runAsync(1, this::doPostprocessing) }; return; } // this mission is fully finished unknownLength = false; enqueued = false; running = false; deleteThisFromFile(); notify(DownloadManagerService.MESSAGE_FINISHED); } private void notifyPostProcessing(int state) { String action; switch (state) { case 1: action = "Running"; break; case 2: action = "Completed"; break; default: action = "Failed"; } Log.d(TAG, action + " postprocessing on " + storage.getName()); if (state == 2) { psState = state; return; } synchronized (LOCK) { // don't return without fully write the current state psState = state; writeThisToFile(); } } /** * Start downloading with multiple threads. */ public void start() { if (running || isFinished() || urls.length < 1) return; // ensure that the previous state is completely paused. joinForThreads(10000); running = true; errCode = ERROR_NOTHING; if (hasInvalidStorage()) { notifyError(ERROR_FILE_CREATION, null); return; } if (current >= urls.length) { notifyFinished(); return; } notify(DownloadManagerService.MESSAGE_RUNNING); if (urls[current] == null) { doRecover(ERROR_RESOURCE_GONE); return; } if (blocks == null) { initializer(); return; } init = null; finishCount = 0; blockAcquired = new boolean[blocks.length]; if (blocks.length < 1) { threads = new Thread[]{runAsync(1, new DownloadRunnableFallback(this))}; } else { int remainingBlocks = 0; for (int block : blocks) if (block >= 0) remainingBlocks++; if (remainingBlocks < 1) { notifyFinished(); return; } threads = new Thread[Math.min(threadCount, remainingBlocks)]; for (int i = 0; i < threads.length; i++) { threads[i] = runAsync(i + 1, new DownloadRunnable(this, i)); } } } /** * Pause the mission */ public void pause() { if (!running) return; if (isPsRunning()) { if (DEBUG) { Log.w(TAG, "pause during post-processing is not applicable."); } return; } running = false; notify(DownloadManagerService.MESSAGE_PAUSED); if (init != null && init.isAlive()) { // NOTE: if start() method is running ¡will no have effect! init.interrupt(); synchronized (LOCK) { resetState(false, true, ERROR_NOTHING); } return; } if (DEBUG && unknownLength) { Log.w(TAG, "pausing a download that can not be resumed (range requests not allowed by the server)."); } init = null; pauseThreads(); } private void pauseThreads() { running = false; joinForThreads(-1); writeThisToFile(); } /** * Removes the downloaded file and the meta file */ @Override public boolean delete() { if (psAlgorithm != null) psAlgorithm.cleanupTemporalDir(); notify(DownloadManagerService.MESSAGE_DELETED); boolean res = deleteThisFromFile(); if (!super.delete()) return false; return res; } /** * Resets the mission state * * @param rollback {@code true} true to forget all progress, otherwise, {@code false} * @param persistChanges {@code true} to commit changes to the metadata file, otherwise, {@code false} */ public void resetState(boolean rollback, boolean persistChanges, int errorCode) { length = 0; errCode = errorCode; errObject = null; unknownLength = false; threads = new Thread[0]; fallbackResumeOffset = 0; blocks = null; blockAcquired = null; if (rollback) current = 0; if (persistChanges) writeThisToFile(); } private void initializer() { init = runAsync(DownloadInitializer.mId, new DownloadInitializer(this)); } private void writeThisToFileAsync() { runAsync(-2, this::writeThisToFile); } /** * Write this {@link DownloadMission} to the meta file asynchronously * if no thread is already running. */ void writeThisToFile() { synchronized (LOCK) { if (metadata == null) return; Utility.writeToFile(metadata, this); writingToFile = false; } } /** * Indicates if the download if fully finished * * @return true, otherwise, false */ public boolean isFinished() { return current >= urls.length && (psAlgorithm == null || psState == 2); } /** * Indicates if the download file is corrupt due a failed post-processing * * @return {@code true} if this mission is unrecoverable */ public boolean isPsFailed() { switch (errCode) { case ERROR_POSTPROCESSING: case ERROR_POSTPROCESSING_STOPPED: return psAlgorithm.worksOnSameFile; } return false; } /** * Indicates if a post-processing algorithm is running * * @return true, otherwise, false */ public boolean isPsRunning() { return psAlgorithm != null && (psState == 1 || psState == 3); } /** * Indicated if the mission is ready * * @return true, otherwise, false */ public boolean isInitialized() { return blocks != null; // DownloadMissionInitializer was executed } /** * Gets the approximated final length of the file * * @return the length in bytes */ public long getLength() { long calculated; if (psState == 1 || psState == 3) { return length; } calculated = offsets[current < offsets.length ? current : (offsets.length - 1)] + length; calculated -= offsets[0];// don't count reserved space return Math.max(calculated, nearLength); } /** * set this mission state on the queue * * @param queue true to add to the queue, otherwise, false */ public void setEnqueued(boolean queue) { enqueued = queue; writeThisToFileAsync(); } /** * Attempts to continue a blocked post-processing * * @param recover {@code true} to retry, otherwise, {@code false} to cancel */ public void psContinue(boolean recover) { psState = 1; errCode = recover ? ERROR_NOTHING : ERROR_POSTPROCESSING; threads[0].interrupt(); } /** * Indicates whatever the backed storage is invalid * * @return {@code true}, if storage is invalid and cannot be used */ public boolean hasInvalidStorage() { // Don't consider ERROR_PROGRESS_LOST as invalid storage - it can be recovered return storage == null || !storage.existsAsFile(); } /** * Indicates whatever is possible to start the mission * * @return {@code true} is this mission its "healthy", otherwise, {@code false} */ public boolean isCorrupt() { if (urls.length < 1) return false; return (isPsFailed() || errCode == ERROR_POSTPROCESSING_HOLD) || isFinished(); } /** * Indicates if mission urls has expired and there an attempt to renovate them * * @return {@code true} if the mission is running a recovery procedure, otherwise, {@code false} */ public boolean isRecovering() { return threads.length > 0 && threads[0] instanceof DownloadMissionRecover && threads[0].isAlive(); } private void doPostprocessing() { errCode = ERROR_NOTHING; errObject = null; Thread thread = Thread.currentThread(); notifyPostProcessing(1); if (DEBUG) { thread.setName("[" + TAG + "] ps = " + psAlgorithm + " filename = " + storage.getName()); } Exception exception = null; try { psAlgorithm.run(this); } catch (Exception err) { Log.e(TAG, "Post-processing failed. " + psAlgorithm.toString(), err); if (err instanceof InterruptedIOException || err instanceof ClosedByInterruptException || thread.isInterrupted()) { notifyError(DownloadMission.ERROR_POSTPROCESSING_STOPPED, null); return; } if (errCode == ERROR_NOTHING) errCode = ERROR_POSTPROCESSING; exception = err; } finally { notifyPostProcessing(errCode == ERROR_NOTHING ? 2 : 0); } if (errCode != ERROR_NOTHING) { if (exception == null) exception = errObject; notifyError(ERROR_POSTPROCESSING, exception); return; } notifyFinished(); } /** * Attempts to recover the download * * @param errorCode error code which trigger the recovery procedure */ void doRecover(int errorCode) { Log.i(TAG, "Attempting to recover the mission: " + storage.getName()); if (recoveryInfo == null) { notifyError(errorCode, null); urls = new String[0];// mark this mission as dead return; } joinForThreads(0); threads = new Thread[]{ runAsync(DownloadMissionRecover.mID, new DownloadMissionRecover(this, errorCode)) }; } private boolean deleteThisFromFile() { synchronized (LOCK) { boolean res = metadata.delete(); metadata = null; return res; } } /** * run a new thread * * @param id id of new thread (used for debugging only) * @param who the Runnable whose {@code run} method is invoked. */ private Thread runAsync(int id, Runnable who) { return runAsync(id, new Thread(who)); } /** * run a new thread * * @param id id of new thread (used for debugging only) * @param who the Thread whose {@code run} method is invoked when this thread is started * @return the passed thread */ private Thread runAsync(int id, Thread who) { // known thread ids: // -2: state saving by notifyProgress() method // -1: wait for saving the state by pause() method // 0: initializer // >=1: any download thread if (DEBUG) { who.setName(String.format("%s[%s] %s", TAG, id, storage.getName())); } who.start(); return who; } /** * Waits at most {@code millis} milliseconds for the thread to die * * @param millis the time to wait in milliseconds */ private void joinForThreads(int millis) { final Thread currentThread = Thread.currentThread(); if (init != null && init != currentThread && init.isAlive()) { init.interrupt(); if (millis > 0) { try { init.join(millis); } catch (InterruptedException e) { Log.w(TAG, "Initializer thread is still running", e); return; } } } // if a thread is still alive, possible reasons: // slow device // the user is spamming start/pause buttons // start() method called quickly after pause() for (Thread thread : threads) { if (!thread.isAlive() || thread == Thread.currentThread()) continue; thread.interrupt(); } try { for (Thread thread : threads) { if (!thread.isAlive()) continue; if (DEBUG) { Log.w(TAG, "thread alive: " + thread.getName()); } if (millis > 0) thread.join(millis); } } catch (InterruptedException e) { throw new RuntimeException("A download thread is still running", e); } } static class HttpError extends Exception { final int statusCode; HttpError(int statusCode) { this.statusCode = statusCode; } @Override public String getMessage() { return "HTTP " + statusCode; } } public static class Block { public int position; public int done; } private static class Lock implements Serializable { // java.lang.Object cannot be used because is not serializable } } ================================================ FILE: app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java ================================================ package us.shandian.giga.get; import android.util.Log; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.DeliveryMethod; import org.schabi.newpipe.extractor.stream.StreamExtractor; import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; import java.io.IOException; import java.io.InterruptedIOException; import java.net.HttpURLConnection; import java.nio.channels.ClosedByInterruptException; import java.util.List; import us.shandian.giga.get.DownloadMission.HttpError; import static us.shandian.giga.get.DownloadMission.ERROR_RESOURCE_GONE; public class DownloadMissionRecover extends Thread { private static final String TAG = "DownloadMissionRecover"; static final int mID = -3; private final DownloadMission mMission; private final boolean mNotInitialized; private final int mErrCode; private HttpURLConnection mConn; private MissionRecoveryInfo mRecovery; private StreamExtractor mExtractor; DownloadMissionRecover(DownloadMission mission, int errCode) { mMission = mission; mNotInitialized = mission.blocks == null && mission.current == 0; mErrCode = errCode; } @Override public void run() { if (mMission.source == null) { mMission.notifyError(mErrCode, null); return; } Exception err = null; int attempt = 0; while (attempt++ < mMission.maxRetry) { try { tryRecover(); return; } catch (InterruptedIOException | ClosedByInterruptException e) { return; } catch (Exception e) { if (!mMission.running || super.isInterrupted()) return; err = e; } } // give up mMission.notifyError(mErrCode, err); } private void tryRecover() throws ExtractionException, IOException, HttpError { if (mExtractor == null) { try { StreamingService svr = NewPipe.getServiceByUrl(mMission.source); mExtractor = svr.getStreamExtractor(mMission.source); mExtractor.fetchPage(); } catch (ExtractionException e) { mExtractor = null; throw e; } } // maybe the following check is redundant if (!mMission.running || super.isInterrupted()) return; if (!mNotInitialized) { // set the current download url to null in case if the recovery // process is canceled. Next time start() method is called the // recovery will be executed, saving time mMission.urls[mMission.current] = null; mRecovery = mMission.recoveryInfo[mMission.current]; resolveStream(); return; } Log.w(TAG, "mission is not fully initialized, this will take a while"); try { for (; mMission.current < mMission.urls.length; mMission.current++) { mRecovery = mMission.recoveryInfo[mMission.current]; if (test()) continue; if (!mMission.running) return; resolveStream(); if (!mMission.running) return; // before continue, check if the current stream was resolved if (mMission.urls[mMission.current] == null) { break; } } } finally { mMission.current = 0; } mMission.writeThisToFile(); if (!mMission.running || super.isInterrupted()) return; mMission.running = false; mMission.start(); } private void resolveStream() throws IOException, ExtractionException, HttpError { // FIXME: this getErrorMessage() always returns "video is unavailable" /*if (mExtractor.getErrorMessage() != null) { mMission.notifyError(mErrCode, new ExtractionException(mExtractor.getErrorMessage())); return; }*/ String url = null; switch (mRecovery.getKind()) { case 'a': for (final AudioStream audio : mExtractor.getAudioStreams()) { if (audio.getAverageBitrate() == mRecovery.getDesiredBitrate() && audio.getFormat() == mRecovery.getFormat() && audio.getDeliveryMethod() == DeliveryMethod.PROGRESSIVE_HTTP) { url = audio.getContent(); break; } } break; case 'v': final List videoStreams; if (mRecovery.isDesired2()) videoStreams = mExtractor.getVideoOnlyStreams(); else videoStreams = mExtractor.getVideoStreams(); for (final VideoStream video : videoStreams) { if (video.getResolution().equals(mRecovery.getDesired()) && video.getFormat() == mRecovery.getFormat() && video.getDeliveryMethod() == DeliveryMethod.PROGRESSIVE_HTTP) { url = video.getContent(); break; } } break; case 's': for (final SubtitlesStream subtitles : mExtractor.getSubtitles(mRecovery .getFormat())) { String tag = subtitles.getLanguageTag(); if (tag.equals(mRecovery.getDesired()) && subtitles.isAutoGenerated() == mRecovery.isDesired2() && subtitles.getDeliveryMethod() == DeliveryMethod.PROGRESSIVE_HTTP) { url = subtitles.getContent(); break; } } break; default: throw new RuntimeException("Unknown stream type"); } resolve(url); } private void resolve(String url) throws IOException, HttpError { if (mRecovery.getValidateCondition() == null) { Log.w(TAG, "validation condition not defined, the resource can be stale"); } if (mMission.unknownLength || mRecovery.getValidateCondition() == null) { recover(url, false); return; } /////////////////////////////////////////////////////////////////////// ////// Validate the http resource doing a range request ///////////////////// try { mConn = mMission.openConnection(url, true, mMission.length - 10, mMission.length); mConn.setRequestProperty("If-Range", mRecovery.getValidateCondition()); mMission.establishConnection(mID, mConn); int code = mConn.getResponseCode(); switch (code) { case 200: case 413: // stale recover(url, true); return; case 206: // in case of validation using the Last-Modified date, check the resource length long[] contentRange = parseContentRange(mConn.getHeaderField("Content-Range")); boolean lengthMismatch = contentRange[2] != -1 && contentRange[2] != mMission.length; recover(url, lengthMismatch); return; } throw new HttpError(code); } finally { disconnect(); } } private void recover(String url, boolean stale) { Log.i(TAG, String.format("recover() name=%s isStale=%s url=%s", mMission.storage.getName(), stale, url) ); mMission.urls[mMission.current] = url; if (url == null) { mMission.urls = new String[0]; mMission.notifyError(ERROR_RESOURCE_GONE, null); return; } if (mNotInitialized) return; if (stale) { mMission.resetState(false, false, DownloadMission.ERROR_NOTHING); } mMission.writeThisToFile(); if (!mMission.running || super.isInterrupted()) return; mMission.running = false; mMission.start(); } private long[] parseContentRange(String value) { long[] range = new long[3]; if (value == null) { // this never should happen return range; } try { value = value.trim(); if (!value.startsWith("bytes")) { return range;// unknown range type } int space = value.lastIndexOf(' ') + 1; int dash = value.indexOf('-', space) + 1; int bar = value.indexOf('/', dash); // start range[0] = Long.parseLong(value.substring(space, dash - 1)); // end range[1] = Long.parseLong(value.substring(dash, bar)); // resource length value = value.substring(bar + 1); if (value.equals("*")) { range[2] = -1;// unknown length received from the server but should be valid } else { range[2] = Long.parseLong(value); } } catch (Exception e) { // nothing to do } return range; } private boolean test() { if (mMission.urls[mMission.current] == null) return false; try { mConn = mMission.openConnection(mMission.urls[mMission.current], true, -1, -1); mMission.establishConnection(mID, mConn); if (mConn.getResponseCode() == 200) return true; } catch (Exception e) { // nothing to do } finally { disconnect(); } return false; } private void disconnect() { try { try { mConn.getInputStream().close(); } finally { mConn.disconnect(); } } catch (Exception e) { // nothing to do } finally { mConn = null; } } @Override public void interrupt() { super.interrupt(); if (mConn != null) disconnect(); } } ================================================ FILE: app/src/main/java/us/shandian/giga/get/DownloadRunnable.java ================================================ package us.shandian.giga.get; import android.util.Log; import org.schabi.newpipe.streams.io.SharpStream; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.nio.channels.ClosedByInterruptException; import java.util.Objects; import us.shandian.giga.get.DownloadMission.Block; import us.shandian.giga.get.DownloadMission.HttpError; import static org.schabi.newpipe.BuildConfig.DEBUG; import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_FORBIDDEN; /** * Runnable to download blocks of a file until the file is completely downloaded, * an error occurs or the process is stopped. */ public class DownloadRunnable extends Thread { private static final String TAG = "DownloadRunnable"; private final DownloadMission mMission; private final int mId; private HttpURLConnection mConn; DownloadRunnable(DownloadMission mission, int id) { mMission = Objects.requireNonNull(mission); mId = id; } private void releaseBlock(Block block, long remain) { // set the block offset to -1 if it is completed mMission.releaseBlock(block.position, remain < 0 ? -1 : block.done); } @Override public void run() { boolean retry = false; Block block = null; int retryCount = 0; SharpStream f; try { f = mMission.storage.getStream(); } catch (IOException e) { mMission.notifyError(e);// this never should happen return; } while (mMission.running && mMission.errCode == DownloadMission.ERROR_NOTHING) { if (!retry) { block = mMission.acquireBlock(); } if (block == null) { if (DEBUG) Log.d(TAG, mId + ":no more blocks left, exiting"); break; } if (DEBUG) { if (retry) Log.d(TAG, mId + ":retry block at position=" + block.position + " from the start"); else Log.d(TAG, mId + ":acquired block at position=" + block.position + " done=" + block.done); } long start = (long)block.position * DownloadMission.BLOCK_SIZE; long end = start + DownloadMission.BLOCK_SIZE - 1; start += block.done; if (end >= mMission.length) { end = mMission.length - 1; } try { mConn = mMission.openConnection(false, start, end); mMission.establishConnection(mId, mConn); // check if the download can be resumed if (mConn.getResponseCode() == 416) { if (block.done > 0) { // try again from the start (of the block) mMission.notifyProgress(-block.done); block.done = 0; retry = true; mConn.disconnect(); continue; } throw new DownloadMission.HttpError(416); } retry = false; // The server may be ignoring the range request if (mConn.getResponseCode() != 206) { if (DEBUG) { Log.e(TAG, mId + ":Unsupported " + mConn.getResponseCode()); } mMission.notifyError(new DownloadMission.HttpError(mConn.getResponseCode())); break; } f.seek(mMission.offsets[mMission.current] + start); try (InputStream is = mConn.getInputStream()) { byte[] buf = new byte[DownloadMission.BUFFER_SIZE]; int len; // use always start <= end // fixes a deadlock because in some videos, youtube is sending one byte alone while (start <= end && mMission.running && (len = is.read(buf, 0, buf.length)) != -1) { f.write(buf, 0, len); start += len; block.done += len; mMission.notifyProgress(len); } } if (DEBUG && mMission.running) { Log.d(TAG, mId + ":position " + block.position + " stopped " + start + "/" + end); } } catch (Exception e) { if (!mMission.running || e instanceof ClosedByInterruptException) break; if (e instanceof HttpError && ((HttpError) e).statusCode == ERROR_HTTP_FORBIDDEN) { // for youtube streams. The url has expired, recover f.close(); if (mId == 1) { // only the first thread will execute the recovery procedure mMission.doRecover(ERROR_HTTP_FORBIDDEN); } return; } if (retryCount++ >= mMission.maxRetry) { mMission.notifyError(e); break; } retry = true; } finally { if (!retry) releaseBlock(block, end - start); } } f.close(); if (DEBUG) { Log.d(TAG, "thread " + mId + " exited from main download loop"); } if (mMission.errCode == DownloadMission.ERROR_NOTHING && mMission.running) { if (DEBUG) { Log.d(TAG, "no error has happened, notifying"); } mMission.notifyFinished(); } if (DEBUG && !mMission.running) { Log.d(TAG, "The mission has been paused. Passing."); } } @Override public void interrupt() { super.interrupt(); try { if (mConn != null) mConn.disconnect(); } catch (Exception e) { // nothing to do } } } ================================================ FILE: app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java ================================================ package us.shandian.giga.get; import android.util.Log; import androidx.annotation.NonNull; import org.schabi.newpipe.streams.io.SharpStream; import java.io.IOException; import java.io.InputStream; import java.net.HttpURLConnection; import java.nio.channels.ClosedByInterruptException; import us.shandian.giga.get.DownloadMission.HttpError; import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_FORBIDDEN; /** * Single-threaded fallback mode */ public class DownloadRunnableFallback extends Thread { private static final String TAG = "DLRunnableFallback"; private final DownloadMission mMission; private int mRetryCount = 0; private InputStream mIs; private SharpStream mF; private HttpURLConnection mConn; DownloadRunnableFallback(@NonNull DownloadMission mission) { mMission = mission; } private void dispose() { try { try { if (mIs != null) mIs.close(); } finally { mConn.disconnect(); } } catch (IOException e) { // nothing to do } if (mF != null) mF.close(); } @Override public void run() { boolean done; long start = mMission.fallbackResumeOffset; if (DEBUG && !mMission.unknownLength && start > 0) { Log.i(TAG, "Resuming a single-thread download at " + start); } try { long rangeStart = (mMission.unknownLength || start < 1) ? -1 : start; int mId = 1; mConn = mMission.openConnection(false, rangeStart, -1); if (mRetryCount == 0 && rangeStart == -1) { // workaround: bypass android connection pool mConn.setRequestProperty("Range", "bytes=0-"); } mMission.establishConnection(mId, mConn); // check if the download can be resumed if (mConn.getResponseCode() == 416 && start > 0) { mMission.notifyProgress(-start); start = 0; mRetryCount--; throw new DownloadMission.HttpError(416); } // secondary check for the file length if (!mMission.unknownLength) mMission.unknownLength = Utility.getContentLength(mConn) == -1; if (mMission.unknownLength || mConn.getResponseCode() == 200) { // restart amount of bytes downloaded mMission.done = mMission.offsets[mMission.current] - mMission.offsets[0]; start = 0; // reset position to avoid writing at wrong offset } mF = mMission.storage.getStream(); mF.seek(mMission.offsets[mMission.current] + start); mIs = mConn.getInputStream(); byte[] buf = new byte[DownloadMission.BUFFER_SIZE]; int len = 0; while (mMission.running && (len = mIs.read(buf, 0, buf.length)) != -1) { mF.write(buf, 0, len); start += len; mMission.notifyProgress(len); } dispose(); // if thread goes interrupted check if the last part is written. This avoid re-download the whole file done = len == -1; } catch (Exception e) { dispose(); mMission.fallbackResumeOffset = start; if (!mMission.running || e instanceof ClosedByInterruptException) return; if (e instanceof HttpError && ((HttpError) e).statusCode == ERROR_HTTP_FORBIDDEN) { // for youtube streams. The url has expired, recover dispose(); mMission.doRecover(ERROR_HTTP_FORBIDDEN); return; } if (mRetryCount++ >= mMission.maxRetry) { mMission.notifyError(e); return; } if (DEBUG) { Log.e(TAG, "got exception, retrying...", e); } run();// try again return; } if (done) { mMission.notifyFinished(); } else { mMission.fallbackResumeOffset = start; } } @Override public void interrupt() { super.interrupt(); if (mConn != null) { try { mConn.disconnect(); } catch (Exception e) { // nothing to do } } } } ================================================ FILE: app/src/main/java/us/shandian/giga/get/FinishedMission.java ================================================ package us.shandian.giga.get; import androidx.annotation.NonNull; public class FinishedMission extends Mission { public FinishedMission() { } public FinishedMission(@NonNull DownloadMission mission) { source = mission.source; length = mission.length; timestamp = mission.timestamp; kind = mission.kind; storage = mission.storage; } } ================================================ FILE: app/src/main/java/us/shandian/giga/get/Mission.java ================================================ package us.shandian.giga.get; import androidx.annotation.NonNull; import java.io.Serializable; import java.util.Calendar; import org.schabi.newpipe.streams.io.StoredFileHelper; public abstract class Mission implements Serializable { private static final long serialVersionUID = 1L;// last bump: 27 march 2019 /** * Source url of the resource */ public String source; /** * Length of the current resource */ public long length; /** * creation timestamp (and maybe unique identifier) */ public long timestamp; public long getTimestamp() { return timestamp; } /** * pre-defined content type */ public char kind; /** * The downloaded file */ public StoredFileHelper storage; /** * Delete the downloaded file * * @return {@code true] if and only if the file is successfully deleted, otherwise, {@code false} */ public boolean delete() { if (storage != null) return storage.delete(); return true; } /** * Indicate if this mission is deleted whatever is stored */ public transient boolean deleted = false; @NonNull @Override public String toString() { final Calendar calendar = Calendar.getInstance(); calendar.setTimeInMillis(timestamp); return "[" + calendar.getTime().toString() + "] " + (storage.isInvalid() ? storage.getName() : storage.getUri()); } } ================================================ FILE: app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.kt ================================================ package us.shandian.giga.get import android.os.Parcelable import java.io.Serializable import kotlinx.parcelize.Parcelize import org.schabi.newpipe.extractor.MediaFormat import org.schabi.newpipe.extractor.stream.AudioStream import org.schabi.newpipe.extractor.stream.Stream import org.schabi.newpipe.extractor.stream.SubtitlesStream import org.schabi.newpipe.extractor.stream.VideoStream @Parcelize class MissionRecoveryInfo( var format: MediaFormat?, var desired: String? = null, var isDesired2: Boolean = false, var desiredBitrate: Int = 0, var kind: Char = Char.MIN_VALUE, var validateCondition: String? = null ) : Serializable, Parcelable { constructor(stream: Stream) : this(format = stream.format) { when (stream) { is AudioStream -> { desiredBitrate = stream.getAverageBitrate() isDesired2 = false kind = 'a' } is VideoStream -> { desired = stream.getResolution() isDesired2 = stream.isVideoOnly() kind = 'v' } is SubtitlesStream -> { desired = stream.languageTag isDesired2 = stream.isAutoGenerated kind = 's' } else -> throw RuntimeException("Unknown stream kind") } } override fun toString(): String { val info: String val str = StringBuilder() str.append("{type=") when (kind) { 'a' -> { str.append("audio") info = "bitrate=$desiredBitrate" } 'v' -> { str.append("video") info = "quality=$desired videoOnly=$isDesired2" } 's' -> { str.append("subtitles") info = "language=$desired autoGenerated=$isDesired2" } else -> { info = "" str.append("other") } } str.append(" format=") .append(format?.getName()) .append(' ') .append(info) .append('}') return str.toString() } } ================================================ FILE: app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java ================================================ package us.shandian.giga.get.sqlite; import android.content.ContentValues; import android.content.Context; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; import android.database.sqlite.SQLiteOpenHelper; import android.net.Uri; import android.util.Log; import androidx.annotation.NonNull; import java.io.File; import java.util.ArrayList; import java.util.Objects; import us.shandian.giga.get.DownloadMission; import us.shandian.giga.get.FinishedMission; import us.shandian.giga.get.Mission; import org.schabi.newpipe.streams.io.StoredFileHelper; /** * SQLite helper to store finished {@link us.shandian.giga.get.FinishedMission}'s */ public class FinishedMissionStore extends SQLiteOpenHelper { // TODO: use NewPipeSQLiteHelper ('s constants) when playlist branch is merged (?) private static final String DATABASE_NAME = "downloads.db"; private static final int DATABASE_VERSION = 4; /** * The table name of download missions (old) */ private static final String MISSIONS_TABLE_NAME_v2 = "download_missions"; /** * The table name of download missions */ private static final String FINISHED_TABLE_NAME = "finished_missions"; /** * The key to the urls of a mission */ private static final String KEY_SOURCE = "url"; /** * The key to the done. */ private static final String KEY_DONE = "bytes_downloaded"; private static final String KEY_TIMESTAMP = "timestamp"; private static final String KEY_KIND = "kind"; private static final String KEY_PATH = "path"; /** * The statement to create the table */ private static final String MISSIONS_CREATE_TABLE = "CREATE TABLE " + FINISHED_TABLE_NAME + " (" + KEY_PATH + " TEXT NOT NULL, " + KEY_SOURCE + " TEXT NOT NULL, " + KEY_DONE + " INTEGER NOT NULL, " + KEY_TIMESTAMP + " INTEGER NOT NULL, " + KEY_KIND + " TEXT NOT NULL, " + " UNIQUE(" + KEY_TIMESTAMP + ", " + KEY_PATH + "));"; private final Context context; public FinishedMissionStore(Context context) { super(context, DATABASE_NAME, null, DATABASE_VERSION); this.context = context; } @Override public void onCreate(SQLiteDatabase db) { db.execSQL(MISSIONS_CREATE_TABLE); } @Override public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { if (oldVersion == 2) { db.execSQL("ALTER TABLE " + MISSIONS_TABLE_NAME_v2 + " ADD COLUMN " + KEY_KIND + " TEXT;"); oldVersion++; } if (oldVersion == 3) { final String KEY_LOCATION = "location"; final String KEY_NAME = "name"; db.execSQL(MISSIONS_CREATE_TABLE); Cursor cursor = db.query(MISSIONS_TABLE_NAME_v2, null, null, null, null, null, KEY_TIMESTAMP); int count = cursor.getCount(); if (count > 0) { db.beginTransaction(); while (cursor.moveToNext()) { ContentValues values = new ContentValues(); values.put( KEY_SOURCE, cursor.getString(cursor.getColumnIndexOrThrow(KEY_SOURCE)) ); values.put( KEY_DONE, cursor.getString(cursor.getColumnIndexOrThrow(KEY_DONE)) ); values.put( KEY_TIMESTAMP, cursor.getLong(cursor.getColumnIndexOrThrow(KEY_TIMESTAMP)) ); values.put(KEY_KIND, cursor.getString(cursor.getColumnIndexOrThrow(KEY_KIND))); values.put(KEY_PATH, Uri.fromFile( new File( cursor.getString(cursor.getColumnIndexOrThrow(KEY_LOCATION)), cursor.getString(cursor.getColumnIndexOrThrow(KEY_NAME)) ) ).toString()); db.insert(FINISHED_TABLE_NAME, null, values); } db.setTransactionSuccessful(); db.endTransaction(); } cursor.close(); db.execSQL("DROP TABLE " + MISSIONS_TABLE_NAME_v2); } } /** * Returns all values of the download mission as ContentValues. * * @param downloadMission the download mission * @return the content values */ private ContentValues getValuesOfMission(@NonNull Mission downloadMission) { ContentValues values = new ContentValues(); values.put(KEY_SOURCE, downloadMission.source); values.put(KEY_PATH, downloadMission.storage.getUri().toString()); values.put(KEY_DONE, downloadMission.length); values.put(KEY_TIMESTAMP, downloadMission.timestamp); values.put(KEY_KIND, String.valueOf(downloadMission.kind)); return values; } private FinishedMission getMissionFromCursor(Cursor cursor) { String kind = Objects.requireNonNull(cursor) .getString(cursor.getColumnIndexOrThrow(KEY_KIND)); if (kind == null || kind.isEmpty()) kind = "?"; String path = cursor.getString(cursor.getColumnIndexOrThrow(KEY_PATH)); FinishedMission mission = new FinishedMission(); mission.source = cursor.getString(cursor.getColumnIndexOrThrow(KEY_SOURCE)); mission.length = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_DONE)); mission.timestamp = cursor.getLong(cursor.getColumnIndexOrThrow(KEY_TIMESTAMP)); mission.kind = kind.charAt(0); try { mission.storage = new StoredFileHelper(context,null, Uri.parse(path), ""); } catch (Exception e) { Log.e("FinishedMissionStore", "failed to load the storage path of: " + path, e); mission.storage = new StoredFileHelper(null, path, "", ""); } return mission; } ////////////////////////////////// // Data source methods /////////////////////////////////// public ArrayList loadFinishedMissions() { SQLiteDatabase database = getReadableDatabase(); Cursor cursor = database.query(FINISHED_TABLE_NAME, null, null, null, null, null, KEY_TIMESTAMP + " DESC"); int count = cursor.getCount(); if (count == 0) return new ArrayList<>(1); ArrayList result = new ArrayList<>(count); while (cursor.moveToNext()) { result.add(getMissionFromCursor(cursor)); } return result; } public void addFinishedMission(DownloadMission downloadMission) { ContentValues values = getValuesOfMission(Objects.requireNonNull(downloadMission)); SQLiteDatabase database = getWritableDatabase(); database.insert(FINISHED_TABLE_NAME, null, values); } public void deleteMission(Mission mission) { String ts = String.valueOf(Objects.requireNonNull(mission).timestamp); SQLiteDatabase database = getWritableDatabase(); if (mission instanceof FinishedMission) { if (mission.storage.isInvalid()) { database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ?", new String[]{ts}); } else { database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ? AND " + KEY_PATH + " = ?", new String[]{ ts, mission.storage.getUri().toString() }); } } else { throw new UnsupportedOperationException("DownloadMission"); } } public void updateMission(Mission mission) { ContentValues values = getValuesOfMission(Objects.requireNonNull(mission)); SQLiteDatabase database = getWritableDatabase(); String ts = String.valueOf(mission.timestamp); int rowsAffected; if (mission instanceof FinishedMission) { if (mission.storage.isInvalid()) { rowsAffected = database.update(FINISHED_TABLE_NAME, values, KEY_TIMESTAMP + " = ?", new String[]{ts}); } else { rowsAffected = database.update(FINISHED_TABLE_NAME, values, KEY_PATH + " = ?", new String[]{ mission.storage.getUri().toString() }); } } else { throw new UnsupportedOperationException("DownloadMission"); } if (rowsAffected != 1) { Log.e("FinishedMissionStore", "Expected 1 row to be affected by update but got " + rowsAffected); } } } ================================================ FILE: app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java ================================================ package us.shandian.giga.io; import org.schabi.newpipe.streams.io.SharpStream; import java.io.IOException; public class ChunkFileInputStream extends SharpStream { private static final int REPORT_INTERVAL = 256 * 1024; private SharpStream source; private final long offset; private final long length; private long position; private long progressReport; private final ProgressReport onProgress; public ChunkFileInputStream(SharpStream target, long start, long end, ProgressReport callback) throws IOException { source = target; offset = start; length = end - start; position = 0; onProgress = callback; progressReport = REPORT_INTERVAL; if (length < 1) { source.close(); throw new IOException("The chunk is empty or invalid"); } if (source.length() < end) { try { throw new IOException(String.format("invalid file length. expected = %s found = %s", end, source.length())); } finally { source.close(); } } source.seek(offset); } /** * Get absolute position on file * * @return the position */ public long getFilePointer() { return offset + position; } @Override public int read() throws IOException { if ((position + 1) > length) { return 0; } int res = source.read(); if (res >= 0) { position++; } return res; } @Override public int read(byte[] b) throws IOException { return read(b, 0, b.length); } @Override public int read(byte[] b, int off, int len) throws IOException { if ((position + len) > length) { len = (int) (length - position); } if (len == 0) { return 0; } int res = source.read(b, off, len); position += res; if (onProgress != null && position > progressReport) { onProgress.report(position); progressReport = position + REPORT_INTERVAL; } return res; } @Override public long skip(long pos) throws IOException { pos = Math.min(pos + position, length); if (pos == 0) { return 0; } source.seek(offset + pos); long oldPos = position; position = pos; return pos - oldPos; } @Override public long available() { return length - position; } @SuppressWarnings("EmptyCatchBlock") @Override public void close() { source.close(); source = null; } @Override public boolean isClosed() { return source == null; } @Override public void rewind() throws IOException { position = 0; source.seek(offset); } @Override public boolean canRewind() { return true; } @Override public boolean canRead() { return true; } @Override public boolean canWrite() { return false; } @Override public void write(byte value) { } @Override public void write(byte[] buffer) { } @Override public void write(byte[] buffer, int offset, int count) { } } ================================================ FILE: app/src/main/java/us/shandian/giga/io/CircularFileWriter.java ================================================ package us.shandian.giga.io; import androidx.annotation.NonNull; import org.schabi.newpipe.streams.io.SharpStream; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.util.Objects; public class CircularFileWriter extends SharpStream { private static final int QUEUE_BUFFER_SIZE = 8 * 1024;// 8 KiB private static final int COPY_BUFFER_SIZE = 128 * 1024; // 128 KiB private static final int NOTIFY_BYTES_INTERVAL = 64 * 1024;// 64 KiB private static final int THRESHOLD_AUX_LENGTH = 15 * 1024 * 1024;// 15 MiB private final OffsetChecker callback; public ProgressReport onProgress; public WriteErrorHandle onWriteError; private long reportPosition; private long maxLengthKnown = -1; private BufferedFile out; private BufferedFile aux; public CircularFileWriter(SharpStream target, File temp, OffsetChecker checker) throws IOException { Objects.requireNonNull(checker); if (!temp.exists()) { if (!temp.createNewFile()) { throw new IOException("Cannot create a temporal file"); } } aux = new BufferedFile(temp); out = new BufferedFile(target); callback = checker; reportPosition = NOTIFY_BYTES_INTERVAL; } private void flushAuxiliar(long amount) throws IOException { if (aux.length < 1) { return; } out.flush(); aux.flush(); boolean underflow = aux.offset < aux.length || out.offset < out.length; byte[] buffer = new byte[COPY_BUFFER_SIZE]; aux.target.seek(0); out.target.seek(out.length); long length = amount; while (length > 0) { int read = (int) Math.min(length, Integer.MAX_VALUE); read = aux.target.read(buffer, 0, Math.min(read, buffer.length)); if (read < 1) { amount -= length; break; } out.writeProof(buffer, read); length -= read; } if (underflow) { if (out.offset >= out.length) { // calculate the aux underflow pointer if (aux.offset < amount) { out.offset += aux.offset; aux.offset = 0; out.target.seek(out.offset); } else { aux.offset -= amount; out.offset = out.length + amount; } } else { aux.offset = 0; } } else { out.offset += amount; aux.offset -= amount; } out.length += amount; if (out.length > maxLengthKnown) { maxLengthKnown = out.length; } if (amount < aux.length) { // move the excess data to the beginning of the file long readOffset = amount; long writeOffset = 0; aux.length -= amount; length = aux.length; while (length > 0) { int read = (int) Math.min(length, Integer.MAX_VALUE); read = aux.target.read(buffer, 0, Math.min(read, buffer.length)); aux.target.seek(writeOffset); aux.writeProof(buffer, read); writeOffset += read; readOffset += read; length -= read; aux.target.seek(readOffset); } aux.target.setLength(aux.length); return; } if (aux.length > THRESHOLD_AUX_LENGTH) { aux.target.setLength(THRESHOLD_AUX_LENGTH);// or setLength(0); } aux.reset(); } /** * Flush any buffer and close the output file. Use this method if the * operation is successful * * @return the final length of the file * @throws IOException if an I/O error occurs */ public long finalizeFile() throws IOException { flushAuxiliar(aux.length); out.flush(); // change file length (if required) long length = Math.max(maxLengthKnown, out.length); if (length != out.target.length()) { out.target.setLength(length); } close(); return length; } /** * Close the file without flushing any buffer */ @Override public void close() { if (out != null) { out.close(); out = null; } if (aux != null) { aux.close(); aux = null; } } @Override public void write(byte b) throws IOException { write(new byte[]{b}, 0, 1); } @Override public void write(byte[] b) throws IOException { write(b, 0, b.length); } @Override public void write(byte[] b, int off, int len) throws IOException { if (len == 0) { return; } long available; long offsetOut = out.getOffset(); long offsetAux = aux.getOffset(); long end = callback.check(); if (end == -1) { available = Integer.MAX_VALUE; } else if (end < offsetOut) { throw new IOException("The reported offset is invalid: " + end + "<" + offsetOut); } else { available = end - offsetOut; } boolean usingAux = aux.length > 0 && offsetOut >= out.length; boolean underflow = offsetAux < aux.length || offsetOut < out.length; if (usingAux) { // before continue calculate the final length of aux long length = offsetAux + len; if (underflow) { if (aux.length > length) { length = aux.length;// the length is not changed } } else { length = aux.length + len; } aux.write(b, off, len); if (length >= THRESHOLD_AUX_LENGTH && length <= available) { flushAuxiliar(available); } } else { if (underflow) { available = out.length - offsetOut; } int length = Math.min(len, (int) Math.min(Integer.MAX_VALUE, available)); out.write(b, off, length); len -= length; off += length; if (len > 0) { aux.write(b, off, len); } } if (onProgress != null) { long absoluteOffset = out.getOffset() + aux.getOffset(); if (absoluteOffset > reportPosition) { reportPosition = absoluteOffset + NOTIFY_BYTES_INTERVAL; onProgress.report(absoluteOffset); } } } @Override public void flush() throws IOException { aux.flush(); out.flush(); long total = out.length + aux.length; if (total > maxLengthKnown) { maxLengthKnown = total;// save the current file length in case the method {@code rewind()} is called } } @Override public long skip(long amount) throws IOException { seek(out.getOffset() + aux.getOffset() + amount); return amount; } @Override public void rewind() throws IOException { if (onProgress != null) { onProgress.report(0);// rollback the whole progress } seek(0); reportPosition = NOTIFY_BYTES_INTERVAL; } @Override public void seek(long offset) throws IOException { long total = out.length + aux.length; if (offset == total) { // do not ignore the seek offset if a underflow exists long relativeOffset = out.getOffset() + aux.getOffset(); if (relativeOffset == total) { return; } } // flush everything, avoid any underflow flush(); if (offset < 0 || offset > total) { throw new IOException("desired offset is outside of range=0-" + total + " offset=" + offset); } if (offset > out.length) { out.seek(out.length); aux.seek(offset - out.length); } else { out.seek(offset); aux.seek(0); } } @Override public boolean isClosed() { return out == null; } @Override public boolean canRewind() { return true; } @Override public boolean canWrite() { return true; } @Override public boolean canSeek() { return true; } // @Override public boolean canRead() { return false; } @Override public int read() { throw new UnsupportedOperationException("write-only"); } @Override public int read(byte[] buffer ) { throw new UnsupportedOperationException("write-only"); } @Override public int read(byte[] buffer, int offset, int count ) { throw new UnsupportedOperationException("write-only"); } @Override public long available() { throw new UnsupportedOperationException("write-only"); } // public interface OffsetChecker { /** * Checks the amount of available space ahead * * @return absolute offset in the file where no more data SHOULD NOT be * written. If the value is -1 the whole file will be used */ long check(); } public interface WriteErrorHandle { /** * Attempts to handle a I/O exception * * @param err the cause * @return {@code true} to retry and continue, otherwise, {@code false} * and throw the exception */ boolean handle(Exception err); } class BufferedFile { final SharpStream target; private long offset; long length; private byte[] queue = new byte[QUEUE_BUFFER_SIZE]; private int queueSize; BufferedFile(File file) throws FileNotFoundException { this.target = new FileStream(file); } BufferedFile(SharpStream target) { this.target = target; } long getOffset() { return offset + queueSize;// absolute offset in the file } void close() { queue = null; target.close(); } void write(byte[] b, int off, int len) throws IOException { while (len > 0) { // if the queue is full, the method available() will flush the queue int read = Math.min(available(), len); // enqueue incoming buffer System.arraycopy(b, off, queue, queueSize, read); queueSize += read; len -= read; off += read; } long total = offset + queueSize; if (total > length) { length = total;// save length } } void flush() throws IOException { writeProof(queue, queueSize); offset += queueSize; queueSize = 0; } protected void rewind() throws IOException { offset = 0; target.seek(0); } int available() throws IOException { if (queueSize >= queue.length) { flush(); return queue.length; } return queue.length - queueSize; } void reset() throws IOException { offset = 0; length = 0; target.seek(0); } void seek(long absoluteOffset) throws IOException { if (absoluteOffset == offset) { return;// nothing to do } offset = absoluteOffset; target.seek(absoluteOffset); } void writeProof(byte[] buffer, int length) throws IOException { if (onWriteError == null) { target.write(buffer, 0, length); return; } while (true) { try { target.write(buffer, 0, length); return; } catch (Exception e) { if (!onWriteError.handle(e)) { throw e;// give up } } } } @NonNull @Override public String toString() { String absLength; try { absLength = Long.toString(target.length()); } catch (IOException e) { absLength = "[" + e.getLocalizedMessage() + "]"; } return String.format( "offset=%s length=%s queue=%s absLength=%s", offset, length, queueSize, absLength ); } } } ================================================ FILE: app/src/main/java/us/shandian/giga/io/FileStream.java ================================================ package us.shandian.giga.io; import androidx.annotation.NonNull; import org.schabi.newpipe.streams.io.SharpStream; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; import java.io.RandomAccessFile; /** * @author kapodamy */ public class FileStream extends SharpStream { public RandomAccessFile source; public FileStream(@NonNull File target) throws FileNotFoundException { this.source = new RandomAccessFile(target, "rw"); } public FileStream(@NonNull String path) throws FileNotFoundException { this.source = new RandomAccessFile(path, "rw"); } @Override public int read() throws IOException { return source.read(); } @Override public int read(byte[] b) throws IOException { return source.read(b); } @Override public int read(byte[] b, int off, int len) throws IOException { return source.read(b, off, len); } @Override public long skip(long pos) throws IOException { return source.skipBytes((int) pos); } @Override public long available() { try { return source.length() - source.getFilePointer(); } catch (IOException e) { return 0; } } @Override public void close() { if (source == null) return; try { source.close(); } catch (IOException err) { // nothing to do } source = null; } @Override public boolean isClosed() { return source == null; } @Override public void rewind() throws IOException { source.seek(0); } @Override public boolean canRewind() { return true; } @Override public boolean canRead() { return true; } @Override public boolean canWrite() { return true; } @Override public boolean canSeek() { return true; } @Override public boolean canSetLength() { return true; } @Override public void write(byte value) throws IOException { source.write(value); } @Override public void write(byte[] buffer) throws IOException { source.write(buffer); } @Override public void write(byte[] buffer, int offset, int count) throws IOException { source.write(buffer, offset, count); } @Override public void setLength(long length) throws IOException { source.setLength(length); } @Override public void seek(long offset) throws IOException { source.seek(offset); } @Override public long length() throws IOException { return source.length(); } } ================================================ FILE: app/src/main/java/us/shandian/giga/io/FileStreamSAF.java ================================================ package us.shandian.giga.io; import android.content.ContentResolver; import android.net.Uri; import android.os.ParcelFileDescriptor; import android.util.Log; import androidx.annotation.NonNull; import org.schabi.newpipe.streams.io.SharpStream; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.nio.channels.FileChannel; public class FileStreamSAF extends SharpStream { private final FileInputStream in; private final FileOutputStream out; private final FileChannel channel; private final ParcelFileDescriptor file; private boolean disposed; public FileStreamSAF(@NonNull ContentResolver contentResolver, Uri fileUri) throws IOException { // Notes: // the file must exists first // ¡read-write mode must allow seek! // It is not guaranteed to work with files in the cloud (virtual files), tested in local storage devices file = contentResolver.openFileDescriptor(fileUri, "rw"); if (file == null) { throw new IOException("Cannot get the ParcelFileDescriptor for " + fileUri.toString()); } in = new FileInputStream(file.getFileDescriptor()); out = new FileOutputStream(file.getFileDescriptor()); channel = out.getChannel();// or use in.getChannel() } @Override public int read() throws IOException { return in.read(); } @Override public int read(byte[] buffer) throws IOException { return in.read(buffer); } @Override public int read(byte[] buffer, int offset, int count) throws IOException { return in.read(buffer, offset, count); } @Override public long skip(long amount) throws IOException { return in.skip(amount);// ¿or use channel.position(channel.position() + amount)? } @Override public long available() { try { return in.available(); } catch (IOException e) { return 0;// ¡but not -1! } } @Override public void rewind() throws IOException { seek(0); } @Override public void close() { try { disposed = true; file.close(); in.close(); out.close(); channel.close(); } catch (IOException e) { Log.e("FileStreamSAF", "close() error", e); } } @Override public boolean isClosed() { return disposed; } @Override public boolean canRewind() { return true; } @Override public boolean canRead() { return true; } @Override public boolean canWrite() { return true; } @Override public boolean canSetLength() { return true; } @Override public boolean canSeek() { return true; } @Override public void write(byte value) throws IOException { out.write(value); } @Override public void write(byte[] buffer) throws IOException { out.write(buffer); } @Override public void write(byte[] buffer, int offset, int count) throws IOException { out.write(buffer, offset, count); } @Override public void setLength(long length) throws IOException { channel.truncate(length); } @Override public void seek(long offset) throws IOException { channel.position(offset); } @Override public long length() throws IOException { return channel.size(); } } ================================================ FILE: app/src/main/java/us/shandian/giga/io/ProgressReport.java ================================================ package us.shandian.giga.io; public interface ProgressReport { /** * Report the size of the new file * * @param progress the new size */ void report(long progress); } ================================================ FILE: app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java ================================================ package us.shandian.giga.postprocessing; import org.schabi.newpipe.streams.Mp4DashReader; import org.schabi.newpipe.streams.Mp4FromDashWriter; import org.schabi.newpipe.streams.io.SharpStream; import java.io.IOException; class M4aNoDash extends Postprocessing { M4aNoDash() { super(false, true, ALGORITHM_M4A_NO_DASH); } @Override boolean test(SharpStream... sources) throws IOException { // check if the mp4 file is DASH (youtube) Mp4DashReader reader = new Mp4DashReader(sources[0]); reader.parse(); switch (reader.getBrands()[0]) { case 0x64617368:// DASH case 0x69736F35:// ISO5 return true; default: return false; } } @Override int process(SharpStream out, SharpStream... sources) throws IOException { Mp4FromDashWriter muxer = new Mp4FromDashWriter(sources[0]); muxer.setMainBrand(0x4D344120);// binary string "M4A " muxer.parseSources(); muxer.selectTracks(0); muxer.build(out); return OK_RESULT; } } ================================================ FILE: app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java ================================================ package us.shandian.giga.postprocessing; import org.schabi.newpipe.streams.Mp4FromDashWriter; import org.schabi.newpipe.streams.io.SharpStream; import java.io.IOException; /** * @author kapodamy */ class Mp4FromDashMuxer extends Postprocessing { Mp4FromDashMuxer() { super(true, true, ALGORITHM_MP4_FROM_DASH_MUXER); } @Override int process(SharpStream out, SharpStream... sources) throws IOException { Mp4FromDashWriter muxer = new Mp4FromDashWriter(sources); muxer.parseSources(); muxer.selectTracks(0, 0); muxer.build(out); return OK_RESULT; } } ================================================ FILE: app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java ================================================ package us.shandian.giga.postprocessing; import androidx.annotation.NonNull; import org.schabi.newpipe.streams.OggFromWebMWriter; import org.schabi.newpipe.streams.io.SharpStream; import java.io.IOException; import java.nio.ByteBuffer; class OggFromWebmDemuxer extends Postprocessing { OggFromWebmDemuxer() { super(true, true, ALGORITHM_OGG_FROM_WEBM_DEMUXER); } @Override boolean test(SharpStream... sources) throws IOException { ByteBuffer buffer = ByteBuffer.allocate(4); sources[0].read(buffer.array()); // youtube uses WebM as container, but the file extension (format suffix) is "*.opus" // check if the file is a webm/mkv file before proceed switch (buffer.getInt()) { case 0x1a45dfa3: return true;// webm/mkv case 0x4F676753: return false;// ogg } throw new UnsupportedOperationException("file not recognized, failed to demux the audio stream"); } @Override int process(SharpStream out, @NonNull SharpStream... sources) throws IOException { OggFromWebMWriter demuxer = new OggFromWebMWriter(sources[0], out, streamInfo); demuxer.parseSource(); demuxer.selectTrack(0); demuxer.build(); return OK_RESULT; } } ================================================ FILE: app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java ================================================ package us.shandian.giga.postprocessing; import android.util.Log; import androidx.annotation.NonNull; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.streams.io.SharpStream; import java.io.File; import java.io.IOException; import java.io.Serializable; import us.shandian.giga.get.DownloadMission; import us.shandian.giga.io.ChunkFileInputStream; import us.shandian.giga.io.CircularFileWriter; import us.shandian.giga.io.CircularFileWriter.OffsetChecker; import us.shandian.giga.io.ProgressReport; import static us.shandian.giga.get.DownloadMission.ERROR_NOTHING; import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING; import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_HOLD; public abstract class Postprocessing implements Serializable { static transient final byte OK_RESULT = ERROR_NOTHING; public transient static final String ALGORITHM_TTML_CONVERTER = "ttml"; public transient static final String ALGORITHM_WEBM_MUXER = "webm"; public transient static final String ALGORITHM_MP4_FROM_DASH_MUXER = "mp4D-mp4"; public transient static final String ALGORITHM_M4A_NO_DASH = "mp4D-m4a"; public transient static final String ALGORITHM_OGG_FROM_WEBM_DEMUXER = "webm-ogg-d"; public static Postprocessing getAlgorithm(@NonNull String algorithmName, String[] args, StreamInfo streamInfo) { Postprocessing instance; switch (algorithmName) { case ALGORITHM_TTML_CONVERTER: instance = new TtmlConverter(); break; case ALGORITHM_WEBM_MUXER: instance = new WebMMuxer(); break; case ALGORITHM_MP4_FROM_DASH_MUXER: instance = new Mp4FromDashMuxer(); break; case ALGORITHM_M4A_NO_DASH: instance = new M4aNoDash(); break; case ALGORITHM_OGG_FROM_WEBM_DEMUXER: instance = new OggFromWebmDemuxer(); break; /*case "example-algorithm": instance = new ExampleAlgorithm();*/ default: throw new UnsupportedOperationException("Unimplemented post-processing algorithm: " + algorithmName); } instance.args = args; instance.streamInfo = streamInfo; return instance; } /** * Get a boolean value that indicate if the given algorithm work on the same * file */ public boolean worksOnSameFile; /** * Indicates whether the selected algorithm needs space reserved at the beginning of the file */ public boolean reserveSpace; /** * Gets the given algorithm short name */ private final String name; private String[] args; protected StreamInfo streamInfo; private transient DownloadMission mission; private transient File tempFile; Postprocessing(boolean reserveSpace, boolean worksOnSameFile, String algorithmName) { this.reserveSpace = reserveSpace; this.worksOnSameFile = worksOnSameFile; this.name = algorithmName;// for debugging only } public void setTemporalDir(@NonNull File directory) { long rnd = (int) (Math.random() * 100000.0f); tempFile = new File(directory, rnd + "_" + System.nanoTime() + ".tmp"); } public void cleanupTemporalDir() { if (tempFile != null && tempFile.exists()) { try { //noinspection ResultOfMethodCallIgnored tempFile.delete(); } catch (Exception e) { // nothing to do } } } public void run(DownloadMission target) throws IOException { this.mission = target; int result; long finalLength = -1; mission.done = 0; long length = mission.storage.length() - mission.offsets[0]; mission.length = Math.max(length, mission.nearLength); final ProgressReport readProgress = (long position) -> { position -= mission.offsets[0]; if (position > mission.done) mission.done = position; }; if (worksOnSameFile) { ChunkFileInputStream[] sources = new ChunkFileInputStream[mission.urls.length]; try { for (int i = 0, j = 1; i < sources.length; i++, j++) { SharpStream source = mission.storage.getStream(); long end = j < sources.length ? mission.offsets[j] : source.length(); sources[i] = new ChunkFileInputStream(source, mission.offsets[i], end, readProgress); } if (test(sources)) { for (SharpStream source : sources) source.rewind(); OffsetChecker checker = () -> { for (ChunkFileInputStream source : sources) { /* * WARNING: never use rewind() in any chunk after any writing (especially on first chunks) * or the CircularFileWriter can lead to unexpected results */ if (source.isClosed() || source.available() < 1) { continue;// the selected source is not used anymore } return source.getFilePointer() - 1; } return -1; }; try (CircularFileWriter out = new CircularFileWriter( mission.storage.getStream(), tempFile, checker)) { out.onProgress = (long position) -> mission.done = position; out.onWriteError = err -> { mission.psState = 3; mission.notifyError(ERROR_POSTPROCESSING_HOLD, err); try { synchronized (this) { while (mission.psState == 3) wait(); } } catch (InterruptedException e) { // nothing to do Log.e(getClass().getSimpleName(), "got InterruptedException"); } return mission.errCode == ERROR_NOTHING; }; result = process(out, sources); if (result == OK_RESULT) finalLength = out.finalizeFile(); } } else { result = OK_RESULT; } } finally { for (SharpStream source : sources) { if (source != null && !source.isClosed()) { source.close(); } } if (tempFile != null) { //noinspection ResultOfMethodCallIgnored tempFile.delete(); tempFile = null; } } } else { result = test() ? process(null) : OK_RESULT; } if (result == OK_RESULT) { if (finalLength != -1) { mission.length = finalLength; } } else { mission.errCode = ERROR_POSTPROCESSING; mission.errObject = new RuntimeException("post-processing algorithm returned " + result); } if (result != OK_RESULT && worksOnSameFile) mission.storage.delete(); this.mission = null; } /** * Test if the post-processing algorithm can be skipped * * @param sources files to be processed * @return {@code true} if the post-processing is required, otherwise, {@code false} * @throws IOException if an I/O error occurs. */ boolean test(SharpStream... sources) throws IOException { return true; } /** * Abstract method to execute the post-processing algorithm * * @param out output stream * @param sources files to be processed * @return an error code, {@code OK_RESULT} means the operation was successful * @throws IOException if an I/O error occurs. */ abstract int process(SharpStream out, SharpStream... sources) throws IOException; String getArgumentAt(int index, String defaultValue) { if (args == null || index >= args.length) { return defaultValue; } return args[index]; } @NonNull @Override public String toString() { StringBuilder str = new StringBuilder(); str.append("{ name=").append(name).append('['); if (args != null) { for (String arg : args) { str.append(", "); str.append(arg); } str.delete(0, 1); } return str.append("] }").toString(); } } ================================================ FILE: app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.java ================================================ package us.shandian.giga.postprocessing; import android.util.Log; import org.schabi.newpipe.streams.SrtFromTtmlWriter; import org.schabi.newpipe.streams.io.SharpStream; import java.io.IOException; /** * @author kapodamy */ class TtmlConverter extends Postprocessing { private static final String TAG = "TtmlConverter"; TtmlConverter() { // due how XmlPullParser works, the xml is fully loaded on the ram super(false, true, ALGORITHM_TTML_CONVERTER); } @Override int process(SharpStream out, SharpStream... sources) throws IOException { // check if the subtitle is already in srt and copy, this should never happen String format = getArgumentAt(0, null); boolean ignoreEmptyFrames = getArgumentAt(1, "true").equals("true"); if (format == null || format.equals("ttml")) { SrtFromTtmlWriter writer = new SrtFromTtmlWriter(out, ignoreEmptyFrames); try { writer.build(sources[0]); } catch (IOException err) { Log.e(TAG, "subtitle conversion failed due to I/O error", err); throw err; } catch (Exception err) { Log.e(TAG, "subtitle conversion failed", err); throw new IOException("TTML to SRT conversion failed", err); } return OK_RESULT; } else if (format.equals("srt")) { byte[] buffer = new byte[8 * 1024]; int read; while ((read = sources[0].read(buffer)) > 0) { out.write(buffer, 0, read); } return OK_RESULT; } throw new UnsupportedOperationException("Can't convert this subtitle, unimplemented format: " + format); } } ================================================ FILE: app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java ================================================ package us.shandian.giga.postprocessing; import org.schabi.newpipe.streams.WebMReader.TrackKind; import org.schabi.newpipe.streams.WebMReader.WebMTrack; import org.schabi.newpipe.streams.WebMWriter; import org.schabi.newpipe.streams.io.SharpStream; import java.io.IOException; /** * @author kapodamy */ class WebMMuxer extends Postprocessing { WebMMuxer() { super(true, true, ALGORITHM_WEBM_MUXER); } @Override int process(SharpStream out, SharpStream... sources) throws IOException { WebMWriter muxer = new WebMWriter(sources); muxer.parseSources(); // youtube uses a webm with a fake video track that acts as a "cover image" int[] indexes = new int[sources.length]; for (int i = 0; i < sources.length; i++) { WebMTrack[] tracks = muxer.getTracksFromSource(i); for (int j = 0; j < tracks.length; j++) { if (tracks[j].kind == TrackKind.Audio) { indexes[i] = j; i = sources.length; break; } } } muxer.selectTracks(indexes); muxer.build(out); return OK_RESULT; } } ================================================ FILE: app/src/main/java/us/shandian/giga/service/DownloadManager.java ================================================ package us.shandian.giga.service; import android.content.Context; import android.os.Handler; import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.DiffUtil; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; import us.shandian.giga.get.DownloadMission; import us.shandian.giga.get.FinishedMission; import us.shandian.giga.get.Mission; import us.shandian.giga.get.sqlite.FinishedMissionStore; import org.schabi.newpipe.streams.io.StoredDirectoryHelper; import org.schabi.newpipe.streams.io.StoredFileHelper; import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; import static us.shandian.giga.get.DownloadMission.ERROR_NOTHING; import static us.shandian.giga.get.DownloadMission.ERROR_PROGRESS_LOST; public class DownloadManager { private static final String TAG = DownloadManager.class.getSimpleName(); enum NetworkState {Unavailable, Operating, MeteredOperating} public static final int SPECIAL_NOTHING = 0; public static final int SPECIAL_PENDING = 1; public static final int SPECIAL_FINISHED = 2; public static final String TAG_AUDIO = "audio"; public static final String TAG_VIDEO = "video"; private static final String DOWNLOADS_METADATA_FOLDER = "pending_downloads"; private final FinishedMissionStore mFinishedMissionStore; private final ArrayList mMissionsPending = new ArrayList<>(); private final ArrayList mMissionsFinished; private final Handler mHandler; private final File mPendingMissionsDir; private NetworkState mLastNetworkStatus = NetworkState.Unavailable; int mPrefMaxRetry; boolean mPrefMeteredDownloads; boolean mPrefQueueLimit; private boolean mSelfMissionsControl; StoredDirectoryHelper mMainStorageAudio; StoredDirectoryHelper mMainStorageVideo; /** * Create a new instance * * @param context Context for the data source for finished downloads * @param handler Thread required for Messaging */ DownloadManager(@NonNull Context context, Handler handler, StoredDirectoryHelper storageVideo, StoredDirectoryHelper storageAudio) { if (DEBUG) { Log.d(TAG, "new DownloadManager instance. 0x" + Integer.toHexString(this.hashCode())); } mFinishedMissionStore = new FinishedMissionStore(context); mHandler = handler; mMainStorageAudio = storageAudio; mMainStorageVideo = storageVideo; mMissionsFinished = loadFinishedMissions(); mPendingMissionsDir = getPendingDir(context); loadPendingMissions(context); } private static File getPendingDir(@NonNull Context context) { File dir = context.getExternalFilesDir(DOWNLOADS_METADATA_FOLDER); if (testDir(dir)) return dir; dir = new File(context.getFilesDir(), DOWNLOADS_METADATA_FOLDER); if (testDir(dir)) return dir; throw new RuntimeException("path to pending downloads are not accessible"); } private static boolean testDir(@Nullable File dir) { if (dir == null) return false; try { if (!Utility.mkdir(dir, false)) { Log.e(TAG, "testDir() cannot create the directory in path: " + dir.getAbsolutePath()); return false; } File tmp = new File(dir, ".tmp"); if (!tmp.createNewFile()) return false; return tmp.delete();// if the file was created, SHOULD BE deleted too } catch (Exception e) { Log.e(TAG, "testDir() failed: " + dir.getAbsolutePath(), e); return false; } } /** * Loads finished missions from the data source and forgets finished missions whose file does * not exist anymore. */ private ArrayList loadFinishedMissions() { ArrayList finishedMissions = mFinishedMissionStore.loadFinishedMissions(); // check if the files exists, otherwise, forget the download for (int i = finishedMissions.size() - 1; i >= 0; i--) { FinishedMission mission = finishedMissions.get(i); if (!mission.storage.existsAsFile()) { if (DEBUG) Log.d(TAG, "downloaded file removed: " + mission.storage.getName()); mFinishedMissionStore.deleteMission(mission); finishedMissions.remove(i); } } return finishedMissions; } private void loadPendingMissions(Context ctx) { File[] subs = mPendingMissionsDir.listFiles(); if (subs == null) { Log.e(TAG, "listFiles() returned null"); return; } if (subs.length < 1) { return; } if (DEBUG) { Log.d(TAG, "Loading pending downloads from directory: " + mPendingMissionsDir.getAbsolutePath()); } File tempDir = pickAvailableTemporalDir(ctx); Log.i(TAG, "using '" + tempDir + "' as temporal directory"); for (File sub : subs) { if (!sub.isFile()) continue; if (sub.getName().equals(".tmp")) continue; DownloadMission mis = Utility.readFromFile(sub); if (mis == null) { //noinspection ResultOfMethodCallIgnored sub.delete(); continue; } // DON'T delete missions that are truly finished - let them be moved to finished list if (mis.isFinished()) { // Move to finished missions instead of deleting setFinished(mis); //noinspection ResultOfMethodCallIgnored sub.delete(); continue; } // DON'T delete missions with storage issues - try to recover them if (mis.hasInvalidStorage() && mis.errCode != ERROR_PROGRESS_LOST) { // Only delete if it's truly unrecoverable (not just progress lost) if (mis.storage == null) { //noinspection ResultOfMethodCallIgnored sub.delete(); continue; } } mis.threads = new Thread[0]; boolean exists; try { mis.storage = StoredFileHelper.deserialize(mis.storage, ctx); exists = !mis.storage.isInvalid() && mis.storage.existsAsFile(); } catch (Exception ex) { Log.e(TAG, "Failed to load the file source of " + mis.storage.toString(), ex); // Don't invalidate storage immediately - try to recover first exists = false; } if (mis.isPsRunning()) { if (mis.psAlgorithm.worksOnSameFile) { // Incomplete post-processing results in a corrupted download file if (exists && mis.storage.isDirect() && !mis.storage.delete()) Log.w(TAG, "Unable to delete incomplete download file: " + sub.getPath()); } mis.psState = 0; mis.errCode = DownloadMission.ERROR_POSTPROCESSING_STOPPED; } else if (!exists) { tryRecover(mis); // Keep the mission even if recovery fails - don't reset to ERROR_PROGRESS_LOST // This allows user to see the failed download and potentially retry if (mis.isInitialized() && mis.errCode == ERROR_NOTHING) { mis.resetState(true, true, ERROR_PROGRESS_LOST); } } if (mis.psAlgorithm != null) { mis.psAlgorithm.cleanupTemporalDir(); mis.psAlgorithm.setTemporalDir(tempDir); } mis.metadata = sub; mis.maxRetry = mPrefMaxRetry; mis.mHandler = mHandler; mMissionsPending.add(mis); } if (mMissionsPending.size() > 1) Collections.sort(mMissionsPending, Comparator.comparingLong(Mission::getTimestamp)); } /** * Start a new download mission * * @param mission the new download mission to add and run (if possible) */ void startMission(DownloadMission mission) { synchronized (this) { mission.timestamp = System.currentTimeMillis(); mission.mHandler = mHandler; mission.maxRetry = mPrefMaxRetry; // create metadata file while (true) { mission.metadata = new File(mPendingMissionsDir, String.valueOf(mission.timestamp)); if (!mission.metadata.isFile() && !mission.metadata.exists()) { try { if (!mission.metadata.createNewFile()) throw new RuntimeException("Cant create download metadata file"); } catch (IOException e) { throw new RuntimeException(e); } break; } mission.timestamp = System.currentTimeMillis(); } mSelfMissionsControl = true; mMissionsPending.add(mission); // Before continue, save the metadata in case the internet connection is not available Utility.writeToFile(mission.metadata, mission); if (mission.storage == null) { // noting to do here mission.errCode = DownloadMission.ERROR_FILE_CREATION; if (mission.errObject != null) mission.errObject = new IOException("DownloadMission.storage == NULL"); return; } boolean start = !mPrefQueueLimit || getRunningMissionsCount() < 1; if (canDownloadInCurrentNetwork() && start) { mission.start(); } } } public void resumeMission(DownloadMission mission) { if (!mission.running) { mission.start(); } } public void pauseMission(DownloadMission mission) { if (mission.running) { mission.setEnqueued(false); mission.pause(); } } public void deleteMission(Mission mission, boolean alsoDeleteFile) { synchronized (this) { if (mission instanceof DownloadMission) { mMissionsPending.remove(mission); } else if (mission instanceof FinishedMission) { mMissionsFinished.remove(mission); mFinishedMissionStore.deleteMission(mission); } if (alsoDeleteFile) { mission.delete(); } } } public void forgetMission(StoredFileHelper storage) { synchronized (this) { Mission mission = getAnyMission(storage); if (mission == null) return; if (mission instanceof DownloadMission) { mMissionsPending.remove(mission); } else if (mission instanceof FinishedMission) { mMissionsFinished.remove(mission); mFinishedMissionStore.deleteMission(mission); } mission.storage = null; mission.delete(); } } public void tryRecover(DownloadMission mission) { StoredDirectoryHelper mainStorage = getMainStorage(mission.storage.getTag()); if (!mission.storage.isInvalid() && mission.storage.create()) return; // using javaIO cannot recreate the file // using SAF in older devices (no tree available) // // force the user to pick again the save path mission.storage.invalidate(); if (mainStorage == null) return; // if the user has changed the save path before this download, the original save path will be lost StoredFileHelper newStorage = mainStorage.createFile(mission.storage.getName(), mission.storage.getType()); if (newStorage != null) mission.storage = newStorage; } /** * Get a pending mission by its path * * @param storage where the file possible is stored * @return the mission or null if no such mission exists */ @Nullable private DownloadMission getPendingMission(StoredFileHelper storage) { for (DownloadMission mission : mMissionsPending) { if (mission.storage.equals(storage)) { return mission; } } return null; } /** * Get the index into {@link #mMissionsFinished} of a finished mission by its path, return * {@code -1} if there is no such mission. This function also checks if the matched mission's * file exists, and, if it does not, the related mission is forgotten about (like in {@link * #loadFinishedMissions()}) and {@code -1} is returned. * * @param storage where the file would be stored * @return the mission index or -1 if no such mission exists */ private int getFinishedMissionIndex(StoredFileHelper storage) { for (int i = 0; i < mMissionsFinished.size(); i++) { if (mMissionsFinished.get(i).storage.equals(storage)) { // If the file does not exist the mission is not valid anymore. Also checking if // length == 0 since the file picker may create an empty file before yielding it, // but that does not mean the file really belonged to a previous mission. if (!storage.existsAsFile() || storage.length() == 0) { if (DEBUG) { Log.d(TAG, "matched downloaded file removed: " + storage.getName()); } mFinishedMissionStore.deleteMission(mMissionsFinished.get(i)); mMissionsFinished.remove(i); return -1; // finished mission whose associated file was removed } return i; } } return -1; } private Mission getAnyMission(StoredFileHelper storage) { synchronized (this) { Mission mission = getPendingMission(storage); if (mission != null) return mission; int idx = getFinishedMissionIndex(storage); if (idx >= 0) return mMissionsFinished.get(idx); } return null; } int getRunningMissionsCount() { int count = 0; synchronized (this) { for (DownloadMission mission : mMissionsPending) { if (mission.running && !mission.isPsFailed() && !mission.isFinished()) count++; } } return count; } public void pauseAllMissions(boolean force) { synchronized (this) { for (DownloadMission mission : mMissionsPending) { if (!mission.running || mission.isPsRunning() || mission.isFinished()) continue; if (force) { // avoid waiting for threads mission.init = null; mission.threads = new Thread[0]; } mission.pause(); } } } public void startAllMissions() { synchronized (this) { for (DownloadMission mission : mMissionsPending) { if (mission.running || mission.isCorrupt()) continue; mission.start(); } } } /** * Set a pending download as finished * * @param mission the desired mission */ void setFinished(DownloadMission mission) { synchronized (this) { mMissionsPending.remove(mission); mMissionsFinished.add(0, new FinishedMission(mission)); mFinishedMissionStore.addFinishedMission(mission); } } /** * runs one or multiple missions in from queue if possible * * @return true if one or multiple missions are running, otherwise, false */ boolean runMissions() { synchronized (this) { if (mMissionsPending.size() < 1) return false; if (!canDownloadInCurrentNetwork()) return false; if (mPrefQueueLimit) { for (DownloadMission mission : mMissionsPending) if (!mission.isFinished() && mission.running) return true; } boolean flag = false; for (DownloadMission mission : mMissionsPending) { if (mission.running || !mission.enqueued || mission.isFinished()) continue; resumeMission(mission); if (mission.errCode != ERROR_NOTHING) continue; if (mPrefQueueLimit) return true; flag = true; } return flag; } } public MissionIterator getIterator() { mSelfMissionsControl = true; return new MissionIterator(); } /** * Forget all finished downloads, but, doesn't delete any file */ public void forgetFinishedDownloads() { synchronized (this) { for (FinishedMission mission : mMissionsFinished) { mFinishedMissionStore.deleteMission(mission); } mMissionsFinished.clear(); } } private boolean canDownloadInCurrentNetwork() { if (mLastNetworkStatus == NetworkState.Unavailable) return false; return !(mPrefMeteredDownloads && mLastNetworkStatus == NetworkState.MeteredOperating); } void handleConnectivityState(NetworkState currentStatus, boolean updateOnly) { if (currentStatus == mLastNetworkStatus) return; mLastNetworkStatus = currentStatus; if (currentStatus == NetworkState.Unavailable) return; if (!mSelfMissionsControl || updateOnly) { return;// don't touch anything without the user interaction } boolean isMetered = mPrefMeteredDownloads && mLastNetworkStatus == NetworkState.MeteredOperating; synchronized (this) { for (DownloadMission mission : mMissionsPending) { if (mission.isCorrupt() || mission.isPsRunning()) continue; if (mission.running && isMetered) { mission.pause(); } else if (!mission.running && !isMetered && mission.enqueued) { mission.start(); if (mPrefQueueLimit) break; } } } } void updateMaximumAttempts() { synchronized (this) { for (DownloadMission mission : mMissionsPending) mission.maxRetry = mPrefMaxRetry; } } public boolean canRecoverMission(DownloadMission mission) { if (mission == null) return false; // Can recover missions with progress lost or storage issues return mission.errCode == ERROR_PROGRESS_LOST || mission.storage == null || !mission.storage.existsAsFile(); } public MissionState checkForExistingMission(StoredFileHelper storage) { synchronized (this) { DownloadMission pending = getPendingMission(storage); if (pending == null) { if (getFinishedMissionIndex(storage) >= 0) return MissionState.Finished; } else { if (pending.isFinished()) { return MissionState.Finished;// this never should happen (race-condition) } else { return pending.running ? MissionState.PendingRunning : MissionState.Pending; } } } return MissionState.None; } private static boolean isDirectoryAvailable(File directory) { return directory != null && directory.canWrite() && directory.exists(); } static File pickAvailableTemporalDir(@NonNull Context ctx) { File dir = ctx.getExternalFilesDir(null); if (isDirectoryAvailable(dir)) return dir; dir = ctx.getFilesDir(); if (isDirectoryAvailable(dir)) return dir; // this never should happen dir = ctx.getDir("muxing_tmp", Context.MODE_PRIVATE); if (isDirectoryAvailable(dir)) return dir; // fallback to cache dir dir = ctx.getCacheDir(); if (isDirectoryAvailable(dir)) return dir; throw new RuntimeException("Not temporal directories are available"); } @Nullable private StoredDirectoryHelper getMainStorage(@NonNull String tag) { if (tag.equals(TAG_AUDIO)) return mMainStorageAudio; if (tag.equals(TAG_VIDEO)) return mMainStorageVideo; Log.w(TAG, "Unknown download category, not [audio video]: " + tag); return null;// this never should happen } public class MissionIterator extends DiffUtil.Callback { final Object FINISHED = new Object(); final Object PENDING = new Object(); ArrayList snapshot; ArrayList current; ArrayList hidden; boolean hasFinished = false; private MissionIterator() { hidden = new ArrayList<>(2); current = null; snapshot = getSpecialItems(); } private ArrayList getSpecialItems() { synchronized (DownloadManager.this) { ArrayList pending = new ArrayList<>(mMissionsPending); ArrayList finished = new ArrayList<>(mMissionsFinished); List remove = new ArrayList<>(hidden); // Don't hide recoverable missions remove.removeIf(mission -> { if (mission instanceof DownloadMission dm && canRecoverMission(dm)) { return false; // Don't remove recoverable missions } return pending.remove(mission) || finished.remove(mission); }); int fakeTotal = pending.size(); if (fakeTotal > 0) fakeTotal++; fakeTotal += finished.size(); if (finished.size() > 0) fakeTotal++; ArrayList list = new ArrayList<>(fakeTotal); if (pending.size() > 0) { list.add(PENDING); list.addAll(pending); } if (finished.size() > 0) { list.add(FINISHED); list.addAll(finished); } hasFinished = finished.size() > 0; return list; } } public MissionItem getItem(int position) { Object object = snapshot.get(position); if (object == PENDING) return new MissionItem(SPECIAL_PENDING); if (object == FINISHED) return new MissionItem(SPECIAL_FINISHED); return new MissionItem(SPECIAL_NOTHING, (Mission) object); } public int getSpecialAtItem(int position) { Object object = snapshot.get(position); if (object == PENDING) return SPECIAL_PENDING; if (object == FINISHED) return SPECIAL_FINISHED; return SPECIAL_NOTHING; } public void start() { current = getSpecialItems(); } public void end() { snapshot = current; current = null; } public void hide(Mission mission) { hidden.add(mission); } public void unHide(Mission mission) { hidden.remove(mission); } public boolean hasFinishedMissions() { return hasFinished; } /** * Check if exists missions running and paused. Corrupted and hidden missions are not counted * * @return two-dimensional array contains the current missions state. * 1° entry: true if has at least one mission running * 2° entry: true if has at least one mission paused */ public boolean[] hasValidPendingMissions() { boolean running = false; boolean paused = false; synchronized (DownloadManager.this) { for (DownloadMission mission : mMissionsPending) { if (hidden.contains(mission) || mission.isCorrupt()) continue; if (mission.running) running = true; else paused = true; } } return new boolean[]{running, paused}; } @Override public int getOldListSize() { return snapshot.size(); } @Override public int getNewListSize() { return current.size(); } @Override public boolean areItemsTheSame(int oldItemPosition, int newItemPosition) { return snapshot.get(oldItemPosition) == current.get(newItemPosition); } @Override public boolean areContentsTheSame(int oldItemPosition, int newItemPosition) { Object x = snapshot.get(oldItemPosition); Object y = current.get(newItemPosition); if (x instanceof Mission && y instanceof Mission) { return ((Mission) x).storage.equals(((Mission) y).storage); } return false; } } public static class MissionItem { public int special; public Mission mission; MissionItem(int s, Mission m) { special = s; mission = m; } MissionItem(int s) { this(s, null); } } } ================================================ FILE: app/src/main/java/us/shandian/giga/service/DownloadManagerService.java ================================================ package us.shandian.giga.service; import static org.schabi.newpipe.BuildConfig.APPLICATION_ID; import static org.schabi.newpipe.BuildConfig.DEBUG; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; import android.content.SharedPreferences.OnSharedPreferenceChangeListener; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.net.ConnectivityManager; import android.net.Network; import android.net.NetworkInfo; import android.net.NetworkRequest; import android.net.Uri; import android.os.Binder; import android.os.Handler; import android.os.Handler.Callback; import android.os.IBinder; import android.os.Message; import android.util.Log; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.collection.SparseArrayCompat; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat.Builder; import androidx.core.app.PendingIntentCompat; import androidx.core.app.ServiceCompat; import androidx.core.content.ContextCompat; import androidx.core.content.IntentCompat; import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; import org.schabi.newpipe.download.DownloadActivity; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.player.helper.LockManager; import org.schabi.newpipe.streams.io.StoredDirectoryHelper; import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.Localization; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Objects; import us.shandian.giga.get.DownloadMission; import us.shandian.giga.get.MissionRecoveryInfo; import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.service.DownloadManager.NetworkState; public class DownloadManagerService extends Service { private static final String TAG = "DownloadManagerService"; public static final int MESSAGE_RUNNING = 0; public static final int MESSAGE_PAUSED = 1; public static final int MESSAGE_FINISHED = 2; public static final int MESSAGE_ERROR = 3; public static final int MESSAGE_DELETED = 4; private static final int FOREGROUND_NOTIFICATION_ID = 1000; private static final int DOWNLOADS_NOTIFICATION_ID = 1001; private static final String EXTRA_URLS = "DownloadManagerService.extra.urls"; private static final String EXTRA_KIND = "DownloadManagerService.extra.kind"; private static final String EXTRA_THREADS = "DownloadManagerService.extra.threads"; private static final String EXTRA_POSTPROCESSING_NAME = "DownloadManagerService.extra.postprocessingName"; private static final String EXTRA_POSTPROCESSING_ARGS = "DownloadManagerService.extra.postprocessingArgs"; private static final String EXTRA_NEAR_LENGTH = "DownloadManagerService.extra.nearLength"; private static final String EXTRA_PATH = "DownloadManagerService.extra.storagePath"; private static final String EXTRA_PARENT_PATH = "DownloadManagerService.extra.storageParentPath"; private static final String EXTRA_STORAGE_TAG = "DownloadManagerService.extra.storageTag"; private static final String EXTRA_RECOVERY_INFO = "DownloadManagerService.extra.recoveryInfo"; private static final String EXTRA_STREAM_INFO = "DownloadManagerService.extra.streamInfo"; private static final String ACTION_RESET_DOWNLOAD_FINISHED = APPLICATION_ID + ".reset_download_finished"; private static final String ACTION_OPEN_DOWNLOADS_FINISHED = APPLICATION_ID + ".open_downloads_finished"; private DownloadManagerBinder mBinder; private DownloadManager mManager; private Notification mNotification; private Handler mHandler; private boolean mForeground = false; private NotificationManager mNotificationManager = null; private boolean mDownloadNotificationEnable = true; private int downloadDoneCount = 0; private Builder downloadDoneNotification = null; private StringBuilder downloadDoneList = null; private final List mEchoObservers = new ArrayList<>(1); private ConnectivityManager mConnectivityManager; private ConnectivityManager.NetworkCallback mNetworkStateListenerL = null; private SharedPreferences mPrefs = null; private final OnSharedPreferenceChangeListener mPrefChangeListener = this::handlePreferenceChange; private boolean mLockAcquired = false; private LockManager mLock = null; private int downloadFailedNotificationID = DOWNLOADS_NOTIFICATION_ID + 1; private Builder downloadFailedNotification = null; private final SparseArrayCompat mFailedDownloads = new SparseArrayCompat<>(5); private Bitmap icLauncher; private Bitmap icDownloadDone; private Bitmap icDownloadFailed; private PendingIntent mOpenDownloadList; /** * notify media scanner on downloaded media file ... * * @param file the downloaded file uri */ private void notifyMediaScanner(Uri file) { sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, file)); } @Override public void onCreate() { super.onCreate(); if (DEBUG) { Log.d(TAG, "onCreate"); } mBinder = new DownloadManagerBinder(); mHandler = new Handler(this::handleMessage); mPrefs = PreferenceManager.getDefaultSharedPreferences(this); mManager = new DownloadManager(this, mHandler, loadMainVideoStorage(), loadMainAudioStorage()); Intent openDownloadListIntent = new Intent(this, DownloadActivity.class) .setAction(Intent.ACTION_MAIN); mOpenDownloadList = PendingIntentCompat.getActivity(this, 0, openDownloadListIntent, PendingIntent.FLAG_UPDATE_CURRENT, false); icLauncher = BitmapFactory.decodeResource(this.getResources(), R.mipmap.ic_launcher); Builder builder = new Builder(this, getString(R.string.notification_channel_id)) .setContentIntent(mOpenDownloadList) .setSmallIcon(android.R.drawable.stat_sys_download) .setLargeIcon(icLauncher) .setContentTitle(getString(R.string.msg_running)) .setContentText(getString(R.string.msg_running_detail)); mNotification = builder.build(); mNotificationManager = ContextCompat.getSystemService(this, NotificationManager.class); mConnectivityManager = ContextCompat.getSystemService(this, ConnectivityManager.class); mNetworkStateListenerL = new ConnectivityManager.NetworkCallback() { @Override public void onAvailable(Network network) { handleConnectivityState(false); } @Override public void onLost(Network network) { handleConnectivityState(false); } }; mConnectivityManager.registerNetworkCallback(new NetworkRequest.Builder().build(), mNetworkStateListenerL); mPrefs.registerOnSharedPreferenceChangeListener(mPrefChangeListener); handlePreferenceChange(mPrefs, getString(R.string.downloads_cross_network)); handlePreferenceChange(mPrefs, getString(R.string.downloads_maximum_retry)); handlePreferenceChange(mPrefs, getString(R.string.downloads_queue_limit)); mLock = new LockManager(this); } @Override public int onStartCommand(final Intent intent, int flags, int startId) { if (DEBUG) { Log.d(TAG, intent == null ? "Restarting" : "Starting"); } if (intent == null) return START_NOT_STICKY; Log.i(TAG, "Got intent: " + intent); String action = intent.getAction(); if (action != null) { if (action.equals(Intent.ACTION_RUN)) { mHandler.post(() -> startMission(intent)); } else if (downloadDoneNotification != null) { if (action.equals(ACTION_RESET_DOWNLOAD_FINISHED) || action.equals(ACTION_OPEN_DOWNLOADS_FINISHED)) { downloadDoneCount = 0; downloadDoneList.setLength(0); } if (action.equals(ACTION_OPEN_DOWNLOADS_FINISHED)) { startActivity(new Intent(this, DownloadActivity.class) .setAction(Intent.ACTION_MAIN) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) ); } return START_NOT_STICKY; } } return START_STICKY; } @Override public void onDestroy() { super.onDestroy(); if (DEBUG) { Log.d(TAG, "Destroying"); } ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); if (mNotificationManager != null && downloadDoneNotification != null) { downloadDoneNotification.setDeleteIntent(null);// prevent NewPipe running when is killed, cleared from recent, etc mNotificationManager.notify(DOWNLOADS_NOTIFICATION_ID, downloadDoneNotification.build()); } manageLock(false); mConnectivityManager.unregisterNetworkCallback(mNetworkStateListenerL); mPrefs.unregisterOnSharedPreferenceChangeListener(mPrefChangeListener); if (icDownloadDone != null) icDownloadDone.recycle(); if (icDownloadFailed != null) icDownloadFailed.recycle(); if (icLauncher != null) icLauncher.recycle(); mHandler = null; mManager.pauseAllMissions(true); } @Override public IBinder onBind(Intent intent) { return mBinder; } private boolean handleMessage(@NonNull Message msg) { if (mHandler == null) return true; DownloadMission mission = (DownloadMission) msg.obj; switch (msg.what) { case MESSAGE_FINISHED: notifyMediaScanner(mission.storage.getUri()); notifyFinishedDownload(mission.storage.getName()); mManager.setFinished(mission); handleConnectivityState(false); updateForegroundState(mManager.runMissions()); break; case MESSAGE_RUNNING: updateForegroundState(true); break; case MESSAGE_ERROR: notifyFailedDownload(mission); handleConnectivityState(false); updateForegroundState(mManager.runMissions()); break; case MESSAGE_PAUSED: updateForegroundState(mManager.getRunningMissionsCount() > 0); break; } if (msg.what != MESSAGE_ERROR) mFailedDownloads.remove(mFailedDownloads.indexOfValue(mission)); for (Callback observer : mEchoObservers) observer.handleMessage(msg); return true; } private void handleConnectivityState(boolean updateOnly) { NetworkInfo info = mConnectivityManager.getActiveNetworkInfo(); NetworkState status; if (info == null) { status = NetworkState.Unavailable; Log.i(TAG, "Active network [connectivity is unavailable]"); } else { boolean connected = info.isConnected(); boolean metered = mConnectivityManager.isActiveNetworkMetered(); if (connected) status = metered ? NetworkState.MeteredOperating : NetworkState.Operating; else status = NetworkState.Unavailable; Log.i(TAG, "Active network [connected=" + connected + " metered=" + metered + "] " + info.toString()); } if (mManager == null) return;// avoid race-conditions while the service is starting mManager.handleConnectivityState(status, updateOnly); } private void handlePreferenceChange(SharedPreferences prefs, @NonNull String key) { if (getString(R.string.downloads_maximum_retry).equals(key)) { try { String value = prefs.getString(key, getString(R.string.downloads_maximum_retry_default)); mManager.mPrefMaxRetry = value == null ? 0 : Integer.parseInt(value); } catch (Exception e) { mManager.mPrefMaxRetry = 0; } mManager.updateMaximumAttempts(); } else if (getString(R.string.downloads_cross_network).equals(key)) { mManager.mPrefMeteredDownloads = prefs.getBoolean(key, false); } else if (getString(R.string.downloads_queue_limit).equals(key)) { mManager.mPrefQueueLimit = prefs.getBoolean(key, true); } else if (getString(R.string.download_path_video_key).equals(key)) { mManager.mMainStorageVideo = loadMainVideoStorage(); } else if (getString(R.string.download_path_audio_key).equals(key)) { mManager.mMainStorageAudio = loadMainAudioStorage(); } } public void updateForegroundState(boolean state) { if (state == mForeground) return; if (state) { startForeground(FOREGROUND_NOTIFICATION_ID, mNotification); } else { ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); } manageLock(state); mForeground = state; } /** * Start a new download mission * * @param context the activity context * @param urls array of urls to download * @param storage where the file is saved * @param kind type of file (a: audio v: video s: subtitle ?: file-extension defined) * @param threads the number of threads maximal used to download chunks of the file. * @param psName the name of the required post-processing algorithm, or {@code null} to ignore. * @param streamInfo stream metadata that may be written into the downloaded file. * @param psArgs the arguments for the post-processing algorithm. * @param nearLength the approximated final length of the file * @param recoveryInfo array of MissionRecoveryInfo, in case is required recover the download */ public static void startMission(Context context, String[] urls, StoredFileHelper storage, char kind, int threads, StreamInfo streamInfo, String psName, String[] psArgs, long nearLength, ArrayList recoveryInfo) { final Intent intent = new Intent(context, DownloadManagerService.class) .setAction(Intent.ACTION_RUN) .putExtra(EXTRA_URLS, urls) .putExtra(EXTRA_KIND, kind) .putExtra(EXTRA_THREADS, threads) .putExtra(EXTRA_POSTPROCESSING_NAME, psName) .putExtra(EXTRA_POSTPROCESSING_ARGS, psArgs) .putExtra(EXTRA_NEAR_LENGTH, nearLength) .putExtra(EXTRA_RECOVERY_INFO, recoveryInfo) .putExtra(EXTRA_PARENT_PATH, storage.getParentUri()) .putExtra(EXTRA_PATH, storage.getUri()) .putExtra(EXTRA_STORAGE_TAG, storage.getTag()) .putExtra(EXTRA_STREAM_INFO, streamInfo); context.startService(intent); } private void startMission(Intent intent) { String[] urls = intent.getStringArrayExtra(EXTRA_URLS); Uri path = IntentCompat.getParcelableExtra(intent, EXTRA_PATH, Uri.class); Uri parentPath = IntentCompat.getParcelableExtra(intent, EXTRA_PARENT_PATH, Uri.class); int threads = intent.getIntExtra(EXTRA_THREADS, 1); char kind = intent.getCharExtra(EXTRA_KIND, '?'); String psName = intent.getStringExtra(EXTRA_POSTPROCESSING_NAME); String[] psArgs = intent.getStringArrayExtra(EXTRA_POSTPROCESSING_ARGS); long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0); String tag = intent.getStringExtra(EXTRA_STORAGE_TAG); StreamInfo streamInfo = (StreamInfo)intent.getSerializableExtra(EXTRA_STREAM_INFO); final var recovery = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_RECOVERY_INFO, MissionRecoveryInfo.class); Objects.requireNonNull(recovery); StoredFileHelper storage; try { storage = new StoredFileHelper(this, parentPath, path, tag); } catch (IOException e) { throw new RuntimeException(e);// this never should happen } Postprocessing ps; if (psName == null) ps = null; else ps = Postprocessing.getAlgorithm(psName, psArgs, streamInfo); final DownloadMission mission = new DownloadMission(urls, storage, kind, ps); mission.threadCount = threads; mission.source = streamInfo.getUrl(); mission.nearLength = nearLength; mission.recoveryInfo = recovery.toArray(new MissionRecoveryInfo[0]); if (ps != null) ps.setTemporalDir(DownloadManager.pickAvailableTemporalDir(this)); handleConnectivityState(true);// first check the actual network status mManager.startMission(mission); } public void notifyFinishedDownload(String name) { if (!mDownloadNotificationEnable || mNotificationManager == null) { return; } if (downloadDoneNotification == null) { downloadDoneList = new StringBuilder(name.length()); icDownloadDone = BitmapFactory.decodeResource(this.getResources(), android.R.drawable.stat_sys_download_done); downloadDoneNotification = new Builder(this, getString(R.string.notification_channel_id)) .setAutoCancel(true) .setLargeIcon(icDownloadDone) .setSmallIcon(android.R.drawable.stat_sys_download_done) .setDeleteIntent(makePendingIntent(ACTION_RESET_DOWNLOAD_FINISHED)) .setContentIntent(makePendingIntent(ACTION_OPEN_DOWNLOADS_FINISHED)); } downloadDoneCount++; if (downloadDoneCount == 1) { downloadDoneList.append(name); downloadDoneNotification.setContentTitle(null); downloadDoneNotification.setContentText(Localization.downloadCount(this, downloadDoneCount)); downloadDoneNotification.setStyle(new NotificationCompat.BigTextStyle() .setBigContentTitle(Localization.downloadCount(this, downloadDoneCount)) .bigText(name) ); } else { downloadDoneList.append('\n'); downloadDoneList.append(name); downloadDoneNotification.setStyle(new NotificationCompat.BigTextStyle().bigText(downloadDoneList)); downloadDoneNotification.setContentTitle(Localization.downloadCount(this, downloadDoneCount)); downloadDoneNotification.setContentText(downloadDoneList); } mNotificationManager.notify(DOWNLOADS_NOTIFICATION_ID, downloadDoneNotification.build()); } public void notifyFailedDownload(DownloadMission mission) { if (!mDownloadNotificationEnable || mFailedDownloads.containsValue(mission)) return; int id = downloadFailedNotificationID++; mFailedDownloads.put(id, mission); if (downloadFailedNotification == null) { icDownloadFailed = BitmapFactory.decodeResource(this.getResources(), android.R.drawable.stat_sys_warning); downloadFailedNotification = new Builder(this, getString(R.string.notification_channel_id)) .setAutoCancel(true) .setLargeIcon(icDownloadFailed) .setSmallIcon(android.R.drawable.stat_sys_warning) .setContentIntent(mOpenDownloadList); } downloadFailedNotification.setContentTitle(getString(R.string.download_failed)); downloadFailedNotification.setContentText(mission.storage.getName()); downloadFailedNotification.setStyle(new NotificationCompat.BigTextStyle() .bigText(mission.storage.getName())); mNotificationManager.notify(id, downloadFailedNotification.build()); } private PendingIntent makePendingIntent(String action) { Intent intent = new Intent(this, DownloadManagerService.class).setAction(action); return PendingIntentCompat.getService(this, intent.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT, false); } private void manageLock(boolean acquire) { if (acquire == mLockAcquired) return; if (acquire) mLock.acquireWifiAndCpu(); else mLock.releaseWifiAndCpu(); mLockAcquired = acquire; } private StoredDirectoryHelper loadMainVideoStorage() { return loadMainStorage(R.string.download_path_video_key, DownloadManager.TAG_VIDEO); } private StoredDirectoryHelper loadMainAudioStorage() { return loadMainStorage(R.string.download_path_audio_key, DownloadManager.TAG_AUDIO); } private StoredDirectoryHelper loadMainStorage(@StringRes int prefKey, String tag) { String path = mPrefs.getString(getString(prefKey), null); if (path == null || path.isEmpty()) return null; if (path.charAt(0) == File.separatorChar) { Log.i(TAG, "Old save path style present: " + path); path = ""; mPrefs.edit().putString(getString(prefKey), "").apply(); } try { return new StoredDirectoryHelper(this, Uri.parse(path), tag); } catch (Exception e) { Log.e(TAG, "Failed to load the storage of " + tag + " from " + path, e); Toast.makeText(this, R.string.no_available_dir, Toast.LENGTH_LONG).show(); } return null; } //////////////////////////////////////////////////////////////////////////////////////////////// // Wrappers for DownloadManager //////////////////////////////////////////////////////////////////////////////////////////////// public class DownloadManagerBinder extends Binder { public DownloadManager getDownloadManager() { return mManager; } @Nullable public StoredDirectoryHelper getMainStorageVideo() { return mManager.mMainStorageVideo; } @Nullable public StoredDirectoryHelper getMainStorageAudio() { return mManager.mMainStorageAudio; } public boolean askForSavePath() { return DownloadManagerService.this.mPrefs.getBoolean( DownloadManagerService.this.getString(R.string.downloads_storage_ask), false ); } public void addMissionEventListener(Callback handler) { mEchoObservers.add(handler); } public void removeMissionEventListener(Callback handler) { mEchoObservers.remove(handler); } public void clearDownloadNotifications() { if (mNotificationManager == null) return; if (downloadDoneNotification != null) { mNotificationManager.cancel(DOWNLOADS_NOTIFICATION_ID); downloadDoneList.setLength(0); downloadDoneCount = 0; } if (downloadFailedNotification != null) { for (; downloadFailedNotificationID > DOWNLOADS_NOTIFICATION_ID; downloadFailedNotificationID--) { mNotificationManager.cancel(downloadFailedNotificationID); } mFailedDownloads.clear(); downloadFailedNotificationID++; } } public void enableNotifications(boolean enable) { mDownloadNotificationEnable = enable; } } } ================================================ FILE: app/src/main/java/us/shandian/giga/service/MissionState.java ================================================ package us.shandian.giga.service; public enum MissionState { None, Pending, PendingRunning, Finished } ================================================ FILE: app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java ================================================ package us.shandian.giga.ui.adapter; import static android.content.Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; import static android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION; import static android.content.Intent.createChooser; import static us.shandian.giga.get.DownloadMission.ERROR_CONNECT_HOST; import static us.shandian.giga.get.DownloadMission.ERROR_FILE_CREATION; import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_NO_CONTENT; import static us.shandian.giga.get.DownloadMission.ERROR_INSUFFICIENT_STORAGE; import static us.shandian.giga.get.DownloadMission.ERROR_NOTHING; import static us.shandian.giga.get.DownloadMission.ERROR_PATH_CREATION; import static us.shandian.giga.get.DownloadMission.ERROR_PERMISSION_DENIED; import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING; import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_HOLD; import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_STOPPED; import static us.shandian.giga.get.DownloadMission.ERROR_PROGRESS_LOST; import static us.shandian.giga.get.DownloadMission.ERROR_RESOURCE_GONE; import static us.shandian.giga.get.DownloadMission.ERROR_SSL_EXCEPTION; import static us.shandian.giga.get.DownloadMission.ERROR_TIMEOUT; import static us.shandian.giga.get.DownloadMission.ERROR_UNKNOWN_EXCEPTION; import static us.shandian.giga.get.DownloadMission.ERROR_UNKNOWN_HOST; import android.annotation.SuppressLint; import android.app.NotificationManager; import android.content.Context; import android.content.Intent; import android.graphics.Color; import android.net.Uri; import android.os.Build; import android.os.Handler; import android.os.Message; import android.util.Log; import android.view.HapticFeedbackConstants; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.webkit.MimeTypeMap; import android.widget.ImageView; import android.widget.PopupMenu; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; import androidx.core.app.NotificationCompat; import androidx.core.content.ContextCompat; import androidx.core.content.FileProvider; import androidx.core.os.HandlerCompat; import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView.Adapter; import androidx.recyclerview.widget.RecyclerView.ViewHolder; import com.google.android.material.snackbar.Snackbar; import org.schabi.newpipe.BuildConfig; 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.NewPipe; import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; import java.io.File; import java.net.URI; import java.util.ArrayList; import java.util.Arrays; import java.util.Iterator; import java.util.Date; import java.util.Locale; import java.text.DateFormat; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.schedulers.Schedulers; import us.shandian.giga.get.DownloadMission; import us.shandian.giga.get.FinishedMission; import us.shandian.giga.get.Mission; import us.shandian.giga.get.MissionRecoveryInfo; import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.ui.common.Deleter; import us.shandian.giga.ui.common.ProgressDrawable; import us.shandian.giga.util.Utility; public class MissionAdapter extends Adapter implements Handler.Callback { private static final String TAG = "MissionAdapter"; private static final String UNDEFINED_PROGRESS = "--.-%"; private static final String DEFAULT_MIME_TYPE = "*/*"; private static final String UNDEFINED_ETA = "--:--"; private static final String UPDATER = "updater"; private static final String DELETE = "deleteFinishedDownloads"; private static final int HASH_NOTIFICATION_ID = 123790; private final Context mContext; private final LayoutInflater mInflater; private final DownloadManager mDownloadManager; private final Deleter mDeleter; private int mLayout; private final DownloadManager.MissionIterator mIterator; private final ArrayList mPendingDownloadsItems = new ArrayList<>(); private final Handler mHandler; private MenuItem mClear; private MenuItem mStartButton; private MenuItem mPauseButton; private final View mEmptyMessage; private RecoverHelper mRecover; private final View mView; private final ArrayList mHidden; private Snackbar mSnackbar; private final CompositeDisposable compositeDisposable = new CompositeDisposable(); public MissionAdapter(Context context, @NonNull DownloadManager downloadManager, View emptyMessage, View root) { mContext = context; mDownloadManager = downloadManager; mInflater = LayoutInflater.from(mContext); mLayout = R.layout.mission_item; mHandler = new Handler(context.getMainLooper()); mEmptyMessage = emptyMessage; mIterator = downloadManager.getIterator(); mDeleter = new Deleter(root, mContext, this, mDownloadManager, mIterator, mHandler); mView = root; mHidden = new ArrayList<>(); checkEmptyMessageVisibility(); onResume(); } @Override @NonNull public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { switch (viewType) { case DownloadManager.SPECIAL_PENDING: case DownloadManager.SPECIAL_FINISHED: return new ViewHolderHeader(mInflater.inflate(R.layout.missions_header, parent, false)); } return new ViewHolderItem(mInflater.inflate(mLayout, parent, false)); } @Override public void onViewRecycled(@NonNull ViewHolder view) { super.onViewRecycled(view); if (view instanceof ViewHolderHeader) return; ViewHolderItem h = (ViewHolderItem) view; if (h.item.mission instanceof DownloadMission) { mPendingDownloadsItems.remove(h); if (mPendingDownloadsItems.size() < 1) { checkMasterButtonsVisibility(); } } h.popupMenu.dismiss(); h.item = null; h.resetSpeedMeasure(); } @Override @SuppressLint("SetTextI18n") public void onBindViewHolder(@NonNull ViewHolder view, @SuppressLint("RecyclerView") int pos) { DownloadManager.MissionItem item = mIterator.getItem(pos); if (view instanceof ViewHolderHeader) { if (item.special == DownloadManager.SPECIAL_NOTHING) return; int str; if (item.special == DownloadManager.SPECIAL_PENDING) { str = R.string.missions_header_pending; } else { str = R.string.missions_header_finished; if (mClear != null) mClear.setVisible(true); } ((ViewHolderHeader) view).header.setText(str); return; } ViewHolderItem h = (ViewHolderItem) view; h.item = item; Utility.FileType type = Utility.getFileType(item.mission.kind, item.mission.storage.getName()); h.icon.setImageResource(Utility.getIconForFileType(type)); h.name.setText(item.mission.storage.getName()); h.progress.setColors(Utility.getBackgroundForFileType(mContext, type), Utility.getForegroundForFileType(mContext, type)); if (h.item.mission instanceof DownloadMission) { DownloadMission mission = (DownloadMission) item.mission; String length = Utility.formatBytes(mission.getLength()); if (mission.running && !mission.isPsRunning()) length += " --.- kB/s"; h.size.setText(length); h.pause.setTitle(mission.unknownLength ? R.string.stop : R.string.pause); updateProgress(h); mPendingDownloadsItems.add(h); h.date.setText(""); } else { h.progress.setMarquee(false); h.status.setText("100%"); h.progress.setProgress(1.0f); h.size.setText(Utility.formatBytes(item.mission.length)); DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault()); Date date = new Date(item.mission.timestamp); h.date.setText(dateFormat.format(date)); } } @Override public int getItemCount() { return mIterator.getOldListSize(); } @Override public int getItemViewType(int position) { return mIterator.getSpecialAtItem(position); } @SuppressLint("DefaultLocale") private void updateProgress(ViewHolderItem h) { if (h == null || h.item == null || h.item.mission instanceof FinishedMission) return; DownloadMission mission = (DownloadMission) h.item.mission; double done = mission.done; long length = mission.getLength(); long now = System.currentTimeMillis(); boolean hasError = mission.errCode != ERROR_NOTHING; // hide on error // show if current resource length is not fetched // show if length is unknown h.progress.setMarquee(mission.isRecovering() || !hasError && (!mission.isInitialized() || mission.unknownLength)); double progress; if (mission.unknownLength) { progress = Double.NaN; h.progress.setProgress(0.0f); } else { progress = done / length; } if (hasError) { h.progress.setProgress(isNotFinite(progress) ? 1d : progress); h.status.setText(R.string.msg_error); } else if (isNotFinite(progress)) { h.status.setText(UNDEFINED_PROGRESS); } else { h.status.setText(String.format("%.2f%%", progress * 100)); h.progress.setProgress(progress); } @StringRes int state; String sizeStr = Utility.formatBytes(length).concat(" "); if (mission.isPsFailed() || mission.errCode == ERROR_POSTPROCESSING_HOLD) { h.size.setText(sizeStr); return; } else if (!mission.running) { state = mission.enqueued ? R.string.queued : R.string.paused; } else if (mission.isPsRunning()) { state = R.string.post_processing; } else if (mission.isRecovering()) { state = R.string.recovering; } else { state = 0; } if (state != 0) { // update state without download speed h.size.setText(sizeStr.concat("(").concat(mContext.getString(state)).concat(")")); h.resetSpeedMeasure(); return; } if (h.lastTimestamp < 0) { h.size.setText(sizeStr); h.lastTimestamp = now; h.lastDone = done; return; } long deltaTime = now - h.lastTimestamp; double deltaDone = done - h.lastDone; if (h.lastDone > done) { h.lastDone = done; h.size.setText(sizeStr); return; } if (deltaDone > 0 && deltaTime > 0) { float speed = (float) ((deltaDone * 1000d) / deltaTime); float averageSpeed = speed; if (h.lastSpeedIdx < 0) { Arrays.fill(h.lastSpeed, speed); h.lastSpeedIdx = 0; } else { for (int i = 0; i < h.lastSpeed.length; i++) { averageSpeed += h.lastSpeed[i]; } averageSpeed /= h.lastSpeed.length + 1.0f; } String speedStr = Utility.formatSpeed(averageSpeed); String etaStr; if (mission.unknownLength) { etaStr = ""; } else { long eta = (long) Math.ceil((length - done) / averageSpeed); etaStr = Utility.formatBytes((long) done) + "/" + Utility.stringifySeconds(eta) + " "; } h.size.setText(sizeStr.concat(etaStr).concat(speedStr)); h.lastTimestamp = now; h.lastDone = done; h.lastSpeed[h.lastSpeedIdx++] = speed; if (h.lastSpeedIdx >= h.lastSpeed.length) h.lastSpeedIdx = 0; } } private void viewWithFileProvider(Mission mission) { if (checkInvalidFile(mission)) return; String mimeType = resolveMimeType(mission); if (BuildConfig.DEBUG) Log.v(TAG, "Mime: " + mimeType + " package: " + BuildConfig.APPLICATION_ID + ".provider"); Intent viewIntent = new Intent(Intent.ACTION_VIEW); viewIntent.setDataAndType(resolveShareableUri(mission), mimeType); viewIntent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); viewIntent.addFlags(FLAG_GRANT_PREFIX_URI_PERMISSION); Intent chooserIntent = createChooser(viewIntent, null); chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | FLAG_GRANT_READ_URI_PERMISSION); ShareUtils.openIntentInApp(mContext, chooserIntent); } private void shareFile(Mission mission) { if (checkInvalidFile(mission)) return; final Intent shareIntent = new Intent(Intent.ACTION_SEND); shareIntent.setType(resolveMimeType(mission)); shareIntent.putExtra(Intent.EXTRA_STREAM, resolveShareableUri(mission)); shareIntent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); final Intent intent = createChooser(shareIntent, null); // unneeded to set a title to the chooser on Android P and higher because the system // ignores this title on these versions if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1) { intent.putExtra(Intent.EXTRA_TITLE, mContext.getString(R.string.share_dialog_title)); } intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); intent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); mContext.startActivity(intent); } /** * Returns an Uri which can be shared to other applications. * * @see * https://stackoverflow.com/questions/38200282/android-os-fileuriexposedexception-file-storage-emulated-0-test-txt-exposed */ private Uri resolveShareableUri(Mission mission) { if (mission.storage.isDirect()) { return FileProvider.getUriForFile( mContext, BuildConfig.APPLICATION_ID + ".provider", new File(URI.create(mission.storage.getUri().toString())) ); } else { return mission.storage.getUri(); } } private static String resolveMimeType(@NonNull Mission mission) { String mimeType; if (!mission.storage.isInvalid()) { mimeType = mission.storage.getType(); if (mimeType != null && mimeType.length() > 0 && !mimeType.equals(StoredFileHelper.DEFAULT_MIME)) return mimeType; } String ext = Utility.getFileExt(mission.storage.getName()); if (ext == null) return DEFAULT_MIME_TYPE; mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.substring(1)); return mimeType == null ? DEFAULT_MIME_TYPE : mimeType; } private boolean checkInvalidFile(@NonNull Mission mission) { if (mission.storage.existsAsFile()) return false; Toast.makeText(mContext, R.string.missing_file, Toast.LENGTH_SHORT).show(); return true; } private ViewHolderItem getViewHolder(Object mission) { for (ViewHolderItem h : mPendingDownloadsItems) { if (h.item.mission == mission) return h; } return null; } @Override public boolean handleMessage(@NonNull Message msg) { if (mStartButton != null && mPauseButton != null) { checkMasterButtonsVisibility(); } switch (msg.what) { case DownloadManagerService.MESSAGE_ERROR: case DownloadManagerService.MESSAGE_FINISHED: case DownloadManagerService.MESSAGE_DELETED: case DownloadManagerService.MESSAGE_PAUSED: break; default: return false; } ViewHolderItem h = getViewHolder(msg.obj); if (h == null) return false; switch (msg.what) { case DownloadManagerService.MESSAGE_FINISHED: case DownloadManagerService.MESSAGE_DELETED: // DownloadManager should mark the download as finished applyChanges(); return true; } updateProgress(h); return true; } private void showError(@NonNull DownloadMission mission) { @StringRes int msg = R.string.general_error; String msgEx = null; switch (mission.errCode) { case 416: msg = R.string.error_http_unsupported_range; break; case 404: msg = R.string.error_http_not_found; break; case ERROR_NOTHING: return;// this never should happen case ERROR_FILE_CREATION: msg = R.string.error_file_creation; break; case ERROR_HTTP_NO_CONTENT: msg = R.string.error_http_no_content; break; case ERROR_PATH_CREATION: msg = R.string.error_path_creation; break; case ERROR_PERMISSION_DENIED: msg = R.string.permission_denied; break; case ERROR_SSL_EXCEPTION: msg = R.string.error_ssl_exception; break; case ERROR_UNKNOWN_HOST: msg = R.string.error_unknown_host; break; case ERROR_CONNECT_HOST: msg = R.string.error_connect_host; break; case ERROR_POSTPROCESSING_STOPPED: msg = R.string.error_postprocessing_stopped; break; case ERROR_POSTPROCESSING: case ERROR_POSTPROCESSING_HOLD: showError(mission, UserAction.DOWNLOAD_POSTPROCESSING, R.string.error_postprocessing_failed); return; case ERROR_INSUFFICIENT_STORAGE: msg = R.string.error_insufficient_storage_left; break; case ERROR_UNKNOWN_EXCEPTION: if (mission.errObject != null) { showError(mission, UserAction.DOWNLOAD_FAILED, R.string.general_error); return; } else { msg = R.string.msg_error; break; } case ERROR_PROGRESS_LOST: msg = R.string.error_progress_lost; break; case ERROR_TIMEOUT: msg = R.string.error_timeout; break; case ERROR_RESOURCE_GONE: msg = R.string.error_download_resource_gone; break; default: if (mission.errCode >= 100 && mission.errCode < 600) { msgEx = "HTTP " + mission.errCode; } else if (mission.errObject == null) { msgEx = "(not_decelerated_error_code)"; } else { showError(mission, UserAction.DOWNLOAD_FAILED, msg); return; } break; } AlertDialog.Builder builder = new AlertDialog.Builder(mContext); if (msgEx != null) builder.setMessage(msgEx); else builder.setMessage(msg); // add report button for non-HTTP errors (range 100-599) if (mission.errObject != null && (mission.errCode < 100 || mission.errCode >= 600)) { @StringRes final int mMsg = msg; builder.setPositiveButton(R.string.error_report_title, (dialog, which) -> showError(mission, UserAction.DOWNLOAD_FAILED, mMsg) ); } builder.setNegativeButton(R.string.ok, (dialog, which) -> dialog.cancel()) .setTitle(mission.storage.getName()) .show(); } private void showError(DownloadMission mission, UserAction action, @StringRes int reason) { StringBuilder request = new StringBuilder(256); request.append(mission.source); request.append(" ["); if (mission.recoveryInfo != null) { for (MissionRecoveryInfo recovery : mission.recoveryInfo) request.append(' ') .append(recovery.toString()) .append(' '); } request.append("]"); Integer service; try { service = NewPipe.getServiceByUrl(mission.source).getServiceId(); } catch (Exception e) { service = null; } ErrorUtil.createNotification(mContext, new ErrorInfo(ErrorInfo.Companion.throwableToStringList(mission.errObject), action, request.toString(), service, reason)); } public void clearFinishedDownloads(boolean delete) { if (delete && mIterator.hasFinishedMissions() && mHidden.isEmpty()) { for (int i = 0; i < mIterator.getOldListSize(); i++) { FinishedMission mission = mIterator.getItem(i).mission instanceof FinishedMission ? (FinishedMission) mIterator.getItem(i).mission : null; if (mission != null) { mIterator.hide(mission); mHidden.add(mission); } } applyChanges(); String msg = Localization.deletedDownloadCount(mContext, mHidden.size()); mSnackbar = Snackbar.make(mView, msg, Snackbar.LENGTH_INDEFINITE); mSnackbar.setAction(R.string.undo, s -> { Iterator i = mHidden.iterator(); while (i.hasNext()) { mIterator.unHide(i.next()); i.remove(); } applyChanges(); mHandler.removeCallbacksAndMessages(DELETE); }); mSnackbar.setActionTextColor(Color.YELLOW); mSnackbar.show(); HandlerCompat.postDelayed(mHandler, this::deleteFinishedDownloads, DELETE, 5000); } else if (!delete) { mDownloadManager.forgetFinishedDownloads(); applyChanges(); } } private void deleteFinishedDownloads() { if (mSnackbar != null) mSnackbar.dismiss(); Iterator i = mHidden.iterator(); while (i.hasNext()) { Mission mission = i.next(); if (mission != null) { mDownloadManager.deleteMission(mission, true); mContext.sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, mission.storage.getUri())); } i.remove(); } } private boolean handlePopupItem(@NonNull ViewHolderItem h, @NonNull MenuItem option) { if (h.item == null) return true; int id = option.getItemId(); DownloadMission mission = h.item.mission instanceof DownloadMission ? (DownloadMission) h.item.mission : null; if (mission != null) { if (id == R.id.start) { h.status.setText(UNDEFINED_PROGRESS); mDownloadManager.resumeMission(mission); return true; } else if (id == R.id.pause) { mDownloadManager.pauseMission(mission); return true; } else if (id == R.id.error_message_view) { showError(mission); return true; } else if (id == R.id.queue) { boolean flag = !h.queue.isChecked(); h.queue.setChecked(flag); mission.setEnqueued(flag); updateProgress(h); return true; } else if (id == R.id.retry) { if (mission.isPsRunning()) { mission.psContinue(true); } else { mDownloadManager.tryRecover(mission); if (mission.storage.isInvalid()) mRecover.tryRecover(mission); else recoverMission(mission); } return true; } else if (id == R.id.cancel) { mission.psContinue(false); return false; } } if (id == R.id.menu_item_share) { shareFile(h.item.mission); return true; } else if (id == R.id.delete) {// delete the entry and the file mDeleter.append(h.item.mission, true); applyChanges(); checkMasterButtonsVisibility(); return true; } else if (id == R.id.delete_entry) {// just delete the entry mDeleter.append(h.item.mission, false); applyChanges(); checkMasterButtonsVisibility(); return true; } else if (id == R.id.md5 || id == R.id.sha1) { final StoredFileHelper storage = h.item.mission.storage; if (!storage.existsAsFile()) { Toast.makeText(mContext, R.string.missing_file, Toast.LENGTH_SHORT).show(); mDeleter.append(h.item.mission, true); applyChanges(); return true; } final NotificationManager notificationManager = ContextCompat.getSystemService(mContext, NotificationManager.class); final NotificationCompat.Builder progressNotificationBuilder = new NotificationCompat.Builder(mContext, mContext.getString(R.string.hash_channel_id)) .setPriority(NotificationCompat.PRIORITY_HIGH) .setSmallIcon(R.drawable.ic_newpipe_triangle_white) .setContentTitle(mContext.getString(R.string.msg_calculating_hash)) .setContentText(mContext.getString(R.string.msg_wait)) .setProgress(0, 0, true) .setOngoing(true); notificationManager.notify(HASH_NOTIFICATION_ID, progressNotificationBuilder .build()); compositeDisposable.add( Observable.fromCallable(() -> Utility.checksum(storage, id)) .subscribeOn(Schedulers.computation()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(result -> { ShareUtils.copyToClipboard(mContext, result); notificationManager.cancel(HASH_NOTIFICATION_ID); }) ); return true; } else if (id == R.id.source) { try { Intent intent = NavigationHelper.getIntentByLink(mContext, h.item.mission.source); intent.addFlags(Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP); mContext.startActivity(intent); } catch (Exception e) { Log.w(TAG, "Selected item has a invalid source", e); } return true; } return false; } public void applyChanges() { mIterator.start(); DiffUtil.calculateDiff(mIterator, true).dispatchUpdatesTo(this); mIterator.end(); checkEmptyMessageVisibility(); if (mClear != null) mClear.setVisible(mIterator.hasFinishedMissions()); } public void forceUpdate() { mIterator.start(); mIterator.end(); for (ViewHolderItem item : mPendingDownloadsItems) { item.resetSpeedMeasure(); } notifyDataSetChanged(); } public void setLinear(boolean isLinear) { mLayout = isLinear ? R.layout.mission_item_linear : R.layout.mission_item; } public void setClearButton(MenuItem clearButton) { if (mClear == null) clearButton.setVisible(mIterator.hasFinishedMissions()); mClear = clearButton; } public void setMasterButtons(MenuItem startButton, MenuItem pauseButton) { boolean init = mStartButton == null || mPauseButton == null; mStartButton = startButton; mPauseButton = pauseButton; if (init) checkMasterButtonsVisibility(); } private void checkEmptyMessageVisibility() { int flag = mIterator.getOldListSize() > 0 ? View.GONE : View.VISIBLE; if (mEmptyMessage.getVisibility() != flag) mEmptyMessage.setVisibility(flag); } public void checkMasterButtonsVisibility() { boolean[] state = mIterator.hasValidPendingMissions(); Log.d(TAG, "checkMasterButtonsVisibility() running=" + state[0] + " paused=" + state[1]); setButtonVisible(mPauseButton, state[0]); setButtonVisible(mStartButton, state[1]); } private static void setButtonVisible(MenuItem button, boolean visible) { if (button.isVisible() != visible) button.setVisible(visible); } public void refreshMissionItems() { for (ViewHolderItem h : mPendingDownloadsItems) { if (((DownloadMission) h.item.mission).running) continue; updateProgress(h); h.resetSpeedMeasure(); } } public void onDestroy() { compositeDisposable.dispose(); mDeleter.dispose(); } public void onResume() { mDeleter.resume(); HandlerCompat.postDelayed(mHandler, this::updater, UPDATER, 0); } public void onPaused() { mDeleter.pause(); mHandler.removeCallbacksAndMessages(UPDATER); } public void recoverMission(DownloadMission mission) { ViewHolderItem h = getViewHolder(mission); if (h == null) return; mission.errObject = null; mission.resetState(true, false, DownloadMission.ERROR_NOTHING); h.status.setText(UNDEFINED_PROGRESS); h.size.setText(Utility.formatBytes(mission.getLength())); h.progress.setMarquee(true); mDownloadManager.resumeMission(mission); } private void updater() { for (ViewHolderItem h : mPendingDownloadsItems) { // check if the mission is running first if (!((DownloadMission) h.item.mission).running) continue; updateProgress(h); } HandlerCompat.postDelayed(mHandler, this::updater, UPDATER, 1000); } private boolean isNotFinite(double value) { return Double.isNaN(value) || Double.isInfinite(value); } public void setRecover(@NonNull RecoverHelper callback) { mRecover = callback; } class ViewHolderItem extends RecyclerView.ViewHolder { DownloadManager.MissionItem item; TextView status; ImageView icon; TextView name; TextView size; TextView date; ProgressDrawable progress; PopupMenu popupMenu; MenuItem retry; MenuItem cancel; MenuItem start; MenuItem pause; MenuItem open; MenuItem queue; MenuItem showError; MenuItem delete; MenuItem source; MenuItem checksum; long lastTimestamp = -1; double lastDone; int lastSpeedIdx; float[] lastSpeed = new float[3]; String estimatedTimeArrival = UNDEFINED_ETA; ViewHolderItem(View view) { super(view); progress = new ProgressDrawable(); itemView.findViewById(R.id.item_bkg).setBackground(progress); status = itemView.findViewById(R.id.item_status); name = itemView.findViewById(R.id.item_name); icon = itemView.findViewById(R.id.item_icon); size = itemView.findViewById(R.id.item_size); date = itemView.findViewById(R.id.item_date); name.setSelected(true); ImageView button = itemView.findViewById(R.id.item_more); popupMenu = buildPopup(button); button.setOnClickListener(v -> showPopupMenu()); Menu menu = popupMenu.getMenu(); retry = menu.findItem(R.id.retry); cancel = menu.findItem(R.id.cancel); start = menu.findItem(R.id.start); pause = menu.findItem(R.id.pause); open = menu.findItem(R.id.menu_item_share); queue = menu.findItem(R.id.queue); showError = menu.findItem(R.id.error_message_view); delete = menu.findItem(R.id.delete); source = menu.findItem(R.id.source); checksum = menu.findItem(R.id.checksum); itemView.setHapticFeedbackEnabled(true); itemView.setOnClickListener(v -> { if (item.mission instanceof FinishedMission) viewWithFileProvider(item.mission); }); itemView.setOnLongClickListener(v -> { v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS); showPopupMenu(); return true; }); } private void showPopupMenu() { retry.setVisible(false); cancel.setVisible(false); start.setVisible(false); pause.setVisible(false); open.setVisible(false); queue.setVisible(false); showError.setVisible(false); delete.setVisible(false); source.setVisible(false); checksum.setVisible(false); DownloadMission mission = item.mission instanceof DownloadMission ? (DownloadMission) item.mission : null; if (mission != null) { if (mission.hasInvalidStorage()) { retry.setVisible(true); delete.setVisible(true); showError.setVisible(true); } else if (mission.isPsRunning()) { switch (mission.errCode) { case ERROR_INSUFFICIENT_STORAGE: case ERROR_POSTPROCESSING_HOLD: retry.setVisible(true); cancel.setVisible(true); showError.setVisible(true); break; } } else { if (mission.running) { pause.setVisible(true); } else { if (mission.errCode != ERROR_NOTHING) { showError.setVisible(true); } queue.setChecked(mission.enqueued); delete.setVisible(true); boolean flag = !mission.isPsFailed() && mission.urls.length > 0; start.setVisible(flag); queue.setVisible(flag); } } } else { open.setVisible(true); delete.setVisible(true); checksum.setVisible(true); } if (item.mission.source != null && !item.mission.source.isEmpty()) { source.setVisible(true); } popupMenu.show(); } private PopupMenu buildPopup(final View button) { PopupMenu popup = new PopupMenu(mContext, button); popup.inflate(R.menu.mission); popup.setOnMenuItemClickListener(option -> handlePopupItem(this, option)); return popup; } private void resetSpeedMeasure() { estimatedTimeArrival = UNDEFINED_ETA; lastTimestamp = -1; lastSpeedIdx = -1; } } static class ViewHolderHeader extends RecyclerView.ViewHolder { TextView header; ViewHolderHeader(View view) { super(view); header = itemView.findViewById(R.id.item_name); } } public interface RecoverHelper { void tryRecover(DownloadMission mission); } } ================================================ FILE: app/src/main/java/us/shandian/giga/ui/common/Deleter.java ================================================ package us.shandian.giga.ui.common; import android.content.Context; import android.content.Intent; import android.graphics.Color; import android.os.Handler; import android.view.View; import androidx.core.os.HandlerCompat; import com.google.android.material.snackbar.Snackbar; import org.schabi.newpipe.R; import java.util.ArrayList; import java.util.Optional; import kotlin.Pair; import us.shandian.giga.get.FinishedMission; import us.shandian.giga.get.Mission; import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManager.MissionIterator; import us.shandian.giga.ui.adapter.MissionAdapter; public class Deleter { private static final String COMMIT = "commit"; private static final String NEXT = "next"; private static final String SHOW = "show"; private static final int TIMEOUT = 5000;// ms private static final int DELAY = 350;// ms private static final int DELAY_RESUME = 400;// ms private Snackbar snackbar; // list of missions to be deleted, and whether to also delete the corresponding file private ArrayList> items; private boolean running = true; private final Context mContext; private final MissionAdapter mAdapter; private final DownloadManager mDownloadManager; private final MissionIterator mIterator; private final Handler mHandler; private final View mView; public Deleter(View v, Context c, MissionAdapter a, DownloadManager d, MissionIterator i, Handler h) { mView = v; mContext = c; mAdapter = a; mDownloadManager = d; mIterator = i; mHandler = h; items = new ArrayList<>(2); } public void append(Mission item, boolean alsoDeleteFile) { /* If a mission is removed from the list while the Snackbar for a previously * removed item is still showing, commit the action for the previous item * immediately. This prevents Snackbars from stacking up in reverse order. */ mHandler.removeCallbacksAndMessages(COMMIT); commit(); mIterator.hide(item); items.add(0, new Pair<>(item, alsoDeleteFile)); show(); } private void forget() { mIterator.unHide(items.remove(0).getFirst()); mAdapter.applyChanges(); show(); } private void show() { if (items.size() < 1) return; pause(); running = true; HandlerCompat.postDelayed(mHandler, this::next, NEXT, DELAY); } private void next() { if (items.size() < 1) return; final Optional fileToBeDeleted = items.stream() .filter(Pair::getSecond) .map(p -> p.getFirst().storage.getName()) .findFirst(); String msg; if (fileToBeDeleted.isPresent()) { msg = mContext.getString(R.string.file_deleted) .concat(":\n") .concat(fileToBeDeleted.get()); } else { msg = mContext.getString(R.string.entry_deleted); } snackbar = Snackbar.make(mView, msg, Snackbar.LENGTH_INDEFINITE); snackbar.setAction(R.string.undo, s -> forget()); snackbar.setActionTextColor(Color.YELLOW); snackbar.show(); HandlerCompat.postDelayed(mHandler, this::commit, COMMIT, TIMEOUT); } private void commit() { if (items.size() < 1) return; while (items.size() > 0) { Pair missionAndAlsoDeleteFile = items.remove(0); Mission mission = missionAndAlsoDeleteFile.getFirst(); boolean alsoDeleteFile = missionAndAlsoDeleteFile.getSecond(); if (mission.deleted) continue; mIterator.unHide(mission); mDownloadManager.deleteMission(mission, alsoDeleteFile); if (mission instanceof FinishedMission) { mContext.sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, mission.storage.getUri())); } break; } if (items.size() < 1) { pause(); return; } show(); } public void pause() { running = false; mHandler.removeCallbacksAndMessages(NEXT); mHandler.removeCallbacksAndMessages(SHOW); mHandler.removeCallbacksAndMessages(COMMIT); if (snackbar != null) snackbar.dismiss(); } public void resume() { if (!running) { HandlerCompat.postDelayed(mHandler, this::show, SHOW, DELAY_RESUME); } } public void dispose() { if (items.size() < 1) return; pause(); for (Pair missionAndAlsoDeleteFile : items) { Mission mission = missionAndAlsoDeleteFile.getFirst(); boolean alsoDeleteFile = missionAndAlsoDeleteFile.getSecond(); mDownloadManager.deleteMission(mission, alsoDeleteFile); } items = null; } } ================================================ FILE: app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.java ================================================ package us.shandian.giga.ui.common; import android.graphics.Canvas; import android.graphics.ColorFilter; import android.graphics.Paint; import android.graphics.Path; import android.graphics.PixelFormat; import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.os.Handler; import android.os.Looper; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; public class ProgressDrawable extends Drawable { private static final int MARQUEE_INTERVAL = 150; private float mProgress; private int mBackgroundColor, mForegroundColor; private Handler mMarqueeHandler; private float mMarqueeProgress; private Path mMarqueeLine; private int mMarqueeSize; private long mMarqueeNext; public ProgressDrawable() { mMarqueeLine = null;// marquee disabled mMarqueeProgress = 0.0f; mMarqueeSize = 0; mMarqueeNext = 0; } public void setColors(@ColorInt int background, @ColorInt int foreground) { mBackgroundColor = background; mForegroundColor = foreground; } public void setProgress(double progress) { mProgress = (float) progress; invalidateSelf(); } public void setMarquee(boolean marquee) { if (marquee == (mMarqueeLine != null)) { return; } mMarqueeLine = marquee ? new Path() : null; mMarqueeHandler = marquee ? new Handler(Looper.getMainLooper()) : null; mMarqueeSize = 0; mMarqueeNext = 0; } @Override public void draw(@NonNull Canvas canvas) { int width = getBounds().width(); int height = getBounds().height(); Paint paint = new Paint(); paint.setColor(mBackgroundColor); canvas.drawRect(0, 0, width, height, paint); paint.setColor(mForegroundColor); if (mMarqueeLine != null) { if (mMarqueeSize < 1) setupMarquee(width, height); int size = mMarqueeSize; Paint paint2 = new Paint(); paint2.setColor(mForegroundColor); paint2.setStrokeWidth(size); paint2.setStyle(Paint.Style.STROKE); size *= 2; if (mMarqueeProgress >= size) { mMarqueeProgress = 1; } else { mMarqueeProgress++; } // render marquee width += size * 2; Path marquee = new Path(); for (int i = -size; i < width; i += size) { marquee.addPath(mMarqueeLine, ((float)i + mMarqueeProgress), 0); } marquee.close(); canvas.drawPath(marquee, paint2);// draw marquee if (System.currentTimeMillis() >= mMarqueeNext) { // program next update mMarqueeNext = System.currentTimeMillis() + MARQUEE_INTERVAL; mMarqueeHandler.postDelayed(this::invalidateSelf, MARQUEE_INTERVAL); } return; } canvas.drawRect(0, 0, (int) (mProgress * width), height, paint); } @Override public void setAlpha(int alpha) { // Unsupported } @Override public void setColorFilter(ColorFilter filter) { // Unsupported } @Override public int getOpacity() { return PixelFormat.OPAQUE; } @Override public void onBoundsChange(Rect rect) { if (mMarqueeLine != null) setupMarquee(rect.width(), rect.height()); } private void setupMarquee(int width, int height) { mMarqueeSize = (int) ((width * 10.0f) / 100.0f);// the size is 10% of the width mMarqueeLine.rewind(); mMarqueeLine.moveTo(-mMarqueeSize, -mMarqueeSize); mMarqueeLine.lineTo(-mMarqueeSize * 4, height + mMarqueeSize); mMarqueeLine.close(); } } ================================================ FILE: app/src/main/java/us/shandian/giga/ui/common/ToolbarActivity.java ================================================ package us.shandian.giga.ui.common; import android.os.Bundle; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.widget.Toolbar; import org.schabi.newpipe.R; public abstract class ToolbarActivity extends AppCompatActivity { protected Toolbar mToolbar; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(getLayoutResource()); mToolbar = this.findViewById(R.id.toolbar); setSupportActionBar(mToolbar); } protected abstract int getLayoutResource(); } ================================================ FILE: app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java ================================================ package us.shandian.giga.ui.fragment; import android.app.Activity; import android.content.ComponentName; import android.content.Context; import android.content.Intent; import android.content.ServiceConnection; import android.content.SharedPreferences; import android.net.Uri; import android.os.Bundle; import android.os.Environment; import android.os.IBinder; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.Toast; import androidx.activity.result.ActivityResult; import androidx.activity.result.ActivityResultLauncher; import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.nononsenseapps.filepicker.Utils; import org.schabi.newpipe.R; import org.schabi.newpipe.settings.NewPipeSettings; import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard; import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.FilePickerActivityHelper; import java.io.File; import java.io.IOException; import us.shandian.giga.get.DownloadMission; import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder; import us.shandian.giga.ui.adapter.MissionAdapter; public class MissionsFragment extends Fragment { private static final String TAG = "MissionsFragment"; private static final int SPAN_SIZE = 2; private SharedPreferences mPrefs; private boolean mLinear; private MenuItem mSwitch; private MenuItem mClear = null; private MenuItem mStart = null; private MenuItem mPause = null; private RecyclerView mList; private View mEmpty; private MissionAdapter mAdapter; private GridLayoutManager mGridManager; private LinearLayoutManager mLinearManager; private Context mContext; private DownloadManagerBinder mBinder; private boolean mForceUpdate; private DownloadMission unsafeMissionTarget = null; private final ActivityResultLauncher requestDownloadSaveAsLauncher = registerForActivityResult(new StartActivityForResult(), this::requestDownloadSaveAsResult); private final ServiceConnection mConnection = new ServiceConnection() { @Override public void onServiceConnected(ComponentName name, IBinder binder) { mBinder = (DownloadManagerBinder) binder; mBinder.clearDownloadNotifications(); mAdapter = new MissionAdapter(mContext, mBinder.getDownloadManager(), mEmpty, getView()); mAdapter.setRecover(MissionsFragment.this::recoverMission); setAdapterButtons(); mBinder.addMissionEventListener(mAdapter); mBinder.enableNotifications(false); updateList(); } @Override public void onServiceDisconnected(ComponentName name) { // What to do? } }; @Override public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { View v = inflater.inflate(R.layout.missions, container, false); mPrefs = PreferenceManager.getDefaultSharedPreferences(requireActivity()); mLinear = mPrefs.getBoolean("linear", false); // Bind the service mContext.bindService(new Intent(mContext, DownloadManagerService.class), mConnection, Context.BIND_AUTO_CREATE); // Views mEmpty = v.findViewById(R.id.list_empty_view); mList = v.findViewById(R.id.mission_recycler); // Init layouts managers mGridManager = new GridLayoutManager(getActivity(), SPAN_SIZE); mGridManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() { @Override public int getSpanSize(int position) { switch (mAdapter.getItemViewType(position)) { case DownloadManager.SPECIAL_PENDING: case DownloadManager.SPECIAL_FINISHED: return SPAN_SIZE; default: return 1; } } }); mLinearManager = new LinearLayoutManager(getActivity()); setHasOptionsMenu(true); return v; } /** * Added in API level 23. */ @Override public void onAttach(@NonNull Context context) { super.onAttach(context); // Bug: in api< 23 this is never called // so mActivity=null // so app crashes with null-pointer exception mContext = context; } /** * deprecated in API level 23, * but must remain to allow compatibility with api<23 */ @SuppressWarnings("deprecation") @Override public void onAttach(@NonNull Activity activity) { super.onAttach(activity); mContext = activity; } @Override public void onDestroy() { super.onDestroy(); if (mBinder == null || mAdapter == null) return; mBinder.removeMissionEventListener(mAdapter); mBinder.enableNotifications(true); mContext.unbindService(mConnection); mAdapter.onDestroy(); mBinder = null; mAdapter = null; } @Override public void onPrepareOptionsMenu(Menu menu) { mSwitch = menu.findItem(R.id.switch_mode); mClear = menu.findItem(R.id.clear_list); mStart = menu.findItem(R.id.start_downloads); mPause = menu.findItem(R.id.pause_downloads); if (mAdapter != null) setAdapterButtons(); super.onPrepareOptionsMenu(menu); } @Override public boolean onOptionsItemSelected(MenuItem item) { int itemId = item.getItemId(); if (itemId == R.id.switch_mode) { mLinear = !mLinear; updateList(); return true; } else if (itemId == R.id.clear_list) { showClearDownloadHistoryPrompt(); return true; } else if (itemId == R.id.start_downloads) { mBinder.getDownloadManager().startAllMissions(); return true; } else if (itemId == R.id.pause_downloads) { mBinder.getDownloadManager().pauseAllMissions(false); mAdapter.refreshMissionItems();// update items view return super.onOptionsItemSelected(item); } return super.onOptionsItemSelected(item); } public void showClearDownloadHistoryPrompt() { // ask the user whether he wants to just clear history or instead delete files on disk new AlertDialog.Builder(mContext) .setTitle(R.string.clear_download_history) .setMessage(R.string.confirm_prompt) // Intentionally misusing buttons' purpose in order to achieve good order .setNegativeButton(R.string.clear_download_history, (dialog, which) -> mAdapter.clearFinishedDownloads(false)) .setNeutralButton(R.string.cancel, null) .setPositiveButton(R.string.delete_downloaded_files, (dialog, which) -> showDeleteDownloadedFilesConfirmationPrompt()) .show(); } public void showDeleteDownloadedFilesConfirmationPrompt() { // make sure the user confirms once more before deleting files on disk new AlertDialog.Builder(mContext) .setTitle(R.string.delete_downloaded_files_confirm) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok, (dialog, which) -> mAdapter.clearFinishedDownloads(true)) .show(); } private void updateList() { if (mLinear) { mList.setLayoutManager(mLinearManager); } else { mList.setLayoutManager(mGridManager); } // destroy all created views in the recycler mList.setAdapter(null); mAdapter.notifyDataSetChanged(); // re-attach the adapter in grid/lineal mode mAdapter.setLinear(mLinear); mList.setAdapter(mAdapter); if (mSwitch != null) { mSwitch.setIcon(mLinear ? R.drawable.ic_apps : R.drawable.ic_list); mSwitch.setTitle(mLinear ? R.string.grid : R.string.list); mPrefs.edit().putBoolean("linear", mLinear).apply(); } } private void setAdapterButtons() { if (mClear == null || mStart == null || mPause == null) return; mAdapter.setClearButton(mClear); mAdapter.setMasterButtons(mStart, mPause); } private void recoverMission(@NonNull DownloadMission mission) { unsafeMissionTarget = mission; final Uri initialPath; if (NewPipeSettings.useStorageAccessFramework(mContext)) { initialPath = null; } else { final File initialSavePath; if (DownloadManager.TAG_AUDIO.equals(mission.storage.getType())) { initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MUSIC); } else { initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MOVIES); } initialPath = Uri.parse(initialSavePath.getAbsolutePath()); } NoFileManagerSafeGuard.launchSafe( requestDownloadSaveAsLauncher, StoredFileHelper.getNewPicker(mContext, mission.storage.getName(), mission.storage.getType(), initialPath), TAG, mContext ); } @Override public void onResume() { super.onResume(); if (mAdapter != null) { mAdapter.onResume(); if (mForceUpdate) { mForceUpdate = false; mAdapter.forceUpdate(); } mBinder.addMissionEventListener(mAdapter); mAdapter.checkMasterButtonsVisibility(); } if (mBinder != null) mBinder.enableNotifications(false); } @Override public void onPause() { super.onPause(); if (mAdapter != null) { mForceUpdate = true; mBinder.removeMissionEventListener(mAdapter); mAdapter.onPaused(); } if (mBinder != null) mBinder.enableNotifications(true); } private void requestDownloadSaveAsResult(final ActivityResult result) { if (result.getResultCode() != Activity.RESULT_OK) { return; } if (unsafeMissionTarget == null || result.getData() == null) { return; } try { Uri fileUri = result.getData().getData(); if (fileUri.getAuthority() != null && FilePickerActivityHelper.isOwnFileUri(mContext, fileUri)) { fileUri = Uri.fromFile(Utils.getFileForUri(fileUri)); } String tag = unsafeMissionTarget.storage.getTag(); unsafeMissionTarget.storage = new StoredFileHelper(mContext, null, fileUri, tag); mAdapter.recoverMission(unsafeMissionTarget); } catch (IOException e) { Toast.makeText(mContext, R.string.general_error, Toast.LENGTH_LONG).show(); } } } ================================================ FILE: app/src/main/java/us/shandian/giga/util/Utility.java ================================================ package us.shandian.giga.util; import android.content.Context; import android.os.Build; import android.os.Environment; import android.os.StatFs; import android.util.Log; import androidx.annotation.ColorInt; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; import com.google.android.exoplayer2.util.Util; import org.schabi.newpipe.R; import org.schabi.newpipe.streams.io.SharpInputStream; import org.schabi.newpipe.streams.io.StoredFileHelper; import java.io.BufferedOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; import java.net.HttpURLConnection; import java.util.Locale; import okio.ByteString; public class Utility { public enum FileType { VIDEO, MUSIC, SUBTITLE, UNKNOWN } public static String formatBytes(long bytes) { Locale locale = Locale.getDefault(); if (bytes < 1024) { return String.format(locale, "%d B", bytes); } else if (bytes < 1024 * 1024) { return String.format(locale, "%.2f kB", bytes / 1024d); } else if (bytes < 1024 * 1024 * 1024) { return String.format(locale, "%.2f MB", bytes / 1024d / 1024d); } else { return String.format(locale, "%.2f GB", bytes / 1024d / 1024d / 1024d); } } public static String formatSpeed(double speed) { Locale locale = Locale.getDefault(); if (speed < 1024) { return String.format(locale, "%.2f B/s", speed); } else if (speed < 1024 * 1024) { return String.format(locale, "%.2f kB/s", speed / 1024); } else if (speed < 1024 * 1024 * 1024) { return String.format(locale, "%.2f MB/s", speed / 1024 / 1024); } else { return String.format(locale, "%.2f GB/s", speed / 1024 / 1024 / 1024); } } public static void writeToFile(@NonNull File file, @NonNull Serializable serializable) { try (ObjectOutputStream objectOutputStream = new ObjectOutputStream(new BufferedOutputStream(new FileOutputStream(file)))) { objectOutputStream.writeObject(serializable); } catch (Exception e) { //nothing to do } //nothing to do } @Nullable @SuppressWarnings("unchecked") public static T readFromFile(File file) { T object; try (ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(file))) { object = (T) objectInputStream.readObject(); } catch (Exception e) { Log.e("Utility", "Failed to deserialize the object", e); object = null; } return object; } @Nullable public static String getFileExt(String url) { int index; if ((index = url.indexOf("?")) > -1) { url = url.substring(0, index); } index = url.lastIndexOf("."); if (index == -1) { return null; } else { String ext = url.substring(index); if ((index = ext.indexOf("%")) > -1) { ext = ext.substring(0, index); } if ((index = ext.indexOf("/")) > -1) { ext = ext.substring(0, index); } return ext.toLowerCase(); } } public static FileType getFileType(char kind, String file) { switch (kind) { case 'v': return FileType.VIDEO; case 'a': return FileType.MUSIC; case 's': return FileType.SUBTITLE; //default '?': } if (file.endsWith(".srt") || file.endsWith(".vtt") || file.endsWith(".ssa")) { return FileType.SUBTITLE; } else if (file.endsWith(".mp3") || file.endsWith(".wav") || file.endsWith(".flac") || file.endsWith(".m4a") || file.endsWith(".opus")) { return FileType.MUSIC; } else if (file.endsWith(".mp4") || file.endsWith(".mpeg") || file.endsWith(".rm") || file.endsWith(".rmvb") || file.endsWith(".flv") || file.endsWith(".webp") || file.endsWith(".webm")) { return FileType.VIDEO; } return FileType.UNKNOWN; } @ColorInt public static int getBackgroundForFileType(Context ctx, FileType type) { int colorRes; switch (type) { case MUSIC: colorRes = R.color.audio_left_to_load_color; break; case VIDEO: colorRes = R.color.video_left_to_load_color; break; case SUBTITLE: colorRes = R.color.subtitle_left_to_load_color; break; default: colorRes = R.color.gray; } return ContextCompat.getColor(ctx, colorRes); } @ColorInt public static int getForegroundForFileType(Context ctx, FileType type) { int colorRes; switch (type) { case MUSIC: colorRes = R.color.audio_already_load_color; break; case VIDEO: colorRes = R.color.video_already_load_color; break; case SUBTITLE: colorRes = R.color.subtitle_already_load_color; break; default: colorRes = R.color.gray; break; } return ContextCompat.getColor(ctx, colorRes); } @DrawableRes public static int getIconForFileType(FileType type) { switch (type) { case MUSIC: return R.drawable.ic_headset; default: case VIDEO: return R.drawable.ic_movie; case SUBTITLE: return R.drawable.ic_subtitles; } } public static String checksum(final StoredFileHelper source, final int algorithmId) throws IOException { ByteString byteString; try (var inputStream = new SharpInputStream(source.getStream())) { byteString = ByteString.of(Util.toByteArray(inputStream)); } if (algorithmId == R.id.md5) { byteString = byteString.md5(); } else if (algorithmId == R.id.sha1) { byteString = byteString.sha1(); } return byteString.hex(); } @SuppressWarnings("ResultOfMethodCallIgnored") public static boolean mkdir(File p, boolean allDirs) { if (p.exists()) return true; if (allDirs) p.mkdirs(); else p.mkdir(); return p.exists(); } public static long getContentLength(HttpURLConnection connection) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { return connection.getContentLengthLong(); } try { return Long.parseLong(connection.getHeaderField("Content-Length")); } catch (Exception err) { // nothing to do } return -1; } /** * Get the content length of the entire file even if the HTTP response is partial * (response code 206). * @param connection http connection * @return content length */ public static long getTotalContentLength(final HttpURLConnection connection) { try { if (connection.getResponseCode() == 206) { final String rangeStr = connection.getHeaderField("Content-Range"); final String bytesStr = rangeStr.split("/", 2)[1]; return Long.parseLong(bytesStr); } else { return getContentLength(connection); } } catch (Exception err) { // nothing to do } return -1; } private static String pad(int number) { return number < 10 ? ("0" + number) : String.valueOf(number); } public static String stringifySeconds(final long seconds) { final int h = (int) Math.floorDiv(seconds, 3600); final int m = (int) Math.floorDiv(seconds - (h * 3600L), 60); final int s = (int) (seconds - (h * 3600) - (m * 60)); String str = ""; if (h < 1 && m < 1) { str = "00:"; } else { if (h > 0) str = pad(h) + ":"; if (m > 0) str += pad(m) + ":"; } return str + pad(s); } } ================================================ FILE: app/src/main/res/animator/custom_fade_in.xml ================================================ ================================================ FILE: app/src/main/res/animator/custom_fade_out.xml ================================================ ================================================ FILE: app/src/main/res/drawable/background_oval_black_transparent.xml ================================================ ================================================ FILE: app/src/main/res/drawable/dashed_border_black.xml ================================================ ================================================ FILE: app/src/main/res/drawable/dashed_border_dark.xml ================================================ ================================================ FILE: app/src/main/res/drawable/dashed_border_light.xml ================================================ ================================================ FILE: app/src/main/res/drawable/drawer_header_bottom_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_add.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_add_circle_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_apps.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_arrow_back.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_arrow_drop_down.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_arrow_drop_up.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_art_track.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_asterisk.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_attach_money.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_backup.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bookmark.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bookmark_white.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_brightness_high.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_brightness_low.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_brightness_medium.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_bug_report.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_campaign.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cast.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_checklist.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_child_care.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_circle.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_close.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cloud.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cloud_download.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_comment.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_computer.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_crop_portrait.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_delete.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_description.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_directions_bike.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_directions_car.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_done.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_drag_handle.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_expand_more.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_explore.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_fastfood.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_favorite.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_file_download.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_filter_list.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_fitness_center.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_fullscreen.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_fullscreen_exit.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_headset.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_headset_shadow.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_heart.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_help.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_history.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_history_white.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_home.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_hourglass_top.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_info_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_insert_emoticon.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_language.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_list.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_live_tv.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_menu_book.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_mic.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_more_vert.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_motorcycle.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_movie.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_music_note.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_next.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_notifications.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_palette.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_pause.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_people.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_person.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_pets.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_picture_in_picture.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_pin.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_placeholder_bandcamp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_placeholder_media_ccc.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_placeholder_peertube.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_play_arrow.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_play_arrow_shadow.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_play_seek_triangle.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_playlist_add.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_playlist_add_check.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_playlist_play.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_podcasts.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_previous.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_public.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_radio.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_refresh.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_repeat.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_replay.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_restaurant.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_rss_feed.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_save.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_school.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_search.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_search_add.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_select_all.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_settings.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_settings_backup_restore.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_share.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_shopping_cart.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_shuffle.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_smart_display.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_sort.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_stars.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_subscriptions.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_subtitles.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_telescope.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_thumb_down.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_thumb_up.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_trending_up.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_tv.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_videogame_asset.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_visibility_on.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_volume_down.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_volume_mute.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_volume_off.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_volume_up.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_watch_later.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_wb_sunny.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_whatshot.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_work.xml ================================================ ================================================ FILE: app/src/main/res/drawable/not_available_monkey.xml ================================================ ================================================ FILE: app/src/main/res/drawable/placeholder_person.xml ================================================ ================================================ FILE: app/src/main/res/drawable/placeholder_thumbnail_playlist.xml ================================================ ================================================ FILE: app/src/main/res/drawable/placeholder_thumbnail_video.xml ================================================ ================================================ FILE: app/src/main/res/drawable/player_controls_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/player_controls_top_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/progress_circular_white.xml ================================================ ================================================ FILE: app/src/main/res/drawable/progress_soundcloud_horizontal_dark.xml ================================================ ================================================ FILE: app/src/main/res/drawable/progress_soundcloud_horizontal_light.xml ================================================ ================================================ FILE: app/src/main/res/drawable/progress_youtube_horizontal_dark.xml ================================================ ================================================ FILE: app/src/main/res/drawable/progress_youtube_horizontal_light.xml ================================================ ================================================ FILE: app/src/main/res/drawable/selector_checked_dark.xml ================================================ ================================================ FILE: app/src/main/res/drawable/selector_checked_light.xml ================================================ ================================================ FILE: app/src/main/res/drawable/selector_dark.xml ================================================ ================================================ FILE: app/src/main/res/drawable/selector_focused_dark.xml ================================================ ================================================ FILE: app/src/main/res/drawable/selector_focused_light.xml ================================================ ================================================ FILE: app/src/main/res/drawable/selector_light.xml ================================================ ================================================ FILE: app/src/main/res/drawable/splash_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/splash_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/toolbar_shadow_dark.xml ================================================ ================================================ FILE: app/src/main/res/drawable/toolbar_shadow_light.xml ================================================ ================================================ FILE: app/src/main/res/drawable-mdpi/volunteer_activism_ic.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night/ic_heart.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night/splash_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable-night-v23/splash_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v23/splash_background.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_about.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_downloader.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_error.xml ================================================