From 6b4f856a2774563b51e0ea860a9717de107ae2f8 Mon Sep 17 00:00:00 2001 From: wb9688 Date: Sat, 30 Mar 2024 18:41:23 +0100 Subject: [PATCH] Autoconvert app/src/main to Kotlin --- ...agmentStatePagerAdapterMenuWorkaround.java | 344 --- ...FragmentStatePagerAdapterMenuWorkaround.kt | 308 ++ .../material/appbar/FlingBehavior.java | 165 -- .../android/material/appbar/FlingBehavior.kt | 141 + .../{FuzzyScore.java => FuzzyScore.kt} | 106 +- app/src/main/java/org/schabi/newpipe/App.java | 269 -- app/src/main/java/org/schabi/newpipe/App.kt | 240 ++ .../java/org/schabi/newpipe/BaseFragment.java | 139 - .../java/org/schabi/newpipe/BaseFragment.kt | 129 + .../org/schabi/newpipe/DownloaderImpl.java | 182 -- .../java/org/schabi/newpipe/DownloaderImpl.kt | 170 ++ .../java/org/schabi/newpipe/ExitActivity.java | 50 - .../java/org/schabi/newpipe/ExitActivity.kt | 44 + .../java/org/schabi/newpipe/MainActivity.java | 927 ------- .../java/org/schabi/newpipe/MainActivity.kt | 820 ++++++ .../org/schabi/newpipe/NewPipeDatabase.java | 72 - .../org/schabi/newpipe/NewPipeDatabase.kt | 54 + .../newpipe/PanicResponderActivity.java | 44 - .../schabi/newpipe/PanicResponderActivity.kt | 43 + .../org/schabi/newpipe/QueueItemMenuUtil.java | 94 - .../org/schabi/newpipe/QueueItemMenuUtil.kt | 97 + .../org/schabi/newpipe/RouterActivity.java | 1092 -------- .../java/org/schabi/newpipe/RouterActivity.kt | 1002 +++++++ .../schabi/newpipe/database/AppDatabase.java | 65 - .../schabi/newpipe/database/AppDatabase.kt | 46 + .../org/schabi/newpipe/database/BasicDAO.java | 39 - .../org/schabi/newpipe/database/BasicDAO.kt | 33 + .../schabi/newpipe/database/LocalItem.java | 13 - .../org/schabi/newpipe/database/LocalItem.kt | 11 + .../{Migrations.java => Migrations.kt} | 299 +- .../database/history/dao/HistoryDAO.java | 7 - .../database/history/dao/HistoryDAO.kt | 7 + .../history/dao/SearchHistoryDAO.java | 52 - .../database/history/dao/SearchHistoryDAO.kt | 37 + .../history/dao/StreamHistoryDAO.java | 89 - .../database/history/dao/StreamHistoryDAO.kt | 59 + .../history/model/StreamHistoryEntity.java | 81 - .../history/model/StreamHistoryEntity.kt | 50 + .../playlist/PlaylistDuplicatesEntry.java | 29 - .../playlist/PlaylistDuplicatesEntry.kt | 23 + .../database/playlist/PlaylistLocalItem.java | 13 - .../database/playlist/PlaylistLocalItem.kt | 10 + .../playlist/PlaylistMetadataEntry.java | 74 - .../playlist/PlaylistMetadataEntry.kt | 41 + .../database/playlist/dao/PlaylistDAO.java | 53 - .../database/playlist/dao/PlaylistDAO.kt | 40 + .../playlist/dao/PlaylistRemoteDAO.java | 68 - .../playlist/dao/PlaylistRemoteDAO.kt | 53 + .../playlist/dao/PlaylistStreamDAO.java | 159 -- .../playlist/dao/PlaylistStreamDAO.kt | 117 + .../playlist/model/PlaylistEntity.java | 100 - .../database/playlist/model/PlaylistEntity.kt | 98 + .../playlist/model/PlaylistRemoteEntity.java | 188 -- .../playlist/model/PlaylistRemoteEntity.kt | 170 ++ .../playlist/model/PlaylistStreamEntity.java | 76 - .../playlist/model/PlaylistStreamEntity.kt | 43 + .../database/stream/dao/StreamStateDAO.java | 48 - .../database/stream/dao/StreamStateDAO.kt | 36 + .../stream/model/StreamStateEntity.java | 112 - .../stream/model/StreamStateEntity.kt | 89 + .../subscription/NotificationMode.java | 14 - .../database/subscription/NotificationMode.kt | 13 + .../subscription/SubscriptionEntity.java | 198 -- .../subscription/SubscriptionEntity.kt | 182 ++ .../newpipe/download/DownloadActivity.java | 97 - .../newpipe/download/DownloadActivity.kt | 80 + .../newpipe/download/DownloadDialog.java | 1149 -------- .../schabi/newpipe/download/DownloadDialog.kt | 1039 +++++++ .../newpipe/download/LoadingDialog.java | 87 - .../schabi/newpipe/download/LoadingDialog.kt | 76 + ...aReportSender.java => AcraReportSender.kt} | 28 +- ...actory.java => AcraReportSenderFactory.kt} | 32 +- .../schabi/newpipe/error/ErrorActivity.java | 348 --- .../org/schabi/newpipe/error/ErrorActivity.kt | 309 +++ .../newpipe/error/ReCaptchaActivity.java | 238 -- .../schabi/newpipe/error/ReCaptchaActivity.kt | 217 ++ .../error/{UserAction.java => UserAction.kt} | 15 +- .../{BackPressable.java => BackPressable.kt} | 6 +- .../newpipe/fragments/BaseStateFragment.java | 226 -- .../newpipe/fragments/BaseStateFragment.kt | 197 ++ .../newpipe/fragments/BlankFragment.java | 30 - .../schabi/newpipe/fragments/BlankFragment.kt | 24 + .../newpipe/fragments/EmptyFragment.java | 33 - .../schabi/newpipe/fragments/EmptyFragment.kt | 30 + .../newpipe/fragments/MainFragment.java | 342 --- .../schabi/newpipe/fragments/MainFragment.kt | 284 ++ .../fragments/OnScrollBelowItemsListener.java | 47 - .../fragments/OnScrollBelowItemsListener.kt | 43 + .../newpipe/fragments/ViewContract.java | 13 - .../schabi/newpipe/fragments/ViewContract.kt | 9 + .../detail/BaseDescriptionFragment.java | 281 -- .../detail/BaseDescriptionFragment.kt | 242 ++ .../fragments/detail/DescriptionFragment.java | 140 - .../fragments/detail/DescriptionFragment.kt | 97 + .../newpipe/fragments/detail/StackItem.java | 56 - .../newpipe/fragments/detail/StackItem.kt | 39 + .../newpipe/fragments/detail/TabAdapter.java | 96 - .../newpipe/fragments/detail/TabAdapter.kt | 76 + .../fragments/detail/VideoDetailFragment.java | 2469 ----------------- .../fragments/detail/VideoDetailFragment.kt | 2214 +++++++++++++++ .../detail/VideoDetailPlayerCrasher.java | 146 - .../detail/VideoDetailPlayerCrasher.kt | 126 + .../fragments/list/BaseListFragment.java | 489 ---- .../fragments/list/BaseListFragment.kt | 431 +++ .../fragments/list/BaseListInfoFragment.java | 288 -- .../fragments/list/BaseListInfoFragment.kt | 250 ++ .../fragments/list/ListViewContract.java | 9 - .../fragments/list/ListViewContract.kt | 8 + .../list/channel/ChannelAboutFragment.java | 94 - .../list/channel/ChannelAboutFragment.kt | 70 + .../list/channel/ChannelFragment.java | 647 ----- .../fragments/list/channel/ChannelFragment.kt | 564 ++++ .../list/channel/ChannelTabFragment.java | 168 -- .../list/channel/ChannelTabFragment.kt | 146 + .../list/comments/CommentRepliesFragment.java | 168 -- .../list/comments/CommentRepliesFragment.kt | 140 + .../list/comments/CommentRepliesInfo.java | 22 - .../list/comments/CommentRepliesInfo.kt | 19 + .../list/comments/CommentsFragment.java | 123 - .../list/comments/CommentsFragment.kt | 95 + .../list/kiosk/DefaultKioskFragment.java | 55 - .../list/kiosk/DefaultKioskFragment.kt | 46 + .../fragments/list/kiosk/KioskFragment.java | 176 -- .../fragments/list/kiosk/KioskFragment.kt | 158 ++ .../playlist/PlaylistControlViewHolder.java | 11 - .../playlist/PlaylistControlViewHolder.kt | 11 + .../list/playlist/PlaylistFragment.java | 514 ---- .../list/playlist/PlaylistFragment.kt | 443 +++ .../fragments/list/search/SearchFragment.java | 1113 -------- .../fragments/list/search/SearchFragment.kt | 1013 +++++++ .../fragments/list/search/SuggestionItem.java | 32 - .../fragments/list/search/SuggestionItem.kt | 18 + .../list/search/SuggestionListAdapter.java | 93 - .../list/search/SuggestionListAdapter.kt | 72 + .../list/videos/RelatedItemsFragment.java | 176 -- .../list/videos/RelatedItemsFragment.kt | 148 + .../list/videos/RelatedItemsInfo.java | 22 - .../fragments/list/videos/RelatedItemsInfo.kt | 18 + .../newpipe/info_list/InfoItemBuilder.java | 131 - .../newpipe/info_list/InfoItemBuilder.kt | 108 + .../newpipe/info_list/InfoListAdapter.java | 358 --- .../newpipe/info_list/InfoListAdapter.kt | 309 +++ .../{ItemViewMode.java => ItemViewMode.kt} | 7 +- .../info_list/dialog/InfoItemDialog.java | 356 --- .../info_list/dialog/InfoItemDialog.kt | 350 +++ .../dialog/StreamDialogDefaultEntry.java | 154 - .../dialog/StreamDialogDefaultEntry.kt | 130 + .../info_list/dialog/StreamDialogEntry.java | 31 - .../info_list/dialog/StreamDialogEntry.kt | 17 + .../holder/ChannelCardInfoItemHolder.java | 22 - .../holder/ChannelCardInfoItemHolder.kt | 14 + .../holder/ChannelGridInfoItemHolder.java | 13 - .../holder/ChannelGridInfoItemHolder.kt | 8 + .../info_list/holder/ChannelInfoItemHolder.kt | 26 + .../holder/ChannelMiniInfoItemHolder.java | 117 - .../holder/ChannelMiniInfoItemHolder.kt | 103 + .../holder/CommentInfoItemHolder.java | 188 -- .../info_list/holder/CommentInfoItemHolder.kt | 170 ++ .../info_list/holder/InfoItemHolder.java | 46 - .../info_list/holder/InfoItemHolder.kt | 37 + .../holder/PlaylistCardInfoItemHolder.java | 17 - .../holder/PlaylistCardInfoItemHolder.kt | 11 + .../holder/PlaylistGridInfoItemHolder.java | 13 - .../holder/PlaylistGridInfoItemHolder.kt | 8 + .../holder/PlaylistInfoItemHolder.java | 12 - .../holder/PlaylistInfoItemHolder.kt | 7 + .../holder/PlaylistMiniInfoItemHolder.java | 65 - .../holder/PlaylistMiniInfoItemHolder.kt | 56 + .../holder/StreamCardInfoItemHolder.java | 16 - .../holder/StreamCardInfoItemHolder.kt | 10 + .../holder/StreamGridInfoItemHolder.java | 12 - .../holder/StreamGridInfoItemHolder.kt | 7 + .../holder/StreamInfoItemHolder.java | 93 - .../info_list/holder/StreamInfoItemHolder.kt | 78 + .../holder/StreamMiniInfoItemHolder.java | 155 -- .../holder/StreamMiniInfoItemHolder.kt | 132 + .../newpipe/local/BaseLocalListFragment.java | 268 -- .../newpipe/local/BaseLocalListFragment.kt | 234 ++ .../newpipe/local/HeaderFooterHolder.java | 14 - .../newpipe/local/HeaderFooterHolder.kt | 6 + .../newpipe/local/LocalItemBuilder.java | 48 - .../LocalItemBuilder.kt} | 27 +- .../newpipe/local/LocalItemListAdapter.java | 410 --- .../newpipe/local/LocalItemListAdapter.kt | 364 +++ .../local/bookmark/BookmarkFragment.java | 556 ---- .../local/bookmark/BookmarkFragment.kt | 482 ++++ .../local/bookmark/MergedPlaylistManager.java | 95 - .../local/bookmark/MergedPlaylistManager.kt | 85 + .../local/dialog/PlaylistAppendDialog.java | 174 -- .../local/dialog/PlaylistAppendDialog.kt | 150 + .../local/dialog/PlaylistCreationDialog.java | 74 - .../local/dialog/PlaylistCreationDialog.kt | 62 + .../newpipe/local/dialog/PlaylistDialog.java | 181 -- .../newpipe/local/dialog/PlaylistDialog.kt | 138 + .../local/history/HistoryEntryAdapter.java | 108 - .../local/history/HistoryEntryAdapter.kt | 93 + .../local/history/HistoryRecordManager.java | 318 --- .../local/history/HistoryRecordManager.kt | 313 +++ .../history/StatisticsPlaylistFragment.java | 395 --- .../history/StatisticsPlaylistFragment.kt | 337 +++ .../LocalBookmarkPlaylistItemHolder.java | 54 - .../holder/LocalBookmarkPlaylistItemHolder.kt | 46 + .../newpipe/local/holder/LocalItemHolder.java | 48 - .../newpipe/local/holder/LocalItemHolder.kt | 38 + .../holder/LocalPlaylistCardItemHolder.java | 17 - .../holder/LocalPlaylistCardItemHolder.kt | 11 + .../holder/LocalPlaylistGridItemHolder.java | 13 - .../holder/LocalPlaylistGridItemHolder.kt | 8 + .../local/holder/LocalPlaylistItemHolder.java | 54 - .../local/holder/LocalPlaylistItemHolder.kt | 43 + .../LocalPlaylistStreamCardItemHolder.java | 17 - .../LocalPlaylistStreamCardItemHolder.kt | 11 + .../LocalPlaylistStreamGridItemHolder.java | 13 - .../LocalPlaylistStreamGridItemHolder.kt | 8 + .../holder/LocalPlaylistStreamItemHolder.java | 141 - .../holder/LocalPlaylistStreamItemHolder.kt | 125 + .../LocalStatisticStreamCardItemHolder.java | 13 - .../LocalStatisticStreamCardItemHolder.kt | 8 + .../LocalStatisticStreamGridItemHolder.java | 13 - .../LocalStatisticStreamGridItemHolder.kt | 8 + .../LocalStatisticStreamItemHolder.java | 161 -- .../holder/LocalStatisticStreamItemHolder.kt | 140 + .../local/holder/PlaylistItemHolder.java | 52 - .../local/holder/PlaylistItemHolder.kt | 46 + .../RemoteBookmarkPlaylistItemHolder.java | 54 - .../RemoteBookmarkPlaylistItemHolder.kt | 46 + .../holder/RemotePlaylistCardItemHolder.java | 17 - .../holder/RemotePlaylistCardItemHolder.kt | 11 + .../holder/RemotePlaylistGridItemHolder.java | 13 - .../holder/RemotePlaylistGridItemHolder.kt | 8 + .../holder/RemotePlaylistItemHolder.java | 52 - .../local/holder/RemotePlaylistItemHolder.kt | 41 + .../local/playlist/LocalPlaylistFragment.java | 888 ------ .../local/playlist/LocalPlaylistFragment.kt | 798 ++++++ .../local/playlist/LocalPlaylistManager.java | 189 -- .../local/playlist/LocalPlaylistManager.kt | 191 ++ .../local/playlist/RemotePlaylistManager.java | 65 - .../local/playlist/RemotePlaylistManager.kt | 63 + .../ImportConfirmationDialog.java | 68 - .../subscription/ImportConfirmationDialog.kt | 58 + .../SubscriptionsImportFragment.java | 228 -- .../SubscriptionsImportFragment.kt | 194 ++ .../services/BaseImportExportService.java | 233 -- .../services/BaseImportExportService.kt | 203 ++ ...ener.java => ImportExportEventListener.kt} | 8 +- .../services/ImportExportJsonHelper.java | 158 -- .../services/ImportExportJsonHelper.kt | 129 + .../services/SubscriptionsExportService.java | 168 -- .../services/SubscriptionsExportService.kt | 149 + .../services/SubscriptionsImportService.java | 327 --- .../services/SubscriptionsImportService.kt | 282 ++ .../newpipe/player/AudioServiceLeakFix.java | 29 - .../newpipe/player/AudioServiceLeakFix.kt | 26 + .../newpipe/player/PlayQueueActivity.java | 676 ----- .../newpipe/player/PlayQueueActivity.kt | 615 ++++ .../org/schabi/newpipe/player/Player.java | 2341 ---------------- .../java/org/schabi/newpipe/player/Player.kt | 2033 ++++++++++++++ .../schabi/newpipe/player/PlayerService.java | 185 -- .../schabi/newpipe/player/PlayerService.kt | 166 ++ .../org/schabi/newpipe/player/PlayerType.java | 32 - .../org/schabi/newpipe/player/PlayerType.kt | 30 + .../NonUriHlsDataSourceFactory.java | 136 - .../datasource/NonUriHlsDataSourceFactory.kt | 119 + .../datasource/YoutubeHttpDataSource.java | 1014 ------- .../datasource/YoutubeHttpDataSource.kt | 894 ++++++ .../player/event/OnKeyDownListener.java | 5 - .../newpipe/player/event/OnKeyDownListener.kt | 5 + .../player/event/PlayerEventListener.java | 16 - .../player/event/PlayerEventListener.kt | 16 + .../event/PlayerServiceEventListener.java | 17 - .../event/PlayerServiceEventListener.kt | 12 + .../PlayerServiceExtendedEventListener.java | 11 - .../PlayerServiceExtendedEventListener.kt | 12 + .../gesture/CustomBottomSheetBehavior.java | 84 - .../gesture/CustomBottomSheetBehavior.kt | 69 + .../newpipe/player/helper/AudioReactor.java | 167 -- .../newpipe/player/helper/AudioReactor.kt | 141 + .../newpipe/player/helper/CacheFactory.java | 46 - .../newpipe/player/helper/CacheFactory.kt | 29 + .../helper/CustomMediaCodecVideoRenderer.java | 54 - .../helper/CustomMediaCodecVideoRenderer.kt | 46 + .../player/helper/CustomRenderersFactory.java | 43 - .../player/helper/CustomRenderersFactory.kt | 34 + .../newpipe/player/helper/LoadController.java | 42 - .../newpipe/player/helper/LoadController.kt | 39 + .../newpipe/player/helper/LockManager.java | 54 - .../newpipe/player/helper/LockManager.kt | 50 + .../helper/PlaybackParameterDialog.java | 597 ---- .../player/helper/PlaybackParameterDialog.kt | 539 ++++ .../player/helper/PlayerDataSource.java | 217 -- .../newpipe/player/helper/PlayerDataSource.kt | 206 ++ .../newpipe/player/helper/PlayerHelper.java | 506 ---- .../newpipe/player/helper/PlayerHelper.kt | 427 +++ .../newpipe/player/helper/PlayerHolder.java | 306 -- .../newpipe/player/helper/PlayerHolder.kt | 276 ++ .../player/helper/PlayerSemitoneHelper.java | 40 - .../player/helper/PlayerSemitoneHelper.kt | 34 + .../player/mediaitem/ExceptionTag.java | 100 - .../newpipe/player/mediaitem/ExceptionTag.kt | 70 + .../player/mediaitem/MediaItemTag.java | 171 -- .../newpipe/player/mediaitem/MediaItemTag.kt | 117 + .../player/mediaitem/PlaceholderTag.java | 85 - .../player/mediaitem/PlaceholderTag.kt | 62 + .../player/mediaitem/StreamInfoTag.java | 139 - .../newpipe/player/mediaitem/StreamInfoTag.kt | 100 + .../mediasession/MediaSessionPlayerUi.java | 290 -- .../mediasession/MediaSessionPlayerUi.kt | 259 ++ .../mediasession/PlayQueueNavigator.java | 159 -- .../player/mediasession/PlayQueueNavigator.kt | 133 + .../SessionConnectorActionProvider.java | 47 - .../SessionConnectorActionProvider.kt | 34 + .../player/mediasource/FailedMediaSource.java | 210 -- .../player/mediasource/FailedMediaSource.kt | 178 ++ .../player/mediasource/LoadedMediaSource.java | 64 - .../player/mediasource/LoadedMediaSource.kt | 54 + .../mediasource/ManagedMediaSource.java | 30 - .../player/mediasource/ManagedMediaSource.kt | 27 + .../ManagedMediaSourcePlaylist.java | 178 -- .../mediasource/ManagedMediaSourcePlaylist.kt | 174 ++ .../mediasource/PlaceholderMediaSource.java | 53 - .../mediasource/PlaceholderMediaSource.kt | 42 + .../notification/NotificationActionData.java | 187 -- .../notification/NotificationActionData.kt | 153 + .../notification/NotificationConstants.java | 186 -- .../notification/NotificationConstants.kt | 137 + .../notification/NotificationPlayerUi.java | 119 - .../notification/NotificationPlayerUi.kt | 97 + .../player/notification/NotificationUtil.java | 302 -- .../player/notification/NotificationUtil.kt | 271 ++ .../player/playback/MediaSourceManager.java | 608 ---- .../player/playback/MediaSourceManager.kt | 527 ++++ ...ybackListener.java => PlaybackListener.kt} | 66 +- .../playback/SurfaceHolderCallback.java | 62 - .../player/playback/SurfaceHolderCallback.kt | 52 + .../playqueue/AbstractInfoPlayQueue.java | 152 - .../player/playqueue/AbstractInfoPlayQueue.kt | 122 + .../player/playqueue/ChannelTabPlayQueue.java | 53 - .../player/playqueue/ChannelTabPlayQueue.kt | 34 + .../newpipe/player/playqueue/PlayQueue.java | 561 ---- .../newpipe/player/playqueue/PlayQueue.kt | 548 ++++ .../player/playqueue/PlayQueueAdapter.java | 228 -- .../player/playqueue/PlayQueueAdapter.kt | 206 ++ .../player/playqueue/PlayQueueItem.java | 142 - .../newpipe/player/playqueue/PlayQueueItem.kt | 60 + .../playqueue/PlayQueueItemBuilder.java | 73 - .../player/playqueue/PlayQueueItemBuilder.kt | 66 + ...ItemHolder.java => PlayQueueItemHolder.kt} | 70 +- .../playqueue/PlayQueueItemTouchCallback.java | 61 - .../playqueue/PlayQueueItemTouchCallback.kt | 52 + .../player/playqueue/PlaylistPlayQueue.java | 46 - .../player/playqueue/PlaylistPlayQueue.kt | 36 + .../player/playqueue/SinglePlayQueue.java | 47 - .../player/playqueue/SinglePlayQueue.kt | 34 + .../player/playqueue/events/AppendEvent.java | 18 - .../player/playqueue/events/AppendEvent.kt | 8 + .../player/playqueue/events/ErrorEvent.java | 24 - .../player/playqueue/events/ErrorEvent.kt | 8 + .../player/playqueue/events/InitEvent.java | 8 - .../player/playqueue/events/InitEvent.kt | 7 + .../player/playqueue/events/MoveEvent.java | 24 - .../player/playqueue/events/MoveEvent.kt | 8 + .../playqueue/events/PlayQueueEvent.java | 7 - .../player/playqueue/events/PlayQueueEvent.kt | 7 + ...ueEventType.java => PlayQueueEventType.kt} | 5 +- .../playqueue/events/RecoveryEvent.java | 24 - .../player/playqueue/events/RecoveryEvent.kt | 8 + .../player/playqueue/events/RemoveEvent.java | 24 - .../player/playqueue/events/RemoveEvent.kt | 8 + .../player/playqueue/events/ReorderEvent.java | 24 - .../player/playqueue/events/ReorderEvent.kt | 8 + .../player/playqueue/events/SelectEvent.java | 24 - .../player/playqueue/events/SelectEvent.kt | 8 + .../resolver/AudioPlaybackResolver.java | 103 - .../player/resolver/AudioPlaybackResolver.kt | 68 + .../player/resolver/PlaybackResolver.java | 559 ---- .../player/resolver/PlaybackResolver.kt | 549 ++++ .../newpipe/player/resolver/Resolver.java | 9 - .../newpipe/player/resolver/Resolver.kt | 5 + .../resolver/VideoPlaybackResolver.java | 204 -- .../player/resolver/VideoPlaybackResolver.kt | 149 + .../SeekbarPreviewThumbnailHelper.java | 102 - .../SeekbarPreviewThumbnailHelper.kt | 80 + .../SeekbarPreviewThumbnailHolder.java | 216 -- .../SeekbarPreviewThumbnailHolder.kt | 187 ++ .../newpipe/player/ui/MainPlayerUi.java | 987 ------- .../schabi/newpipe/player/ui/MainPlayerUi.kt | 838 ++++++ .../schabi/newpipe/player/ui/PlayerUi.java | 212 -- .../org/schabi/newpipe/player/ui/PlayerUi.kt | 160 ++ .../newpipe/player/ui/PlayerUiList.java | 90 - .../schabi/newpipe/player/ui/PlayerUiList.kt | 85 + .../newpipe/player/ui/PopupPlayerUi.java | 592 ---- .../schabi/newpipe/player/ui/PopupPlayerUi.kt | 503 ++++ .../newpipe/player/ui/VideoPlayerUi.java | 1620 ----------- .../schabi/newpipe/player/ui/VideoPlayerUi.kt | 1393 ++++++++++ .../settings/AppearanceSettingsFragment.java | 83 - .../settings/AppearanceSettingsFragment.kt | 70 + .../BackupRestoreSettingsFragment.java | 271 -- .../settings/BackupRestoreSettingsFragment.kt | 235 ++ .../settings/BasePreferenceFragment.java | 56 - .../settings/BasePreferenceFragment.kt | 48 + .../settings/ContentSettingsFragment.java | 91 - .../settings/ContentSettingsFragment.kt | 70 + .../settings/DebugSettingsFragment.java | 117 - .../newpipe/settings/DebugSettingsFragment.kt | 96 + .../settings/DownloadSettingsFragment.java | 282 -- .../settings/DownloadSettingsFragment.kt | 246 ++ .../settings/ExoPlayerSettingsFragment.java | 45 - .../settings/ExoPlayerSettingsFragment.kt | 33 + .../settings/HistorySettingsFragment.java | 159 -- .../settings/HistorySettingsFragment.kt | 165 ++ .../settings/MainSettingsFragment.java | 71 - .../newpipe/settings/MainSettingsFragment.kt | 60 + .../newpipe/settings/NewPipeSettings.java | 187 -- .../newpipe/settings/NewPipeSettings.kt | 165 ++ .../PeertubeInstanceListFragment.java | 413 --- .../settings/PeertubeInstanceListFragment.kt | 363 +++ .../settings/SelectChannelFragment.java | 213 -- .../newpipe/settings/SelectChannelFragment.kt | 181 ++ .../newpipe/settings/SelectKioskFragment.java | 165 -- .../newpipe/settings/SelectKioskFragment.kt | 136 + .../settings/SelectPlaylistFragment.java | 192 -- .../settings/SelectPlaylistFragment.kt | 159 ++ .../newpipe/settings/SettingMigrations.java | 236 -- .../newpipe/settings/SettingMigrations.kt | 189 ++ .../newpipe/settings/SettingsActivity.java | 393 --- .../newpipe/settings/SettingsActivity.kt | 346 +++ .../settings/SettingsResourceRegistry.java | 152 - .../settings/SettingsResourceRegistry.kt | 136 + .../settings/UpdateSettingsFragment.java | 76 - .../settings/UpdateSettingsFragment.kt | 73 + .../settings/VideoAudioSettingsFragment.java | 195 -- .../settings/VideoAudioSettingsFragment.kt | 177 ++ .../custom/NotificationActionsPreference.java | 117 - .../custom/NotificationActionsPreference.kt | 96 + .../settings/custom/NotificationSlot.java | 172 -- .../settings/custom/NotificationSlot.kt | 150 + .../PreferenceFuzzySearchFunction.java | 96 - .../PreferenceFuzzySearchFunction.kt | 79 + .../preferencesearch/PreferenceParser.java | 193 -- .../preferencesearch/PreferenceParser.kt | 175 ++ .../PreferenceSearchAdapter.java | 86 - .../PreferenceSearchAdapter.kt | 59 + .../PreferenceSearchConfiguration.java | 42 - .../PreferenceSearchConfiguration.kt | 37 + .../PreferenceSearchFragment.java | 73 - .../PreferenceSearchFragment.kt | 61 + .../PreferenceSearchItem.java | 102 - .../preferencesearch/PreferenceSearchItem.kt | 88 + .../PreferenceSearchResultHighlighter.java | 125 - .../PreferenceSearchResultHighlighter.kt | 111 + .../PreferenceSearchResultListener.java | 7 - .../PreferenceSearchResultListener.kt | 5 + .../preferencesearch/PreferenceSearcher.java | 36 - .../preferencesearch/PreferenceSearcher.kt | 24 + .../preferencesearch/package-info.java | 10 - .../settings/preferencesearch/package-info.kt | 1041 +++++++ .../newpipe/settings/tabs/AddTabDialog.java | 97 - .../newpipe/settings/tabs/AddTabDialog.kt | 71 + .../settings/tabs/ChooseTabsFragment.java | 418 --- .../settings/tabs/ChooseTabsFragment.kt | 362 +++ .../org/schabi/newpipe/settings/tabs/Tab.java | 655 ----- .../org/schabi/newpipe/settings/tabs/Tab.kt | 570 ++++ .../newpipe/settings/tabs/TabsJsonHelper.java | 121 - .../newpipe/settings/tabs/TabsJsonHelper.kt | 98 + .../newpipe/settings/tabs/TabsManager.java | 85 - .../newpipe/settings/tabs/TabsManager.kt | 82 + .../schabi/newpipe/streams/DataReader.java | 267 -- .../org/schabi/newpipe/streams/DataReader.kt | 244 ++ .../schabi/newpipe/streams/Mp4DashReader.java | 943 ------- .../schabi/newpipe/streams/Mp4DashReader.kt | 820 ++++++ .../newpipe/streams/Mp4FromDashWriter.java | 912 ------ .../newpipe/streams/Mp4FromDashWriter.kt | 809 ++++++ .../newpipe/streams/OggFromWebMWriter.java | 416 --- .../newpipe/streams/OggFromWebMWriter.kt | 364 +++ .../newpipe/streams/SrtFromTtmlWriter.java | 103 - .../newpipe/streams/SrtFromTtmlWriter.kt | 90 + .../schabi/newpipe/streams/WebMReader.java | 537 ---- .../org/schabi/newpipe/streams/WebMReader.kt | 457 +++ .../schabi/newpipe/streams/WebMWriter.java | 761 ----- .../org/schabi/newpipe/streams/WebMWriter.kt | 652 +++++ .../streams/io/NoFileManagerSafeGuard.java | 75 - .../streams/io/NoFileManagerSafeGuard.kt | 66 + .../newpipe/streams/io/SharpInputStream.java | 52 - .../newpipe/streams/io/SharpInputStream.kt | 48 + .../newpipe/streams/io/SharpOutputStream.java | 46 - .../newpipe/streams/io/SharpOutputStream.kt | 43 + .../newpipe/streams/io/SharpStream.java | 70 - .../schabi/newpipe/streams/io/SharpStream.kt | 67 + .../streams/io/StoredDirectoryHelper.java | 399 --- .../streams/io/StoredDirectoryHelper.kt | 350 +++ .../newpipe/streams/io/StoredFileHelper.java | 577 ---- .../newpipe/streams/io/StoredFileHelper.kt | 517 ++++ .../newpipe/util/AudioTrackAdapter.java | 94 - .../schabi/newpipe/util/AudioTrackAdapter.kt | 69 + .../schabi/newpipe/util/ChannelTabHelper.java | 151 - .../schabi/newpipe/util/ChannelTabHelper.kt | 115 + .../util/DependentPreferenceHelper.java | 51 - .../newpipe/util/DependentPreferenceHelper.kt | 42 + .../org/schabi/newpipe/util/DeviceUtils.java | 338 --- .../org/schabi/newpipe/util/DeviceUtils.kt | 334 +++ .../schabi/newpipe/util/ExtractorHelper.java | 358 --- .../schabi/newpipe/util/ExtractorHelper.kt | 324 +++ .../newpipe/util/FallbackViewHolder.java | 11 - .../schabi/newpipe/util/FallbackViewHolder.kt | 6 + .../util/FilePickerActivityHelper.java | 147 - .../newpipe/util/FilePickerActivityHelper.kt | 128 + .../schabi/newpipe/util/FilenameUtils.java | 70 - .../org/schabi/newpipe/util/FilenameUtils.kt | 58 + .../org/schabi/newpipe/util/InfoCache.java | 175 -- .../java/org/schabi/newpipe/util/InfoCache.kt | 141 + .../org/schabi/newpipe/util/KeyboardUtil.java | 55 - .../org/schabi/newpipe/util/KeyboardUtil.kt | 49 + .../schabi/newpipe/util/KioskTranslator.java | 84 - .../schabi/newpipe/util/KioskTranslator.kt | 59 + .../org/schabi/newpipe/util/ListHelper.java | 880 ------ .../org/schabi/newpipe/util/ListHelper.kt | 805 ++++++ .../org/schabi/newpipe/util/Localization.java | 447 --- .../org/schabi/newpipe/util/Localization.kt | 407 +++ .../schabi/newpipe/util/NavigationHelper.java | 734 ----- .../schabi/newpipe/util/NavigationHelper.kt | 672 +++++ .../newpipe/util/NewPipeTextViewHelper.java | 61 - .../newpipe/util/NewPipeTextViewHelper.kt | 49 + .../schabi/newpipe/util/OnClickGesture.java | 15 - .../org/schabi/newpipe/util/OnClickGesture.kt | 14 + .../schabi/newpipe/util/PeertubeHelper.java | 69 - .../org/schabi/newpipe/util/PeertubeHelper.kt | 60 + .../schabi/newpipe/util/PermissionHelper.java | 146 - .../schabi/newpipe/util/PermissionHelper.kt | 136 + .../schabi/newpipe/util/PlayButtonHelper.java | 90 - .../schabi/newpipe/util/PlayButtonHelper.kt | 84 + .../newpipe/util/SecondaryStreamHelper.java | 75 - .../newpipe/util/SecondaryStreamHelper.kt | 69 + .../schabi/newpipe/util/SerializedCache.java | 120 - .../schabi/newpipe/util/SerializedCache.kt | 89 + .../schabi/newpipe/util/ServiceHelper.java | 213 -- .../org/schabi/newpipe/util/ServiceHelper.kt | 171 ++ .../schabi/newpipe/util/SliderStrategy.java | 79 - .../org/schabi/newpipe/util/SliderStrategy.kt | 73 + .../schabi/newpipe/util/SparseItemUtil.java | 127 - .../org/schabi/newpipe/util/SparseItemUtil.kt | 118 + .../org/schabi/newpipe/util/StateSaver.java | 341 --- .../org/schabi/newpipe/util/StateSaver.kt | 296 ++ .../newpipe/util/StreamItemAdapter.java | 470 ---- .../schabi/newpipe/util/StreamItemAdapter.kt | 423 +++ .../schabi/newpipe/util/StreamTypeUtil.java | 50 - .../org/schabi/newpipe/util/StreamTypeUtil.kt | 46 + .../org/schabi/newpipe/util/ThemeHelper.java | 415 --- .../org/schabi/newpipe/util/ThemeHelper.kt | 379 +++ .../org/schabi/newpipe/util/ZipHelper.java | 115 - .../java/org/schabi/newpipe/util/ZipHelper.kt | 113 + .../util/debounce/DebounceSavable.java | 15 - .../newpipe/util/debounce/DebounceSavable.kt | 13 + .../newpipe/util/debounce/DebounceSaver.java | 81 - .../newpipe/util/debounce/DebounceSaver.kt | 66 + .../external_communication/KoreUtils.java | 72 - .../util/external_communication/KoreUtils.kt | 64 + .../external_communication/ShareUtils.java | 413 --- .../util/external_communication/ShareUtils.kt | 382 +++ .../newpipe/util/image/ImageStrategy.java | 195 -- .../newpipe/util/image/ImageStrategy.kt | 170 ++ .../newpipe/util/image/PicassoHelper.java | 224 -- .../newpipe/util/image/PicassoHelper.kt | 193 ++ .../util/image/PreferredImageQuality.java | 39 - .../util/image/PreferredImageQuality.kt | 36 + .../util/text/CommentTextOnTouchListener.java | 42 - .../util/text/CommentTextOnTouchListener.kt | 39 + .../text/HashtagLongPressClickableSpan.java | 36 - .../text/HashtagLongPressClickableSpan.kt | 18 + .../util/text/InternalUrlsHandler.java | 177 -- .../newpipe/util/text/InternalUrlsHandler.kt | 159 ++ .../util/text/LongPressClickableSpan.java | 12 - .../util/text/LongPressClickableSpan.kt | 8 + .../text/LongPressLinkMovementMethod.java | 77 - .../util/text/LongPressLinkMovementMethod.kt | 64 + .../newpipe/util/text/TextEllipsizer.java | 193 -- .../newpipe/util/text/TextEllipsizer.kt | 174 ++ .../newpipe/util/text/TextLinkifier.java | 369 --- .../schabi/newpipe/util/text/TextLinkifier.kt | 340 +++ .../newpipe/util/text/TimestampExtractor.java | 83 - .../newpipe/util/text/TimestampExtractor.kt | 64 + .../text/TimestampLongPressClickableSpan.java | 78 - .../text/TimestampLongPressClickableSpan.kt | 49 + .../schabi/newpipe/util/text/TouchUtils.java | 38 - .../schabi/newpipe/util/text/TouchUtils.kt | 27 + .../util/text/UrlLongPressClickableSpan.java | 41 - .../util/text/UrlLongPressClickableSpan.kt | 21 + ...{PatternsCompat.java => PatternsCompat.kt} | 58 +- .../newpipe/views/AnimatedProgressBar.java | 66 - .../newpipe/views/AnimatedProgressBar.kt | 46 + .../schabi/newpipe/views/CollapsibleView.java | 249 -- .../schabi/newpipe/views/CollapsibleView.kt | 207 ++ .../views/CustomCollapsingToolbarLayout.java | 41 - .../views/CustomCollapsingToolbarLayout.kt | 35 + .../newpipe/views/ExpandableSurfaceView.java | 110 - .../newpipe/views/ExpandableSurfaceView.kt | 92 + .../newpipe/views/FocusAwareCoordinator.java | 105 - .../newpipe/views/FocusAwareCoordinator.kt | 87 + .../newpipe/views/FocusAwareDrawerLayout.java | 115 - .../newpipe/views/FocusAwareDrawerLayout.kt | 86 + .../newpipe/views/FocusAwareSeekBar.java | 147 - .../schabi/newpipe/views/FocusAwareSeekBar.kt | 112 + .../newpipe/views/FocusOverlayView.java | 293 -- .../schabi/newpipe/views/FocusOverlayView.kt | 241 ++ .../schabi/newpipe/views/NewPipeEditText.java | 46 - .../schabi/newpipe/views/NewPipeEditText.kt | 33 + ...cyclerView.java => NewPipeRecyclerView.kt} | 231 +- .../schabi/newpipe/views/NewPipeTextView.java | 58 - .../schabi/newpipe/views/NewPipeTextView.kt | 44 + .../newpipe/views/ScrollableTabLayout.java | 134 - .../newpipe/views/ScrollableTabLayout.kt | 117 + .../views/SuperScrollLayoutManager.java | 174 -- .../newpipe/views/SuperScrollLayoutManager.kt | 129 + .../giga/get/DownloadInitializer.java | 208 -- .../shandian/giga/get/DownloadInitializer.kt | 168 ++ .../us/shandian/giga/get/DownloadMission.java | 853 ------ .../us/shandian/giga/get/DownloadMission.kt | 760 +++++ .../giga/get/DownloadMissionRecover.java | 321 --- .../giga/get/DownloadMissionRecover.kt | 277 ++ .../shandian/giga/get/DownloadRunnable.java | 184 -- .../us/shandian/giga/get/DownloadRunnable.kt | 145 + .../giga/get/DownloadRunnableFallback.java | 155 -- .../giga/get/DownloadRunnableFallback.kt | 117 + .../us/shandian/giga/get/FinishedMission.java | 18 - .../us/shandian/giga/get/FinishedMission.kt | 12 + .../java/us/shandian/giga/get/Mission.java | 64 - .../main/java/us/shandian/giga/get/Mission.kt | 60 + .../giga/get/sqlite/FinishedMissionStore.java | 234 -- .../giga/get/sqlite/FinishedMissionStore.kt | 194 ++ .../giga/io/ChunkFileInputStream.java | 155 -- .../shandian/giga/io/ChunkFileInputStream.kt | 124 + .../shandian/giga/io/CircularFileWriter.java | 486 ---- .../us/shandian/giga/io/CircularFileWriter.kt | 433 +++ .../java/us/shandian/giga/io/FileStream.java | 131 - .../java/us/shandian/giga/io/FileStream.kt | 118 + .../us/shandian/giga/io/FileStreamSAF.java | 150 - .../java/us/shandian/giga/io/FileStreamSAF.kt | 132 + ...{ProgressReport.java => ProgressReport.kt} | 7 +- .../giga/postprocessing/M4aNoDash.java | 41 - .../shandian/giga/postprocessing/M4aNoDash.kt | 29 + .../giga/postprocessing/Mp4FromDashMuxer.java | 27 - .../giga/postprocessing/Mp4FromDashMuxer.kt | 19 + .../postprocessing/OggFromWebmDemuxer.java | 44 - .../giga/postprocessing/OggFromWebmDemuxer.kt | 28 + .../giga/postprocessing/Postprocessing.java | 258 -- .../giga/postprocessing/Postprocessing.kt | 213 ++ .../giga/postprocessing/TtmlConverter.java | 50 - .../giga/postprocessing/TtmlConverter.kt | 40 + .../giga/postprocessing/WebMMuxer.java | 44 - .../shandian/giga/postprocessing/WebMMuxer.kt | 36 + .../giga/service/DownloadManager.java | 719 ----- .../shandian/giga/service/DownloadManager.kt | 598 ++++ .../giga/service/DownloadManagerService.java | 588 ---- .../giga/service/DownloadManagerService.kt | 455 +++ .../shandian/giga/service/MissionState.java | 5 - .../us/shandian/giga/service/MissionState.kt | 8 + .../giga/ui/adapter/MissionAdapter.java | 977 ------- .../giga/ui/adapter/MissionAdapter.kt | 773 ++++++ .../us/shandian/giga/ui/common/Deleter.java | 143 - .../us/shandian/giga/ui/common/Deleter.kt | 109 + .../giga/ui/common/ProgressDrawable.java | 132 - .../giga/ui/common/ProgressDrawable.kt | 112 + .../giga/ui/common/ToolbarActivity.java | 24 - .../giga/ui/common/ToolbarActivity.kt | 18 + .../giga/ui/fragment/MissionsFragment.java | 341 --- .../giga/ui/fragment/MissionsFragment.kt | 294 ++ .../java/us/shandian/giga/util/Utility.java | 290 -- .../java/us/shandian/giga/util/Utility.kt | 235 ++ .../player/playqueue/PlayQueueTest.java | 18 +- 668 files changed, 57989 insertions(+), 66076 deletions(-) delete mode 100644 app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.java create mode 100644 app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.kt delete mode 100644 app/src/main/java/com/google/android/material/appbar/FlingBehavior.java create mode 100644 app/src/main/java/com/google/android/material/appbar/FlingBehavior.kt rename app/src/main/java/org/apache/commons/text/similarity/{FuzzyScore.java => FuzzyScore.kt} (65%) delete mode 100644 app/src/main/java/org/schabi/newpipe/App.java create mode 100644 app/src/main/java/org/schabi/newpipe/App.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/BaseFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/BaseFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/DownloaderImpl.java create mode 100644 app/src/main/java/org/schabi/newpipe/DownloaderImpl.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/ExitActivity.java create mode 100644 app/src/main/java/org/schabi/newpipe/ExitActivity.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/MainActivity.java create mode 100644 app/src/main/java/org/schabi/newpipe/MainActivity.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java create mode 100644 app/src/main/java/org/schabi/newpipe/NewPipeDatabase.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/PanicResponderActivity.java create mode 100644 app/src/main/java/org/schabi/newpipe/PanicResponderActivity.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java create mode 100644 app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/RouterActivity.java create mode 100644 app/src/main/java/org/schabi/newpipe/RouterActivity.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/AppDatabase.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/AppDatabase.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/BasicDAO.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/BasicDAO.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/LocalItem.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/LocalItem.kt rename app/src/main/java/org/schabi/newpipe/database/{Migrations.java => Migrations.kt} (57%) delete mode 100644 app/src/main/java/org/schabi/newpipe/database/history/dao/HistoryDAO.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/history/dao/HistoryDAO.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java create mode 100644 app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java create mode 100644 app/src/main/java/org/schabi/newpipe/download/DownloadActivity.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java create mode 100644 app/src/main/java/org/schabi/newpipe/download/DownloadDialog.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/download/LoadingDialog.java create mode 100644 app/src/main/java/org/schabi/newpipe/download/LoadingDialog.kt rename app/src/main/java/org/schabi/newpipe/error/{AcraReportSender.java => AcraReportSender.kt} (62%) rename app/src/main/java/org/schabi/newpipe/error/{AcraReportSenderFactory.java => AcraReportSenderFactory.kt} (55%) delete mode 100644 app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java create mode 100644 app/src/main/java/org/schabi/newpipe/error/ErrorActivity.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java create mode 100644 app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.kt rename app/src/main/java/org/schabi/newpipe/error/{UserAction.java => UserAction.kt} (81%) rename app/src/main/java/org/schabi/newpipe/fragments/{BackPressable.java => BackPressable.kt} (65%) delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/BlankFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/BlankFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/MainFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/ViewContract.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/ViewContract.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/ListViewContract.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/ListViewContract.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/DefaultKioskFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/DefaultKioskFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistControlViewHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistControlViewHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionItem.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionItem.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsInfo.java create mode 100644 app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsInfo.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java create mode 100644 app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java create mode 100644 app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.kt rename app/src/main/java/org/schabi/newpipe/info_list/{ItemViewMode.java => ItemViewMode.kt} (84%) delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java create mode 100644 app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java create mode 100644 app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java create mode 100644 app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelCardInfoItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelCardInfoItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelGridInfoItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelGridInfoItemHolder.kt create mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelInfoItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/InfoItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/InfoItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistCardInfoItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistCardInfoItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistGridInfoItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistGridInfoItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistInfoItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistInfoItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/StreamCardInfoItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/StreamCardInfoItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/StreamGridInfoItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/StreamGridInfoItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/HeaderFooterHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/HeaderFooterHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/LocalItemBuilder.java rename app/src/main/java/org/schabi/newpipe/{info_list/holder/ChannelInfoItemHolder.java => local/LocalItemBuilder.kt} (58%) delete mode 100644 app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistCardItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistCardItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistGridItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistGridItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamCardItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamCardItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamGridItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamGridItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamCardItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamCardItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamGridItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamGridItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/PlaylistItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/PlaylistItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistCardItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistCardItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistGridItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistGridItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.kt rename app/src/main/java/org/schabi/newpipe/local/subscription/services/{ImportExportEventListener.java => ImportExportEventListener.kt} (60%) delete mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java create mode 100644 app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/AudioServiceLeakFix.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/AudioServiceLeakFix.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/Player.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/Player.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/PlayerService.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/PlayerService.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/PlayerType.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/PlayerType.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/event/OnKeyDownListener.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/event/OnKeyDownListener.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/gesture/CustomBottomSheetBehavior.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/gesture/CustomBottomSheetBehavior.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/CustomMediaCodecVideoRenderer.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/CustomMediaCodecVideoRenderer.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/CustomRenderersFactory.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/CustomRenderersFactory.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/LoadController.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/LockManager.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/LockManager.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/PlayerSemitoneHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/helper/PlayerSemitoneHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/mediaitem/PlaceholderTag.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/mediaitem/PlaceholderTag.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/notification/NotificationPlayerUi.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/notification/NotificationPlayerUi.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.kt rename app/src/main/java/org/schabi/newpipe/player/playback/{PlaybackListener.java => PlaybackListener.kt} (68%) delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.kt rename app/src/main/java/org/schabi/newpipe/player/playqueue/{PlayQueueItemHolder.java => PlayQueueItemHolder.kt} (53%) delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTouchCallback.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTouchCallback.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/events/AppendEvent.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/events/AppendEvent.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/events/ErrorEvent.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/events/ErrorEvent.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/events/InitEvent.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/events/InitEvent.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/events/MoveEvent.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/events/MoveEvent.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/events/PlayQueueEvent.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/events/PlayQueueEvent.kt rename app/src/main/java/org/schabi/newpipe/player/playqueue/events/{PlayQueueEventType.java => PlayQueueEventType.kt} (84%) delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/events/RecoveryEvent.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/events/RecoveryEvent.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/events/RemoveEvent.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/events/RemoveEvent.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/events/ReorderEvent.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/events/ReorderEvent.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/events/SelectEvent.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/playqueue/events/SelectEvent.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/resolver/Resolver.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/resolver/Resolver.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/ui/PlayerUi.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/ui/PlayerUi.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java create mode 100644 app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/ExoPlayerSettingsFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/ExoPlayerSettingsFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceParser.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceParser.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearcher.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearcher.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/package-info.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/preferencesearch/package-info.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/tabs/AddTabDialog.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/tabs/AddTabDialog.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/tabs/TabsJsonHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/tabs/TabsJsonHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/settings/tabs/TabsManager.java create mode 100644 app/src/main/java/org/schabi/newpipe/settings/tabs/TabsManager.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/streams/DataReader.java create mode 100644 app/src/main/java/org/schabi/newpipe/streams/DataReader.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java create mode 100644 app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java create mode 100644 app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java create mode 100644 app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/streams/SrtFromTtmlWriter.java create mode 100644 app/src/main/java/org/schabi/newpipe/streams/SrtFromTtmlWriter.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/streams/WebMReader.java create mode 100644 app/src/main/java/org/schabi/newpipe/streams/WebMReader.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java create mode 100644 app/src/main/java/org/schabi/newpipe/streams/WebMWriter.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/streams/io/NoFileManagerSafeGuard.java create mode 100644 app/src/main/java/org/schabi/newpipe/streams/io/NoFileManagerSafeGuard.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/streams/io/SharpInputStream.java create mode 100644 app/src/main/java/org/schabi/newpipe/streams/io/SharpInputStream.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/streams/io/SharpOutputStream.java create mode 100644 app/src/main/java/org/schabi/newpipe/streams/io/SharpOutputStream.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java create mode 100644 app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/DependentPreferenceHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/DependentPreferenceHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/DeviceUtils.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/FallbackViewHolder.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/FallbackViewHolder.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/FilenameUtils.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/FilenameUtils.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/InfoCache.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/InfoCache.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/KioskTranslator.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/ListHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/ListHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/Localization.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/Localization.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/NavigationHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/NewPipeTextViewHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/NewPipeTextViewHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/OnClickGesture.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/OnClickGesture.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/PeertubeHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/PeertubeHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/PermissionHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/SerializedCache.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/SerializedCache.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/ServiceHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/SliderStrategy.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/SliderStrategy.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/StateSaver.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/StateSaver.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/ThemeHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/ZipHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/ZipHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/text/CommentTextOnTouchListener.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/text/CommentTextOnTouchListener.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/text/LongPressClickableSpan.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/text/LongPressClickableSpan.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/text/LongPressLinkMovementMethod.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/text/LongPressLinkMovementMethod.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/text/TouchUtils.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/text/TouchUtils.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java create mode 100644 app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.kt rename app/src/main/java/org/schabi/newpipe/util/urlfinder/{PatternsCompat.java => PatternsCompat.kt} (71%) delete mode 100644 app/src/main/java/org/schabi/newpipe/views/AnimatedProgressBar.java create mode 100644 app/src/main/java/org/schabi/newpipe/views/AnimatedProgressBar.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java create mode 100644 app/src/main/java/org/schabi/newpipe/views/CollapsibleView.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/views/CustomCollapsingToolbarLayout.java create mode 100644 app/src/main/java/org/schabi/newpipe/views/CustomCollapsingToolbarLayout.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java create mode 100644 app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.java create mode 100644 app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/views/FocusAwareDrawerLayout.java create mode 100644 app/src/main/java/org/schabi/newpipe/views/FocusAwareDrawerLayout.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java create mode 100644 app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/views/FocusOverlayView.java create mode 100644 app/src/main/java/org/schabi/newpipe/views/FocusOverlayView.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/views/NewPipeEditText.java create mode 100644 app/src/main/java/org/schabi/newpipe/views/NewPipeEditText.kt rename app/src/main/java/org/schabi/newpipe/views/{NewPipeRecyclerView.java => NewPipeRecyclerView.kt} (50%) delete mode 100644 app/src/main/java/org/schabi/newpipe/views/NewPipeTextView.java create mode 100644 app/src/main/java/org/schabi/newpipe/views/NewPipeTextView.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/views/ScrollableTabLayout.java create mode 100644 app/src/main/java/org/schabi/newpipe/views/ScrollableTabLayout.kt delete mode 100644 app/src/main/java/org/schabi/newpipe/views/SuperScrollLayoutManager.java create mode 100644 app/src/main/java/org/schabi/newpipe/views/SuperScrollLayoutManager.kt delete mode 100644 app/src/main/java/us/shandian/giga/get/DownloadInitializer.java create mode 100644 app/src/main/java/us/shandian/giga/get/DownloadInitializer.kt delete mode 100644 app/src/main/java/us/shandian/giga/get/DownloadMission.java create mode 100644 app/src/main/java/us/shandian/giga/get/DownloadMission.kt delete mode 100644 app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java create mode 100644 app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.kt delete mode 100644 app/src/main/java/us/shandian/giga/get/DownloadRunnable.java create mode 100644 app/src/main/java/us/shandian/giga/get/DownloadRunnable.kt delete mode 100644 app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java create mode 100644 app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.kt delete mode 100644 app/src/main/java/us/shandian/giga/get/FinishedMission.java create mode 100644 app/src/main/java/us/shandian/giga/get/FinishedMission.kt delete mode 100644 app/src/main/java/us/shandian/giga/get/Mission.java create mode 100644 app/src/main/java/us/shandian/giga/get/Mission.kt delete mode 100644 app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java create mode 100644 app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.kt delete mode 100644 app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java create mode 100644 app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.kt delete mode 100644 app/src/main/java/us/shandian/giga/io/CircularFileWriter.java create mode 100644 app/src/main/java/us/shandian/giga/io/CircularFileWriter.kt delete mode 100644 app/src/main/java/us/shandian/giga/io/FileStream.java create mode 100644 app/src/main/java/us/shandian/giga/io/FileStream.kt delete mode 100644 app/src/main/java/us/shandian/giga/io/FileStreamSAF.java create mode 100644 app/src/main/java/us/shandian/giga/io/FileStreamSAF.kt rename app/src/main/java/us/shandian/giga/io/{ProgressReport.java => ProgressReport.kt} (50%) delete mode 100644 app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java create mode 100644 app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.kt delete mode 100644 app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java create mode 100644 app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.kt delete mode 100644 app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java create mode 100644 app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.kt delete mode 100644 app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java create mode 100644 app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.kt delete mode 100644 app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.java create mode 100644 app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.kt delete mode 100644 app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java create mode 100644 app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.kt delete mode 100644 app/src/main/java/us/shandian/giga/service/DownloadManager.java create mode 100644 app/src/main/java/us/shandian/giga/service/DownloadManager.kt delete mode 100755 app/src/main/java/us/shandian/giga/service/DownloadManagerService.java create mode 100755 app/src/main/java/us/shandian/giga/service/DownloadManagerService.kt delete mode 100644 app/src/main/java/us/shandian/giga/service/MissionState.java create mode 100644 app/src/main/java/us/shandian/giga/service/MissionState.kt delete mode 100644 app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java create mode 100644 app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.kt delete mode 100644 app/src/main/java/us/shandian/giga/ui/common/Deleter.java create mode 100644 app/src/main/java/us/shandian/giga/ui/common/Deleter.kt delete mode 100644 app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.java create mode 100644 app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.kt delete mode 100644 app/src/main/java/us/shandian/giga/ui/common/ToolbarActivity.java create mode 100644 app/src/main/java/us/shandian/giga/ui/common/ToolbarActivity.kt delete mode 100644 app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java create mode 100644 app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.kt delete mode 100644 app/src/main/java/us/shandian/giga/util/Utility.java create mode 100644 app/src/main/java/us/shandian/giga/util/Utility.kt diff --git a/app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.java b/app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.java deleted file mode 100644 index 8d03a1486..000000000 --- a/app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.java +++ /dev/null @@ -1,344 +0,0 @@ -/* - * 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: - *

- * - * - * @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); - } - } - } - } - } -} diff --git a/app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.kt b/app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.kt new file mode 100644 index 000000000..a907d5352 --- /dev/null +++ b/app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.kt @@ -0,0 +1,308 @@ +/* + * 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.core.os.BundleCompat +import androidx.lifecycle.Lifecycle +import androidx.viewpager.widget.PagerAdapter + +// TODO: Replace this deprecated class with its ViewPager2 counterpart +/** + * This is a copy from [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:** + * + * + * * [.saveState] + * * [.restoreState] + * + * + */ +@Suppress("deprecation") +@Deprecated("""Switch to {@link androidx.viewpager2.widget.ViewPager2} and use + {@link androidx.viewpager2.adapter.FragmentStateAdapter} instead.""") +abstract class FragmentStatePagerAdapterMenuWorkaround +/** + * Constructor for [FragmentStatePagerAdapterMenuWorkaround]. + * + * If [.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT] is passed in, then only the current + * Fragment is in the [Lifecycle.State.RESUMED] state, while all other fragments are + * capped at [Lifecycle.State.STARTED]. If [.BEHAVIOR_SET_USER_VISIBLE_HINT] is + * passed, all fragments are in the [Lifecycle.State.RESUMED] state and there will be + * callbacks to [Fragment.setUserVisibleHint]. + * + * @param fm fragment manager that will interact with this adapter + * @param behavior determines if only current fragments are in a resumed state + */(private val mFragmentManager: FragmentManager, + @param:Behavior private val mBehavior: Int) : PagerAdapter() { + @Retention(AnnotationRetention.SOURCE) + @IntDef([BEHAVIOR_SET_USER_VISIBLE_HINT, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT]) + private annotation class Behavior + + private var mCurTransaction: FragmentTransaction? = null + private val mSavedState = ArrayList() + private val mFragments = ArrayList() + private var mCurrentPrimaryItem: Fragment? = null + private var mExecutingFinishUpdate = false + + /** + * Constructor for [FragmentStatePagerAdapterMenuWorkaround] + * that sets the fragment manager for the adapter. This is the equivalent of calling + * [.FragmentStatePagerAdapterMenuWorkaround] and passing in + * [.BEHAVIOR_SET_USER_VISIBLE_HINT]. + * + * + * Fragments will have [Fragment.setUserVisibleHint] 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}""") + constructor(fm: FragmentManager) : this(fm, BEHAVIOR_SET_USER_VISIBLE_HINT) + + /** + * @param position the position of the item you want + * @return the [Fragment] associated with a specified position + */ + abstract fun getItem(position: Int): Fragment + override fun startUpdate(container: ViewGroup) { + check(container.id != View.NO_ID) { + ("ViewPager with adapter " + this + + " requires a view id") + } + } + + @Suppress("deprecation") + override fun instantiateItem(container: ViewGroup, position: Int): Any { + // 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) { + val f = mFragments[position] + if (f != null) { + return f + } + } + if (mCurTransaction == null) { + mCurTransaction = mFragmentManager.beginTransaction() + } + val fragment = getItem(position) + if (DEBUG) { + Log.v(TAG, "Adding item #$position: f=$fragment") + } + if (mSavedState.size > position) { + val fss = mSavedState[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[position] = fragment + mCurTransaction!!.add(container.id, fragment) + if (mBehavior == BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + mCurTransaction!!.setMaxLifecycle(fragment, Lifecycle.State.STARTED) + } + return fragment + } + + override fun destroyItem(container: ViewGroup, position: Int, + `object`: Any) { + val fragment = `object` as Fragment + if (mCurTransaction == null) { + mCurTransaction = mFragmentManager.beginTransaction() + } + if (DEBUG) { + Log.v(TAG, "Removing item #" + position + ": f=" + `object` + + " v=" + `object`.view) + } + while (mSavedState.size <= position) { + mSavedState.add(null) + } + mSavedState[position] = if (fragment.isAdded) mFragmentManager.saveFragmentInstanceState(fragment) else null + mFragments.set(position, null) + mCurTransaction!!.remove(fragment) + if (fragment == mCurrentPrimaryItem) { + mCurrentPrimaryItem = null + } + } + + @Suppress("deprecation") + override fun setPrimaryItem(container: ViewGroup, position: Int, + `object`: Any) { + val fragment = `object` as Fragment + 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 fun finishUpdate(container: ViewGroup) { + 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 fun isViewFromObject(view: View, `object`: Any): Boolean { + return (`object` as Fragment).view === view + } + + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + private val selectedFragment = "selected_fragment" + + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + override fun saveState(): Parcelable? { + var state: Bundle? = null + if (!mSavedState.isEmpty()) { + state = Bundle() + state.putParcelableArrayList("states", mSavedState) + } + for (i in mFragments.indices) { + val f = mFragments[i] + if (f != null && f.isAdded) { + if (state == null) { + state = Bundle() + } + val 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 fun restoreState(state: Parcelable?, loader: ClassLoader?) { + if (state != null) { + val bundle = state as Bundle + bundle.classLoader = loader + val states = BundleCompat.getParcelableArrayList(bundle, "states", + Fragment.SavedState::class.java) + mSavedState.clear() + mFragments.clear() + if (states != null) { + mSavedState.addAll(states) + } + val keys: Iterable = bundle.keySet() + for (key in keys) { + if (key.startsWith("f")) { + val index = key.substring(1).toInt() + val f = mFragmentManager.getFragment(bundle, key) + if (f != null) { + while (mFragments.size <= index) { + mFragments.add(null) + } + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + val wasSelected = (bundle.getString(selectedFragment, "") + == key) + f.setMenuVisibility(wasSelected) + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + mFragments[index] = f + } else { + Log.w(TAG, "Bad fragment at key $key") + } + } + } + } + } + + companion object { + private const val TAG = "FragmentStatePagerAdapt" + private const val DEBUG = false + + /** + * Indicates that [Fragment.setUserVisibleHint] will be called when the current + * fragment changes. + * + * @see .FragmentStatePagerAdapterMenuWorkaround + */ + @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}. + """) + val BEHAVIOR_SET_USER_VISIBLE_HINT = 0 + + /** + * Indicates that only the current fragment will be in the [Lifecycle.State.RESUMED] + * state. All other Fragments are capped at [Lifecycle.State.STARTED]. + * + * @see .FragmentStatePagerAdapterMenuWorkaround + */ + const val BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT = 1 + } +} diff --git a/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java b/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java deleted file mode 100644 index 52754e8fa..000000000 --- a/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java +++ /dev/null @@ -1,165 +0,0 @@ -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); - } - } -} diff --git a/app/src/main/java/com/google/android/material/appbar/FlingBehavior.kt b/app/src/main/java/com/google/android/material/appbar/FlingBehavior.kt new file mode 100644 index 000000000..fed9dc868 --- /dev/null +++ b/app/src/main/java/com/google/android/material/appbar/FlingBehavior.kt @@ -0,0 +1,141 @@ +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.coordinatorlayout.widget.CoordinatorLayout +import org.schabi.newpipe.R +import java.lang.reflect.Field +import java.util.List + +// See https://stackoverflow.com/questions/56849221#57997489 +class FlingBehavior(context: Context?, attrs: AttributeSet?) : AppBarLayout.Behavior(context, attrs) { + private val focusScrollRect = Rect() + private var allowScroll = true + private val globalRect = Rect() + private val skipInterceptionOfElements = List.of( + R.id.itemsListPanel, R.id.playbackSeekBar, + R.id.playPauseButton, R.id.playPreviousButton, R.id.playNextButton) + + override fun onRequestChildRectangleOnScreen( + coordinatorLayout: CoordinatorLayout, child: AppBarLayout, + rectangle: Rect, immediate: Boolean): Boolean { + focusScrollRect.set(rectangle) + coordinatorLayout.offsetDescendantRectToMyCoords(child, focusScrollRect) + val height = coordinatorLayout.height + if (focusScrollRect.top <= 0 && focusScrollRect.bottom >= height) { + // the child is too big to fit inside ourselves completely, ignore request + return false + } + val dy: Int + dy = if (focusScrollRect.bottom > height) { + focusScrollRect.top + } else if (focusScrollRect.top < 0) { + // scrolling up + -(height - focusScrollRect.bottom) + } else { + // nothing to do + return false + } + val consumed = scroll(coordinatorLayout, child, dy, getMaxDragOffset(child), 0) + return consumed == dy + } + + override fun onInterceptTouchEvent(parent: CoordinatorLayout, + child: AppBarLayout, + ev: MotionEvent): Boolean { + for (element in skipInterceptionOfElements) { + val view = child.findViewById(element) + if (view != null) { + val visible = view.getGlobalVisibleRect(globalRect) + if (visible && globalRect.contains(ev.rawX.toInt(), ev.rawY.toInt())) { + allowScroll = false + return false + } + } + } + allowScroll = true + when (ev.actionMasked) { + MotionEvent.ACTION_DOWN -> { + // remove reference to old nested scrolling child + resetNestedScrollingChild() + // Stop fling when your finger touches the screen + stopAppBarLayoutFling() + } + + else -> {} + } + return super.onInterceptTouchEvent(parent, child, ev) + } + + override fun onStartNestedScroll(parent: CoordinatorLayout, + child: AppBarLayout, + directTargetChild: View, + target: View, + nestedScrollAxes: Int, + type: Int): Boolean { + return allowScroll && super.onStartNestedScroll( + parent, child, directTargetChild, target, nestedScrollAxes, type) + } + + override fun onNestedFling(coordinatorLayout: CoordinatorLayout, + child: AppBarLayout, + target: View, velocityX: Float, + velocityY: Float, consumed: Boolean): Boolean { + return allowScroll && super.onNestedFling( + coordinatorLayout, child, target, velocityX, velocityY, consumed) + } + + private val scrollerField: OverScroller? + private get() { + try { + val headerBehaviorType: Class<*>? = this.javaClass + .superclass.superclass.superclass + if (headerBehaviorType != null) { + val field = headerBehaviorType.getDeclaredField("scroller") + field.isAccessible = true + return field[this] as OverScroller + } + } catch (e: NoSuchFieldException) { + // ? + } catch (e: IllegalAccessException) { + } + return null + } + private val lastNestedScrollingChildRefField: Field? + private get() { + try { + val headerBehaviorType: Class<*>? = this.javaClass.superclass.superclass + if (headerBehaviorType != null) { + val field = headerBehaviorType.getDeclaredField("lastNestedScrollingChildRef") + field.isAccessible = true + return field + } + } catch (e: NoSuchFieldException) { + // ? + } + return null + } + + private fun resetNestedScrollingChild() { + val field = lastNestedScrollingChildRefField + if (field != null) { + try { + val value = field[this] + if (value != null) { + field[this] = null + } + } catch (e: IllegalAccessException) { + // ? + } + } + } + + private fun stopAppBarLayoutFling() { + val scroller = scrollerField + scroller?.forceFinished(true) + } +} diff --git a/app/src/main/java/org/apache/commons/text/similarity/FuzzyScore.java b/app/src/main/java/org/apache/commons/text/similarity/FuzzyScore.kt similarity index 65% rename from app/src/main/java/org/apache/commons/text/similarity/FuzzyScore.java rename to app/src/main/java/org/apache/commons/text/similarity/FuzzyScore.kt index bbab7fd78..a747e446d 100644 --- a/app/src/main/java/org/apache/commons/text/similarity/FuzzyScore.java +++ b/app/src/main/java/org/apache/commons/text/similarity/FuzzyScore.kt @@ -14,51 +14,55 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.apache.commons.text.similarity; +package org.apache.commons.text.similarity -import java.util.Locale; +import org.apache.commons.text.similarity.FuzzyScore +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 - * + * [ + * apache/commons-text (8cfdafc) FuzzyScore.java +](https://git.io/JyYJg) * */ -public class FuzzyScore { - +class FuzzyScore(locale: Locale?) { + /** + * Gets the locale. + * + * @return The locale + */ /** * Locale used to change the case of text. */ - private final Locale locale; - + val locale: Locale /** - * This returns a {@link Locale}-specific {@link FuzzyScore}. + * This returns a [Locale]-specific [FuzzyScore]. * * @param locale The string matching logic is case insensitive. - A {@link Locale} is necessary to normalize both Strings to lower case. + * A [Locale] is necessary to normalize both Strings to lower case. * @throws IllegalArgumentException - * This is thrown if the {@link Locale} parameter is {@code null}. + * This is thrown if the [Locale] parameter is `null`. */ - public FuzzyScore(final Locale locale) { - if (locale == null) { - throw new IllegalArgumentException("Locale must not be null"); - } - this.locale = locale; + init { + requireNotNull(locale) { "Locale must not be null" } + this.locale = locale } /** @@ -76,73 +80,57 @@ public class FuzzyScore { * 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 + * null * @return result score - * @throws IllegalArgumentException if the term or query is {@code null} + * @throws IllegalArgumentException if the term or query is `null` */ - public Integer fuzzyScore(final CharSequence term, final CharSequence query) { - if (term == null || query == null) { - throw new IllegalArgumentException("CharSequences must not be null"); - } + fun fuzzyScore(term: CharSequence?, query: CharSequence?): Int { + require(!(term == null || query == null)) { "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); + val termLowerCase = term.toString().lowercase(locale) + val queryLowerCase = query.toString().lowercase(locale) // the resulting score - int score = 0; + var score = 0 // the position in the term which will be scanned next for potential // query character matches - int termIndex = 0; + var 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); - + var previousMatchingCharacterIndex = Int.MIN_VALUE + for (queryIndex in 0 until queryLowerCase.length) { + val queryChar = queryLowerCase[queryIndex] + var termCharacterMatchFound = false + while (termIndex < termLowerCase.length + && !termCharacterMatchFound) { + val termChar = termLowerCase[termIndex] if (queryChar == termChar) { // simple character matches result in one point - score++; + score++ // subsequent character matches further improve // the score. if (previousMatchingCharacterIndex + 1 == termIndex) { - score += 2; + score += 2 } - - previousMatchingCharacterIndex = termIndex; + previousMatchingCharacterIndex = termIndex // we can leave the nested loop. Every character in the // query can match at most one character in the term. - termCharacterMatchFound = true; + termCharacterMatchFound = true } + termIndex++ } } - - return score; + return score } - - /** - * Gets the locale. - * - * @return The locale - */ - public Locale getLocale() { - return locale; - } - } diff --git a/app/src/main/java/org/schabi/newpipe/App.java b/app/src/main/java/org/schabi/newpipe/App.java deleted file mode 100644 index d92425d20..000000000 --- a/app/src/main/java/org/schabi/newpipe/App.java +++ /dev/null @@ -1,269 +0,0 @@ -package org.schabi.newpipe; - -import android.app.Application; -import android.content.Context; -import android.content.SharedPreferences; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.core.app.NotificationChannelCompat; -import androidx.core.app.NotificationManagerCompat; -import androidx.preference.PreferenceManager; - -import com.jakewharton.processphoenix.ProcessPhoenix; - -import org.acra.ACRA; -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.ktx.ExceptionUtils; -import org.schabi.newpipe.settings.NewPipeSettings; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.image.ImageStrategy; -import org.schabi.newpipe.util.image.PicassoHelper; -import org.schabi.newpipe.util.ServiceHelper; -import org.schabi.newpipe.util.StateSaver; -import org.schabi.newpipe.util.image.PreferredImageQuality; - -import java.io.IOException; -import java.io.InterruptedIOException; -import java.net.SocketException; -import java.util.List; -import java.util.Objects; - -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; - -/* - * Copyright (C) Hans-Christoph Steiner 2016 - * App.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 App extends Application { - public static final String PACKAGE_NAME = BuildConfig.APPLICATION_ID; - private static final String TAG = App.class.toString(); - - private boolean isFirstRun = false; - private static App app; - - @NonNull - public static App getApp() { - return app; - } - - @Override - protected void attachBaseContext(final Context base) { - super.attachBaseContext(base); - initACRA(); - } - - @Override - public void onCreate() { - super.onCreate(); - - app = 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 - final int 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(getApplicationContext())); - - StateSaver.init(this); - initNotificationChannels(); - - ServiceHelper.initServices(this); - - // Initialize image loader - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); - PicassoHelper.init(this); - ImageStrategy.setPreferredImageQuality(PreferredImageQuality.fromPreferenceKey(this, - prefs.getString(getString(R.string.image_quality_key), - getString(R.string.image_quality_default)))); - PicassoHelper.setIndicatorsEnabled(MainActivity.DEBUG - && prefs.getBoolean(getString(R.string.show_image_indicators_key), false)); - - configureRxJavaErrorHandler(); - } - - @Override - public void onTerminate() { - super.onTerminate(); - PicassoHelper.terminate(); - } - - protected Downloader getDownloader() { - final DownloaderImpl downloader = DownloaderImpl.init(null); - setCookiesToDownloader(downloader); - return downloader; - } - - protected void setCookiesToDownloader(final DownloaderImpl downloader) { - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences( - getApplicationContext()); - final String key = getApplicationContext().getString(R.string.recaptcha_cookies_key); - downloader.setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, prefs.getString(key, null)); - downloader.updateYoutubeRestrictedModeCookies(getApplicationContext()); - } - - private void configureRxJavaErrorHandler() { - // https://github.com/ReactiveX/RxJava/wiki/What's-different-in-2.0#error-handling - RxJavaPlugins.setErrorHandler(new Consumer() { - @Override - public void accept(@NonNull final Throwable throwable) { - Log.e(TAG, "RxJavaPlugins.ErrorHandler called with -> : " - + "throwable = [" + throwable.getClass().getName() + "]"); - - final Throwable actualThrowable; - if (throwable instanceof UndeliverableException) { - // As UndeliverableException is a wrapper, - // get the cause of it to get the "real" exception - actualThrowable = Objects.requireNonNull(throwable.getCause()); - } else { - actualThrowable = throwable; - } - - final List errors; - if (actualThrowable instanceof CompositeException) { - errors = ((CompositeException) actualThrowable).getExceptions(); - } else { - errors = List.of(actualThrowable); - } - - for (final Throwable error : 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); - } - } - - private boolean isThrowableIgnored(@NonNull final Throwable throwable) { - // Don't crash the application over a simple network problem - return ExceptionUtils.hasAssignableCause(throwable, - // network api cancellation - IOException.class, SocketException.class, - // blocking code disposed - InterruptedException.class, InterruptedIOException.class); - } - - private boolean isThrowableCritical(@NonNull final Throwable throwable) { - // Though these exceptions cannot be ignored - return ExceptionUtils.hasAssignableCause(throwable, - NullPointerException.class, IllegalArgumentException.class, // bug in app - OnErrorNotImplementedException.class, MissingBackpressureException.class, - IllegalStateException.class); // bug in operator - } - - private void reportException(@NonNull final Throwable throwable) { - // Throw uncaught exception that will trigger the report system - Thread.currentThread().getUncaughtExceptionHandler() - .uncaughtException(Thread.currentThread(), throwable); - } - }); - } - - /** - * Called in {@link #attachBaseContext(Context)} after calling the {@code super} method. - * Should be overridden if MultiDex is enabled, since it has to be initialized before ACRA. - */ - protected void initACRA() { - if (ACRA.isACRASenderServiceProcess()) { - return; - } - - final CoreConfigurationBuilder acraConfig = new CoreConfigurationBuilder() - .withBuildConfigClass(BuildConfig.class); - ACRA.init(this, acraConfig); - } - - private void initNotificationChannels() { - // Keep the importance below DEFAULT to avoid making noise on every notification update for - // the main and update channels - final List notificationChannelCompats = List.of( - new 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(), - new 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(), - new 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(), - new 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(), - new 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() - ); - - final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this); - notificationManager.createNotificationChannelsCompat(notificationChannelCompats); - } - - protected boolean isDisposedRxExceptionsReported() { - return false; - } - - public boolean isFirstRun() { - return isFirstRun; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/App.kt b/app/src/main/java/org/schabi/newpipe/App.kt new file mode 100644 index 000000000..aa71ad1d9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/App.kt @@ -0,0 +1,240 @@ +package org.schabi.newpipe + +import android.app.Application +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationManagerCompat +import androidx.preference.PreferenceManager +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 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.ktx.hasAssignableCause +import org.schabi.newpipe.settings.NewPipeSettings +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.PicassoHelper +import org.schabi.newpipe.util.image.PreferredImageQuality +import java.io.IOException +import java.io.InterruptedIOException +import java.net.SocketException +import java.util.Objects + +/* +* Copyright (C) Hans-Christoph Steiner 2016 +* App.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 . +*/ +open class App() : Application() { + private var isFirstRun: Boolean = false + override fun attachBaseContext(base: Context) { + super.attachBaseContext(base) + initACRA() + } + + public override fun onCreate() { + super.onCreate() + app = 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: Int = 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(getApplicationContext())) + StateSaver.init(this) + initNotificationChannels() + ServiceHelper.initServices(this) + + // Initialize image loader + val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) + PicassoHelper.init(this) + ImageStrategy.setPreferredImageQuality(PreferredImageQuality.Companion.fromPreferenceKey(this, + prefs.getString(getString(R.string.image_quality_key), + getString(R.string.image_quality_default)))) + PicassoHelper.setIndicatorsEnabled((MainActivity.Companion.DEBUG + && prefs.getBoolean(getString(R.string.show_image_indicators_key), false))) + configureRxJavaErrorHandler() + } + + public override fun onTerminate() { + super.onTerminate() + PicassoHelper.terminate() + } + + protected open fun getDownloader(): Downloader? { + val downloader: DownloaderImpl? = DownloaderImpl.Companion.init(null) + setCookiesToDownloader(downloader) + return downloader + } + + protected fun setCookiesToDownloader(downloader: DownloaderImpl?) { + val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences( + getApplicationContext()) + val key: String = getApplicationContext().getString(R.string.recaptcha_cookies_key) + downloader!!.setCookie(ReCaptchaActivity.Companion.RECAPTCHA_COOKIES_KEY, prefs.getString(key, null)) + downloader.updateYoutubeRestrictedModeCookies(getApplicationContext()) + } + + private fun configureRxJavaErrorHandler() { + // https://github.com/ReactiveX/RxJava/wiki/What's-different-in-2.0#error-handling + RxJavaPlugins.setErrorHandler(object : Consumer { + public override fun accept(throwable: Throwable) { + Log.e(TAG, ("RxJavaPlugins.ErrorHandler called with -> : " + + "throwable = [" + throwable.javaClass.getName() + "]")) + val actualThrowable: Throwable + if (throwable is UndeliverableException) { + // As UndeliverableException is a wrapper, + // get the cause of it to get the "real" exception + actualThrowable = Objects.requireNonNull(throwable.cause) + } else { + actualThrowable = throwable + } + val errors: List + if (actualThrowable is CompositeException) { + errors = actualThrowable.getExceptions() + } else { + errors = java.util.List.of(actualThrowable) + } + for (error: Throwable 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) + } + } + + private fun isThrowableIgnored(throwable: Throwable): Boolean { + // Don't crash the application over a simple network problem + return throwable.hasAssignableCause( // network api cancellation + IOException::class.java, SocketException::class.java, // blocking code disposed + InterruptedException::class.java, InterruptedIOException::class.java) + } + + private fun isThrowableCritical(throwable: Throwable): Boolean { + // Though these exceptions cannot be ignored + return throwable.hasAssignableCause(NullPointerException::class.java, IllegalArgumentException::class.java, // bug in app + OnErrorNotImplementedException::class.java, MissingBackpressureException::class.java, IllegalStateException::class.java) // bug in operator + } + + private fun reportException(throwable: Throwable) { + // Throw uncaught exception that will trigger the report system + Thread.currentThread().getUncaughtExceptionHandler() + .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 = 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 notificationChannelCompats: List = java.util.List.of( + 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(), + 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(), + 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(), + 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(), + 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 notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(this) + notificationManager.createNotificationChannelsCompat(notificationChannelCompats) + } + + protected open fun isDisposedRxExceptionsReported(): Boolean { + return false + } + + fun isFirstRun(): Boolean { + return isFirstRun + } + + companion object { + val PACKAGE_NAME: String = BuildConfig.APPLICATION_ID + private val TAG: String = App::class.java.toString() + private var app: App? = null + fun getApp(): App { + return (app)!! + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/BaseFragment.java b/app/src/main/java/org/schabi/newpipe/BaseFragment.java deleted file mode 100644 index 7a06771dd..000000000 --- a/app/src/main/java/org/schabi/newpipe/BaseFragment.java +++ /dev/null @@ -1,139 +0,0 @@ -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 icepick.Icepick; -import icepick.State; - -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); - Icepick.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); - Icepick.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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/BaseFragment.kt b/app/src/main/java/org/schabi/newpipe/BaseFragment.kt new file mode 100644 index 000000000..93abfa6a8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/BaseFragment.kt @@ -0,0 +1,129 @@ +package org.schabi.newpipe + +import android.content.Context +import android.os.Bundle +import android.util.Log +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import icepick.Icepick +import icepick.State + +abstract class BaseFragment() : Fragment() { + protected val TAG: String = javaClass.getSimpleName() + "@" + Integer.toHexString(hashCode()) + protected var activity: AppCompatActivity? = null + + //These values are used for controlling fragments when they are part of the frontpage + @State + protected var useAsFrontPage: Boolean = false + fun useAsFrontPage(value: Boolean) { + useAsFrontPage = value + } + + /*////////////////////////////////////////////////////////////////////////// + // Fragment's Lifecycle + ////////////////////////////////////////////////////////////////////////// */ + public override fun onAttach(context: Context) { + super.onAttach(context) + activity = context as AppCompatActivity? + } + + public override fun onDetach() { + super.onDetach() + activity = null + } + + public override fun onCreate(savedInstanceState: Bundle?) { + if (DEBUG) { + Log.d(TAG, ("onCreate() called with: " + + "savedInstanceState = [" + savedInstanceState + "]")) + } + super.onCreate(savedInstanceState) + Icepick.restoreInstanceState(this, savedInstanceState) + if (savedInstanceState != null) { + onRestoreInstanceState(savedInstanceState) + } + } + + public override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) { + super.onViewCreated(rootView, savedInstanceState) + if (DEBUG) { + Log.d(TAG, ("onViewCreated() called with: " + + "rootView = [" + rootView + "], " + + "savedInstanceState = [" + savedInstanceState + "]")) + } + initViews(rootView, savedInstanceState) + initListeners() + } + + public override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + Icepick.saveInstanceState(this, outState) + } + + protected open fun onRestoreInstanceState(savedInstanceState: Bundle) {} + /*////////////////////////////////////////////////////////////////////////// + // Init + ////////////////////////////////////////////////////////////////////////// */ + /** + * This method is called in [.onViewCreated] to initialize the views. + * + * + * + * [.initListeners] is called after this method to initialize the corresponding + * listeners. + * + * @param rootView The inflated view for this fragment + * (provided by [.onViewCreated]) + * @param savedInstanceState The saved state of this fragment + * (provided by [.onViewCreated]) + */ + protected open fun initViews(rootView: View, savedInstanceState: Bundle?) {} + + /** + * Initialize the listeners for this fragment. + * + * + * + * This method is called after [.initViews] + * in [.onViewCreated]. + * + */ + protected open fun initListeners() {} + + /*////////////////////////////////////////////////////////////////////////// + // Utils + ////////////////////////////////////////////////////////////////////////// */ + open fun setTitle(title: String?) { + 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) + } + } + + protected val fM: FragmentManager? + /** + * Finds the root fragment by looping through all of the parent fragments. The root fragment + * is supposed to be [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. + * [org.schabi.newpipe.fragments.MainFragment] + */ + protected get() { + var current: Fragment? = this + while (current!!.getParentFragment() != null) { + current = current.getParentFragment() + } + return current.getFragmentManager() + } + + companion object { + protected val DEBUG: Boolean = MainActivity.Companion.DEBUG + } +} diff --git a/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java b/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java deleted file mode 100644 index 9ddbe96df..000000000 --- a/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java +++ /dev/null @@ -1,182 +0,0 @@ -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; rv:91.0) Gecko/20100101 Firefox/91.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<>(); - } - - /** - * 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); - } - - for (final Map.Entry> pair : headers.entrySet()) { - final String headerName = pair.getKey(); - final List headerValueList = pair.getValue(); - - if (headerValueList.size() > 1) { - requestBuilder.removeHeader(headerName); - for (final String headerValue : headerValueList) { - requestBuilder.addHeader(headerName, headerValue); - } - } else if (headerValueList.size() == 1) { - requestBuilder.header(headerName, headerValueList.get(0)); - } - - } - - final okhttp3.Response response = client.newCall(requestBuilder.build()).execute(); - - if (response.code() == 429) { - response.close(); - - throw new ReCaptchaException("reCaptcha Challenge requested", url); - } - - final ResponseBody body = response.body(); - String responseBodyToReturn = null; - - if (body != null) { - responseBodyToReturn = body.string(); - } - - final String latestUrl = response.request().url().toString(); - return new Response(response.code(), response.message(), response.headers().toMultimap(), - responseBodyToReturn, latestUrl); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/DownloaderImpl.kt b/app/src/main/java/org/schabi/newpipe/DownloaderImpl.kt new file mode 100644 index 000000000..640b6eab5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/DownloaderImpl.kt @@ -0,0 +1,170 @@ +package org.schabi.newpipe + +import android.content.Context +import androidx.preference.PreferenceManager +import androidx.room.RoomDatabase.Builder.build +import okhttp3.OkHttpClient +import okhttp3.OkHttpClient.Builder.build +import okhttp3.OkHttpClient.Builder.readTimeout +import okhttp3.Request.Builder.addHeader +import okhttp3.Request.Builder.build +import okhttp3.Request.Builder.header +import okhttp3.Request.Builder.method +import okhttp3.Request.Builder.removeHeader +import okhttp3.Request.Builder.url +import okhttp3.RequestBody +import okhttp3.ResponseBody +import org.schabi.newpipe.database.stream.model.StreamEntity.url +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.Objects +import java.util.concurrent.TimeUnit +import java.util.function.Function +import java.util.function.Predicate +import java.util.stream.Collectors +import java.util.stream.Stream + +class DownloaderImpl private constructor(builder: Builder) : Downloader() { + private val mCookies: MutableMap + private val client: OkHttpClient + + init { + client = builder + .readTimeout(30, TimeUnit.SECONDS) // .cache(new Cache(new File(context.getExternalCacheDir(), "okhttp"), + // 16 * 1024 * 1024)) + .build() + mCookies = HashMap() + } + + fun getCookies(url: String): String { + val youtubeCookie: String? = if (url.contains(YOUTUBE_DOMAIN)) getCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY) else null + + // Recaptcha cookie is always added TODO: not sure if this is necessary + return Stream.of(youtubeCookie, getCookie(ReCaptchaActivity.Companion.RECAPTCHA_COOKIES_KEY)) + .filter(Predicate({ obj: String? -> Objects.nonNull(obj) })) + .flatMap(Function>({ cookies: String? -> Arrays.stream(cookies!!.split("; *".toRegex()).dropLastWhile({ it.isEmpty() }).toTypedArray()) })) + .distinct() + .collect(Collectors.joining("; ")) + } + + fun getCookie(key: String): String? { + return mCookies.get(key) + } + + fun setCookie(key: String, cookie: String?) { + mCookies.put(key, cookie) + } + + fun removeCookie(key: String) { + mCookies.remove(key) + } + + fun updateYoutubeRestrictedModeCookies(context: Context) { + val restrictedModeEnabledKey: String = context.getString(R.string.youtube_restricted_mode_enabled) + val restrictedModeEnabled: Boolean = PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(restrictedModeEnabledKey, false) + updateYoutubeRestrictedModeCookies(restrictedModeEnabled) + } + + fun updateYoutubeRestrictedModeCookies(youtubeRestrictedModeEnabled: Boolean) { + if (youtubeRestrictedModeEnabled) { + setCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY, + YOUTUBE_RESTRICTED_MODE_COOKIE) + } else { + removeCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY) + } + InfoCache.Companion.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 + */ + @Throws(IOException::class) + fun getContentLength(url: String?): Long { + try { + val response: Response = head(url) + return response.getHeader("Content-Length")!!.toLong() + } catch (e: NumberFormatException) { + throw IOException("Invalid content length", e) + } catch (e: ReCaptchaException) { + throw IOException(e) + } + } + + @Throws(IOException::class, ReCaptchaException::class) + public override fun execute(request: Request): Response { + val httpMethod: String = request.httpMethod() + val url: String = request.url() + val headers: Map> = request.headers() + val dataToSend: ByteArray? = request.dataToSend() + var requestBody: RequestBody? = null + if (dataToSend != null) { + requestBody = RequestBody.create(dataToSend) + } + val requestBuilder: Builder = Builder() + .method(httpMethod, requestBody).url(url) + .addHeader("User-Agent", USER_AGENT) + val cookies: String = getCookies(url) + if (!cookies.isEmpty()) { + requestBuilder.addHeader("Cookie", cookies) + } + for (pair: Map.Entry> in headers.entries) { + val headerName: String = pair.key + val headerValueList: List = pair.value + if (headerValueList.size > 1) { + requestBuilder.removeHeader(headerName) + for (headerValue: String? in headerValueList) { + requestBuilder.addHeader(headerName, headerValue) + } + } else if (headerValueList.size == 1) { + requestBuilder.header(headerName, headerValueList.get(0)) + } + } + val response: okhttp3.Response = client.newCall(requestBuilder.build()).execute() + if (response.code() == 429) { + response.close() + throw ReCaptchaException("reCaptcha Challenge requested", url) + } + val body: ResponseBody? = response.body() + var responseBodyToReturn: String? = null + if (body != null) { + responseBodyToReturn = body.string() + } + val latestUrl: String = response.request().url().toString() + return Response(response.code(), response.message(), response.headers().toMultimap(), + responseBodyToReturn, latestUrl) + } + + companion object { + val USER_AGENT: String = "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0" + val YOUTUBE_RESTRICTED_MODE_COOKIE_KEY: String = "youtube_restricted_mode_key" + val YOUTUBE_RESTRICTED_MODE_COOKIE: String = "PREF=f2=8000000" + val YOUTUBE_DOMAIN: String = "youtube.com" + private var instance: DownloaderImpl? = null + + /** + * 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 [DownloaderImpl] + */ + fun init(builder: Builder?): DownloaderImpl? { + instance = DownloaderImpl( + if (builder != null) builder else Builder()) + return instance + } + + fun getInstance(): DownloaderImpl? { + return instance + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/ExitActivity.java b/app/src/main/java/org/schabi/newpipe/ExitActivity.java deleted file mode 100644 index bd1351f0c..000000000 --- a/app/src/main/java/org/schabi/newpipe/ExitActivity.java +++ /dev/null @@ -1,50 +0,0 @@ -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; - -/* - * Copyright (C) Hans-Christoph Steiner 2016 - * ExitActivity.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 ExitActivity extends Activity { - - public static void exitAndRemoveFromRecentApps(final Activity activity) { - final Intent intent = new Intent(activity, ExitActivity.class); - - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK - | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS - | Intent.FLAG_ACTIVITY_CLEAR_TASK - | Intent.FLAG_ACTIVITY_NO_ANIMATION); - - activity.startActivity(intent); - } - - @SuppressLint("NewApi") - @Override - protected void onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - finishAndRemoveTask(); - - NavigationHelper.restartApp(this); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/ExitActivity.kt b/app/src/main/java/org/schabi/newpipe/ExitActivity.kt new file mode 100644 index 000000000..114a856f3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ExitActivity.kt @@ -0,0 +1,44 @@ +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 + +/* +* Copyright (C) Hans-Christoph Steiner 2016 +* ExitActivity.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 . +*/ +class ExitActivity() : Activity() { + @SuppressLint("NewApi") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + finishAndRemoveTask() + NavigationHelper.restartApp(this) + } + + companion object { + fun exitAndRemoveFromRecentApps(activity: Activity) { + val intent: 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) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java deleted file mode 100644 index 175694125..000000000 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ /dev/null @@ -1,927 +0,0 @@ -/* - * 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 static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - -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.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.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.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.views.FocusOverlayView; - -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_ABOUT = 1; - - private static final int ORDER = 0; - - /*////////////////////////////////////////////////////////////////////////// - // Activity's LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected void onCreate(final Bundle savedInstanceState) { - if (DEBUG) { - Log.d(TAG, "onCreate() called with: " - + "savedInstanceState = [" + savedInstanceState + "]"); - } - - ThemeHelper.setDayNightMode(this); - ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this)); - - assureCorrectAppLanguage(this); - super.onCreate(savedInstanceState); - - 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.getApp().isFirstRun() - && ReleaseVersionUtil.INSTANCE.isReleaseApk()) { - UpdateSettingsFragment.askForConsentToUpdateChecks(this); - } - } - - @Override - protected void onPostCreate(final Bundle savedInstanceState) { - super.onPostCreate(savedInstanceState); - - final App app = App.getApp(); - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app); - - if (prefs.getBoolean(app.getString(R.string.update_app_key), false) - && prefs.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); - } - } - - 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 - 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_tabs_group, kioskMenuItemId, 0, KioskTranslator - .getTranslatedKioskName(ks, this)) - .setIcon(KioskTranslator.getKioskIcon(ks)); - kioskMenuItemId++; - } - - 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); - - //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_ABOUT, ORDER, R.string.tab_about) - .setIcon(R.drawable.ic_info_outline); - } - - private boolean drawerItemSelected(final MenuItem item) { - switch (item.getGroupId()) { - case R.id.menu_services_group: - changeService(item); - break; - case R.id.menu_tabs_group: - try { - tabSelected(item); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Selecting main page tab", e); - } - break; - case R.id.menu_options_about_group: - optionsAboutSelected(item); - break; - default: - 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) throws ExtractionException { - 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; - default: - 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++; - } - break; - } - } - - private void optionsAboutSelected(final MenuItem item) { - switch (item.getItemId()) { - case ITEM_ID_SETTINGS: - NavigationHelper.openSettings(this); - 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_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() { - assureCorrectAppLanguage(this); - // Change the date format to match the selected language on resume - Localization.initPrettyTime(Localization.resolvePrettyTime(getApplicationContext())); - 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); - } - - final SharedPreferences sharedPreferences = - PreferenceManager.getDefaultSharedPreferences(this); - if (sharedPreferences.getBoolean(Constants.KEY_THEME_CHANGE, false)) { - if (DEBUG) { - Log.d(TAG, "Theme has changed, recreating activity..."); - } - sharedPreferences.edit().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..."); - } - sharedPreferences.edit().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)) { - 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); - registerReceiver(broadcastReceiver, intentFilter); - } - } - - 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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.kt b/app/src/main/java/org/schabi/newpipe/MainActivity.kt new file mode 100644 index 000000000..f113326de --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.kt @@ -0,0 +1,820 @@ +/* + * 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.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.widget.AdapterView +import android.widget.ArrayAdapter +import android.widget.FrameLayout +import android.widget.Spinner +import androidx.appcompat.app.ActionBar +import androidx.appcompat.app.ActionBarDrawerToggle +import androidx.appcompat.app.AppCompatActivity +import androidx.core.app.ActivityCompat +import androidx.core.view.GravityCompat +import androidx.drawerlayout.widget.DrawerLayout +import androidx.drawerlayout.widget.DrawerLayout.SimpleDrawerListener +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 com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback +import com.google.android.material.navigation.NavigationView +import org.schabi.newpipe.NewVersionWorker.Companion.enqueueNewVersionCheckingWork +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.Companion.showUiErrorSnackbar +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.StreamingService +import org.schabi.newpipe.extractor.StreamingService.LinkType +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.Companion.initialize +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.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.isReleaseApk +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.views.FocusOverlayView +import java.util.Objects + +class MainActivity() : AppCompatActivity() { + private var mainBinding: ActivityMainBinding? = null + private var drawerHeaderBinding: DrawerHeaderBinding? = null + private var drawerLayoutBinding: DrawerLayoutBinding? = null + private var toolbarLayoutBinding: ToolbarLayoutBinding? = null + private var toggle: ActionBarDrawerToggle? = null + private var servicesShown: Boolean = false + private var broadcastReceiver: BroadcastReceiver? = null + + /*////////////////////////////////////////////////////////////////////////// + // Activity's LifeCycle + ////////////////////////////////////////////////////////////////////////// */ + override fun onCreate(savedInstanceState: Bundle?) { + if (DEBUG) { + Log.d(TAG, ("onCreate() called with: " + + "savedInstanceState = [" + savedInstanceState + "]")) + } + ThemeHelper.setDayNightMode(this) + ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this)) + Localization.assureCorrectAppLanguage(this) + super.onCreate(savedInstanceState) + 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 (e: Exception) { + showUiErrorSnackbar(this, "Setting up drawer", e) + } + if (DeviceUtils.isTv(this)) { + FocusOverlayView.Companion.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. + initialize(this) + } + if ((!UpdateSettingsFragment.Companion.wasUserAskedForConsent(this) + && !App.Companion.getApp().isFirstRun() + && isReleaseApk)) { + UpdateSettingsFragment.Companion.askForConsentToUpdateChecks(this) + } + } + + override fun onPostCreate(savedInstanceState: Bundle?) { + super.onPostCreate(savedInstanceState) + val app: App = App.Companion.getApp() + val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(app) + if ((prefs.getBoolean(app.getString(R.string.update_app_key), false) + && prefs.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. + enqueueNewVersionCheckingWork(app, false) + } + } + + @Throws(ExtractionException::class) + private fun setupDrawer() { + addDrawerMenuForCurrentService() + toggle = ActionBarDrawerToggle(this, mainBinding!!.getRoot(), + toolbarLayoutBinding!!.toolbar, R.string.drawer_open, R.string.drawer_close) + toggle!!.syncState() + mainBinding!!.getRoot().addDrawerListener(toggle!!) + mainBinding!!.getRoot().addDrawerListener(object : SimpleDrawerListener() { + private var lastService: Int = 0 + public override fun onDrawerOpened(drawerView: View) { + lastService = ServiceHelper.getSelectedServiceId(this@MainActivity) + } + + public override fun onDrawerClosed(drawerView: View) { + if (servicesShown) { + toggleServices() + } + if (lastService != ServiceHelper.getSelectedServiceId(this@MainActivity)) { + ActivityCompat.recreate(this@MainActivity) + } + } + }) + drawerLayoutBinding!!.navigation.setNavigationItemSelectedListener(NavigationView.OnNavigationItemSelectedListener({ item: MenuItem -> drawerItemSelected(item) })) + setupDrawerHeader() + } + + /** + * Builds the drawer menu for the current service. + * + * @throws ExtractionException if the service didn't provide available kiosks + */ + @Throws(ExtractionException::class) + private fun addDrawerMenuForCurrentService() { + //Tabs + val currentServiceId: Int = ServiceHelper.getSelectedServiceId(this) + val service: StreamingService = NewPipe.getService(currentServiceId) + var kioskMenuItemId: Int = 0 + for (ks: String in service.getKioskList().getAvailableKiosks()) { + drawerLayoutBinding!!.navigation.getMenu() + .add(R.id.menu_tabs_group, kioskMenuItemId, 0, KioskTranslator.getTranslatedKioskName(ks, this)) + .setIcon(KioskTranslator.getKioskIcon(ks)) + kioskMenuItemId++ + } + 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) + + //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_ABOUT, ORDER, R.string.tab_about) + .setIcon(R.drawable.ic_info_outline) + } + + private fun drawerItemSelected(item: MenuItem): Boolean { + when (item.getGroupId()) { + R.id.menu_services_group -> changeService(item) + R.id.menu_tabs_group -> try { + tabSelected(item) + } catch (e: Exception) { + showUiErrorSnackbar(this, "Selecting main page tab", e) + } + + R.id.menu_options_about_group -> optionsAboutSelected(item) + else -> return false + } + mainBinding!!.getRoot().closeDrawers() + return true + } + + private fun changeService(item: MenuItem) { + drawerLayoutBinding!!.navigation.getMenu() + .getItem(ServiceHelper.getSelectedServiceId(this)) + .setChecked(false) + ServiceHelper.setSelectedServiceId(this, item.getItemId()) + drawerLayoutBinding!!.navigation.getMenu() + .getItem(ServiceHelper.getSelectedServiceId(this)) + .setChecked(true) + } + + @Throws(ExtractionException::class) + private fun tabSelected(item: MenuItem) { + when (item.getItemId()) { + ITEM_ID_SUBSCRIPTIONS -> NavigationHelper.openSubscriptionFragment(getSupportFragmentManager()) + ITEM_ID_FEED -> openFeedFragment(getSupportFragmentManager()) + ITEM_ID_BOOKMARKS -> NavigationHelper.openBookmarksFragment(getSupportFragmentManager()) + ITEM_ID_DOWNLOADS -> NavigationHelper.openDownloads(this) + ITEM_ID_HISTORY -> NavigationHelper.openStatisticFragment(getSupportFragmentManager()) + else -> { + val currentService: StreamingService? = ServiceHelper.getSelectedService(this) + var kioskMenuItemId: Int = 0 + for (kioskId: String in currentService!!.getKioskList().getAvailableKiosks()) { + if (kioskMenuItemId == item.getItemId()) { + NavigationHelper.openKioskFragment(getSupportFragmentManager(), + currentService.getServiceId(), kioskId) + break + } + kioskMenuItemId++ + } + } + } + } + + private fun optionsAboutSelected(item: MenuItem) { + when (item.getItemId()) { + ITEM_ID_SETTINGS -> NavigationHelper.openSettings(this) + ITEM_ID_ABOUT -> NavigationHelper.openAbout(this) + } + } + + private fun setupDrawerHeader() { + drawerHeaderBinding!!.drawerHeaderActionButton.setOnClickListener(View.OnClickListener({ view: 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) { + val layoutParams: ViewGroup.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 fun 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_options_about_group) + + // Show up or down arrow + drawerHeaderBinding!!.drawerArrow.setImageResource( + if (servicesShown) R.drawable.ic_arrow_drop_up else R.drawable.ic_arrow_drop_down) + if (servicesShown) { + showServices() + } else { + try { + addDrawerMenuForCurrentService() + } catch (e: Exception) { + showUiErrorSnackbar(this, "Showing main page tabs", e) + } + } + } + + private fun showServices() { + for (s: StreamingService in NewPipe.getServices()) { + val title: String = s.getServiceInfo().getName() + val 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 fun enhancePeertubeMenu(menuItem: MenuItem) { + val currentInstance: PeertubeInstance? = PeertubeHelper.getCurrentInstance() + menuItem.setTitle(currentInstance!!.getName()) + val spinner: Spinner = InstanceSpinnerLayoutBinding.inflate(LayoutInflater.from(this)) + .getRoot() + val instances: List? = PeertubeHelper.getInstanceList(this) + val items: MutableList = ArrayList() + var defaultSelect: Int = 0 + for (instance: PeertubeInstance? in instances!!) { + items.add(instance!!.getName()) + if ((instance.getUrl() == currentInstance.getUrl())) { + defaultSelect = items.size - 1 + } + } + val adapter: ArrayAdapter = 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(object : AdapterView.OnItemSelectedListener { + public override fun onItemSelected(parent: AdapterView<*>?, view: View, + position: Int, id: Long) { + val newInstance: PeertubeInstance? = instances.get(position) + if ((newInstance!!.getUrl() == PeertubeHelper.getCurrentInstance().getUrl())) { + return + } + PeertubeHelper.selectInstance(newInstance, getApplicationContext()) + changeService(menuItem) + mainBinding!!.getRoot().closeDrawers() + Handler(Looper.getMainLooper()).postDelayed(Runnable({ + getSupportFragmentManager().popBackStack(null, + FragmentManager.POP_BACK_STACK_INCLUSIVE) + ActivityCompat.recreate(this@MainActivity) + }), 300) + } + + public override fun onNothingSelected(parent: AdapterView<*>?) {} + }) + menuItem.setActionView(spinner) + } + + override fun onDestroy() { + super.onDestroy() + if (!isChangingConfigurations()) { + StateSaver.clearStateFiles() + } + if (broadcastReceiver != null) { + unregisterReceiver(broadcastReceiver) + } + } + + override fun onResume() { + Localization.assureCorrectAppLanguage(this) + // Change the date format to match the selected language on resume + Localization.initPrettyTime(Localization.resolvePrettyTime(getApplicationContext())) + 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 { + val selectedServiceId: Int = ServiceHelper.getSelectedServiceId(this) + val selectedServiceName: String = NewPipe.getService(selectedServiceId) + .getServiceInfo().getName() + drawerHeaderBinding!!.drawerHeaderServiceView.setText(selectedServiceName) + drawerHeaderBinding!!.drawerHeaderServiceIcon.setImageResource(ServiceHelper.getIcon(selectedServiceId)) + drawerHeaderBinding!!.drawerHeaderServiceView.post(Runnable({ drawerHeaderBinding!!.drawerHeaderServiceView.setSelected(true) })) + drawerHeaderBinding!!.drawerHeaderActionButton.setContentDescription( + getString(R.string.drawer_header_description) + selectedServiceName) + } catch (e: Exception) { + showUiErrorSnackbar(this, "Setting up service toggle", e) + } + val sharedPreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) + if (sharedPreferences.getBoolean(KEY_THEME_CHANGE, false)) { + if (DEBUG) { + Log.d(TAG, "Theme has changed, recreating activity...") + } + sharedPreferences.edit().putBoolean(KEY_THEME_CHANGE, false).apply() + ActivityCompat.recreate(this) + } + if (sharedPreferences.getBoolean(KEY_MAIN_PAGE_CHANGE, false)) { + if (DEBUG) { + Log.d(TAG, "main page has changed, recreating main fragment...") + } + sharedPreferences.edit().putBoolean(KEY_MAIN_PAGE_CHANGE, false).apply() + NavigationHelper.openMainActivity(this) + } + val isHistoryEnabled: Boolean = sharedPreferences.getBoolean( + getString(R.string.enable_watch_history_key), true) + drawerLayoutBinding!!.navigation.getMenu().findItem(ITEM_ID_HISTORY) + .setVisible(isHistoryEnabled) + } + + override fun onNewIntent(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 + val action: String? = intent.getAction() + if (((action != null && (action == Intent.ACTION_MAIN)) + && intent.hasCategory(Intent.CATEGORY_LAUNCHER))) { + return + } + } + super.onNewIntent(intent) + setIntent(intent) + handleIntent(intent) + } + + public override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + val fragment: Fragment? = getSupportFragmentManager() + .findFragmentById(R.id.fragment_player_holder) + if ((fragment is OnKeyDownListener + && !bottomSheetHiddenOrCollapsed())) { + // Provide keyDown event to fragment which then sends this event + // to the main player service + return ((fragment as OnKeyDownListener).onKeyDown(keyCode) + || super.onKeyDown(keyCode, event)) + } + return super.onKeyDown(keyCode, event) + } + + public override fun 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()) { + val fm: FragmentManager = getSupportFragmentManager() + val 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 is BackPressable) { + if ((fragment as BackPressable).onBackPressed()) { + return + } + } else if (fragment is 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 { + val fragmentPlayer: Fragment? = 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 is BackPressable) { + if (!(fragmentPlayer as BackPressable).onBackPressed()) { + BottomSheetBehavior.from(mainBinding!!.fragmentPlayerHolder) + .setState(BottomSheetBehavior.STATE_COLLAPSED) + } + return + } + } + if (getSupportFragmentManager().getBackStackEntryCount() == 1) { + finish() + } else { + super.onBackPressed() + } + } + + public override fun onRequestPermissionsResult(requestCode: Int, + permissions: Array, + grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + for (i: Int in grantResults) { + if (i == PackageManager.PERMISSION_DENIED) { + return + } + } + when (requestCode) { + PermissionHelper.DOWNLOADS_REQUEST_CODE -> NavigationHelper.openDownloads(this) + PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE -> { + val fragment: Fragment? = getSupportFragmentManager() + .findFragmentById(R.id.fragment_player_holder) + if (fragment is VideoDetailFragment) { + fragment.openDownloadDialog() + } + } + + PermissionHelper.POST_NOTIFICATIONS_REQUEST_CODE -> initialize(this) + } + } + + /** + * Implement the following diagram behavior for the up button: + *

+     * +---------------+
+     * |  Main Screen  +----+
+     * +-------+-------+    |
+     * |            |
+     * â–² Up         | Search Button
+     * |            |
+     * +----+-----+      |
+     * +------------+  Search  |â—„-----+
+     * |            +----+-----+
+     * |   Open          |
+     * |  something      â–² Up
+     * |                 |
+     * |    +------------+-------------+
+     * |    |                          |
+     * |    |  Video    <->  Channel   |
+     * +---â–º|  Channel  <->  Playlist  |
+     * |  Video    <->  ....      |
+     * |                          |
+     * +--------------------------+
+    
* + */ + private fun onHomeButtonPressed() { + val fm: FragmentManager = getSupportFragmentManager() + val fragment: Fragment? = fm.findFragmentById(R.id.fragment_holder) + if (fragment is 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 + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreateOptionsMenu(menu: Menu): Boolean { + if (DEBUG) { + Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu + "]") + } + super.onCreateOptionsMenu(menu) + val fragment: Fragment? = getSupportFragmentManager().findFragmentById(R.id.fragment_holder) + if (!(fragment is SearchFragment)) { + toolbarLayoutBinding!!.toolbarSearchContainer.getRoot().setVisibility(View.GONE) + } + val actionBar: ActionBar? = getSupportActionBar() + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(false) + } + updateDrawerNavigation() + return true + } + + public override fun onOptionsItemSelected(item: MenuItem): Boolean { + 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 fun initFragments() { + if (DEBUG) { + Log.d(TAG, "initFragments() called") + } + StateSaver.clearStateFiles() + if (getIntent() != null && getIntent().hasExtra(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 fun updateDrawerNavigation() { + if (getSupportActionBar() == null) { + return + } + val fragment: Fragment? = getSupportFragmentManager() + .findFragmentById(R.id.fragment_holder) + if (fragment is MainFragment) { + getSupportActionBar()!!.setDisplayHomeAsUpEnabled(false) + if (toggle != null) { + toggle!!.syncState() + toolbarLayoutBinding!!.toolbar.setNavigationOnClickListener(View.OnClickListener({ v: View? -> + 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(View.OnClickListener({ v: View? -> onHomeButtonPressed() })) + } + } + + private fun handleIntent(intent: Intent) { + try { + if (DEBUG) { + Log.d(TAG, "handleIntent() called with: intent = [" + intent + "]") + } + if (intent.hasExtra(KEY_LINK_TYPE)) { + val url: String? = intent.getStringExtra(KEY_URL) + val serviceId: Int = intent.getIntExtra(KEY_SERVICE_ID, 0) + var title: String? = intent.getStringExtra(KEY_TITLE) + if (title == null) { + title = "" + } + val linkType: LinkType? = (intent + .getSerializableExtra(KEY_LINK_TYPE) as LinkType?) + assert(linkType != null) + when (linkType) { + LinkType.STREAM -> { + val intentCacheKey: String? = intent.getStringExtra( + Player.Companion.PLAY_QUEUE_KEY) + val playQueue: PlayQueue? = if (intentCacheKey != null) SerializedCache.Companion.getInstance() + .take(intentCacheKey, PlayQueue::class.java) else null + val switchingPlayers: Boolean = intent.getBooleanExtra( + VideoDetailFragment.Companion.KEY_SWITCHING_PLAYERS, false) + NavigationHelper.openVideoDetailFragment( + getApplicationContext(), getSupportFragmentManager(), + serviceId, url, title, playQueue, switchingPlayers) + } + + LinkType.CHANNEL -> NavigationHelper.openChannelFragment(getSupportFragmentManager(), + serviceId, url, title) + + LinkType.PLAYLIST -> NavigationHelper.openPlaylistFragment(getSupportFragmentManager(), + serviceId, url, title) + } + } else if (intent.hasExtra(KEY_OPEN_SEARCH)) { + var searchString: String? = intent.getStringExtra(KEY_SEARCH_STRING) + if (searchString == null) { + searchString = "" + } + val serviceId: Int = intent.getIntExtra(KEY_SERVICE_ID, 0) + NavigationHelper.openSearchFragment( + getSupportFragmentManager(), + serviceId, + searchString) + } else { + NavigationHelper.gotoMainFragment(getSupportFragmentManager()) + } + } catch (e: Exception) { + showUiErrorSnackbar(this, "Handling intent", e) + } + } + + private fun openMiniPlayerIfMissing() { + val fragmentPlayer: Fragment? = 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 fun openMiniPlayerUponPlayerStarted() { + if ((getIntent().getSerializableExtra(KEY_LINK_TYPE) + === LinkType.STREAM)) { + // handleIntent() already takes care of opening video detail fragment + // due to an intent containing a STREAM link + return + } + if (PlayerHolder.Companion.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 = object : BroadcastReceiver() { + public override fun onReceive(context: Context, intent: Intent) { + if (Objects.equals(intent.getAction(), + VideoDetailFragment.Companion.ACTION_PLAYER_STARTED)) { + 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 + } + } + } + val intentFilter: IntentFilter = IntentFilter() + intentFilter.addAction(VideoDetailFragment.Companion.ACTION_PLAYER_STARTED) + registerReceiver(broadcastReceiver, intentFilter) + } + } + + private fun openDetailFragmentFromCommentReplies( + fm: FragmentManager, + popBackStack: Boolean + ) { + // obtain the name of the fragment under the replies fragment that's going to be popped + val fragmentUnderEntryName: String? + 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 + val repliesFragment: CommentRepliesFragment? = fm.findFragmentByTag(CommentRepliesFragment.Companion.TAG) as CommentRepliesFragment? + val rootComment: CommentsInfoItem? = if (repliesFragment == null) null else 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.Companion.TAG == fragmentUnderEntryName)) { + return + } + val behavior: BottomSheetBehavior = 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(object : BottomSheetCallback() { + public override fun onStateChanged(bottomSheet: View, + newState: Int) { + if (newState == BottomSheetBehavior.STATE_EXPANDED) { + val detailFragment: Fragment? = fm.findFragmentById( + R.id.fragment_player_holder) + if (detailFragment is VideoDetailFragment && rootComment != null) { + // should always be the case + detailFragment.scrollToComment(rootComment) + } + behavior.removeBottomSheetCallback(this) + } + } + + public override fun onSlide(bottomSheet: View, slideOffset: Float) { + // not needed, listener is removed once the sheet is expanded + } + }) + behavior.setState(BottomSheetBehavior.STATE_EXPANDED) + } + + private fun bottomSheetHiddenOrCollapsed(): Boolean { + val bottomSheetBehavior: BottomSheetBehavior = BottomSheetBehavior.from(mainBinding!!.fragmentPlayerHolder) + val sheetState: Int = bottomSheetBehavior.getState() + return (sheetState == BottomSheetBehavior.STATE_HIDDEN + || sheetState == BottomSheetBehavior.STATE_COLLAPSED) + } + + companion object { + private val TAG: String = "MainActivity" + val DEBUG: Boolean = !BuildConfig.BUILD_TYPE.equals("release") + private val ITEM_ID_SUBSCRIPTIONS: Int = -1 + private val ITEM_ID_FEED: Int = -2 + private val ITEM_ID_BOOKMARKS: Int = -3 + private val ITEM_ID_DOWNLOADS: Int = -4 + private val ITEM_ID_HISTORY: Int = -5 + private val ITEM_ID_SETTINGS: Int = 0 + private val ITEM_ID_ABOUT: Int = 1 + private val ORDER: Int = 0 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java deleted file mode 100644 index 21c5354f4..000000000 --- a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java +++ /dev/null @@ -1,72 +0,0 @@ -package org.schabi.newpipe; - -import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME; -import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2; -import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3; -import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4; -import static org.schabi.newpipe.database.Migrations.MIGRATION_4_5; -import static org.schabi.newpipe.database.Migrations.MIGRATION_5_6; -import static org.schabi.newpipe.database.Migrations.MIGRATION_6_7; -import static org.schabi.newpipe.database.Migrations.MIGRATION_7_8; -import static org.schabi.newpipe.database.Migrations.MIGRATION_8_9; - -import android.content.Context; -import android.database.Cursor; - -import androidx.annotation.NonNull; -import androidx.room.Room; - -import org.schabi.newpipe.database.AppDatabase; - -public final class NewPipeDatabase { - private static volatile AppDatabase databaseInstance; - - private NewPipeDatabase() { - //no instance - } - - private static AppDatabase getDatabase(final Context context) { - return Room - .databaseBuilder(context.getApplicationContext(), AppDatabase.class, 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(); - } - - @NonNull - public static AppDatabase getInstance(@NonNull final Context context) { - AppDatabase result = databaseInstance; - if (result == null) { - synchronized (NewPipeDatabase.class) { - result = databaseInstance; - if (result == null) { - databaseInstance = getDatabase(context); - result = databaseInstance; - } - } - } - - return result; - } - - public static void checkpoint() { - if (databaseInstance == null) { - throw new IllegalStateException("database is not initialized"); - } - final Cursor c = databaseInstance.query("pragma wal_checkpoint(full)", null); - if (c.moveToFirst() && c.getInt(0) == 1) { - throw new RuntimeException("Checkpoint was blocked from completing"); - } - } - - public static void close() { - if (databaseInstance != null) { - synchronized (NewPipeDatabase.class) { - if (databaseInstance != null) { - databaseInstance.close(); - databaseInstance = null; - } - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.kt b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.kt new file mode 100644 index 000000000..197d72260 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.kt @@ -0,0 +1,54 @@ +package org.schabi.newpipe + +import android.content.Context +import android.database.Cursor +import androidx.room.Room.databaseBuilder +import org.schabi.newpipe.database.AppDatabase +import org.schabi.newpipe.database.Migrations +import kotlin.concurrent.Volatile + +object NewPipeDatabase { + @Volatile + private var databaseInstance: AppDatabase? = null + private fun getDatabase(context: Context): AppDatabase { + return databaseBuilder(context.getApplicationContext(), AppDatabase::class.java, AppDatabase.Companion.DATABASE_NAME) + .addMigrations(Migrations.MIGRATION_1_2, Migrations.MIGRATION_2_3, Migrations.MIGRATION_3_4, Migrations.MIGRATION_4_5, + Migrations.MIGRATION_5_6, Migrations.MIGRATION_6_7, Migrations.MIGRATION_7_8, Migrations.MIGRATION_8_9) + .build() + } + + fun getInstance(context: Context): AppDatabase { + var result: AppDatabase? = databaseInstance + if (result == null) { + synchronized(NewPipeDatabase::class.java, { + result = databaseInstance + if (result == null) { + databaseInstance = getDatabase(context) + result = databaseInstance + } + }) + } + return (result)!! + } + + fun checkpoint() { + if (databaseInstance == null) { + throw IllegalStateException("database is not initialized") + } + val c: Cursor = databaseInstance!!.query("pragma wal_checkpoint(full)", null) + if (c.moveToFirst() && c.getInt(0) == 1) { + throw RuntimeException("Checkpoint was blocked from completing") + } + } + + fun close() { + if (databaseInstance != null) { + synchronized(NewPipeDatabase::class.java, { + if (databaseInstance != null) { + databaseInstance!!.close() + databaseInstance = null + } + }) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/PanicResponderActivity.java b/app/src/main/java/org/schabi/newpipe/PanicResponderActivity.java deleted file mode 100644 index f0d1af81a..000000000 --- a/app/src/main/java/org/schabi/newpipe/PanicResponderActivity.java +++ /dev/null @@ -1,44 +0,0 @@ -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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/PanicResponderActivity.kt b/app/src/main/java/org/schabi/newpipe/PanicResponderActivity.kt new file mode 100644 index 000000000..4fe3daa12 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/PanicResponderActivity.kt @@ -0,0 +1,43 @@ +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 . +*/ +class PanicResponderActivity() : Activity() { + @SuppressLint("NewApi") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val intent: Intent? = getIntent() + if (intent != null && (PANIC_TRIGGER_ACTION == 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.Companion.exitAndRemoveFromRecentApps(this) + } + finishAndRemoveTask() + } + + companion object { + val PANIC_TRIGGER_ACTION: String = "info.guardianproject.panic.action.TRIGGER" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java b/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java deleted file mode 100644 index e6177f6a3..000000000 --- a/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java +++ /dev/null @@ -1,94 +0,0 @@ -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 -> { - switch (menuItem.getItemId()) { - case R.id.menu_item_remove: - final int index = playQueue.indexOf(item); - playQueue.remove(index); - return true; - case 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; - case R.id.menu_item_append_playlist: - PlaylistDialog.createCorrespondingDialog( - context, - List.of(new StreamEntity(item)), - dialog -> dialog.show( - fragmentManager, - "QueueItemMenuUtil@append_playlist" - ) - ); - - return true; - case 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; - case R.id.menu_item_share: - shareText(context, item.getTitle(), item.getUrl(), - item.getThumbnails()); - return true; - case 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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.kt b/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.kt new file mode 100644 index 000000000..51ad2d167 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.kt @@ -0,0 +1,97 @@ +package org.schabi.newpipe + +import android.content.Context +import android.view.ContextThemeWrapper +import android.view.MenuItem +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.extractor.stream.StreamInfo +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 org.schabi.newpipe.util.external_communication.ShareUtils +import java.util.List +import java.util.function.Consumer + +object QueueItemMenuUtil { + fun openPopupMenu(playQueue: PlayQueue?, + item: PlayQueueItem, + view: View?, + hideDetails: Boolean, + fragmentManager: FragmentManager?, + context: Context) { + val themeWrapper: ContextThemeWrapper = ContextThemeWrapper(context, R.style.DarkPopupMenu) + val popupMenu: PopupMenu = 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(PopupMenu.OnMenuItemClickListener({ menuItem: MenuItem -> + when (menuItem.getItemId()) { + R.id.menu_item_remove -> { + val index: Int = playQueue!!.indexOf(item) + playQueue.remove(index) + return@setOnMenuItemClickListener true + } + + 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@setOnMenuItemClickListener true + } + + R.id.menu_item_append_playlist -> { + PlaylistDialog.Companion.createCorrespondingDialog( + context, + List.of(StreamEntity(item)), + Consumer({ dialog: PlaylistDialog -> + dialog.show( + (fragmentManager)!!, + "QueueItemMenuUtil@append_playlist" + ) + }) + ) + return@setOnMenuItemClickListener true + } + + 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. + Consumer({ uploaderUrl: String? -> + NavigationHelper.openChannelFragmentUsingIntent( + context, item.getServiceId(), uploaderUrl, item.getUploader() + ) + })) + return@setOnMenuItemClickListener true + } + + R.id.menu_item_share -> { + ShareUtils.shareText(context, item.getTitle(), item.getUrl(), + item.getThumbnails()) + return@setOnMenuItemClickListener true + } + + R.id.menu_item_download -> { + SparseItemUtil.fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(), + Consumer({ info: StreamInfo -> + val downloadDialog: DownloadDialog = DownloadDialog(context, + info) + downloadDialog.show((fragmentManager)!!, "downloadDialog") + })) + return@setOnMenuItemClickListener true + } + } + false + })) + popupMenu.show() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java deleted file mode 100644 index c59dc7532..000000000 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ /dev/null @@ -1,1092 +0,0 @@ -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 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.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.SoundCloudGoPlusContentException; -import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException; -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.ktx.ExceptionUtils; -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.Localization; -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 icepick.Icepick; -import icepick.State; -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); - Localization.assureCorrectAppLanguage(this); - - // 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); - Icepick.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); - Icepick.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)))); - } - - /** - * @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.getThrowable() != null) { - errorInfo.getThrowable().printStackTrace(); - } - - if (errorInfo.getThrowable() instanceof ReCaptchaException) { - 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); - context.startActivity(intent); - } else if (errorInfo.getThrowable() != null - && ExceptionUtils.isNetworkRelated(errorInfo.getThrowable())) { - Toast.makeText(context, R.string.network_error, Toast.LENGTH_LONG).show(); - } else if (errorInfo.getThrowable() instanceof AgeRestrictedContentException) { - Toast.makeText(context, R.string.restricted_video_no_stream, - Toast.LENGTH_LONG).show(); - } else if (errorInfo.getThrowable() instanceof GeographicRestrictionException) { - Toast.makeText(context, R.string.georestricted_content, Toast.LENGTH_LONG).show(); - } else if (errorInfo.getThrowable() instanceof PaidContentException) { - Toast.makeText(context, R.string.paid_content, Toast.LENGTH_LONG).show(); - } else if (errorInfo.getThrowable() instanceof PrivateContentException) { - Toast.makeText(context, R.string.private_content, Toast.LENGTH_LONG).show(); - } else if (errorInfo.getThrowable() instanceof SoundCloudGoPlusContentException) { - Toast.makeText(context, R.string.soundcloud_go_plus_content, - Toast.LENGTH_LONG).show(); - } else if (errorInfo.getThrowable() instanceof YoutubeMusicPremiumContentException) { - Toast.makeText(context, R.string.youtube_music_premium_content, - Toast.LENGTH_LONG).show(); - } else if (errorInfo.getThrowable() instanceof ContentNotAvailableException) { - Toast.makeText(context, R.string.content_not_available, Toast.LENGTH_LONG).show(); - } else if (errorInfo.getThrowable() instanceof ContentNotSupportedException) { - Toast.makeText(context, R.string.content_not_supported, Toast.LENGTH_LONG).show(); - } else { - ErrorUtil.createNotification(context, errorInfo); - } - - 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)) { - - 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)); - - 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 List capabilities = - currentService.getServiceInfo().getMediaCapabilities(); - - // Check if the service supports the choice - if ((isVideoPlayerSelected && capabilities.contains(VIDEO)) - || (isAudioPlayerSelected && 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 List capabilities = - service.getServiceInfo().getMediaCapabilities(); - - if (linkType == LinkType.STREAM) { - if (capabilities.contains(VIDEO)) { - returnedItems.add(videoPlayer); - returnedItems.add(popupPlayer); - } - if (capabilities.contains(AUDIO)) { - returnedItems.add(backgroundPlayer); - } - // 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_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))) - ); - 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()) - )) - ) - ); - } - } - - 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))); - } - } - - 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); - } - }; - } - - @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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.kt b/app/src/main/java/org/schabi/newpipe/RouterActivity.kt new file mode 100644 index 000000000..57d49b1f5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.kt @@ -0,0 +1,1002 @@ +package org.schabi.newpipe + +import android.annotation.SuppressLint +import android.app.IntentService +import android.content.Context +import android.content.DialogInterface +import android.content.DialogInterface.OnShowListener +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.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.Lifecycle.State.isAtLeast +import androidx.lifecycle.Lifecycle.addObserver +import androidx.lifecycle.Lifecycle.currentState +import androidx.lifecycle.Lifecycle.removeObserver +import androidx.lifecycle.LifecycleOwner +import androidx.preference.PreferenceManager +import icepick.Icepick +import icepick.State +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.core.SingleEmitter +import io.reactivex.rxjava3.core.SingleOnSubscribe +import io.reactivex.rxjava3.core.SingleTransformer +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.functions.Cancellable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.schabi.newpipe.RouterActivity.FetcherService +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.Companion.createNotification +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.StreamingService.ServiceInfo.MediaCapability +import org.schabi.newpipe.extractor.channel.ChannelInfo +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.SoundCloudGoPlusContentException +import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException +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.ktx.isNetworkRelated +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.DeviceUtils +import org.schabi.newpipe.util.ExtractorHelper +import org.schabi.newpipe.util.Localization +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.Companion.firstUrlFromInput +import org.schabi.newpipe.views.FocusOverlayView +import java.io.Serializable +import java.lang.ref.WeakReference +import java.util.Arrays +import java.util.Optional +import java.util.concurrent.Callable +import java.util.function.Function +import java.util.function.IntPredicate +import java.util.function.Predicate + +/** + * Get the url from the intent and open it in the chosen preferred player. + */ +class RouterActivity() : AppCompatActivity() { + protected val disposables: CompositeDisposable = CompositeDisposable() + + @State + protected var currentServiceId: Int = -1 + + @State + protected var currentLinkType: LinkType? = null + + @State + protected var selectedRadioPosition: Int = -1 + protected var selectedPreviously: Int = -1 + protected var currentUrl: String? = null + private var currentService: StreamingService? = null + private var selectionIsDownload: Boolean = false + private var selectionIsAddToPlaylist: Boolean = false + private var alertDialogChoice: AlertDialog? = null + private var dismissListener: FragmentManager.FragmentLifecycleCallbacks? = null + override fun onCreate(savedInstanceState: Bundle?) { + ThemeHelper.setDayNightMode(this) + setTheme(if (ThemeHelper.isLightThemeSelected(this)) R.style.RouterActivityThemeLight else R.style.RouterActivityThemeDark) + Localization.assureCorrectAppLanguage(this) + + // 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 + or WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL + or 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 + val params: WindowManager.LayoutParams = getWindow().getAttributes() + params.alpha = 0f + getWindow().setAttributes(params) + super.onCreate(savedInstanceState) + Icepick.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) + val fm: FragmentManager = getSupportFragmentManager() + if (dismissListener == null) { + dismissListener = object : FragmentManager.FragmentLifecycleCallbacks() { + public override fun onFragmentDestroyed(fm: FragmentManager, + f: Fragment) { + super.onFragmentDestroyed(fm, f) + if (f is 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 fun onStop() { + super.onStop() + // we need to dismiss the dialog before leaving the activity or we get leaks + if (alertDialogChoice != null) { + alertDialogChoice!!.dismiss() + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + Icepick.saveInstanceState(this, outState) + } + + override fun 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 fun onDestroy() { + super.onDestroy() + if (dismissListener != null) { + getSupportFragmentManager().unregisterFragmentLifecycleCallbacks(dismissListener!!) + } + disposables.clear() + } + + public override fun finish() { + // allow the activity to recreate in case orientation changes + if (!isChangingConfigurations()) { + super.finish() + } + } + + private fun handleUrl(url: String?) { + disposables.add(Observable + .fromCallable(Callable({ + 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@fromCallable currentLinkType != LinkType.NONE + } catch (e: ExtractionException) { + // this can be reached only when the url is completely unsupported + return@fromCallable false + } + })) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(io.reactivex.rxjava3.functions.Consumer({ isUrlSupported: Boolean -> + if (isUrlSupported) { + onSuccess() + } else { + showUnsupportedUrlDialog(url) + } + }), io.reactivex.rxjava3.functions.Consumer({ throwable: Throwable? -> + handleError(this, ErrorInfo((throwable)!!, + UserAction.SHARE_TO_NEWPIPE, "Getting service from url: " + url)) + }))) + } + + protected fun showUnsupportedUrlDialog(url: String?) { + val context: Context = getThemeWrapperContext() + 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, + DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> ShareUtils.openUrlInBrowser(this, url) })) + .setNegativeButton(R.string.share, + DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> shareText(this, "", url) })) // no subject + .setNeutralButton(R.string.cancel, null) + .setOnDismissListener(DialogInterface.OnDismissListener({ dialog: DialogInterface? -> finish() })) + .show() + } + + protected fun onSuccess() { + val preferences: SharedPreferences = PreferenceManager + .getDefaultSharedPreferences(this) + val choiceChecker: ChoiceAvailabilityChecker = 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)) { + val selectedChoice: String = choiceChecker.getSelectedChoiceKey() + val isExtVideoEnabled: Boolean = preferences.getBoolean( + getString(R.string.use_external_video_player_key), false) + val isExtAudioEnabled: Boolean = preferences.getBoolean( + getString(R.string.use_external_audio_player_key), false) + val isVideoPlayerSelected: Boolean = ((selectedChoice == getString(R.string.video_player_key)) || (selectedChoice == getString(R.string.popup_player_key))) + val isAudioPlayerSelected: Boolean = (selectedChoice == getString(R.string.background_player_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 + } + val capabilities: List = currentService!!.getServiceInfo().getMediaCapabilities() + + // Check if the service supports the choice + if (((isVideoPlayerSelected && capabilities.contains(MediaCapability.VIDEO)) + || (isAudioPlayerSelected && capabilities.contains(MediaCapability.AUDIO)))) { + handleChoice(selectedChoice) + } else { + handleChoice(getString(R.string.show_info_key)) + } + return + } + + // Default / Ask always + val availableChoices: List = choiceChecker.getAvailableChoices() + when (availableChoices.size) { + 1 -> handleChoice(availableChoices.get(0).key) + 0 -> handleChoice(getString(R.string.show_info_key)) + else -> showDialog(availableChoices) + } + } + + /** + * This is a helper class for checking if the choices are available and/or selected. + */ + internal inner class ChoiceAvailabilityChecker( + private val availableChoices: List, + private val selectedChoiceKey: String) { + fun getAvailableChoices(): List { + return availableChoices + } + + fun getSelectedChoiceKey(): String { + return selectedChoiceKey + } + + fun isAvailableAndSelected(@StringRes vararg wantedKeys: Int): Boolean { + return Arrays.stream(wantedKeys).anyMatch(IntPredicate({ wantedKey: Int -> this.isAvailableAndSelected(wantedKey) })) + } + + fun isAvailableAndSelected(@StringRes wantedKey: Int): Boolean { + val wanted: String = getString(wantedKey) + // Check if the wanted option is selected + if (!(selectedChoiceKey == wanted)) { + return false + } + // Check if it's available + return availableChoices.stream().anyMatch(Predicate({ item: AdapterChoiceItem -> (wanted == item.key) })) + } + } + + private fun showDialog(choices: List) { + val preferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this) + val themeWrapperContext: Context = getThemeWrapperContext() + val layoutInflater: LayoutInflater = LayoutInflater.from(themeWrapperContext) + val binding: SingleChoiceDialogViewBinding = SingleChoiceDialogViewBinding.inflate(layoutInflater) + val radioGroup: RadioGroup = binding.list + val dialogButtonsClickListener: DialogInterface.OnClickListener = DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> + val indexOfChild: Int = radioGroup.indexOfChild( + radioGroup.findViewById(radioGroup.getCheckedRadioButtonId())) + val choice: AdapterChoiceItem = 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 = 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(DialogInterface.OnDismissListener({ dialog: DialogInterface? -> + if (!selectionIsDownload && !selectionIsAddToPlaylist) { + finish() + } + })) + .create() + alertDialogChoice!!.setOnShowListener(OnShowListener({ dialog: DialogInterface? -> + setDialogButtonsState( + alertDialogChoice!!, radioGroup.getCheckedRadioButtonId() != -1) + })) + radioGroup.setOnCheckedChangeListener(RadioGroup.OnCheckedChangeListener({ group: RadioGroup?, checkedId: Int -> setDialogButtonsState(alertDialogChoice!!, true) })) + val radioButtonsClickListener: View.OnClickListener = View.OnClickListener({ v: View? -> + val indexOfChild: Int = radioGroup.indexOfChild(v) + if (indexOfChild == -1) { + return@OnClickListener + } + selectedPreviously = selectedRadioPosition + selectedRadioPosition = indexOfChild + if (selectedPreviously == selectedRadioPosition) { + handleChoice(choices.get(selectedRadioPosition).key) + } + }) + var id: Int = 12345 + for (item: AdapterChoiceItem in choices) { + val 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(RadioGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + radioButton.setOnClickListener(radioButtonsClickListener) + radioGroup.addView(radioButton) + } + if (selectedRadioPosition == -1) { + val lastSelectedPlayer: String? = preferences.getString( + getString(R.string.preferred_open_action_last_selected_key), null) + if (!TextUtils.isEmpty(lastSelectedPlayer)) { + for (i in choices.indices) { + val c: AdapterChoiceItem = choices.get(i) + if ((lastSelectedPlayer == c.key)) { + selectedRadioPosition = i + break + } + } + } + } + selectedRadioPosition = MathUtils.clamp(selectedRadioPosition, -1, choices.size - 1) + if (selectedRadioPosition != -1) { + (radioGroup.getChildAt(selectedRadioPosition) as RadioButton).setChecked(true) + } + selectedPreviously = selectedRadioPosition + alertDialogChoice!!.show() + if (DeviceUtils.isTv(this)) { + FocusOverlayView.Companion.setupFocusObserver(alertDialogChoice!!) + } + } + + private fun getChoicesForService(service: StreamingService?, + linkType: LinkType?): List { + val showInfo: AdapterChoiceItem = AdapterChoiceItem( + getString(R.string.show_info_key), getString(R.string.show_info), + R.drawable.ic_info_outline) + val videoPlayer: AdapterChoiceItem = AdapterChoiceItem( + getString(R.string.video_player_key), getString(R.string.video_player), + R.drawable.ic_play_arrow) + val backgroundPlayer: AdapterChoiceItem = AdapterChoiceItem( + getString(R.string.background_player_key), getString(R.string.background_player), + R.drawable.ic_headset) + val popupPlayer: AdapterChoiceItem = AdapterChoiceItem( + getString(R.string.popup_player_key), getString(R.string.popup_player), + R.drawable.ic_picture_in_picture) + val returnedItems: MutableList = ArrayList() + returnedItems.add(showInfo) // Always present + val capabilities: List = service!!.getServiceInfo().getMediaCapabilities() + if (linkType == LinkType.STREAM) { + if (capabilities.contains(MediaCapability.VIDEO)) { + returnedItems.add(videoPlayer) + returnedItems.add(popupPlayer) + } + if (capabilities.contains(MediaCapability.AUDIO)) { + returnedItems.add(backgroundPlayer) + } + // download is redundant for linkType CHANNEL AND PLAYLIST (till playlist downloading is + // not supported ) + returnedItems.add(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(AdapterChoiceItem(getString(R.string.add_to_playlist_key), + getString(R.string.add_to_playlist), + R.drawable.ic_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 + val preferences: SharedPreferences = PreferenceManager + .getDefaultSharedPreferences(this) + val isExtVideoEnabled: Boolean = preferences.getBoolean( + getString(R.string.use_external_video_player_key), false) + val isExtAudioEnabled: Boolean = preferences.getBoolean( + getString(R.string.use_external_audio_player_key), false) + if (capabilities.contains(MediaCapability.VIDEO) && !isExtVideoEnabled) { + returnedItems.add(videoPlayer) + returnedItems.add(popupPlayer) + } + if (capabilities.contains(MediaCapability.AUDIO) && !isExtAudioEnabled) { + returnedItems.add(backgroundPlayer) + } + } + return returnedItems + } + + protected fun getThemeWrapperContext(): Context { + return ContextThemeWrapper(this, if (ThemeHelper.isLightThemeSelected(this)) R.style.LightTheme else R.style.DarkTheme) + } + + private fun setDialogButtonsState(dialog: AlertDialog, state: Boolean) { + val negativeButton: Button? = dialog.getButton(DialogInterface.BUTTON_NEGATIVE) + val positiveButton: Button? = dialog.getButton(DialogInterface.BUTTON_POSITIVE) + if (negativeButton == null || positiveButton == null) { + return + } + negativeButton.setEnabled(state) + positiveButton.setEnabled(state) + } + + private fun handleText() { + val searchString: String? = getIntent().getStringExtra(Intent.EXTRA_TEXT) + val serviceId: Int = getIntent().getIntExtra(KEY_SERVICE_ID, 0) + val intent: Intent = Intent(getThemeWrapperContext(), MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(intent) + NavigationHelper.openSearch(getThemeWrapperContext(), serviceId, searchString) + } + + private fun handleChoice(selectedChoiceKey: String) { + val validChoicesList: List = 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 == getString(R.string.popup_player_key)) && !PermissionHelper.isPopupEnabledElseAsk(this))) { + finish() + return + } + if ((selectedChoiceKey == getString(R.string.download_key))) { + if (PermissionHelper.checkStoragePermissions(this, + PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { + selectionIsDownload = true + openDownloadDialog() + } + return + } + if ((selectedChoiceKey == 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 == getString(R.string.show_info_key)) || canHandleChoiceLikeShowInfo(selectedChoiceKey))) { + disposables.add(Observable + .fromCallable(Callable({ NavigationHelper.getIntentByLink(this, currentUrl) })) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(io.reactivex.rxjava3.functions.Consumer({ intent: Intent? -> + startActivity(intent) + finish() + }), io.reactivex.rxjava3.functions.Consumer({ throwable: Throwable? -> + handleError(this, ErrorInfo((throwable)!!, + UserAction.SHARE_TO_NEWPIPE, "Starting info activity: " + currentUrl)) + })) + ) + return + } + val intent: Intent = Intent(this, FetcherService::class.java) + val choice: Choice = Choice(currentService!!.getServiceId(), currentLinkType, + currentUrl, selectedChoiceKey) + intent.putExtra(FetcherService.KEY_CHOICE, choice) + startService(intent) + finish() + } + + private fun canHandleChoiceLikeShowInfo(selectedChoiceKey: String): Boolean { + if (!(selectedChoiceKey == 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 + } + val isExtVideoEnabled: Boolean = 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 + val playerType: PlayerType? = PlayerHolder.Companion.getInstance().getType() + return playerType == null || playerType == PlayerType.MAIN + } + + class PersistentFragment() : Fragment() { + private var weakContext: WeakReference? = null + private val disposables: CompositeDisposable = CompositeDisposable() + private var running: Int = 0 + @Synchronized + private fun inFlight(started: Boolean) { + if (started) { + running++ + } else { + running-- + if (running <= 0) { + getActivityContext().ifPresent(java.util.function.Consumer({ context: AppCompatActivity? -> + context!!.getSupportFragmentManager() + .beginTransaction().remove(this).commit() + })) + } + } + } + + public override fun onAttach(activityContext: Context) { + super.onAttach(activityContext) + weakContext = WeakReference(activityContext as AppCompatActivity) + } + + public override fun onDetach() { + super.onDetach() + weakContext = null + } + + @Suppress("deprecation") + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setRetainInstance(true) + } + + public override fun onDestroy() { + super.onDestroy() + disposables.clear() + } + + /** + * @return the activity context, if there is one and the activity is not finishing + */ + private fun getActivityContext(): Optional { + return Optional.ofNullable(weakContext) + .map(Function({ obj: WeakReference? -> obj!!.get() })) + .filter(Predicate({ context: AppCompatActivity? -> !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 fun runOnVisible(runnable: java.util.function.Consumer) { + getActivityContext().ifPresentOrElse(java.util.function.Consumer({ context: AppCompatActivity? -> + if (getLifecycle().currentState.isAtLeast(Lifecycle.State.STARTED)) { + context!!.runOnUiThread(Runnable({ + runnable.accept((context)) + inFlight(false) + })) + } else { + getLifecycle().addObserver(object : DefaultLifecycleObserver { + public override fun onResume(owner: LifecycleOwner) { + getLifecycle().removeObserver(this) + getActivityContext().ifPresentOrElse(java.util.function.Consumer({ context: AppCompatActivity? -> + context!!.runOnUiThread(Runnable({ + runnable.accept((context)) + inFlight(false) + })) + }), + Runnable({ 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 + val i: Intent = Intent(context, RouterActivity::class.java) + i.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) + startActivity(i) + } + } + }), Runnable({ // this branch is executed if there is no activity context + inFlight(false) + }) + ) + } + + fun pleaseWait(single: Single): Single { + // 'abuse' ambWith() here to cancel the toast for us when the wait is over + return single.ambWith(Single.create(SingleOnSubscribe({ emitter: SingleEmitter -> + getActivityContext().ifPresent(java.util.function.Consumer({ context: AppCompatActivity? -> + context!!.runOnUiThread(Runnable({ + + // Getting the stream info usually takes a moment + // Notifying the user here to ensure that no confusion arises + val toast: Toast = Toast.makeText(context, + getString(R.string.processing_may_take_a_moment), + Toast.LENGTH_LONG) + toast.show() + emitter.setCancellable(Cancellable({ toast.cancel() })) + })) + })) + }))) + } + + @SuppressLint("CheckResult") + fun openDownloadDialog(currentServiceId: Int, currentUrl: String?) { + inFlight(true) + val loadingDialog: LoadingDialog = LoadingDialog(R.string.loading_metadata_title) + loadingDialog.show(getParentFragmentManager(), "loadingDialog") + disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .compose(SingleTransformer({ single: Single -> pleaseWait(single) })) + .subscribe(io.reactivex.rxjava3.functions.Consumer({ result: StreamInfo -> + runOnVisible(java.util.function.Consumer({ ctx: AppCompatActivity -> + loadingDialog.dismiss() + val fm: FragmentManager = ctx.getSupportFragmentManager() + val downloadDialog: DownloadDialog = DownloadDialog(ctx, result) + // dismiss listener to be handled by FragmentManager + downloadDialog.show(fm, "downloadDialog") + }) + ) + }), io.reactivex.rxjava3.functions.Consumer({ throwable: Throwable? -> + runOnVisible(java.util.function.Consumer({ ctx: AppCompatActivity -> + loadingDialog.dismiss() + (ctx as RouterActivity).showUnsupportedUrlDialog(currentUrl) + })) + }))) + } + + fun openAddToPlaylistDialog(currentServiceId: Int, currentUrl: String?) { + inFlight(true) + disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, false) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .compose(SingleTransformer({ single: Single -> pleaseWait(single) })) + .subscribe( + io.reactivex.rxjava3.functions.Consumer({ info: StreamInfo? -> + getActivityContext().ifPresent(java.util.function.Consumer({ context: AppCompatActivity? -> + PlaylistDialog.Companion.createCorrespondingDialog(context, + java.util.List.of(StreamEntity((info)!!)), + java.util.function.Consumer({ playlistDialog: PlaylistDialog -> + runOnVisible(java.util.function.Consumer({ ctx: AppCompatActivity -> + // dismiss listener to be handled by FragmentManager + val fm: FragmentManager = ctx.getSupportFragmentManager() + playlistDialog.show(fm, "addToPlaylistDialog") + })) + }) + ) + })) + }), + io.reactivex.rxjava3.functions.Consumer({ throwable: Throwable? -> + runOnVisible(java.util.function.Consumer({ ctx: AppCompatActivity -> + handleError(ctx, ErrorInfo( + (throwable)!!, + UserAction.REQUESTED_STREAM, + "Tried to add " + currentUrl + " to a playlist", + (ctx as RouterActivity).currentService!!.getServiceId()) + ) + })) + }) + ) + ) + } + } + + private fun openAddToPlaylistDialog() { + getPersistFragment().openAddToPlaylistDialog(currentServiceId, currentUrl) + } + + private fun openDownloadDialog() { + getPersistFragment().openDownloadDialog(currentServiceId, currentUrl) + } + + private fun getPersistFragment(): PersistentFragment { + val fm: FragmentManager = getSupportFragmentManager() + var persistFragment: PersistentFragment? = fm.findFragmentByTag("PERSIST_FRAGMENT") as PersistentFragment? + if (persistFragment == null) { + persistFragment = PersistentFragment() + fm.beginTransaction() + .add(persistFragment, "PERSIST_FRAGMENT") + .commitNow() + } + return persistFragment + } + + public override fun onRequestPermissionsResult(requestCode: Int, + permissions: Array, + grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + for (i: Int in grantResults) { + if (i == PackageManager.PERMISSION_DENIED) { + finish() + return + } + } + if (requestCode == PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE) { + openDownloadDialog() + } + } + + private class AdapterChoiceItem internal constructor(val key: String, val description: String, @field:DrawableRes val icon: Int) + class Choice internal constructor(val serviceId: Int, val linkType: LinkType?, + val url: String?, val playerChoice: String) : Serializable { + public override fun toString(): String { + return serviceId.toString() + ":" + url + " > " + linkType + " ::: " + playerChoice + } + } + + class FetcherService() : IntentService(FetcherService::class.java.getSimpleName()) { + private var fetcher: Disposable? = null + public override fun onCreate() { + super.onCreate() + startForeground(ID, createNotification().build()) + } + + override fun onHandleIntent(intent: Intent?) { + if (intent == null) { + return + } + val serializable: Serializable? = intent.getSerializableExtra(KEY_CHOICE) + if (!(serializable is Choice)) { + return + } + handleChoice(serializable) + } + + fun handleChoice(choice: Choice) { + var single: Single? = null + var userAction: UserAction = UserAction.SOMETHING_ELSE + when (choice.linkType) { + LinkType.STREAM -> { + single = ExtractorHelper.getStreamInfo(choice.serviceId, choice.url, false) + userAction = UserAction.REQUESTED_STREAM + } + + LinkType.CHANNEL -> { + single = ExtractorHelper.getChannelInfo(choice.serviceId, choice.url, false) + userAction = UserAction.REQUESTED_CHANNEL + } + + LinkType.PLAYLIST -> { + single = ExtractorHelper.getPlaylistInfo(choice.serviceId, choice.url, false) + userAction = UserAction.REQUESTED_PLAYLIST + } + } + if (single != null) { + val finalUserAction: UserAction = userAction + val resultHandler: java.util.function.Consumer = getResultHandler(choice) + fetcher = single + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ info: Info? -> + resultHandler.accept(info) + if (fetcher != null) { + fetcher!!.dispose() + } + }, io.reactivex.rxjava3.functions.Consumer({ throwable: Throwable? -> + handleError(this, ErrorInfo((throwable)!!, finalUserAction, + choice.url + " opened with " + choice.playerChoice, + choice.serviceId)) + })) + } + } + + fun getResultHandler(choice: Choice): java.util.function.Consumer { + return java.util.function.Consumer({ info: Info? -> + val videoPlayerKey: String = getString(R.string.video_player_key) + val backgroundPlayerKey: String = getString(R.string.background_player_key) + val popupPlayerKey: String = getString(R.string.popup_player_key) + val preferences: SharedPreferences = PreferenceManager + .getDefaultSharedPreferences(this) + val isExtVideoEnabled: Boolean = preferences.getBoolean( + getString(R.string.use_external_video_player_key), false) + val isExtAudioEnabled: Boolean = preferences.getBoolean( + getString(R.string.use_external_audio_player_key), false) + val playQueue: PlayQueue + if (info is StreamInfo) { + if ((choice.playerChoice == backgroundPlayerKey) && isExtAudioEnabled) { + NavigationHelper.playOnExternalAudioPlayer(this, info) + return@Consumer + } else if ((choice.playerChoice == videoPlayerKey) && isExtVideoEnabled) { + NavigationHelper.playOnExternalVideoPlayer(this, info) + return@Consumer + } + playQueue = SinglePlayQueue(info as StreamInfo?) + } else if (info is ChannelInfo) { + val playableTab: Optional = info.getTabs() + .stream() + .filter(Predicate({ obj: ListLinkHandler? -> ChannelTabHelper.isStreamsTab() })) + .findFirst() + if (playableTab.isPresent()) { + playQueue = ChannelTabPlayQueue(info.getServiceId(), playableTab.get()) + } else { + return@Consumer // there is no playable tab + } + } else if (info is PlaylistInfo) { + playQueue = PlaylistPlayQueue(info) + } else { + return@Consumer + } + if ((choice.playerChoice == videoPlayerKey)) { + NavigationHelper.playOnMainPlayer(this, playQueue, false) + } else if ((choice.playerChoice == backgroundPlayerKey)) { + NavigationHelper.playOnBackgroundPlayer(this, playQueue, true) + } else if ((choice.playerChoice == popupPlayerKey)) { + NavigationHelper.playOnPopupPlayer(this, playQueue, true) + } + }) + } + + public override fun onDestroy() { + super.onDestroy() + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + if (fetcher != null) { + fetcher!!.dispose() + } + } + + private fun createNotification(): NotificationCompat.Builder { + return 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)) + } + + companion object { + val KEY_CHOICE: String = "key_choice" + private val ID: Int = 456 + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + ////////////////////////////////////////////////////////////////////////// */ + private fun getUrl(intent: Intent): String? { + var foundUrl: String? = 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 + val extraText: String? = intent.getStringExtra(Intent.EXTRA_TEXT) + foundUrl = firstUrlFromInput(extraText) + } + return foundUrl + } + + companion object { + /** + * @param context the context. It will be `finish()`ed at the end of the handling if it is + * an instance of [RouterActivity]. + * @param errorInfo the error information + */ + private fun handleError(context: Context, errorInfo: ErrorInfo) { + if (errorInfo.throwable != null) { + errorInfo.throwable!!.printStackTrace() + } + if (errorInfo.throwable is ReCaptchaException) { + Toast.makeText(context, R.string.recaptcha_request_toast, Toast.LENGTH_LONG).show() + // Starting ReCaptcha Challenge Activity + val intent: Intent = Intent(context, ReCaptchaActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + } else if ((errorInfo.throwable != null + && errorInfo.throwable!!.isNetworkRelated)) { + Toast.makeText(context, R.string.network_error, Toast.LENGTH_LONG).show() + } else if (errorInfo.throwable is AgeRestrictedContentException) { + Toast.makeText(context, R.string.restricted_video_no_stream, + Toast.LENGTH_LONG).show() + } else if (errorInfo.throwable is GeographicRestrictionException) { + Toast.makeText(context, R.string.georestricted_content, Toast.LENGTH_LONG).show() + } else if (errorInfo.throwable is PaidContentException) { + Toast.makeText(context, R.string.paid_content, Toast.LENGTH_LONG).show() + } else if (errorInfo.throwable is PrivateContentException) { + Toast.makeText(context, R.string.private_content, Toast.LENGTH_LONG).show() + } else if (errorInfo.throwable is SoundCloudGoPlusContentException) { + Toast.makeText(context, R.string.soundcloud_go_plus_content, + Toast.LENGTH_LONG).show() + } else if (errorInfo.throwable is YoutubeMusicPremiumContentException) { + Toast.makeText(context, R.string.youtube_music_premium_content, + Toast.LENGTH_LONG).show() + } else if (errorInfo.throwable is ContentNotAvailableException) { + Toast.makeText(context, R.string.content_not_available, Toast.LENGTH_LONG).show() + } else if (errorInfo.throwable is ContentNotSupportedException) { + Toast.makeText(context, R.string.content_not_supported, Toast.LENGTH_LONG).show() + } else { + createNotification(context, errorInfo) + } + if (context is RouterActivity) { + context.finish() + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java deleted file mode 100644 index 04d93a238..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java +++ /dev/null @@ -1,65 +0,0 @@ -package org.schabi.newpipe.database; - -import static org.schabi.newpipe.database.Migrations.DB_VER_9; - -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( - 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 - }, - version = DB_VER_9 -) -public abstract class AppDatabase extends RoomDatabase { - public static final String DATABASE_NAME = "newpipe.db"; - - public abstract SearchHistoryDAO searchHistoryDAO(); - - public abstract StreamDAO streamDAO(); - - public abstract StreamHistoryDAO streamHistoryDAO(); - - public abstract StreamStateDAO streamStateDAO(); - - public abstract PlaylistDAO playlistDAO(); - - public abstract PlaylistStreamDAO playlistStreamDAO(); - - public abstract PlaylistRemoteDAO playlistRemoteDAO(); - - public abstract FeedDAO feedDAO(); - - public abstract FeedGroupDAO feedGroupDAO(); - - public abstract SubscriptionDAO subscriptionDAO(); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.kt b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.kt new file mode 100644 index 000000000..93d643d86 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.kt @@ -0,0 +1,46 @@ +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(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], version = Migrations.DB_VER_9) +abstract class AppDatabase() : RoomDatabase() { + abstract fun searchHistoryDAO(): SearchHistoryDAO? + abstract fun streamDAO(): StreamDAO + abstract fun streamHistoryDAO(): StreamHistoryDAO? + abstract fun streamStateDAO(): StreamStateDAO? + abstract fun playlistDAO(): PlaylistDAO? + abstract fun playlistStreamDAO(): PlaylistStreamDAO? + abstract fun playlistRemoteDAO(): PlaylistRemoteDAO? + abstract fun feedDAO(): FeedDAO? + abstract fun feedGroupDAO(): FeedGroupDAO? + abstract fun subscriptionDAO(): SubscriptionDAO? + + companion object { + val DATABASE_NAME: String = "newpipe.db" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/BasicDAO.java b/app/src/main/java/org/schabi/newpipe/database/BasicDAO.java deleted file mode 100644 index 255f5ba8d..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/BasicDAO.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.schabi.newpipe.database; - -import androidx.room.Dao; -import androidx.room.Delete; -import androidx.room.Insert; -import androidx.room.Update; - -import java.util.Collection; -import java.util.List; - -import io.reactivex.rxjava3.core.Flowable; - -@Dao -public interface BasicDAO { - /* Inserts */ - @Insert - long insert(Entity entity); - - @Insert - List insertAll(Collection entities); - - /* Searches */ - Flowable> getAll(); - - Flowable> listByService(int serviceId); - - /* Deletes */ - @Delete - void delete(Entity entity); - - int deleteAll(); - - /* Updates */ - @Update - int update(Entity entity); - - @Update - void update(Collection entities); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/BasicDAO.kt b/app/src/main/java/org/schabi/newpipe/database/BasicDAO.kt new file mode 100644 index 000000000..bc368c3a7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/BasicDAO.kt @@ -0,0 +1,33 @@ +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 +open 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?) +} diff --git a/app/src/main/java/org/schabi/newpipe/database/LocalItem.java b/app/src/main/java/org/schabi/newpipe/database/LocalItem.java deleted file mode 100644 index 54b856b06..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/LocalItem.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.schabi.newpipe.database; - -public interface LocalItem { - LocalItemType getLocalItemType(); - - enum LocalItemType { - PLAYLIST_LOCAL_ITEM, - PLAYLIST_REMOTE_ITEM, - - PLAYLIST_STREAM_ITEM, - STATISTIC_STREAM_ITEM, - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt b/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt new file mode 100644 index 000000000..4723eacf2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt @@ -0,0 +1,11 @@ +package org.schabi.newpipe.database + +open interface LocalItem { + fun getLocalItemType(): LocalItemType + enum class LocalItemType { + PLAYLIST_LOCAL_ITEM, + PLAYLIST_REMOTE_ITEM, + PLAYLIST_STREAM_ITEM, + STATISTIC_STREAM_ITEM + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/Migrations.java b/app/src/main/java/org/schabi/newpipe/database/Migrations.kt similarity index 57% rename from app/src/main/java/org/schabi/newpipe/database/Migrations.java rename to app/src/main/java/org/schabi/newpipe/database/Migrations.kt index c9f630869..a0ea3c78a 100644 --- a/app/src/main/java/org/schabi/newpipe/database/Migrations.java +++ b/app/src/main/java/org/schabi/newpipe/database/Migrations.kt @@ -1,15 +1,11 @@ -package org.schabi.newpipe.database; +package org.schabi.newpipe.database -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.room.migration.Migration; -import androidx.sqlite.db.SupportSQLiteDatabase; - -import org.schabi.newpipe.MainActivity; - -public final class Migrations { +import android.util.Log +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +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 // @@ -17,25 +13,21 @@ public final class Migrations { // If you add a migration point it out in the pull request, so that // // others remember to test it themselves. // ///////////////////////////////////////////////////////////////////////////// - - public static final int DB_VER_1 = 1; - public static final int DB_VER_2 = 2; - public static final int DB_VER_3 = 3; - public static final int DB_VER_4 = 4; - public static final int DB_VER_5 = 5; - public static final int DB_VER_6 = 6; - public static final int DB_VER_7 = 7; - public static final int DB_VER_8 = 8; - public static final int DB_VER_9 = 9; - - private static final String TAG = Migrations.class.getName(); - public static final boolean DEBUG = MainActivity.DEBUG; - - public static final Migration MIGRATION_1_2 = new Migration(DB_VER_1, DB_VER_2) { - @Override - public void migrate(@NonNull final SupportSQLiteDatabase database) { + val DB_VER_1: Int = 1 + val DB_VER_2: Int = 2 + val DB_VER_3: Int = 3 + val DB_VER_4: Int = 4 + val DB_VER_5: Int = 5 + val DB_VER_6: Int = 6 + val DB_VER_7: Int = 7 + val DB_VER_8: Int = 8 + val DB_VER_9: Int = 9 + private val TAG: String = Migrations::class.java.getName() + val DEBUG: Boolean = MainActivity.Companion.DEBUG + val MIGRATION_1_2: Migration = object : Migration(DB_VER_1, DB_VER_2) { + public override fun migrate(database: SupportSQLiteDatabase) { if (DEBUG) { - Log.d(TAG, "Start migrating database"); + Log.d(TAG, "Start migrating database") } /* * Unfortunately these queries must be hardcoded due to the possibility of @@ -45,170 +37,152 @@ public final class Migrations { // 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. - database.execSQL("CREATE INDEX `index_search_history_search` " - + "ON `search_history` (`search`)"); - database.execSQL("CREATE TABLE IF NOT EXISTS `streams` " + database.execSQL(("CREATE INDEX `index_search_history_search` " + + "ON `search_history` (`search`)")) + database.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)"); - database.execSQL("CREATE UNIQUE INDEX `index_streams_service_id_url` " - + "ON `streams` (`service_id`, `url`)"); - database.execSQL("CREATE TABLE IF NOT EXISTS `stream_history` " + + "`thumbnail_url` TEXT)")) + database.execSQL(("CREATE UNIQUE INDEX `index_streams_service_id_url` " + + "ON `streams` (`service_id`, `url`)")) + database.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 )"); - database.execSQL("CREATE INDEX `index_stream_history_stream_id` " - + "ON `stream_history` (`stream_id`)"); - database.execSQL("CREATE TABLE IF NOT EXISTS `stream_state` " + + "ON UPDATE CASCADE ON DELETE CASCADE )")) + database.execSQL(("CREATE INDEX `index_stream_history_stream_id` " + + "ON `stream_history` (`stream_id`)")) + database.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 )"); - database.execSQL("CREATE TABLE IF NOT EXISTS `playlists` " + + "REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )")) + database.execSQL(("CREATE TABLE IF NOT EXISTS `playlists` " + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " - + "`name` TEXT, `thumbnail_url` TEXT)"); - database.execSQL("CREATE INDEX `index_playlists_name` ON `playlists` (`name`)"); - database.execSQL("CREATE TABLE IF NOT EXISTS `playlist_stream_join` " + + "`name` TEXT, `thumbnail_url` TEXT)")) + database.execSQL("CREATE INDEX `index_playlists_name` ON `playlists` (`name`)") + database.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)"); - database.execSQL("CREATE UNIQUE INDEX " + + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)")) + database.execSQL(("CREATE UNIQUE INDEX " + "`index_playlist_stream_join_playlist_id_join_index` " - + "ON `playlist_stream_join` (`playlist_id`, `join_index`)"); - database.execSQL("CREATE INDEX `index_playlist_stream_join_stream_id` " - + "ON `playlist_stream_join` (`stream_id`)"); - database.execSQL("CREATE TABLE IF NOT EXISTS `remote_playlists` " + + "ON `playlist_stream_join` (`playlist_id`, `join_index`)")) + database.execSQL(("CREATE INDEX `index_playlist_stream_join_stream_id` " + + "ON `playlist_stream_join` (`stream_id`)")) + database.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)"); - database.execSQL("CREATE INDEX `index_remote_playlists_name` " - + "ON `remote_playlists` (`name`)"); - database.execSQL("CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " - + "ON `remote_playlists` (`service_id`, `url`)"); + + "`thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)")) + database.execSQL(("CREATE INDEX `index_remote_playlists_name` " + + "ON `remote_playlists` (`name`)")) + database.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 - database.execSQL("INSERT OR IGNORE INTO streams (service_id, url, title, " + database.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"); + + "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 - database.execSQL("INSERT INTO stream_history (stream_id, access_date, repeat_count)" + database.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"); - - database.execSQL("DROP TABLE IF EXISTS watch_history"); - + + "ORDER BY creation_date DESC")) + database.execSQL("DROP TABLE IF EXISTS watch_history") if (DEBUG) { - Log.d(TAG, "Stop migrating database"); + Log.d(TAG, "Stop migrating database") } } - }; - - public static final Migration MIGRATION_2_3 = new Migration(DB_VER_2, DB_VER_3) { - @Override - public void migrate(@NonNull final SupportSQLiteDatabase database) { + } + val MIGRATION_2_3: Migration = object : Migration(DB_VER_2, DB_VER_3) { + public override fun migrate(database: SupportSQLiteDatabase) { // Add NOT NULLs and new fields - database.execSQL("CREATE TABLE IF NOT EXISTS streams_new " + database.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)"); - - database.execSQL("INSERT INTO streams_new (uid, service_id, url, title, stream_type, " + + "is_upload_date_approximation INTEGER)")) + database.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"); - - database.execSQL("DROP TABLE streams"); - database.execSQL("ALTER TABLE streams_new RENAME TO streams"); - database.execSQL("CREATE UNIQUE INDEX index_streams_service_id_url " - + "ON streams (service_id, url)"); + + "FROM streams WHERE url IS NOT NULL")) + database.execSQL("DROP TABLE streams") + database.execSQL("ALTER TABLE streams_new RENAME TO streams") + database.execSQL(("CREATE UNIQUE INDEX index_streams_service_id_url " + + "ON streams (service_id, url)")) // Tables for feed feature - database.execSQL("CREATE TABLE IF NOT EXISTS feed " + database.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)"); - database.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)"); - database.execSQL("CREATE TABLE IF NOT EXISTS feed_group " + + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)")) + database.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)") + database.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)"); - database.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)"); - database.execSQL("CREATE TABLE IF NOT EXISTS feed_group_subscription_join " + + "icon_id INTEGER NOT NULL, sort_order INTEGER NOT NULL)")) + database.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)") + database.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)"); - database.execSQL("CREATE INDEX index_feed_group_subscription_join_subscription_id " - + "ON feed_group_subscription_join (subscription_id)"); - database.execSQL("CREATE TABLE IF NOT EXISTS feed_last_updated " + + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)")) + database.execSQL(("CREATE INDEX index_feed_group_subscription_join_subscription_id " + + "ON feed_group_subscription_join (subscription_id)")) + database.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)"); + + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)")) } - }; - - public static final Migration MIGRATION_3_4 = new Migration(DB_VER_3, DB_VER_4) { - @Override - public void migrate(@NonNull final SupportSQLiteDatabase database) { + } + val MIGRATION_3_4: Migration = object : Migration(DB_VER_3, DB_VER_4) { + public override fun migrate(database: SupportSQLiteDatabase) { database.execSQL( "ALTER TABLE streams ADD COLUMN uploader_url TEXT" - ); + ) } - }; - - public static final Migration MIGRATION_4_5 = new Migration(DB_VER_4, DB_VER_5) { - @Override - public void migrate(@NonNull final SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` " - + "INTEGER NOT NULL DEFAULT 0"); + } + val MIGRATION_4_5: Migration = object : Migration(DB_VER_4, DB_VER_5) { + public override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL(("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` " + + "INTEGER NOT NULL DEFAULT 0")) } - }; - - public static final Migration MIGRATION_5_6 = new Migration(DB_VER_5, DB_VER_6) { - @Override - public void migrate(@NonNull final SupportSQLiteDatabase database) { - database.execSQL("ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` " - + "INTEGER NOT NULL DEFAULT 0"); + } + val MIGRATION_5_6: Migration = object : Migration(DB_VER_5, DB_VER_6) { + public override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL(("ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` " + + "INTEGER NOT NULL DEFAULT 0")) } - }; - - public static final Migration MIGRATION_6_7 = new Migration(DB_VER_6, DB_VER_7) { - @Override - public void migrate(@NonNull final SupportSQLiteDatabase database) { + } + val MIGRATION_6_7: Migration = object : Migration(DB_VER_6, DB_VER_7) { + public override fun migrate(database: SupportSQLiteDatabase) { // Create a new column thumbnail_stream_id - database.execSQL("ALTER TABLE `playlists` ADD COLUMN `thumbnail_stream_id` " - + "INTEGER NOT NULL DEFAULT -1"); + database.execSQL(("ALTER TABLE `playlists` ADD COLUMN `thumbnail_stream_id` " + + "INTEGER NOT NULL DEFAULT -1")) // Migrate the thumbnail_url to the thumbnail_stream_id - database.execSQL("UPDATE playlists SET thumbnail_stream_id = (" + database.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" @@ -216,92 +190,81 @@ public final class Migrations { + " 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)"); + + " WHERE playlist_uid = playlists.uid)")) // Remove the thumbnail_url field in the playlist table - database.execSQL("CREATE TABLE IF NOT EXISTS `playlists_new`" + database.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)"); - - database.execSQL("INSERT INTO playlists_new" + + "thumbnail_stream_id INTEGER NOT NULL)")) + database.execSQL(("INSERT INTO playlists_new" + " SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id " - + " FROM playlists"); - - - database.execSQL("DROP TABLE playlists"); - database.execSQL("ALTER TABLE playlists_new RENAME TO playlists"); - database.execSQL("CREATE INDEX IF NOT EXISTS " - + "`index_playlists_name` ON `playlists` (`name`)"); + + " FROM playlists")) + database.execSQL("DROP TABLE playlists") + database.execSQL("ALTER TABLE playlists_new RENAME TO playlists") + database.execSQL(("CREATE INDEX IF NOT EXISTS " + + "`index_playlists_name` ON `playlists` (`name`)")) } - }; - - public static final Migration MIGRATION_7_8 = new Migration(DB_VER_7, DB_VER_8) { - @Override - public void migrate(@NonNull final SupportSQLiteDatabase database) { - database.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)"); - database.execSQL("UPDATE search_history SET search = trim(search)"); + } + val MIGRATION_7_8: Migration = object : Migration(DB_VER_7, DB_VER_8) { + public override fun migrate(database: SupportSQLiteDatabase) { + database.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)")) + database.execSQL("UPDATE search_history SET search = trim(search)") } - }; - - public static final Migration MIGRATION_8_9 = new Migration(DB_VER_8, DB_VER_9) { - @Override - public void migrate(@NonNull final SupportSQLiteDatabase database) { + } + val MIGRATION_8_9: Migration = object : Migration(DB_VER_8, DB_VER_9) { + public override fun migrate(database: SupportSQLiteDatabase) { try { - database.beginTransaction(); + database.beginTransaction() // Update playlists. // Create a temp table to initialize display_index. - database.execSQL("CREATE TABLE `playlists_tmp` " + database.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)"); - database.execSQL("INSERT INTO `playlists_tmp` " + + "`display_index` INTEGER NOT NULL)")) + database.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`"); + + "FROM `playlists`")) // Replace the old table, note that this also removes the index on the name which // we don't need anymore. - database.execSQL("DROP TABLE `playlists`"); - database.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`"); + database.execSQL("DROP TABLE `playlists`") + database.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`") // Update remote_playlists. // Create a temp table to initialize display_index. - database.execSQL("CREATE TABLE `remote_playlists_tmp` " + database.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)"); - database.execSQL("INSERT INTO `remote_playlists_tmp` (`uid`, `service_id`, " + + "`stream_count` INTEGER)")) + database.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`"); + + "-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. - database.execSQL("DROP TABLE `remote_playlists`"); - database.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`"); + database.execSQL("DROP TABLE `remote_playlists`") + database.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`") // Create index on the new table. - database.execSQL("CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " - + "ON `remote_playlists` (`service_id`, `url`)"); - - database.setTransactionSuccessful(); + database.execSQL(("CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " + + "ON `remote_playlists` (`service_id`, `url`)")) + database.setTransactionSuccessful() } finally { - database.endTransaction(); + database.endTransaction() } } - }; - - private Migrations() { } } diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/HistoryDAO.java b/app/src/main/java/org/schabi/newpipe/database/history/dao/HistoryDAO.java deleted file mode 100644 index 1ade08122..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/history/dao/HistoryDAO.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.schabi.newpipe.database.history.dao; - -import org.schabi.newpipe.database.BasicDAO; - -public interface HistoryDAO extends BasicDAO { - T getLatestEntry(); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/HistoryDAO.kt b/app/src/main/java/org/schabi/newpipe/database/history/dao/HistoryDAO.kt new file mode 100644 index 000000000..a33550f94 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/history/dao/HistoryDAO.kt @@ -0,0 +1,7 @@ +package org.schabi.newpipe.database.history.dao + +import org.schabi.newpipe.database.BasicDAO + +open interface HistoryDAO : BasicDAO { + fun getLatestEntry(): T +} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.java b/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.java deleted file mode 100644 index 8a281bdb4..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.java +++ /dev/null @@ -1,52 +0,0 @@ -package org.schabi.newpipe.database.history.dao; - -import androidx.annotation.Nullable; -import androidx.room.Dao; -import androidx.room.Query; - -import org.schabi.newpipe.database.history.model.SearchHistoryEntry; - -import java.util.List; - -import io.reactivex.rxjava3.core.Flowable; - -import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.CREATION_DATE; -import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.ID; -import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.SEARCH; -import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.SERVICE_ID; -import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.TABLE_NAME; - -@Dao -public interface SearchHistoryDAO extends HistoryDAO { - String ORDER_BY_CREATION_DATE = " ORDER BY " + CREATION_DATE + " DESC"; - String ORDER_BY_MAX_CREATION_DATE = " ORDER BY MAX(" + CREATION_DATE + ") DESC"; - - @Query("SELECT * FROM " + TABLE_NAME - + " WHERE " + ID + " = (SELECT MAX(" + ID + ") FROM " + TABLE_NAME + ")") - @Nullable - SearchHistoryEntry getLatestEntry(); - - @Query("DELETE FROM " + TABLE_NAME) - @Override - int deleteAll(); - - @Query("DELETE FROM " + TABLE_NAME + " WHERE " + SEARCH + " = :query") - int deleteAllWhereQuery(String query); - - @Query("SELECT * FROM " + TABLE_NAME + ORDER_BY_CREATION_DATE) - @Override - Flowable> getAll(); - - @Query("SELECT " + SEARCH + " FROM " + TABLE_NAME + " GROUP BY " + SEARCH - + ORDER_BY_MAX_CREATION_DATE + " LIMIT :limit") - Flowable> getUniqueEntries(int limit); - - @Query("SELECT * FROM " + TABLE_NAME - + " WHERE " + SERVICE_ID + " = :serviceId" + ORDER_BY_CREATION_DATE) - @Override - Flowable> listByService(int serviceId); - - @Query("SELECT " + SEARCH + " FROM " + TABLE_NAME + " WHERE " + SEARCH + " LIKE :query || '%'" - + " GROUP BY " + SEARCH + ORDER_BY_MAX_CREATION_DATE + " LIMIT :limit") - Flowable> getSimilarEntries(String query, int limit); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.kt b/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.kt new file mode 100644 index 000000000..b3550d191 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.kt @@ -0,0 +1,37 @@ +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.history.model.SearchHistoryEntry + +@Dao +open interface SearchHistoryDAO : HistoryDAO { + @Query(("SELECT * FROM " + SearchHistoryEntry.TABLE_NAME + + " WHERE " + SearchHistoryEntry.ID + " = (SELECT MAX(" + SearchHistoryEntry.ID + ") FROM " + SearchHistoryEntry.TABLE_NAME + ")")) + public override fun getLatestEntry(): SearchHistoryEntry? + @Query("DELETE FROM " + SearchHistoryEntry.TABLE_NAME) + public override fun deleteAll(): Int + + @Query("DELETE FROM " + SearchHistoryEntry.TABLE_NAME + " WHERE " + SearchHistoryEntry.SEARCH + " = :query") + fun deleteAllWhereQuery(query: String?): Int + @Query("SELECT * FROM " + SearchHistoryEntry.TABLE_NAME + ORDER_BY_CREATION_DATE) + public override fun getAll(): Flowable>? + + @Query(("SELECT " + SearchHistoryEntry.SEARCH + " FROM " + SearchHistoryEntry.TABLE_NAME + " GROUP BY " + SearchHistoryEntry.SEARCH + + ORDER_BY_MAX_CREATION_DATE + " LIMIT :limit")) + fun getUniqueEntries(limit: Int): Flowable?>? + + @Query(("SELECT * FROM " + SearchHistoryEntry.TABLE_NAME + + " WHERE " + SearchHistoryEntry.SERVICE_ID + " = :serviceId" + ORDER_BY_CREATION_DATE)) + public override fun listByService(serviceId: Int): Flowable>? + + @Query(("SELECT " + SearchHistoryEntry.SEARCH + " FROM " + SearchHistoryEntry.TABLE_NAME + " WHERE " + SearchHistoryEntry.SEARCH + " LIKE :query || '%'" + + " GROUP BY " + SearchHistoryEntry.SEARCH + ORDER_BY_MAX_CREATION_DATE + " LIMIT :limit")) + fun getSimilarEntries(query: String?, limit: Int): Flowable?>? + + companion object { + val ORDER_BY_CREATION_DATE: String = " ORDER BY " + SearchHistoryEntry.CREATION_DATE + " DESC" + val ORDER_BY_MAX_CREATION_DATE: String = " ORDER BY MAX(" + SearchHistoryEntry.CREATION_DATE + ") DESC" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java deleted file mode 100644 index 150d4a8e5..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java +++ /dev/null @@ -1,89 +0,0 @@ -package org.schabi.newpipe.database.history.dao; - -import androidx.annotation.Nullable; -import androidx.room.Dao; -import androidx.room.Query; -import androidx.room.RewriteQueriesToDropUnusedColumns; - -import org.schabi.newpipe.database.history.model.StreamHistoryEntity; -import org.schabi.newpipe.database.history.model.StreamHistoryEntry; -import org.schabi.newpipe.database.stream.StreamStatisticsEntry; - -import java.util.List; - -import io.reactivex.rxjava3.core.Flowable; - -import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.JOIN_STREAM_ID; -import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_ACCESS_DATE; -import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_HISTORY_TABLE; -import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_REPEAT_COUNT; -import static org.schabi.newpipe.database.stream.StreamStatisticsEntry.STREAM_LATEST_DATE; -import static org.schabi.newpipe.database.stream.StreamStatisticsEntry.STREAM_WATCH_COUNT; -import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID; -import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; - -@Dao -public abstract class StreamHistoryDAO implements HistoryDAO { - @Query("SELECT * FROM " + STREAM_HISTORY_TABLE - + " WHERE " + STREAM_ACCESS_DATE + " = " - + "(SELECT MAX(" + STREAM_ACCESS_DATE + ") FROM " + STREAM_HISTORY_TABLE + ")") - @Override - @Nullable - public abstract StreamHistoryEntity getLatestEntry(); - - @Override - @Query("SELECT * FROM " + STREAM_HISTORY_TABLE) - public abstract Flowable> getAll(); - - @Override - @Query("DELETE FROM " + STREAM_HISTORY_TABLE) - public abstract int deleteAll(); - - @Override - public Flowable> listByService(final int serviceId) { - throw new UnsupportedOperationException(); - } - - @Query("SELECT * FROM " + STREAM_TABLE - + " INNER JOIN " + STREAM_HISTORY_TABLE - + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID - + " ORDER BY " + STREAM_ACCESS_DATE + " DESC") - public abstract Flowable> getHistory(); - - - @Query("SELECT * FROM " + STREAM_TABLE - + " INNER JOIN " + STREAM_HISTORY_TABLE - + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID - + " ORDER BY " + STREAM_ID + " ASC") - public abstract Flowable> getHistorySortedById(); - - @Query("SELECT * FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID - + " = :streamId ORDER BY " + STREAM_ACCESS_DATE + " DESC LIMIT 1") - @Nullable - public abstract StreamHistoryEntity getLatestEntry(long streamId); - - @Query("DELETE FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId") - public abstract int deleteStreamHistory(long streamId); - - @RewriteQueriesToDropUnusedColumns - @Query("SELECT * FROM " + STREAM_TABLE - - // Select the latest entry and watch count for each stream id on history table - + " INNER JOIN " - + "(SELECT " + JOIN_STREAM_ID + ", " - + " MAX(" + STREAM_ACCESS_DATE + ") AS " + STREAM_LATEST_DATE + ", " - + " SUM(" + STREAM_REPEAT_COUNT + ") AS " + STREAM_WATCH_COUNT - + " FROM " + STREAM_HISTORY_TABLE + " GROUP BY " + JOIN_STREAM_ID + ")" - - + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID - - + " LEFT JOIN " - + "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", " - + STREAM_PROGRESS_MILLIS - + " FROM " + STREAM_STATE_TABLE + " )" - + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS) - public abstract Flowable> getStatistics(); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt new file mode 100644 index 000000000..0068aacc8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt @@ -0,0 +1,59 @@ +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.history.model.StreamHistoryEntity +import org.schabi.newpipe.database.history.model.StreamHistoryEntry +import org.schabi.newpipe.database.stream.StreamStatisticsEntry +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.stream.model.StreamStateEntity + +@Dao +abstract class StreamHistoryDAO() : HistoryDAO { + @Query(("SELECT * FROM " + StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE + + " WHERE " + StreamHistoryEntity.Companion.STREAM_ACCESS_DATE + " = " + + "(SELECT MAX(" + StreamHistoryEntity.Companion.STREAM_ACCESS_DATE + ") FROM " + StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE + ")")) + abstract override fun getLatestEntry(): StreamHistoryEntity? + @Query("SELECT * FROM " + StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE) + abstract override fun getAll(): Flowable?>? + @Query("DELETE FROM " + StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE) + abstract override fun deleteAll(): Int + public override fun listByService(serviceId: Int): Flowable>? { + throw UnsupportedOperationException() + } + + @Query(("SELECT * FROM " + StreamEntity.STREAM_TABLE + + " INNER JOIN " + StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE + + " ON " + StreamEntity.STREAM_ID + " = " + StreamHistoryEntity.Companion.JOIN_STREAM_ID + + " ORDER BY " + StreamHistoryEntity.Companion.STREAM_ACCESS_DATE + " DESC")) + abstract fun getHistory(): Flowable?>? + + @Query(("SELECT * FROM " + StreamEntity.STREAM_TABLE + + " INNER JOIN " + StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE + + " ON " + StreamEntity.STREAM_ID + " = " + StreamHistoryEntity.Companion.JOIN_STREAM_ID + + " ORDER BY " + StreamEntity.STREAM_ID + " ASC")) + abstract fun getHistorySortedById(): Flowable?> + + @Query(("SELECT * FROM " + StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE + " WHERE " + StreamHistoryEntity.Companion.JOIN_STREAM_ID + + " = :streamId ORDER BY " + StreamHistoryEntity.Companion.STREAM_ACCESS_DATE + " DESC LIMIT 1")) + abstract fun getLatestEntry(streamId: Long): StreamHistoryEntity? + @Query("DELETE FROM " + StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE + " WHERE " + StreamHistoryEntity.Companion.JOIN_STREAM_ID + " = :streamId") + abstract fun deleteStreamHistory(streamId: Long): Int + + @RewriteQueriesToDropUnusedColumns + @Query(("SELECT * FROM " + StreamEntity.STREAM_TABLE // Select the latest entry and watch count for each stream id on history table + + " INNER JOIN " + + "(SELECT " + StreamHistoryEntity.Companion.JOIN_STREAM_ID + ", " + + " MAX(" + StreamHistoryEntity.Companion.STREAM_ACCESS_DATE + ") AS " + StreamStatisticsEntry.STREAM_LATEST_DATE + ", " + + " SUM(" + StreamHistoryEntity.Companion.STREAM_REPEAT_COUNT + ") AS " + StreamStatisticsEntry.STREAM_WATCH_COUNT + + " FROM " + StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE + " GROUP BY " + StreamHistoryEntity.Companion.JOIN_STREAM_ID + ")" + + " ON " + StreamEntity.STREAM_ID + " = " + StreamHistoryEntity.Companion.JOIN_STREAM_ID + + " LEFT JOIN " + + "(SELECT " + StreamHistoryEntity.Companion.JOIN_STREAM_ID + " AS " + StreamStateEntity.Companion.JOIN_STREAM_ID_ALIAS + ", " + + StreamStateEntity.Companion.STREAM_PROGRESS_MILLIS + + " FROM " + StreamStateEntity.Companion.STREAM_STATE_TABLE + " )" + + " ON " + StreamEntity.STREAM_ID + " = " + StreamStateEntity.Companion.JOIN_STREAM_ID_ALIAS)) + abstract fun getStatistics(): Flowable?> +} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.java b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.java deleted file mode 100644 index a9d69afe8..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.java +++ /dev/null @@ -1,81 +0,0 @@ -package org.schabi.newpipe.database.history.model; - -import androidx.annotation.NonNull; -import androidx.room.ColumnInfo; -import androidx.room.Entity; -import androidx.room.ForeignKey; -import androidx.room.Index; - -import org.schabi.newpipe.database.stream.model.StreamEntity; - -import java.time.OffsetDateTime; - -import static androidx.room.ForeignKey.CASCADE; -import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.JOIN_STREAM_ID; -import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_ACCESS_DATE; -import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_HISTORY_TABLE; - -@Entity(tableName = STREAM_HISTORY_TABLE, - primaryKeys = {JOIN_STREAM_ID, STREAM_ACCESS_DATE}, - // No need to index for timestamp as they will almost always be unique - indices = {@Index(value = {JOIN_STREAM_ID})}, - foreignKeys = { - @ForeignKey(entity = StreamEntity.class, - parentColumns = StreamEntity.STREAM_ID, - childColumns = JOIN_STREAM_ID, - onDelete = CASCADE, onUpdate = CASCADE) - }) -public class StreamHistoryEntity { - public static final String STREAM_HISTORY_TABLE = "stream_history"; - public static final String JOIN_STREAM_ID = "stream_id"; - public static final String STREAM_ACCESS_DATE = "access_date"; - public static final String STREAM_REPEAT_COUNT = "repeat_count"; - - @ColumnInfo(name = JOIN_STREAM_ID) - private long streamUid; - - @NonNull - @ColumnInfo(name = STREAM_ACCESS_DATE) - private OffsetDateTime accessDate; - - @ColumnInfo(name = STREAM_REPEAT_COUNT) - private long repeatCount; - - /** - * @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 - */ - public StreamHistoryEntity(final long streamUid, - @NonNull final OffsetDateTime accessDate, - final long repeatCount) { - this.streamUid = streamUid; - this.accessDate = accessDate; - this.repeatCount = repeatCount; - } - - public long getStreamUid() { - return streamUid; - } - - public void setStreamUid(final long streamUid) { - this.streamUid = streamUid; - } - - @NonNull - public OffsetDateTime getAccessDate() { - return accessDate; - } - - public void setAccessDate(@NonNull final OffsetDateTime accessDate) { - this.accessDate = accessDate; - } - - public long getRepeatCount() { - return repeatCount; - } - - public void setRepeatCount(final long repeatCount) { - this.repeatCount = repeatCount; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.kt b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.kt new file mode 100644 index 000000000..5c041e219 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.kt @@ -0,0 +1,50 @@ +package org.schabi.newpipe.database.history.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import org.schabi.newpipe.database.history.model.StreamHistoryEntity +import org.schabi.newpipe.database.stream.model.StreamEntity +import java.time.OffsetDateTime + +@Entity(tableName = StreamHistoryEntity.STREAM_HISTORY_TABLE, primaryKeys = [StreamHistoryEntity.JOIN_STREAM_ID, StreamHistoryEntity.STREAM_ACCESS_DATE], indices = [Index(value = [StreamHistoryEntity.JOIN_STREAM_ID])], foreignKeys = [ForeignKey(entity = StreamEntity::class, parentColumns = StreamEntity.STREAM_ID, childColumns = StreamHistoryEntity.JOIN_STREAM_ID, onDelete = CASCADE, onUpdate = CASCADE)]) +class StreamHistoryEntity +/** + * @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 + */(@field:ColumnInfo(name = JOIN_STREAM_ID) private var streamUid: Long, + @field:ColumnInfo(name = STREAM_ACCESS_DATE) private var accessDate: OffsetDateTime, + @field:ColumnInfo(name = STREAM_REPEAT_COUNT) private var repeatCount: Long) { + fun getStreamUid(): Long { + return streamUid + } + + fun setStreamUid(streamUid: Long) { + this.streamUid = streamUid + } + + fun getAccessDate(): OffsetDateTime { + return accessDate + } + + fun setAccessDate(accessDate: OffsetDateTime) { + this.accessDate = accessDate + } + + fun getRepeatCount(): Long { + return repeatCount + } + + fun setRepeatCount(repeatCount: Long) { + this.repeatCount = repeatCount + } + + companion object { + val STREAM_HISTORY_TABLE: String = "stream_history" + val JOIN_STREAM_ID: String = "stream_id" + val STREAM_ACCESS_DATE: String = "access_date" + val STREAM_REPEAT_COUNT: String = "repeat_count" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.java deleted file mode 100644 index 3be85e6e1..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.schabi.newpipe.database.playlist; - -import androidx.room.ColumnInfo; - -/** - * This class adds a field to {@link 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(String) - */ -public class PlaylistDuplicatesEntry extends PlaylistMetadataEntry { - public static final String PLAYLIST_TIMES_STREAM_IS_CONTAINED = "timesStreamIsContained"; - @ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED) - public final long timesStreamIsContained; - - @SuppressWarnings("checkstyle:ParameterNumber") - public PlaylistDuplicatesEntry(final long uid, - final String name, - final String thumbnailUrl, - final boolean isThumbnailPermanent, - final long thumbnailStreamId, - final long displayIndex, - final long streamCount, - final long timesStreamIsContained) { - super(uid, name, thumbnailUrl, isThumbnailPermanent, thumbnailStreamId, displayIndex, - streamCount); - this.timesStreamIsContained = timesStreamIsContained; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.kt new file mode 100644 index 000000000..a434d1999 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.kt @@ -0,0 +1,23 @@ +package org.schabi.newpipe.database.playlist + +import androidx.room.ColumnInfo + +/** + * 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 + */ +class PlaylistDuplicatesEntry(uid: Long, + name: String, + thumbnailUrl: String, + isThumbnailPermanent: Boolean, + thumbnailStreamId: Long, + displayIndex: Long, + streamCount: Long, + @field:ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED) val timesStreamIsContained: Long) : PlaylistMetadataEntry(uid, name, thumbnailUrl, isThumbnailPermanent, thumbnailStreamId, displayIndex, + streamCount) { + companion object { + val PLAYLIST_TIMES_STREAM_IS_CONTAINED: String = "timesStreamIsContained" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java deleted file mode 100644 index 072c49e2c..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.schabi.newpipe.database.playlist; - -import org.schabi.newpipe.database.LocalItem; - -public interface PlaylistLocalItem extends LocalItem { - String getOrderingName(); - - long getDisplayIndex(); - - long getUid(); - - void setDisplayIndex(long displayIndex); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt new file mode 100644 index 000000000..d335c4489 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt @@ -0,0 +1,10 @@ +package org.schabi.newpipe.database.playlist + +import org.schabi.newpipe.database.LocalItem + +open interface PlaylistLocalItem : LocalItem { + fun getOrderingName(): String + fun getDisplayIndex(): Long + fun getUid(): Long + fun setDisplayIndex(displayIndex: Long) +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java deleted file mode 100644 index 03a1e1e30..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java +++ /dev/null @@ -1,74 +0,0 @@ -package org.schabi.newpipe.database.playlist; - -import androidx.room.ColumnInfo; - -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_DISPLAY_INDEX; -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME; -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT; -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL; - -public class PlaylistMetadataEntry implements PlaylistLocalItem { - public static final String PLAYLIST_STREAM_COUNT = "streamCount"; - - @ColumnInfo(name = PLAYLIST_ID) - private final long uid; - @ColumnInfo(name = PLAYLIST_NAME) - public final String name; - @ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT) - private final boolean isThumbnailPermanent; - @ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID) - private final long thumbnailStreamId; - @ColumnInfo(name = PLAYLIST_THUMBNAIL_URL) - public final String thumbnailUrl; - @ColumnInfo(name = PLAYLIST_DISPLAY_INDEX) - private long displayIndex; - @ColumnInfo(name = PLAYLIST_STREAM_COUNT) - public final long streamCount; - - public PlaylistMetadataEntry(final long uid, final String name, final String thumbnailUrl, - final boolean isThumbnailPermanent, final long thumbnailStreamId, - final long displayIndex, final long streamCount) { - this.uid = uid; - this.name = name; - this.thumbnailUrl = thumbnailUrl; - this.isThumbnailPermanent = isThumbnailPermanent; - this.thumbnailStreamId = thumbnailStreamId; - this.displayIndex = displayIndex; - this.streamCount = streamCount; - } - - @Override - public LocalItemType getLocalItemType() { - return LocalItemType.PLAYLIST_LOCAL_ITEM; - } - - @Override - public String getOrderingName() { - return name; - } - - public boolean isThumbnailPermanent() { - return isThumbnailPermanent; - } - - public long getThumbnailStreamId() { - return thumbnailStreamId; - } - - @Override - public long getDisplayIndex() { - return displayIndex; - } - - @Override - public long getUid() { - return uid; - } - - @Override - public void setDisplayIndex(final long displayIndex) { - this.displayIndex = displayIndex; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.kt new file mode 100644 index 000000000..a7b24124d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.kt @@ -0,0 +1,41 @@ +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(@field:ColumnInfo(name = PlaylistEntity.Companion.PLAYLIST_ID) private val uid: Long, @JvmField @field:ColumnInfo(name = PlaylistEntity.Companion.PLAYLIST_NAME) val name: String, @field:ColumnInfo(name = PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_URL) val thumbnailUrl: String, + @field:ColumnInfo(name = PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_PERMANENT) private val isThumbnailPermanent: Boolean, @field:ColumnInfo(name = PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_STREAM_ID) private val thumbnailStreamId: Long, + @field:ColumnInfo(name = PlaylistEntity.Companion.PLAYLIST_DISPLAY_INDEX) private var displayIndex: Long, @field:ColumnInfo(name = PLAYLIST_STREAM_COUNT) val streamCount: Long) : PlaylistLocalItem { + public override fun getLocalItemType(): LocalItemType { + return LocalItemType.PLAYLIST_LOCAL_ITEM + } + + public override fun getOrderingName(): String { + return name + } + + fun isThumbnailPermanent(): Boolean { + return isThumbnailPermanent + } + + fun getThumbnailStreamId(): Long { + return thumbnailStreamId + } + + public override fun getDisplayIndex(): Long { + return displayIndex + } + + public override fun getUid(): Long { + return uid + } + + public override fun setDisplayIndex(displayIndex: Long) { + this.displayIndex = displayIndex + } + + companion object { + val PLAYLIST_STREAM_COUNT: String = "streamCount" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java deleted file mode 100644 index d8071e0af..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java +++ /dev/null @@ -1,53 +0,0 @@ -package org.schabi.newpipe.database.playlist.dao; - -import androidx.room.Dao; -import androidx.room.Query; -import androidx.room.Transaction; - -import org.schabi.newpipe.database.BasicDAO; -import org.schabi.newpipe.database.playlist.model.PlaylistEntity; - -import java.util.List; - -import io.reactivex.rxjava3.core.Flowable; - -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE; - -@Dao -public interface PlaylistDAO extends BasicDAO { - @Override - @Query("SELECT * FROM " + PLAYLIST_TABLE) - Flowable> getAll(); - - @Override - @Query("DELETE FROM " + PLAYLIST_TABLE) - int deleteAll(); - - @Override - default Flowable> listByService(final int serviceId) { - throw new UnsupportedOperationException(); - } - - @Query("SELECT * FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId") - Flowable> getPlaylist(long playlistId); - - @Query("DELETE FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId") - int deletePlaylist(long playlistId); - - @Query("SELECT COUNT(*) FROM " + PLAYLIST_TABLE) - Flowable getCount(); - - @Transaction - default long upsertPlaylist(final PlaylistEntity playlist) { - final long playlistId = playlist.getUid(); - - if (playlistId == -1) { - // This situation is probably impossible. - return insert(playlist); - } else { - update(playlist); - return playlistId; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt new file mode 100644 index 000000000..0bf9d86f2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt @@ -0,0 +1,40 @@ +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 +open interface PlaylistDAO : BasicDAO { + @Query("SELECT * FROM " + PlaylistEntity.Companion.PLAYLIST_TABLE) + public override fun getAll(): Flowable?>? + @Query("DELETE FROM " + PlaylistEntity.Companion.PLAYLIST_TABLE) + public override fun deleteAll(): Int + public override fun listByService(serviceId: Int): Flowable?>? { + throw UnsupportedOperationException() + } + + @Query("SELECT * FROM " + PlaylistEntity.Companion.PLAYLIST_TABLE + " WHERE " + PlaylistEntity.Companion.PLAYLIST_ID + " = :playlistId") + fun getPlaylist(playlistId: Long): Flowable> + + @Query("DELETE FROM " + PlaylistEntity.Companion.PLAYLIST_TABLE + " WHERE " + PlaylistEntity.Companion.PLAYLIST_ID + " = :playlistId") + fun deletePlaylist(playlistId: Long): Int + + @Query("SELECT COUNT(*) FROM " + PlaylistEntity.Companion.PLAYLIST_TABLE) + fun getCount(): Flowable + + @Transaction + fun upsertPlaylist(playlist: PlaylistEntity): Long { + val playlistId: Long = playlist.getUid() + if (playlistId == -1L) { + // This situation is probably impossible. + return insert(playlist) + } else { + update(playlist) + return playlistId + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java deleted file mode 100644 index 8ab8a2afd..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java +++ /dev/null @@ -1,68 +0,0 @@ -package org.schabi.newpipe.database.playlist.dao; - -import androidx.room.Dao; -import androidx.room.Query; -import androidx.room.Transaction; - -import org.schabi.newpipe.database.BasicDAO; -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; - -import java.util.List; - -import io.reactivex.rxjava3.core.Flowable; - -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_DISPLAY_INDEX; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL; - -@Dao -public interface PlaylistRemoteDAO extends BasicDAO { - @Override - @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE) - Flowable> getAll(); - - @Override - @Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE) - int deleteAll(); - - @Override - @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE - + " WHERE " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") - Flowable> listByService(int serviceId); - - @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE " - + REMOTE_PLAYLIST_ID + " = :playlistId") - Flowable> getPlaylist(long playlistId); - - @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE " - + REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") - Flowable> getPlaylist(long serviceId, String url); - - @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE - + " ORDER BY " + REMOTE_PLAYLIST_DISPLAY_INDEX) - Flowable> getPlaylists(); - - @Query("SELECT " + REMOTE_PLAYLIST_ID + " FROM " + REMOTE_PLAYLIST_TABLE - + " WHERE " + REMOTE_PLAYLIST_URL + " = :url " - + "AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") - Long getPlaylistIdInternal(long serviceId, String url); - - @Transaction - default long upsert(final PlaylistRemoteEntity playlist) { - final Long playlistId = getPlaylistIdInternal(playlist.getServiceId(), playlist.getUrl()); - - if (playlistId == null) { - return insert(playlist); - } else { - playlist.setUid(playlistId); - update(playlist); - return playlistId; - } - } - - @Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE - + " WHERE " + REMOTE_PLAYLIST_ID + " = :playlistId") - int deletePlaylist(long playlistId); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt new file mode 100644 index 000000000..b53060e17 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt @@ -0,0 +1,53 @@ +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 +open interface PlaylistRemoteDAO : BasicDAO { + @Query("SELECT * FROM " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE) + public override fun getAll(): Flowable?>? + @Query("DELETE FROM " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE) + public override fun deleteAll(): Int + + @Query(("SELECT * FROM " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE + + " WHERE " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")) + public override fun listByService(serviceId: Int): Flowable?>? + + @Query(("SELECT * FROM " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE + " WHERE " + + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_ID + " = :playlistId")) + fun getPlaylist(playlistId: Long): Flowable?>? + + @Query(("SELECT * FROM " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE + " WHERE " + + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_URL + " = :url AND " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")) + fun getPlaylist(serviceId: Long, url: String?): Flowable?> + + @Query(("SELECT * FROM " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE + + " ORDER BY " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_DISPLAY_INDEX)) + fun getPlaylists(): Flowable?> + + @Query(("SELECT " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_ID + " FROM " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE + + " WHERE " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_URL + " = :url " + + "AND " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")) + fun getPlaylistIdInternal(serviceId: Long, url: String?): Long + + @Transaction + fun upsert(playlist: PlaylistRemoteEntity): Long { + val playlistId: Long = getPlaylistIdInternal(playlist.getServiceId().toLong(), playlist.getUrl()) + if (playlistId == null) { + return insert(playlist) + } else { + playlist.setUid(playlistId) + update(playlist) + return playlistId + } + } + + @Query(("DELETE FROM " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE + + " WHERE " + PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_ID + " = :playlistId")) + fun deletePlaylist(playlistId: Long): Int +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java deleted file mode 100644 index 85b891770..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java +++ /dev/null @@ -1,159 +0,0 @@ -package org.schabi.newpipe.database.playlist.dao; - -import androidx.room.Dao; -import androidx.room.Query; -import androidx.room.RewriteQueriesToDropUnusedColumns; -import androidx.room.Transaction; - -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; -import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity; - -import java.util.List; - -import io.reactivex.rxjava3.core.Flowable; - -import static org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry.PLAYLIST_TIMES_STREAM_IS_CONTAINED; -import static org.schabi.newpipe.database.playlist.PlaylistMetadataEntry.PLAYLIST_STREAM_COUNT; -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_DISPLAY_INDEX; -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.DEFAULT_THUMBNAIL; -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME; -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE; -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT; -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL; -import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_INDEX; -import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_PLAYLIST_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_STREAM_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE; -import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID; -import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE; -import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_THUMBNAIL_URL; -import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_URL; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; - -@Dao -public interface PlaylistStreamDAO extends BasicDAO { - @Override - @Query("SELECT * FROM " + PLAYLIST_STREAM_JOIN_TABLE) - Flowable> getAll(); - - @Override - @Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE) - int deleteAll(); - - @Override - default Flowable> listByService(final int serviceId) { - throw new UnsupportedOperationException(); - } - - @Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE - + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId") - void deleteBatch(long playlistId); - - @Query("SELECT COALESCE(MAX(" + JOIN_INDEX + "), -1)" - + " FROM " + PLAYLIST_STREAM_JOIN_TABLE - + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId") - Flowable getMaximumIndexOf(long playlistId); - - @Query("SELECT CASE WHEN COUNT(*) != 0 then " + STREAM_ID - + " ELSE " + PlaylistEntity.DEFAULT_THUMBNAIL_ID + " END" - + " FROM " + STREAM_TABLE - + " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE - + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID - + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId " - + " LIMIT 1" - ) - Flowable getAutomaticThumbnailStreamId(long playlistId); - - @RewriteQueriesToDropUnusedColumns - @Transaction - @Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN " - // get ids of streams of the given playlist - + "(SELECT " + JOIN_STREAM_ID + "," + JOIN_INDEX - + " FROM " + PLAYLIST_STREAM_JOIN_TABLE - + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId)" - - // then merge with the stream metadata - + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID - - + " LEFT JOIN " - + "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", " - + STREAM_PROGRESS_MILLIS - + " FROM " + STREAM_STATE_TABLE + " )" - + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS - - + " ORDER BY " + JOIN_INDEX + " ASC") - Flowable> getOrderedStreamsOf(long playlistId); - - @Transaction - @Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", " - + PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", " - + PLAYLIST_DISPLAY_INDEX + ", " - - + " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = " - + PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'" - + " ELSE (SELECT " + STREAM_THUMBNAIL_URL - + " FROM " + STREAM_TABLE - + " WHERE " + STREAM_TABLE + "." + STREAM_ID + " = " + PLAYLIST_THUMBNAIL_STREAM_ID - + " ) END AS " + PLAYLIST_THUMBNAIL_URL + ", " - - + "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT - + " FROM " + PLAYLIST_TABLE - + " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE - + " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID - + " GROUP BY " + PLAYLIST_ID - + " ORDER BY " + PLAYLIST_DISPLAY_INDEX) - Flowable> getPlaylistMetadata(); - - @RewriteQueriesToDropUnusedColumns - @Transaction - @Query("SELECT *, MIN(" + JOIN_INDEX + ")" - + " FROM " + STREAM_TABLE + " INNER JOIN" - + " (SELECT " + JOIN_STREAM_ID + "," + JOIN_INDEX - + " FROM " + PLAYLIST_STREAM_JOIN_TABLE - + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId)" - + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID - + " LEFT JOIN " - + "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", " - + STREAM_PROGRESS_MILLIS - + " FROM " + STREAM_STATE_TABLE + " )" - + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS - + " GROUP BY " + STREAM_ID - + " ORDER BY MIN(" + JOIN_INDEX + ") ASC") - Flowable> getStreamsWithoutDuplicates(long playlistId); - - @Transaction - @Query("SELECT " + PLAYLIST_TABLE + "." + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", " - + PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", " - + PLAYLIST_DISPLAY_INDEX + ", " - - + " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = " - + PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'" - + " ELSE (SELECT " + STREAM_THUMBNAIL_URL - + " FROM " + STREAM_TABLE - + " WHERE " + STREAM_TABLE + "." + STREAM_ID + " = " + PLAYLIST_THUMBNAIL_STREAM_ID - + " ) END AS " + PLAYLIST_THUMBNAIL_URL + ", " - - + "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT + ", " - + "COALESCE(SUM(" + STREAM_URL + " = :streamUrl), 0) AS " - + PLAYLIST_TIMES_STREAM_IS_CONTAINED - - + " FROM " + PLAYLIST_TABLE - + " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE - + " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID - - + " LEFT JOIN " + STREAM_TABLE - + " ON " + STREAM_TABLE + "." + STREAM_ID + " = " + JOIN_STREAM_ID - + " AND :streamUrl = :streamUrl" - - + " GROUP BY " + JOIN_PLAYLIST_ID - + " ORDER BY " + PLAYLIST_DISPLAY_INDEX) - Flowable> getPlaylistDuplicatesMetadata(String streamUrl); -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt new file mode 100644 index 000000000..c135fecdb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt @@ -0,0 +1,117 @@ +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 +import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.stream.model.StreamStateEntity + +@Dao +open interface PlaylistStreamDAO : BasicDAO { + @Query("SELECT * FROM " + PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE) + public override fun getAll(): Flowable?>? + @Query("DELETE FROM " + PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE) + public override fun deleteAll(): Int + public override fun listByService(serviceId: Int): Flowable?>? { + throw UnsupportedOperationException() + } + + @Query(("DELETE FROM " + PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE + + " WHERE " + PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID + " = :playlistId")) + fun deleteBatch(playlistId: Long) + + @Query(("SELECT COALESCE(MAX(" + PlaylistStreamEntity.Companion.JOIN_INDEX + "), -1)" + + " FROM " + PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE + + " WHERE " + PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID + " = :playlistId")) + fun getMaximumIndexOf(playlistId: Long): Flowable + + @Query(("SELECT CASE WHEN COUNT(*) != 0 then " + StreamEntity.STREAM_ID + + " ELSE " + PlaylistEntity.Companion.DEFAULT_THUMBNAIL_ID + " END" + + " FROM " + StreamEntity.STREAM_TABLE + + " LEFT JOIN " + PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE + + " ON " + StreamEntity.STREAM_ID + " = " + PlaylistStreamEntity.Companion.JOIN_STREAM_ID + + " WHERE " + PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID + " = :playlistId " + + " LIMIT 1")) + fun getAutomaticThumbnailStreamId(playlistId: Long): Flowable + + @RewriteQueriesToDropUnusedColumns + @Transaction + @Query(("SELECT * FROM " + StreamEntity.STREAM_TABLE + " INNER JOIN " // get ids of streams of the given playlist + + "(SELECT " + PlaylistStreamEntity.Companion.JOIN_STREAM_ID + "," + PlaylistStreamEntity.Companion.JOIN_INDEX + + " FROM " + PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE + + " WHERE " + PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID + " = :playlistId)" // then merge with the stream metadata + + " ON " + StreamEntity.STREAM_ID + " = " + PlaylistStreamEntity.Companion.JOIN_STREAM_ID + + " LEFT JOIN " + + "(SELECT " + PlaylistStreamEntity.Companion.JOIN_STREAM_ID + " AS " + StreamStateEntity.Companion.JOIN_STREAM_ID_ALIAS + ", " + + StreamStateEntity.Companion.STREAM_PROGRESS_MILLIS + + " FROM " + StreamStateEntity.Companion.STREAM_STATE_TABLE + " )" + + " ON " + StreamEntity.STREAM_ID + " = " + StreamStateEntity.Companion.JOIN_STREAM_ID_ALIAS + + " ORDER BY " + PlaylistStreamEntity.Companion.JOIN_INDEX + " ASC")) + fun getOrderedStreamsOf(playlistId: Long): Flowable?> + + @Transaction + @Query(("SELECT " + PlaylistEntity.Companion.PLAYLIST_ID + ", " + PlaylistEntity.Companion.PLAYLIST_NAME + ", " + + PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_PERMANENT + ", " + PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_STREAM_ID + ", " + + PlaylistEntity.Companion.PLAYLIST_DISPLAY_INDEX + ", " + + " CASE WHEN " + PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_STREAM_ID + " = " + + PlaylistEntity.Companion.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + PlaylistEntity.Companion.DEFAULT_THUMBNAIL + "'" + + " ELSE (SELECT " + StreamEntity.STREAM_THUMBNAIL_URL + + " FROM " + StreamEntity.STREAM_TABLE + + " WHERE " + StreamEntity.STREAM_TABLE + "." + StreamEntity.STREAM_ID + " = " + PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_STREAM_ID + + " ) END AS " + PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_URL + ", " + + "COALESCE(COUNT(" + PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID + "), 0) AS " + PlaylistMetadataEntry.Companion.PLAYLIST_STREAM_COUNT + + " FROM " + PlaylistEntity.Companion.PLAYLIST_TABLE + + " LEFT JOIN " + PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE + + " ON " + PlaylistEntity.Companion.PLAYLIST_TABLE + "." + PlaylistEntity.Companion.PLAYLIST_ID + " = " + PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID + + " GROUP BY " + PlaylistEntity.Companion.PLAYLIST_ID + + " ORDER BY " + PlaylistEntity.Companion.PLAYLIST_DISPLAY_INDEX)) + fun getPlaylistMetadata(): Flowable?> + + @RewriteQueriesToDropUnusedColumns + @Transaction + @Query(("SELECT *, MIN(" + PlaylistStreamEntity.Companion.JOIN_INDEX + ")" + + " FROM " + StreamEntity.STREAM_TABLE + " INNER JOIN" + + " (SELECT " + PlaylistStreamEntity.Companion.JOIN_STREAM_ID + "," + PlaylistStreamEntity.Companion.JOIN_INDEX + + " FROM " + PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE + + " WHERE " + PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID + " = :playlistId)" + + " ON " + StreamEntity.STREAM_ID + " = " + PlaylistStreamEntity.Companion.JOIN_STREAM_ID + + " LEFT JOIN " + + "(SELECT " + PlaylistStreamEntity.Companion.JOIN_STREAM_ID + " AS " + StreamStateEntity.Companion.JOIN_STREAM_ID_ALIAS + ", " + + StreamStateEntity.Companion.STREAM_PROGRESS_MILLIS + + " FROM " + StreamStateEntity.Companion.STREAM_STATE_TABLE + " )" + + " ON " + StreamEntity.STREAM_ID + " = " + StreamStateEntity.Companion.JOIN_STREAM_ID_ALIAS + + " GROUP BY " + StreamEntity.STREAM_ID + + " ORDER BY MIN(" + PlaylistStreamEntity.Companion.JOIN_INDEX + ") ASC")) + fun getStreamsWithoutDuplicates(playlistId: Long): Flowable?> + + @Transaction + @Query(("SELECT " + PlaylistEntity.Companion.PLAYLIST_TABLE + "." + PlaylistEntity.Companion.PLAYLIST_ID + ", " + PlaylistEntity.Companion.PLAYLIST_NAME + ", " + + PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_PERMANENT + ", " + PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_STREAM_ID + ", " + + PlaylistEntity.Companion.PLAYLIST_DISPLAY_INDEX + ", " + + " CASE WHEN " + PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_STREAM_ID + " = " + + PlaylistEntity.Companion.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + PlaylistEntity.Companion.DEFAULT_THUMBNAIL + "'" + + " ELSE (SELECT " + StreamEntity.STREAM_THUMBNAIL_URL + + " FROM " + StreamEntity.STREAM_TABLE + + " WHERE " + StreamEntity.STREAM_TABLE + "." + StreamEntity.STREAM_ID + " = " + PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_STREAM_ID + + " ) END AS " + PlaylistEntity.Companion.PLAYLIST_THUMBNAIL_URL + ", " + + "COALESCE(COUNT(" + PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID + "), 0) AS " + PlaylistMetadataEntry.Companion.PLAYLIST_STREAM_COUNT + ", " + + "COALESCE(SUM(" + StreamEntity.STREAM_URL + " = :streamUrl), 0) AS " + + PlaylistDuplicatesEntry.Companion.PLAYLIST_TIMES_STREAM_IS_CONTAINED + + " FROM " + PlaylistEntity.Companion.PLAYLIST_TABLE + + " LEFT JOIN " + PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE + + " ON " + PlaylistEntity.Companion.PLAYLIST_TABLE + "." + PlaylistEntity.Companion.PLAYLIST_ID + " = " + PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID + + " LEFT JOIN " + StreamEntity.STREAM_TABLE + + " ON " + StreamEntity.STREAM_TABLE + "." + StreamEntity.STREAM_ID + " = " + PlaylistStreamEntity.Companion.JOIN_STREAM_ID + + " AND :streamUrl = :streamUrl" + + " GROUP BY " + PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID + + " ORDER BY " + PlaylistEntity.Companion.PLAYLIST_DISPLAY_INDEX)) + fun getPlaylistDuplicatesMetadata(streamUrl: String?): Flowable?> +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java deleted file mode 100644 index e0c1a06b7..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java +++ /dev/null @@ -1,100 +0,0 @@ -package org.schabi.newpipe.database.playlist.model; - -import androidx.room.ColumnInfo; -import androidx.room.Entity; -import androidx.room.Ignore; -import androidx.room.PrimaryKey; - -import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; - -@Entity(tableName = PLAYLIST_TABLE) -public class PlaylistEntity { - - public static final String DEFAULT_THUMBNAIL = "drawable://" - + R.drawable.placeholder_thumbnail_playlist; - public static final long DEFAULT_THUMBNAIL_ID = -1; - - public static final String PLAYLIST_TABLE = "playlists"; - public static final String PLAYLIST_ID = "uid"; - public static final String PLAYLIST_NAME = "name"; - public static final String PLAYLIST_THUMBNAIL_URL = "thumbnail_url"; - public static final String PLAYLIST_DISPLAY_INDEX = "display_index"; - public static final String PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent"; - public static final String PLAYLIST_THUMBNAIL_STREAM_ID = "thumbnail_stream_id"; - - @PrimaryKey(autoGenerate = true) - @ColumnInfo(name = PLAYLIST_ID) - private long uid = 0; - - @ColumnInfo(name = PLAYLIST_NAME) - private String name; - - @ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT) - private boolean isThumbnailPermanent; - - @ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID) - private long thumbnailStreamId; - - @ColumnInfo(name = PLAYLIST_DISPLAY_INDEX) - private long displayIndex; - - public PlaylistEntity(final String name, final boolean isThumbnailPermanent, - final long thumbnailStreamId, final long displayIndex) { - this.name = name; - this.isThumbnailPermanent = isThumbnailPermanent; - this.thumbnailStreamId = thumbnailStreamId; - this.displayIndex = displayIndex; - } - - @Ignore - public PlaylistEntity(final PlaylistMetadataEntry item) { - this.uid = item.getUid(); - this.name = item.name; - this.isThumbnailPermanent = item.isThumbnailPermanent(); - this.thumbnailStreamId = item.getThumbnailStreamId(); - this.displayIndex = item.getDisplayIndex(); - } - - public long getUid() { - return uid; - } - - public void setUid(final long uid) { - this.uid = uid; - } - - public String getName() { - return name; - } - - public void setName(final String name) { - this.name = name; - } - - public long getThumbnailStreamId() { - return thumbnailStreamId; - } - - public void setThumbnailStreamId(final long thumbnailStreamId) { - this.thumbnailStreamId = thumbnailStreamId; - } - - public boolean getIsThumbnailPermanent() { - return isThumbnailPermanent; - } - - public void setIsThumbnailPermanent(final boolean isThumbnailSet) { - this.isThumbnailPermanent = isThumbnailSet; - } - - public long getDisplayIndex() { - return displayIndex; - } - - public void setDisplayIndex(final long displayIndex) { - this.displayIndex = displayIndex; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.kt new file mode 100644 index 000000000..87a4f1b68 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.kt @@ -0,0 +1,98 @@ +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.R +import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry +import org.schabi.newpipe.database.playlist.model.PlaylistEntity + +@Entity(tableName = PlaylistEntity.PLAYLIST_TABLE) +class PlaylistEntity { + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = PLAYLIST_ID) + private var uid: Long = 0 + + @ColumnInfo(name = PLAYLIST_NAME) + private var name: String? + + @ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT) + private var isThumbnailPermanent: Boolean + + @ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID) + private var thumbnailStreamId: Long + + @ColumnInfo(name = PLAYLIST_DISPLAY_INDEX) + private var displayIndex: Long + + constructor(name: String?, isThumbnailPermanent: Boolean, + thumbnailStreamId: Long, displayIndex: Long) { + this.name = name + this.isThumbnailPermanent = isThumbnailPermanent + this.thumbnailStreamId = thumbnailStreamId + this.displayIndex = displayIndex + } + + @Ignore + constructor(item: PlaylistMetadataEntry) { + uid = item.getUid() + name = item.name + isThumbnailPermanent = item.isThumbnailPermanent() + thumbnailStreamId = item.getThumbnailStreamId() + displayIndex = item.getDisplayIndex() + } + + fun getUid(): Long { + return uid + } + + fun setUid(uid: Long) { + this.uid = uid + } + + fun getName(): String? { + return name + } + + fun setName(name: String?) { + this.name = name + } + + fun getThumbnailStreamId(): Long { + return thumbnailStreamId + } + + fun setThumbnailStreamId(thumbnailStreamId: Long) { + this.thumbnailStreamId = thumbnailStreamId + } + + fun getIsThumbnailPermanent(): Boolean { + return isThumbnailPermanent + } + + fun setIsThumbnailPermanent(isThumbnailSet: Boolean) { + isThumbnailPermanent = isThumbnailSet + } + + fun getDisplayIndex(): Long { + return displayIndex + } + + fun setDisplayIndex(displayIndex: Long) { + this.displayIndex = displayIndex + } + + companion object { + val DEFAULT_THUMBNAIL: String = ("drawable://" + + R.drawable.placeholder_thumbnail_playlist) + val DEFAULT_THUMBNAIL_ID: Long = -1 + val PLAYLIST_TABLE: String = "playlists" + val PLAYLIST_ID: String = "uid" + val PLAYLIST_NAME: String = "name" + val PLAYLIST_THUMBNAIL_URL: String = "thumbnail_url" + val PLAYLIST_DISPLAY_INDEX: String = "display_index" + val PLAYLIST_THUMBNAIL_PERMANENT: String = "is_thumbnail_permanent" + val PLAYLIST_THUMBNAIL_STREAM_ID: String = "thumbnail_stream_id" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java deleted file mode 100644 index 60027a057..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java +++ /dev/null @@ -1,188 +0,0 @@ -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.playlist.PlaylistLocalItem; -import org.schabi.newpipe.extractor.playlist.PlaylistInfo; -import org.schabi.newpipe.util.Constants; -import org.schabi.newpipe.util.image.ImageStrategy; - -import static org.schabi.newpipe.database.LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_NAME; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE; -import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL; - -@Entity(tableName = REMOTE_PLAYLIST_TABLE, - indices = { - @Index(value = {REMOTE_PLAYLIST_SERVICE_ID, REMOTE_PLAYLIST_URL}, unique = true) - }) -public class PlaylistRemoteEntity implements PlaylistLocalItem { - public static final String REMOTE_PLAYLIST_TABLE = "remote_playlists"; - public static final String REMOTE_PLAYLIST_ID = "uid"; - public static final String REMOTE_PLAYLIST_SERVICE_ID = "service_id"; - public static final String REMOTE_PLAYLIST_NAME = "name"; - public static final String REMOTE_PLAYLIST_URL = "url"; - public static final String REMOTE_PLAYLIST_THUMBNAIL_URL = "thumbnail_url"; - public static final String REMOTE_PLAYLIST_UPLOADER_NAME = "uploader"; - public static final String REMOTE_PLAYLIST_DISPLAY_INDEX = "display_index"; - public static final String REMOTE_PLAYLIST_STREAM_COUNT = "stream_count"; - - @PrimaryKey(autoGenerate = true) - @ColumnInfo(name = REMOTE_PLAYLIST_ID) - private long uid = 0; - - @ColumnInfo(name = REMOTE_PLAYLIST_SERVICE_ID) - private int serviceId = Constants.NO_SERVICE_ID; - - @ColumnInfo(name = REMOTE_PLAYLIST_NAME) - private String name; - - @ColumnInfo(name = REMOTE_PLAYLIST_URL) - private String url; - - @ColumnInfo(name = REMOTE_PLAYLIST_THUMBNAIL_URL) - private String thumbnailUrl; - - @ColumnInfo(name = REMOTE_PLAYLIST_UPLOADER_NAME) - private String uploader; - - @ColumnInfo(name = REMOTE_PLAYLIST_DISPLAY_INDEX) - private long displayIndex = -1; // Make sure the new item is on the top - - @ColumnInfo(name = REMOTE_PLAYLIST_STREAM_COUNT) - private Long streamCount; - - public PlaylistRemoteEntity(final int serviceId, final String name, final String url, - final String thumbnailUrl, final String uploader, - final Long streamCount) { - this.serviceId = serviceId; - this.name = name; - this.url = url; - this.thumbnailUrl = thumbnailUrl; - this.uploader = uploader; - this.streamCount = streamCount; - } - - @Ignore - public PlaylistRemoteEntity(final int serviceId, final String name, final String url, - final String thumbnailUrl, final String uploader, - final long displayIndex, final Long streamCount) { - this.serviceId = serviceId; - this.name = name; - this.url = url; - this.thumbnailUrl = thumbnailUrl; - this.uploader = uploader; - this.displayIndex = displayIndex; - this.streamCount = streamCount; - } - - @Ignore - public PlaylistRemoteEntity(final PlaylistInfo info) { - this(info.getServiceId(), info.getName(), info.getUrl(), - // use uploader avatar when no thumbnail is available - ImageStrategy.imageListToDbUrl(info.getThumbnails().isEmpty() - ? info.getUploaderAvatars() : info.getThumbnails()), - info.getUploaderName(), info.getStreamCount()); - } - - @Ignore - public boolean isIdenticalTo(final PlaylistInfo info) { - /* - * Returns boolean comparing the online playlist and the local copy. - * (False if info changed such as playlist name or track count) - */ - return getServiceId() == info.getServiceId() - && getStreamCount() == info.getStreamCount() - && TextUtils.equals(getName(), info.getName()) - && TextUtils.equals(getUrl(), info.getUrl()) - // 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(getThumbnailUrl(), - ImageStrategy.imageListToDbUrl(info.getThumbnails())) - && TextUtils.equals(getUploader(), info.getUploaderName()); - } - - @Override - public long getUid() { - return uid; - } - - public void setUid(final long uid) { - this.uid = uid; - } - - public int getServiceId() { - return serviceId; - } - - public void setServiceId(final int serviceId) { - this.serviceId = serviceId; - } - - public String getName() { - return name; - } - - public void setName(final String name) { - this.name = name; - } - - public String getThumbnailUrl() { - return thumbnailUrl; - } - - public void setThumbnailUrl(final String thumbnailUrl) { - this.thumbnailUrl = thumbnailUrl; - } - - public String getUrl() { - return url; - } - - public void setUrl(final String url) { - this.url = url; - } - - public String getUploader() { - return uploader; - } - - public void setUploader(final String uploader) { - this.uploader = uploader; - } - - @Override - public long getDisplayIndex() { - return displayIndex; - } - - @Override - public void setDisplayIndex(final long displayIndex) { - this.displayIndex = displayIndex; - } - - public Long getStreamCount() { - return streamCount; - } - - public void setStreamCount(final Long streamCount) { - this.streamCount = streamCount; - } - - @Override - public LocalItemType getLocalItemType() { - return PLAYLIST_REMOTE_ITEM; - } - - @Override - public String getOrderingName() { - return name; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.kt new file mode 100644 index 000000000..9f4910257 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.kt @@ -0,0 +1,170 @@ +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 +import org.schabi.newpipe.extractor.playlist.PlaylistInfo +import org.schabi.newpipe.util.image.ImageStrategy + +@Entity(tableName = PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE, indices = [Index(value = [PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID, PlaylistRemoteEntity.REMOTE_PLAYLIST_URL], unique = true)]) +class PlaylistRemoteEntity : PlaylistLocalItem { + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = REMOTE_PLAYLIST_ID) + private var uid: Long = 0 + + @ColumnInfo(name = REMOTE_PLAYLIST_SERVICE_ID) + private var serviceId: Int = NO_SERVICE_ID + + @ColumnInfo(name = REMOTE_PLAYLIST_NAME) + private var name: String + + @ColumnInfo(name = REMOTE_PLAYLIST_URL) + private var url: String + + @ColumnInfo(name = REMOTE_PLAYLIST_THUMBNAIL_URL) + private var thumbnailUrl: String? + + @ColumnInfo(name = REMOTE_PLAYLIST_UPLOADER_NAME) + private var uploader: String + + @ColumnInfo(name = REMOTE_PLAYLIST_DISPLAY_INDEX) + private var displayIndex: Long = -1 // Make sure the new item is on the top + + @ColumnInfo(name = REMOTE_PLAYLIST_STREAM_COUNT) + private var streamCount: Long + + constructor(serviceId: Int, name: String, url: String, + thumbnailUrl: String?, uploader: String, + streamCount: Long) { + this.serviceId = serviceId + this.name = name + this.url = url + this.thumbnailUrl = thumbnailUrl + this.uploader = uploader + this.streamCount = streamCount + } + + @Ignore + constructor(serviceId: Int, name: String, url: String, + thumbnailUrl: String?, uploader: String, + displayIndex: Long, streamCount: Long) { + this.serviceId = serviceId + this.name = name + this.url = url + this.thumbnailUrl = thumbnailUrl + this.uploader = uploader + this.displayIndex = displayIndex + this.streamCount = streamCount + } + + @Ignore + constructor(info: PlaylistInfo) : this(info.getServiceId(), info.getName(), info.getUrl(), // use uploader avatar when no thumbnail is available + ImageStrategy.imageListToDbUrl(if (info.getThumbnails().isEmpty()) info.getUploaderAvatars() else info.getThumbnails()), + info.getUploaderName(), info.getStreamCount()) + + @Ignore + fun isIdenticalTo(info: PlaylistInfo): Boolean { + /* + * Returns boolean comparing the online playlist and the local copy. + * (False if info changed such as playlist name or track count) + */ + return ((getServiceId() == info.getServiceId() + ) && (getStreamCount() == info.getStreamCount() + ) && TextUtils.equals(getName(), info.getName()) + && TextUtils.equals(getUrl(), info.getUrl()) // 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(getThumbnailUrl(), + ImageStrategy.imageListToDbUrl(info.getThumbnails())) + && TextUtils.equals(getUploader(), info.getUploaderName())) + } + + public override fun getUid(): Long { + return uid + } + + fun setUid(uid: Long) { + this.uid = uid + } + + fun getServiceId(): Int { + return serviceId + } + + fun setServiceId(serviceId: Int) { + this.serviceId = serviceId + } + + fun getName(): String { + return name + } + + fun setName(name: String) { + this.name = name + } + + fun getThumbnailUrl(): String? { + return thumbnailUrl + } + + fun setThumbnailUrl(thumbnailUrl: String?) { + this.thumbnailUrl = thumbnailUrl + } + + fun getUrl(): String { + return url + } + + fun setUrl(url: String) { + this.url = url + } + + fun getUploader(): String { + return uploader + } + + fun setUploader(uploader: String) { + this.uploader = uploader + } + + public override fun getDisplayIndex(): Long { + return displayIndex + } + + public override fun setDisplayIndex(displayIndex: Long) { + this.displayIndex = displayIndex + } + + fun getStreamCount(): Long { + return streamCount + } + + fun setStreamCount(streamCount: Long) { + this.streamCount = streamCount + } + + public override fun getLocalItemType(): LocalItemType { + return LocalItemType.PLAYLIST_REMOTE_ITEM + } + + public override fun getOrderingName(): String { + return name + } + + companion object { + val REMOTE_PLAYLIST_TABLE: String = "remote_playlists" + val REMOTE_PLAYLIST_ID: String = "uid" + val REMOTE_PLAYLIST_SERVICE_ID: String = "service_id" + val REMOTE_PLAYLIST_NAME: String = "name" + val REMOTE_PLAYLIST_URL: String = "url" + val REMOTE_PLAYLIST_THUMBNAIL_URL: String = "thumbnail_url" + val REMOTE_PLAYLIST_UPLOADER_NAME: String = "uploader" + val REMOTE_PLAYLIST_DISPLAY_INDEX: String = "display_index" + val REMOTE_PLAYLIST_STREAM_COUNT: String = "stream_count" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.java b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.java deleted file mode 100644 index f3208b6d5..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.java +++ /dev/null @@ -1,76 +0,0 @@ -package org.schabi.newpipe.database.playlist.model; - -import androidx.room.ColumnInfo; -import androidx.room.Entity; -import androidx.room.ForeignKey; -import androidx.room.Index; - -import org.schabi.newpipe.database.stream.model.StreamEntity; - -import static androidx.room.ForeignKey.CASCADE; -import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_INDEX; -import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_PLAYLIST_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_STREAM_ID; -import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE; - -@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 = PlaylistEntity.PLAYLIST_ID, - childColumns = JOIN_PLAYLIST_ID, - onDelete = CASCADE, onUpdate = CASCADE, deferred = true), - @ForeignKey(entity = StreamEntity.class, - parentColumns = StreamEntity.STREAM_ID, - childColumns = JOIN_STREAM_ID, - onDelete = CASCADE, onUpdate = CASCADE, deferred = true) - }) -public class PlaylistStreamEntity { - public static final String PLAYLIST_STREAM_JOIN_TABLE = "playlist_stream_join"; - public static final String JOIN_PLAYLIST_ID = "playlist_id"; - public static final String JOIN_STREAM_ID = "stream_id"; - public static final String JOIN_INDEX = "join_index"; - - @ColumnInfo(name = JOIN_PLAYLIST_ID) - private long playlistUid; - - @ColumnInfo(name = JOIN_STREAM_ID) - private long streamUid; - - @ColumnInfo(name = JOIN_INDEX) - private int index; - - public PlaylistStreamEntity(final long playlistUid, final long streamUid, final int index) { - this.playlistUid = playlistUid; - this.streamUid = streamUid; - this.index = index; - } - - public long getPlaylistUid() { - return playlistUid; - } - - public void setPlaylistUid(final long playlistUid) { - this.playlistUid = playlistUid; - } - - public long getStreamUid() { - return streamUid; - } - - public void setStreamUid(final long streamUid) { - this.streamUid = streamUid; - } - - public int getIndex() { - return index; - } - - public void setIndex(final int index) { - this.index = index; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.kt new file mode 100644 index 000000000..9ff4f0228 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.kt @@ -0,0 +1,43 @@ +package org.schabi.newpipe.database.playlist.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import org.schabi.newpipe.database.playlist.model.PlaylistEntity +import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity +import org.schabi.newpipe.database.stream.model.StreamEntity + +@Entity(tableName = PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE, primaryKeys = [PlaylistStreamEntity.JOIN_PLAYLIST_ID, PlaylistStreamEntity.JOIN_INDEX], indices = [Index(value = [PlaylistStreamEntity.JOIN_PLAYLIST_ID, PlaylistStreamEntity.JOIN_INDEX], unique = true), Index(value = [PlaylistStreamEntity.JOIN_STREAM_ID])], foreignKeys = [ForeignKey(entity = PlaylistEntity::class, parentColumns = PlaylistEntity.Companion.PLAYLIST_ID, childColumns = PlaylistStreamEntity.JOIN_PLAYLIST_ID, onDelete = CASCADE, onUpdate = CASCADE, deferred = true), ForeignKey(entity = StreamEntity::class, parentColumns = StreamEntity.STREAM_ID, childColumns = PlaylistStreamEntity.JOIN_STREAM_ID, onDelete = CASCADE, onUpdate = CASCADE, deferred = true)]) +class PlaylistStreamEntity(@field:ColumnInfo(name = JOIN_PLAYLIST_ID) private var playlistUid: Long, @field:ColumnInfo(name = JOIN_STREAM_ID) private var streamUid: Long, @field:ColumnInfo(name = JOIN_INDEX) private var index: Int) { + fun getPlaylistUid(): Long { + return playlistUid + } + + fun setPlaylistUid(playlistUid: Long) { + this.playlistUid = playlistUid + } + + fun getStreamUid(): Long { + return streamUid + } + + fun setStreamUid(streamUid: Long) { + this.streamUid = streamUid + } + + fun getIndex(): Int { + return index + } + + fun setIndex(index: Int) { + this.index = index + } + + companion object { + val PLAYLIST_STREAM_JOIN_TABLE: String = "playlist_stream_join" + val JOIN_PLAYLIST_ID: String = "playlist_id" + val JOIN_STREAM_ID: String = "stream_id" + val JOIN_INDEX: String = "join_index" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java deleted file mode 100644 index 06371248d..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java +++ /dev/null @@ -1,48 +0,0 @@ -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 org.schabi.newpipe.database.BasicDAO; -import org.schabi.newpipe.database.stream.model.StreamStateEntity; - -import java.util.List; - -import io.reactivex.rxjava3.core.Flowable; - -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; - -@Dao -public interface StreamStateDAO extends BasicDAO { - @Override - @Query("SELECT * FROM " + STREAM_STATE_TABLE) - Flowable> getAll(); - - @Override - @Query("DELETE FROM " + STREAM_STATE_TABLE) - int deleteAll(); - - @Override - default Flowable> listByService(final int serviceId) { - throw new UnsupportedOperationException(); - } - - @Query("SELECT * FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId") - Flowable> getState(long streamId); - - @Query("DELETE FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId") - int deleteState(long streamId); - - @Insert(onConflict = OnConflictStrategy.IGNORE) - void silentInsertInternal(StreamStateEntity streamState); - - @Transaction - default long upsert(final StreamStateEntity stream) { - silentInsertInternal(stream); - return update(stream); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt new file mode 100644 index 000000000..5e6606897 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt @@ -0,0 +1,36 @@ +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 +open interface StreamStateDAO : BasicDAO { + @Query("SELECT * FROM " + StreamStateEntity.Companion.STREAM_STATE_TABLE) + public override fun getAll(): Flowable?>? + @Query("DELETE FROM " + StreamStateEntity.Companion.STREAM_STATE_TABLE) + public override fun deleteAll(): Int + public override fun listByService(serviceId: Int): Flowable?>? { + throw UnsupportedOperationException() + } + + @Query("SELECT * FROM " + StreamStateEntity.Companion.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.Companion.JOIN_STREAM_ID + " = :streamId") + fun getState(streamId: Long): Flowable?> + + @Query("DELETE FROM " + StreamStateEntity.Companion.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.Companion.JOIN_STREAM_ID + " = :streamId") + fun deleteState(streamId: Long): Int + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun silentInsertInternal(streamState: StreamStateEntity?) + + @Transaction + fun upsert(stream: StreamStateEntity?): Long { + silentInsertInternal(stream) + return update(stream).toLong() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java deleted file mode 100644 index 627acea45..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java +++ /dev/null @@ -1,112 +0,0 @@ -package org.schabi.newpipe.database.stream.model; - -import androidx.annotation.Nullable; -import androidx.room.ColumnInfo; -import androidx.room.Entity; -import androidx.room.ForeignKey; - -import java.util.Objects; - -import static androidx.room.ForeignKey.CASCADE; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; - -@Entity(tableName = STREAM_STATE_TABLE, - primaryKeys = {JOIN_STREAM_ID}, - foreignKeys = { - @ForeignKey(entity = StreamEntity.class, - parentColumns = StreamEntity.STREAM_ID, - childColumns = JOIN_STREAM_ID, - onDelete = CASCADE, onUpdate = CASCADE) - }) -public class StreamStateEntity { - public static final String STREAM_STATE_TABLE = "stream_state"; - public static final String JOIN_STREAM_ID = "stream_id"; - // This additional field is required for the SQL query because 'stream_id' is used - // for some other joins already - public static final String JOIN_STREAM_ID_ALIAS = "stream_id_alias"; - public static final String STREAM_PROGRESS_MILLIS = "progress_time"; - - /** - * Playback state will not be saved, if playback time is less than this threshold (5000ms = 5s). - */ - public static final long PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS = 5000; - - /** - * Stream will be considered finished if the playback time left exceeds this threshold - * (60000ms = 60s). - * @see #isFinished(long) - * @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreams() - * @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreamsForGroup(long) - */ - public static final long PLAYBACK_FINISHED_END_MILLISECONDS = 60000; - - @ColumnInfo(name = JOIN_STREAM_ID) - private long streamUid; - - @ColumnInfo(name = STREAM_PROGRESS_MILLIS) - private long progressMillis; - - public StreamStateEntity(final long streamUid, final long progressMillis) { - this.streamUid = streamUid; - this.progressMillis = progressMillis; - } - - public long getStreamUid() { - return streamUid; - } - - public void setStreamUid(final long streamUid) { - this.streamUid = streamUid; - } - - public long getProgressMillis() { - return progressMillis; - } - - public void setProgressMillis(final long progressMillis) { - this.progressMillis = progressMillis; - } - - /** - * The state will be considered valid, and thus be saved, if the progress is more than {@link - * #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 - */ - public boolean isValid(final long durationInSeconds) { - 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 {@link - * #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. - * @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreams() - * @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreamsForGroup(long) - * @param durationInSeconds the duration of the stream connected with this state, in seconds - * @return whether the stream is finished or not - */ - public boolean isFinished(final long durationInSeconds) { - return progressMillis >= durationInSeconds * 1000 - PLAYBACK_FINISHED_END_MILLISECONDS - && progressMillis >= durationInSeconds * 1000 * 3 / 4; - } - - @Override - public boolean equals(@Nullable final Object obj) { - if (obj instanceof StreamStateEntity) { - return ((StreamStateEntity) obj).streamUid == streamUid - && ((StreamStateEntity) obj).progressMillis == progressMillis; - } else { - return false; - } - } - - @Override - public int hashCode() { - return Objects.hash(streamUid, progressMillis); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.kt b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.kt new file mode 100644 index 000000000..44d0684d4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.kt @@ -0,0 +1,89 @@ +package org.schabi.newpipe.database.stream.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.stream.model.StreamStateEntity +import java.util.Objects + +@Entity(tableName = StreamStateEntity.STREAM_STATE_TABLE, primaryKeys = [StreamStateEntity.JOIN_STREAM_ID], foreignKeys = [ForeignKey(entity = StreamEntity::class, parentColumns = StreamEntity.STREAM_ID, childColumns = StreamStateEntity.JOIN_STREAM_ID, onDelete = CASCADE, onUpdate = CASCADE)]) +class StreamStateEntity(@field:ColumnInfo(name = JOIN_STREAM_ID) private var streamUid: Long, @field:ColumnInfo(name = STREAM_PROGRESS_MILLIS) private var progressMillis: Long) { + fun getStreamUid(): Long { + return streamUid + } + + fun setStreamUid(streamUid: Long) { + this.streamUid = streamUid + } + + fun getProgressMillis(): Long { + return progressMillis + } + + fun setProgressMillis(progressMillis: Long) { + this.progressMillis = progressMillis + } + + /** + * 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. + * @see org.schabi.newpipe.database.feed.dao.FeedDAO.getLiveOrNotPlayedStreams + * @see org.schabi.newpipe.database.feed.dao.FeedDAO.getLiveOrNotPlayedStreamsForGroup + * @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) + } + + public override fun equals(obj: Any?): Boolean { + if (obj is StreamStateEntity) { + return (obj.streamUid == streamUid + && obj.progressMillis == progressMillis) + } else { + return false + } + } + + public override fun hashCode(): Int { + return Objects.hash(streamUid, progressMillis) + } + + companion object { + val STREAM_STATE_TABLE: String = "stream_state" + val JOIN_STREAM_ID: String = "stream_id" + + // This additional field is required for the SQL query because 'stream_id' is used + // for some other joins already + val JOIN_STREAM_ID_ALIAS: String = "stream_id_alias" + val STREAM_PROGRESS_MILLIS: String = "progress_time" + + /** + * Playback state will not be saved, if playback time is less than this threshold (5000ms = 5s). + */ + val PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS: Long = 5000 + + /** + * Stream will be considered finished if the playback time left exceeds this threshold + * (60000ms = 60s). + * @see .isFinished + * @see org.schabi.newpipe.database.feed.dao.FeedDAO.getLiveOrNotPlayedStreams + * @see org.schabi.newpipe.database.feed.dao.FeedDAO.getLiveOrNotPlayedStreamsForGroup + */ + val PLAYBACK_FINISHED_END_MILLISECONDS: Long = 60000 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.java b/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.java deleted file mode 100644 index 07e0eb7d3..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.java +++ /dev/null @@ -1,14 +0,0 @@ -package org.schabi.newpipe.database.subscription; - -import androidx.annotation.IntDef; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; - -@IntDef({NotificationMode.DISABLED, NotificationMode.ENABLED}) -@Retention(RetentionPolicy.SOURCE) -public @interface NotificationMode { - - int DISABLED = 0; - int ENABLED = 1; - //other values reserved for the future -} diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.kt new file mode 100644 index 000000000..7fc9050fb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.kt @@ -0,0 +1,13 @@ +package org.schabi.newpipe.database.subscription + +import androidx.annotation.IntDef +import org.schabi.newpipe.database.subscription.NotificationMode + +@IntDef([NotificationMode.DISABLED, NotificationMode.ENABLED]) +@Retention(AnnotationRetention.SOURCE) +annotation class NotificationMode() { + companion object { + val DISABLED: Int = 0 + val ENABLED: Int = 1 //other values reserved for the future + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java deleted file mode 100644 index a61a22a84..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java +++ /dev/null @@ -1,198 +0,0 @@ -package org.schabi.newpipe.database.subscription; - -import androidx.annotation.NonNull; -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.Constants; -import org.schabi.newpipe.util.image.ImageStrategy; - -import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID; -import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE; -import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_URL; - -@Entity(tableName = SUBSCRIPTION_TABLE, - indices = {@Index(value = {SUBSCRIPTION_SERVICE_ID, SUBSCRIPTION_URL}, unique = true)}) -public class SubscriptionEntity { - public static final String SUBSCRIPTION_UID = "uid"; - public static final String SUBSCRIPTION_TABLE = "subscriptions"; - public static final String SUBSCRIPTION_SERVICE_ID = "service_id"; - public static final String SUBSCRIPTION_URL = "url"; - public static final String SUBSCRIPTION_NAME = "name"; - public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url"; - public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count"; - public static final String SUBSCRIPTION_DESCRIPTION = "description"; - public static final String SUBSCRIPTION_NOTIFICATION_MODE = "notification_mode"; - - @PrimaryKey(autoGenerate = true) - private long uid = 0; - - @ColumnInfo(name = SUBSCRIPTION_SERVICE_ID) - private int serviceId = Constants.NO_SERVICE_ID; - - @ColumnInfo(name = SUBSCRIPTION_URL) - private String url; - - @ColumnInfo(name = SUBSCRIPTION_NAME) - private String name; - - @ColumnInfo(name = SUBSCRIPTION_AVATAR_URL) - private String avatarUrl; - - @ColumnInfo(name = SUBSCRIPTION_SUBSCRIBER_COUNT) - private Long subscriberCount; - - @ColumnInfo(name = SUBSCRIPTION_DESCRIPTION) - private String description; - - @ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE) - private int notificationMode; - - @Ignore - public static SubscriptionEntity from(@NonNull final ChannelInfo info) { - final SubscriptionEntity result = new SubscriptionEntity(); - result.setServiceId(info.getServiceId()); - result.setUrl(info.getUrl()); - result.setData(info.getName(), ImageStrategy.imageListToDbUrl(info.getAvatars()), - info.getDescription(), info.getSubscriberCount()); - return result; - } - - public long getUid() { - return uid; - } - - public void setUid(final long uid) { - this.uid = uid; - } - - public int getServiceId() { - return serviceId; - } - - public void setServiceId(final int serviceId) { - this.serviceId = serviceId; - } - - public String getUrl() { - return url; - } - - public void setUrl(final String url) { - this.url = url; - } - - public String getName() { - return name; - } - - public void setName(final String name) { - this.name = name; - } - - public String getAvatarUrl() { - return avatarUrl; - } - - public void setAvatarUrl(final String avatarUrl) { - this.avatarUrl = avatarUrl; - } - - public Long getSubscriberCount() { - return subscriberCount; - } - - public void setSubscriberCount(final Long subscriberCount) { - this.subscriberCount = subscriberCount; - } - - public String getDescription() { - return description; - } - - public void setDescription(final String description) { - this.description = description; - } - - @NotificationMode - public int getNotificationMode() { - return notificationMode; - } - - public void setNotificationMode(@NotificationMode final int notificationMode) { - this.notificationMode = notificationMode; - } - - @Ignore - public void setData(final String n, final String au, final String d, final Long sc) { - this.setName(n); - this.setAvatarUrl(au); - this.setDescription(d); - this.setSubscriberCount(sc); - } - - @Ignore - public ChannelInfoItem toChannelInfoItem() { - final ChannelInfoItem item = new ChannelInfoItem(getServiceId(), getUrl(), getName()); - item.setThumbnails(ImageStrategy.dbUrlToImageList(getAvatarUrl())); - item.setSubscriberCount(getSubscriberCount()); - item.setDescription(getDescription()); - return item; - } - - - // TODO: Remove these generated methods by migrating this class to a data class from Kotlin. - @Override - @SuppressWarnings("EqualsReplaceableByObjectsCall") - public boolean equals(final Object o) { - if (this == o) { - return true; - } - if (o == null || getClass() != o.getClass()) { - return false; - } - - final SubscriptionEntity that = (SubscriptionEntity) o; - - if (uid != that.uid) { - return false; - } - if (serviceId != that.serviceId) { - return false; - } - if (!url.equals(that.url)) { - return false; - } - if (name != null ? !name.equals(that.name) : that.name != null) { - return false; - } - if (avatarUrl != null ? !avatarUrl.equals(that.avatarUrl) : that.avatarUrl != null) { - return false; - } - if (subscriberCount != null - ? !subscriberCount.equals(that.subscriberCount) - : that.subscriberCount != null) { - return false; - } - return description != null - ? description.equals(that.description) - : that.description == null; - } - - @Override - public int hashCode() { - int result = (int) (uid ^ (uid >>> 32)); - result = 31 * result + serviceId; - result = 31 * result + url.hashCode(); - result = 31 * result + (name != null ? name.hashCode() : 0); - result = 31 * result + (avatarUrl != null ? avatarUrl.hashCode() : 0); - result = 31 * result + (subscriberCount != null ? subscriberCount.hashCode() : 0); - result = 31 * result + (description != null ? description.hashCode() : 0); - return result; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt new file mode 100644 index 000000000..5bfa7c164 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt @@ -0,0 +1,182 @@ +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.database.subscription.SubscriptionEntity +import org.schabi.newpipe.extractor.channel.ChannelInfo +import org.schabi.newpipe.extractor.channel.ChannelInfoItem +import org.schabi.newpipe.util.image.ImageStrategy + +@Entity(tableName = SubscriptionEntity.SUBSCRIPTION_TABLE, indices = [Index(value = [SubscriptionEntity.SUBSCRIPTION_SERVICE_ID, SubscriptionEntity.SUBSCRIPTION_URL], unique = true)]) +class SubscriptionEntity() { + @PrimaryKey(autoGenerate = true) + private var uid: Long = 0 + + @ColumnInfo(name = SUBSCRIPTION_SERVICE_ID) + private var serviceId: Int = NO_SERVICE_ID + + @ColumnInfo(name = SUBSCRIPTION_URL) + private var url: String? = null + + @ColumnInfo(name = SUBSCRIPTION_NAME) + private var name: String? = null + + @ColumnInfo(name = SUBSCRIPTION_AVATAR_URL) + private var avatarUrl: String? = null + + @ColumnInfo(name = SUBSCRIPTION_SUBSCRIBER_COUNT) + private var subscriberCount: Long? = null + + @ColumnInfo(name = SUBSCRIPTION_DESCRIPTION) + private var description: String? = null + + @ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE) + private var notificationMode: Int = 0 + fun getUid(): Long { + return uid + } + + fun setUid(uid: Long) { + this.uid = uid + } + + fun getServiceId(): Int { + return serviceId + } + + fun setServiceId(serviceId: Int) { + this.serviceId = serviceId + } + + fun getUrl(): String? { + return url + } + + fun setUrl(url: String?) { + this.url = url + } + + fun getName(): String? { + return name + } + + fun setName(name: String?) { + this.name = name + } + + fun getAvatarUrl(): String? { + return avatarUrl + } + + fun setAvatarUrl(avatarUrl: String?) { + this.avatarUrl = avatarUrl + } + + fun getSubscriberCount(): Long? { + return subscriberCount + } + + fun setSubscriberCount(subscriberCount: Long?) { + this.subscriberCount = subscriberCount + } + + fun getDescription(): String? { + return description + } + + fun setDescription(description: String?) { + this.description = description + } + + @NotificationMode + fun getNotificationMode(): Int { + return notificationMode + } + + fun setNotificationMode(@NotificationMode notificationMode: Int) { + this.notificationMode = notificationMode + } + + @Ignore + fun setData(n: String?, au: String?, d: String?, sc: Long?) { + setName(n) + setAvatarUrl(au) + setDescription(d) + setSubscriberCount(sc) + } + + @Ignore + fun toChannelInfoItem(): ChannelInfoItem { + val item: ChannelInfoItem = ChannelInfoItem(getServiceId(), getUrl(), getName()) + item.setThumbnails(ImageStrategy.dbUrlToImageList(getAvatarUrl())) + item.setSubscriberCount((getSubscriberCount())!!) + item.setDescription(getDescription()) + return item + } + + // TODO: Remove these generated methods by migrating this class to a data class from Kotlin. + public override fun equals(o: Any?): Boolean { + if (this === o) { + return true + } + if (o == null || javaClass != o.javaClass) { + return false + } + val that: SubscriptionEntity = o as SubscriptionEntity + if (uid != that.uid) { + return false + } + if (serviceId != that.serviceId) { + return false + } + if (!(url == that.url)) { + return false + } + if (if (name != null) !(name == that.name) else that.name != null) { + return false + } + if (if (avatarUrl != null) !(avatarUrl == that.avatarUrl) else that.avatarUrl != null) { + return false + } + if (if (subscriberCount != null) !(subscriberCount == that.subscriberCount) else that.subscriberCount != null) { + return false + } + return if (description != null) (description == that.description) else that.description == null + } + + public override fun hashCode(): Int { + var result: Int = (uid xor (uid ushr 32)).toInt() + result = 31 * result + serviceId + result = 31 * result + url.hashCode() + result = 31 * result + (if (name != null) name.hashCode() else 0) + result = 31 * result + (if (avatarUrl != null) avatarUrl.hashCode() else 0) + result = 31 * result + (if (subscriberCount != null) subscriberCount.hashCode() else 0) + result = 31 * result + (if (description != null) description.hashCode() else 0) + return result + } + + companion object { + val SUBSCRIPTION_UID: String = "uid" + val SUBSCRIPTION_TABLE: String = "subscriptions" + val SUBSCRIPTION_SERVICE_ID: String = "service_id" + val SUBSCRIPTION_URL: String = "url" + val SUBSCRIPTION_NAME: String = "name" + val SUBSCRIPTION_AVATAR_URL: String = "avatar_url" + val SUBSCRIPTION_SUBSCRIBER_COUNT: String = "subscriber_count" + val SUBSCRIPTION_DESCRIPTION: String = "description" + val SUBSCRIPTION_NOTIFICATION_MODE: String = "notification_mode" + @JvmStatic + @Ignore + fun from(info: ChannelInfo): SubscriptionEntity { + val result: SubscriptionEntity = SubscriptionEntity() + result.setServiceId(info.getServiceId()) + result.setUrl(info.getUrl()) + result.setData(info.getName(), ImageStrategy.imageListToDbUrl(info.getAvatars()), + info.getDescription(), info.getSubscriberCount()) + return result + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java b/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java deleted file mode 100644 index 37eefed96..000000000 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java +++ /dev/null @@ -1,97 +0,0 @@ -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; - -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - -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); - - assureCorrectAppLanguage(this); - 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); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.kt b/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.kt new file mode 100644 index 000000000..8b8c33be8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.kt @@ -0,0 +1,80 @@ +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.OnGlobalLayoutListener +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.Localization +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 + +class DownloadActivity() : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Service + val i: Intent = Intent() + i.setClass(this, DownloadManagerService::class.java) + startService(i) + Localization.assureCorrectAppLanguage(this) + ThemeHelper.setTheme(this) + super.onCreate(savedInstanceState) + val downloaderBinding: ActivityDownloaderBinding = ActivityDownloaderBinding.inflate(getLayoutInflater()) + setContentView(downloaderBinding.getRoot()) + setSupportActionBar(downloaderBinding.toolbarLayout.toolbar) + val actionBar: ActionBar? = getSupportActionBar() + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true) + actionBar.setTitle(R.string.downloads_title) + actionBar.setDisplayShowTitleEnabled(true) + } + getWindow().getDecorView().getViewTreeObserver() + .addOnGlobalLayoutListener(object : OnGlobalLayoutListener { + public override fun onGlobalLayout() { + updateFragments() + getWindow().getDecorView().getViewTreeObserver().removeOnGlobalLayoutListener(this) + } + }) + if (DeviceUtils.isTv(this)) { + FocusOverlayView.Companion.setupFocusObserver(this) + } + } + + private fun updateFragments() { + val fragment: MissionsFragment = MissionsFragment() + getSupportFragmentManager().beginTransaction() + .replace(R.id.frame, fragment, MISSIONS_FRAGMENT_TAG) + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .commit() + } + + public override fun onCreateOptionsMenu(menu: Menu): Boolean { + super.onCreateOptionsMenu(menu) + val inflater: MenuInflater = getMenuInflater() + inflater.inflate(R.menu.download_menu, menu) + return true + } + + public override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.getItemId()) { + android.R.id.home -> { + onBackPressed() + return true + } + + else -> return super.onOptionsItemSelected(item) + } + } + + companion object { + private val MISSIONS_FRAGMENT_TAG: String = "fragment_tag" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java deleted file mode 100644 index bbdb46292..000000000 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ /dev/null @@ -1,1149 +0,0 @@ -package org.schabi.newpipe.download; - -import static org.schabi.newpipe.extractor.stream.DeliveryMethod.PROGRESSIVE_HTTP; -import static org.schabi.newpipe.util.ListHelper.getStreamsOfSpecifiedDelivery; -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - -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.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.view.menu.ActionMenuItemView; -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.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.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.AudioTrackAdapter; -import org.schabi.newpipe.util.AudioTrackAdapter.AudioTracksWrapper; -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 icepick.Icepick; -import icepick.State; -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 ActionMenuItemView 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)); - Icepick.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.findViewById(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); - Icepick.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.getServiceId())))); - 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.getServiceId())))); - 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.getServiceId())))); - } - - 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; - - switch (checkedId) { - case R.id.audio_button: - setupAudioSpinner(); - break; - case R.id.video_button: - setupVideoSpinner(); - break; - case R.id.subtitle_button: - setupSubtitleSpinner(); - flag = false; - break; - } - - 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 + "]"); - } - - switch (parent.getId()) { - case R.id.quality_spinner: - switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) { - case R.id.video_button: - selectedVideoIndex = position; - onVideoStreamSelected(); - break; - case R.id.subtitle_button: - selectedSubtitleIndex = position; - break; - } - onItemSelectedSetFileName(); - break; - case R.id.audio_track_spinner: - final boolean trackChanged = selectedAudioTrackIndex != position; - selectedAudioTrackIndex = position; - if (trackChanged) { - updateSecondaryStreams(); - fetchStreamsSize(); - } - break; - case 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 - - switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) { - case R.id.audio_button: - case 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); - } - break; - - case 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)); - break; - } - } - } - - @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) { - assureCorrectAppLanguage(requireContext()); - 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("."); - - switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) { - case 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(); - } - break; - case 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(); - } - break; - case 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(); - } - break; - default: - 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 memory space (for api 24 and up) - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { - final long freeSpace = mainStorage.getFreeMemory(); - 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. - switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) { - case 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; - } - break; - case 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; - } - } - break; - case 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 - }; - } - break; - default: - 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.getUrl(), psName, psArgs, nearLength, new ArrayList<>(recoveryInfo)); - - Toast.makeText(context, getString(R.string.download_has_started), - Toast.LENGTH_SHORT).show(); - - dismiss(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.kt b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.kt new file mode 100644 index 000000000..77beb59eb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.kt @@ -0,0 +1,1039 @@ +package org.schabi.newpipe.download + +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.content.ServiceConnection +import android.content.SharedPreferences +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.os.Environment +import android.os.IBinder +import android.provider.Settings +import android.text.Editable +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.ActivityResultCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.IdRes +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.view.menu.ActionMenuItemView +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.nononsenseapps.filepicker.Utils +import icepick.Icepick +import icepick.State +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.functions.Consumer +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.Companion.createNotification +import org.schabi.newpipe.error.ErrorUtil.Companion.showSnackbar +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.DeliveryMethod +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 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 +import java.io.File +import java.io.IOException +import java.util.Locale +import java.util.Objects +import java.util.Optional +import java.util.function.Function + +class DownloadDialog : DialogFragment, RadioGroup.OnCheckedChangeListener, AdapterView.OnItemSelectedListener { + @State + var currentInfo: StreamInfo? = null + + @State + var wrappedVideoStreams: StreamInfoWrapper? = null + + @State + var wrappedSubtitleStreams: StreamInfoWrapper? = null + + @State + var wrappedAudioTracks: AudioTracksWrapper? = null + + @State + var selectedAudioTrackIndex: Int = 0 + + @State + var selectedVideoIndex: Int = 0 // set in the constructor + + @State + var selectedAudioIndex: Int = 0 // default to the first item + + @State + var selectedSubtitleIndex: Int = 0 // default to the first item + private var mainStorageAudio: StoredDirectoryHelper? = null + private var mainStorageVideo: StoredDirectoryHelper? = null + private var downloadManager: DownloadManager? = null + private var okButton: ActionMenuItemView? = null + private var context: Context? = null + private var askForSavePath: Boolean = false + private var audioTrackAdapter: AudioTrackAdapter? = null + private var audioStreamsAdapter: StreamItemAdapter? = null + private var videoStreamsAdapter: StreamItemAdapter? = null + private var subtitleStreamsAdapter: StreamItemAdapter? = null + private val disposables: CompositeDisposable = CompositeDisposable() + private var dialogBinding: DownloadDialogBinding? = null + private var prefs: SharedPreferences? = null + + // Variables for file name and MIME type when picking new folder because it's not set yet + private var filenameTmp: String? = null + private var mimeTmp: String? = null + private val requestDownloadSaveAsLauncher: ActivityResultLauncher = registerForActivityResult( + StartActivityForResult(), ActivityResultCallback({ result: ActivityResult -> requestDownloadSaveAsResult(result) })) + private val requestDownloadPickAudioFolderLauncher: ActivityResultLauncher = registerForActivityResult( + StartActivityForResult(), ActivityResultCallback({ result: ActivityResult -> requestDownloadPickAudioFolderResult(result) })) + private val requestDownloadPickVideoFolderLauncher: ActivityResultLauncher = registerForActivityResult( + StartActivityForResult(), ActivityResultCallback({ result: ActivityResult -> requestDownloadPickVideoFolderResult(result) })) + + /*////////////////////////////////////////////////////////////////////////// + // Instance creation + ////////////////////////////////////////////////////////////////////////// */ + constructor() + + /** + * 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) + */ + constructor(context: Context, info: StreamInfo) { + currentInfo = info + val audioStreams: List = ListHelper.getStreamsOfSpecifiedDelivery(info.getAudioStreams(), DeliveryMethod.PROGRESSIVE_HTTP) + val groupedAudioStreams: List?>? = ListHelper.getGroupedAudioStreams(context, audioStreams) + wrappedAudioTracks = AudioTracksWrapper((groupedAudioStreams)!!, context) + selectedAudioTrackIndex = ListHelper.getDefaultAudioTrackGroup(context, groupedAudioStreams) + + // TODO: Adapt this code when the downloader support other types of stream deliveries + val videoStreams: List = ListHelper.getSortedStreamVideosList( + context, + ListHelper.getStreamsOfSpecifiedDelivery(info.getVideoStreams(), DeliveryMethod.PROGRESSIVE_HTTP), + ListHelper.getStreamsOfSpecifiedDelivery(info.getVideoOnlyStreams(), DeliveryMethod.PROGRESSIVE_HTTP), + false, // If there are multiple languages available, prefer streams without audio + // to allow language selection + wrappedAudioTracks!!.size() > 1 + ) + wrappedVideoStreams = StreamInfoWrapper(videoStreams, context) + wrappedSubtitleStreams = StreamInfoWrapper( + ListHelper.getStreamsOfSpecifiedDelivery(info.getSubtitles(), DeliveryMethod.PROGRESSIVE_HTTP), context) + selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams) + } + + /*////////////////////////////////////////////////////////////////////////// + // Android lifecycle + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreate(savedInstanceState: Bundle?) { + 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)) + Icepick.restoreInstanceState(this, savedInstanceState) + audioTrackAdapter = AudioTrackAdapter(wrappedAudioTracks) + subtitleStreamsAdapter = StreamItemAdapter(wrappedSubtitleStreams) + updateSecondaryStreams() + val intent: Intent = Intent(context, DownloadManagerService::class.java) + context!!.startService(intent) + context!!.bindService(intent, object : ServiceConnection { + public override fun onServiceConnected(cname: ComponentName, service: IBinder) { + val mgr: DownloadManagerBinder = service as DownloadManagerBinder + mainStorageAudio = mgr.getMainStorageAudio() + mainStorageVideo = mgr.getMainStorageVideo() + downloadManager = mgr.getDownloadManager() + askForSavePath = mgr.askForSavePath() + okButton!!.setEnabled(true) + context!!.unbindService(this) + } + + public override fun onServiceDisconnected(name: ComponentName) { + // nothing to do + } + }, Context.BIND_AUTO_CREATE) + } + + /** + * Update the displayed video streams based on the selected audio track. + */ + private fun updateSecondaryStreams() { + val audioStreams: StreamInfoWrapper? = getWrappedAudioStreams() + val secondaryStreams: SparseArrayCompat?> = SparseArrayCompat(4) + val videoStreams: List? = wrappedVideoStreams.getStreamsList() + wrappedVideoStreams!!.resetInfo() + for (i in videoStreams!!.indices) { + if (!videoStreams.get(i)!!.isVideoOnly()) { + continue + } + val audioStream: AudioStream? = SecondaryStreamHelper.Companion.getAudioStreamFor( + (context)!!, audioStreams.getStreamsList(), (videoStreams.get(i))!!) + if (audioStream != null) { + secondaryStreams.append(i, SecondaryStreamHelper(audioStreams, audioStream)) + } else if (DEBUG) { + val 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") + } + } + } + videoStreamsAdapter = StreamItemAdapter(wrappedVideoStreams, secondaryStreams) + audioStreamsAdapter = StreamItemAdapter(audioStreams) + } + + public override fun onCreateView(inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?): View? { + if (DEBUG) { + Log.d(TAG, ("onCreateView() called with: " + + "inflater = [" + inflater + "], container = [" + container + "], " + + "savedInstanceState = [" + savedInstanceState + "]")) + } + return inflater.inflate(R.layout.download_dialog, container) + } + + public override fun onViewCreated(view: View, + savedInstanceState: Bundle?) { + 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()) + val threads: Int = prefs.getInt(getString(R.string.default_download_threads), 3) + dialogBinding!!.threadsCount.setText(threads.toString()) + dialogBinding!!.threads.setProgress(threads - 1) + dialogBinding!!.threads.setOnSeekBarChangeListener(object : SimpleOnSeekBarChangeListener() { + public override fun onProgressChanged(seekbar: SeekBar, + progress: Int, + fromUser: Boolean) { + val newProgress: Int = progress + 1 + prefs.edit().putInt(getString(R.string.default_download_threads), newProgress) + .apply() + dialogBinding!!.threadsCount.setText(newProgress.toString()) + } + }) + fetchStreamsSize() + } + + private fun initToolbar(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(View.OnClickListener({ v: View? -> dismiss() })) + toolbar.setNavigationContentDescription(R.string.cancel) + okButton = toolbar.findViewById(R.id.okay) + okButton.setEnabled(false) // disable until the download service connection is done + toolbar.setOnMenuItemClickListener(Toolbar.OnMenuItemClickListener({ item: MenuItem -> + if (item.getItemId() == R.id.okay) { + prepareSelectedDownload() + return@setOnMenuItemClickListener true + } + false + })) + } + + public override fun onDestroy() { + super.onDestroy() + disposables.clear() + } + + public override fun onDestroyView() { + dialogBinding = null + super.onDestroyView() + } + + public override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + Icepick.saveInstanceState(this, outState) + } + + /*////////////////////////////////////////////////////////////////////////// + // Video, audio and subtitle spinners + ////////////////////////////////////////////////////////////////////////// */ + private fun fetchStreamsSize() { + disposables.clear() + disposables.add(StreamInfoWrapper.Companion.fetchMoreInfoForWrapper(wrappedVideoStreams) + .subscribe(Consumer({ result: Boolean? -> + if ((dialogBinding!!.videoAudioGroup.getCheckedRadioButtonId() + == R.id.video_button)) { + setupVideoSpinner() + } + }), Consumer({ throwable: Throwable? -> + showSnackbar((context)!!, + ErrorInfo((throwable)!!, UserAction.DOWNLOAD_OPEN_DIALOG, + "Downloading video stream size", + currentInfo!!.getServiceId())) + }))) + disposables.add(StreamInfoWrapper.Companion.fetchMoreInfoForWrapper(getWrappedAudioStreams()) + .subscribe(Consumer({ result: Boolean? -> + if ((dialogBinding!!.videoAudioGroup.getCheckedRadioButtonId() + == R.id.audio_button)) { + setupAudioSpinner() + } + }), Consumer({ throwable: Throwable? -> + showSnackbar((context)!!, + ErrorInfo((throwable)!!, UserAction.DOWNLOAD_OPEN_DIALOG, + "Downloading audio stream size", + currentInfo!!.getServiceId())) + }))) + disposables.add(StreamInfoWrapper.Companion.fetchMoreInfoForWrapper(wrappedSubtitleStreams) + .subscribe(Consumer({ result: Boolean? -> + if ((dialogBinding!!.videoAudioGroup.getCheckedRadioButtonId() + == R.id.subtitle_button)) { + setupSubtitleSpinner() + } + }), Consumer({ throwable: Throwable? -> + showSnackbar((context)!!, + ErrorInfo((throwable)!!, UserAction.DOWNLOAD_OPEN_DIALOG, + "Downloading subtitle stream size", + currentInfo!!.getServiceId())) + }))) + } + + private fun setupAudioTrackSpinner() { + if (getContext() == null) { + return + } + dialogBinding!!.audioTrackSpinner.setAdapter(audioTrackAdapter) + dialogBinding!!.audioTrackSpinner.setSelection(selectedAudioTrackIndex) + } + + private fun 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( + if (wrappedAudioTracks!!.size() > 1) View.VISIBLE else View.GONE) + dialogBinding!!.audioTrackPresentInVideoText.setVisibility(View.GONE) + } + + private fun 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 fun onVideoStreamSelected() { + val isVideoOnly: Boolean = videoStreamsAdapter!!.getItem(selectedVideoIndex)!!.isVideoOnly() + dialogBinding!!.audioTrackSpinner.setVisibility( + if (isVideoOnly && wrappedAudioTracks!!.size() > 1) View.VISIBLE else View.GONE) + dialogBinding!!.audioTrackPresentInVideoText.setVisibility( + if (!isVideoOnly && wrappedAudioTracks!!.size() > 1) View.VISIBLE else View.GONE) + } + + private fun 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 fun requestDownloadPickAudioFolderResult(result: ActivityResult) { + requestDownloadPickFolderResult( + result, getString(R.string.download_path_audio_key), DownloadManager.Companion.TAG_AUDIO) + } + + private fun requestDownloadPickVideoFolderResult(result: ActivityResult) { + requestDownloadPickFolderResult( + result, getString(R.string.download_path_video_key), DownloadManager.Companion.TAG_VIDEO) + } + + private fun requestDownloadSaveAsResult(result: ActivityResult) { + if (result.getResultCode() != Activity.RESULT_OK) { + return + } + if (result.getData() == null || result.getData()!!.getData() == null) { + showFailedDialog(R.string.general_error) + return + } + if (FilePickerActivityHelper.Companion.isOwnFileUri((context)!!, result.getData()!!.getData()!!)) { + val file: File = Utils.getFileForUri(result.getData()!!.getData()!!) + checkSelectedDownload(null, Uri.fromFile(file), file.getName(), + StoredFileHelper.Companion.DEFAULT_MIME) + return + } + val docFile: DocumentFile? = 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 fun requestDownloadPickFolderResult(result: ActivityResult, + key: String, + tag: String) { + if (result.getResultCode() != Activity.RESULT_OK) { + return + } + if (result.getData() == null || result.getData()!!.getData() == null) { + showFailedDialog(R.string.general_error) + return + } + var uri: Uri? = result.getData()!!.getData() + if (FilePickerActivityHelper.Companion.isOwnFileUri((context)!!, (uri)!!)) { + uri = Uri.fromFile(Utils.getFileForUri((uri))) + } else { + context!!.grantUriPermission(context!!.getPackageName(), uri, + StoredDirectoryHelper.Companion.PERMISSION_FLAGS) + } + PreferenceManager.getDefaultSharedPreferences((context)!!).edit().putString(key, + uri.toString()).apply() + try { + val mainStorage: StoredDirectoryHelper = StoredDirectoryHelper((context)!!, (uri)!!, tag) + checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), + filenameTmp, mimeTmp) + } catch (e: IOException) { + showFailedDialog(R.string.general_error) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Listeners + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCheckedChanged(group: RadioGroup, @IdRes checkedId: Int) { + if (DEBUG) { + Log.d(TAG, ("onCheckedChanged() called with: " + + "group = [" + group + "], checkedId = [" + checkedId + "]")) + } + var flag: Boolean = true + when (checkedId) { + R.id.audio_button -> setupAudioSpinner() + R.id.video_button -> setupVideoSpinner() + R.id.subtitle_button -> { + setupSubtitleSpinner() + flag = false + } + } + dialogBinding!!.threads.setEnabled(flag) + } + + public override fun onItemSelected(parent: AdapterView<*>, + view: View, + position: Int, + id: Long) { + if (DEBUG) { + Log.d(TAG, ("onItemSelected() called with: " + + "parent = [" + parent + "], view = [" + view + "], " + + "position = [" + position + "], id = [" + id + "]")) + } + when (parent.getId()) { + R.id.quality_spinner -> { + when (dialogBinding!!.videoAudioGroup.getCheckedRadioButtonId()) { + R.id.video_button -> { + selectedVideoIndex = position + onVideoStreamSelected() + } + + R.id.subtitle_button -> selectedSubtitleIndex = position + } + onItemSelectedSetFileName() + } + + R.id.audio_track_spinner -> { + val trackChanged: Boolean = selectedAudioTrackIndex != position + selectedAudioTrackIndex = position + if (trackChanged) { + updateSecondaryStreams() + fetchStreamsSize() + } + } + + R.id.audio_stream_spinner -> selectedAudioIndex = position + } + } + + private fun onItemSelectedSetFileName() { + val fileName: String? = FilenameUtils.createFilename(getContext(), currentInfo!!.getName()) + val prevFileName: String = Optional.ofNullable(dialogBinding!!.fileName.getText()) + .map(Function({ obj: Editable -> obj.toString() })) + .orElse("") + if ((prevFileName.isEmpty() + || (prevFileName == fileName) || prevFileName.startsWith(getString(R.string.caption_file_name, fileName, "")))) { + // only update the file name field if it was not edited by the user + when (dialogBinding!!.videoAudioGroup.getCheckedRadioButtonId()) { + R.id.audio_button, R.id.video_button -> if (!(prevFileName == 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) + } + + R.id.subtitle_button -> { + val setSubtitleLanguageCode: String = 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)) + } + } + } + } + + public override fun onNothingSelected(parent: AdapterView<*>?) {} + + /*////////////////////////////////////////////////////////////////////////// + // Download + ////////////////////////////////////////////////////////////////////////// */ + protected fun setupDownloadOptions() { + setRadioButtonsState(false) + setupAudioTrackSpinner() + val isVideoStreamsAvailable: Boolean = videoStreamsAdapter!!.getCount() > 0 + val isAudioStreamsAvailable: Boolean = audioStreamsAdapter!!.getCount() > 0 + val isSubtitleStreamsAvailable: Boolean = subtitleStreamsAdapter!!.getCount() > 0 + dialogBinding!!.audioButton.setVisibility(if (isAudioStreamsAvailable) View.VISIBLE else View.GONE) + dialogBinding!!.videoButton.setVisibility(if (isVideoStreamsAvailable) View.VISIBLE else View.GONE) + dialogBinding!!.subtitleButton.setVisibility(if (isSubtitleStreamsAvailable) View.VISIBLE else View.GONE) + prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val defaultMedia: String? = prefs.getString(getString(R.string.last_used_download_type), + getString(R.string.last_download_type_video_key)) + if ((isVideoStreamsAvailable + && ((defaultMedia == getString(R.string.last_download_type_video_key))))) { + dialogBinding!!.videoButton.setChecked(true) + setupVideoSpinner() + } else if ((isAudioStreamsAvailable + && ((defaultMedia == getString(R.string.last_download_type_audio_key))))) { + dialogBinding!!.audioButton.setChecked(true) + setupAudioSpinner() + } else if ((isSubtitleStreamsAvailable + && ((defaultMedia == 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 fun setRadioButtonsState(enabled: Boolean) { + dialogBinding!!.audioButton.setEnabled(enabled) + dialogBinding!!.videoButton.setEnabled(enabled) + dialogBinding!!.subtitleButton.setEnabled(enabled) + } + + private fun getWrappedAudioStreams(): StreamInfoWrapper? { + if (selectedAudioTrackIndex < 0 || selectedAudioTrackIndex > wrappedAudioTracks!!.size()) { + return StreamInfoWrapper.Companion.empty() + } + return wrappedAudioTracks.getTracksList().get(selectedAudioTrackIndex) + } + + private fun getSubtitleIndexBy(streams: List): Int { + val preferredLocalization: Localization = NewPipe.getPreferredLocalization() + var candidate: Int = 0 + for (i in streams.indices) { + val streamLocale: Locale = streams.get(i)!!.getLocale() + val languageEquals: Boolean = (streamLocale.getLanguage() != null + ) && (preferredLocalization.getLanguageCode() != null + ) && (streamLocale.getLanguage() + == Locale(preferredLocalization.getLanguageCode()).getLanguage()) + val countryEquals: Boolean = (streamLocale.getCountry() != null + && (streamLocale.getCountry() == preferredLocalization.getCountryCode())) + if (languageEquals) { + if (countryEquals) { + return i + } + candidate = i + } + } + return candidate + } + + private fun getNameEditText(): String { + val str: String = Objects.requireNonNull(dialogBinding!!.fileName.getText()).toString() + .trim({ it <= ' ' }) + return FilenameUtils.createFilename(context, if (str.isEmpty()) currentInfo!!.getName() else str) + } + + private fun showFailedDialog(@StringRes msg: Int) { + org.schabi.newpipe.util.Localization.assureCorrectAppLanguage(requireContext()) + AlertDialog.Builder((context)!!) + .setTitle(R.string.general_error) + .setMessage(msg) + .setNegativeButton(getString(R.string.ok), null) + .show() + } + + private fun launchDirectoryPicker(launcher: ActivityResultLauncher) { + NoFileManagerSafeGuard.launchSafe(launcher, StoredDirectoryHelper.Companion.getPicker(context), TAG, + context) + } + + private fun prepareSelectedDownload() { + val mainStorage: StoredDirectoryHelper? + val format: MediaFormat? + val selectedMediaType: String + val size: Long + + // first, build the filename and get the output folder (if possible) + // later, run a very very very large file checking logic + filenameTmp = getNameEditText() + "." + when (dialogBinding!!.videoAudioGroup.getCheckedRadioButtonId()) { + 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() + } + } + + 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() + } + } + + 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 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) { + val initialPath: Uri? + if (NewPipeSettings.useStorageAccessFramework(context)) { + initialPath = null + } else { + val initialSavePath: File + 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.Companion.getNewPicker((context)!!, filenameTmp, (mimeTmp)!!, initialPath), TAG, + context) + return + } + + // Check for free memory space (for api 24 and up) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + val freeSpace: Long = mainStorage!!.getFreeMemory() + if (freeSpace <= size) { + Toast.makeText(context, getString(R.string.error_insufficient_storage), Toast.LENGTH_LONG).show() + // move the user to storage setting tab + val storageSettingsIntent: Intent = 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 fun checkSelectedDownload(mainStorage: StoredDirectoryHelper?, + targetFile: Uri?, + filename: String?, + mime: String?) { + var storage: StoredFileHelper? + try { + if (mainStorage == null) { + // using SAF on older android version + storage = StoredFileHelper(context, null, (targetFile)!!, "") + } else if (targetFile == null) { + // the file does not exist, but it is probably used in a pending download + storage = StoredFileHelper(mainStorage.getUri(), filename, mime, + mainStorage.getTag()) + } else { + // the target filename is already use, attempt to use it + storage = StoredFileHelper(context, mainStorage.getUri(), targetFile, + mainStorage.getTag()) + } + } catch (e: Exception) { + createNotification(requireContext(), + ErrorInfo(e, UserAction.DOWNLOAD_FAILED, "Getting storage")) + return + } + + // get state of potential mission referring to the same file + val state: MissionState? = downloadManager!!.checkForExistingMission(storage) + @StringRes val msgBtn: Int + @StringRes val msgBody: Int + when (state) { + MissionState.Finished -> { + msgBtn = R.string.overwrite + msgBody = R.string.overwrite_finished_warning + } + + MissionState.Pending -> { + msgBtn = R.string.overwrite + msgBody = R.string.download_already_pending + } + + MissionState.PendingRunning -> { + msgBtn = R.string.generate_unique_name + msgBody = R.string.download_already_running + } + + MissionState.None -> { + 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 + } + + else -> return // unreachable + } + val askDialog: AlertDialog.Builder = AlertDialog.Builder((context)!!) + .setTitle(R.string.download_dialog_title) + .setMessage(msgBody) + .setNegativeButton(R.string.cancel, null) + val finalStorage: StoredFileHelper = storage + if (mainStorage == null) { + // This part is called if: + // * using SAF on older android version + // * save path not defined + when (state) { + MissionState.Pending, MissionState.Finished -> askDialog.setPositiveButton(msgBtn, DialogInterface.OnClickListener({ dialog: DialogInterface, which: Int -> + dialog.dismiss() + downloadManager!!.forgetMission(finalStorage) + continueSelectedDownload(finalStorage) + })) + } + askDialog.show() + return + } + askDialog.setPositiveButton(msgBtn, DialogInterface.OnClickListener({ dialog: DialogInterface, which: Int -> + dialog.dismiss() + var storageNew: StoredFileHelper? + when (state) { + MissionState.Finished, MissionState.Pending -> { + downloadManager!!.forgetMission(finalStorage) + if (targetFile == null) { + storageNew = mainStorage.createFile(filename, mime) + } else { + try { + // try take (or steal) the file + storageNew = StoredFileHelper(context, mainStorage.getUri(), + targetFile, mainStorage.getTag()) + } catch (e: IOException) { + 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) + } + } + + MissionState.None -> { + if (targetFile == null) { + storageNew = mainStorage.createFile(filename, mime) + } else { + try { + storageNew = StoredFileHelper(context, mainStorage.getUri(), + targetFile, mainStorage.getTag()) + } catch (e: IOException) { + 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) + } + } + + MissionState.PendingRunning -> { + storageNew = mainStorage.createUniqueFile((filename)!!, mime) + if (storageNew == null) { + showFailedDialog(R.string.error_file_creation) + } else { + continueSelectedDownload(storageNew) + } + } + } + })) + askDialog.show() + } + + private fun continueSelectedDownload(storage: StoredFileHelper) { + 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 (e: IOException) { + Log.e(TAG, "Failed to truncate the file: " + storage.getUri().toString(), e) + showFailedDialog(R.string.overwrite_failed) + return + } + val selectedStream: Stream? + var secondaryStream: Stream? = null + val kind: Char + var threads: Int = dialogBinding!!.threads.getProgress() + 1 + val urls: Array + val recoveryInfo: List + var psName: String? = null + var psArgs: Array? = null + var nearLength: Long = 0 + when (dialogBinding!!.videoAudioGroup.getCheckedRadioButtonId()) { + R.id.audio_button -> { + kind = 'a' + selectedStream = audioStreamsAdapter!!.getItem(selectedAudioIndex) + if (selectedStream!!.getFormat() == MediaFormat.M4A) { + psName = Postprocessing.Companion.ALGORITHM_M4A_NO_DASH + } else if (selectedStream.getFormat() == MediaFormat.WEBMA_OPUS) { + psName = Postprocessing.Companion.ALGORITHM_OGG_FROM_WEBM_DEMUXER + } + } + + R.id.video_button -> { + kind = 'v' + selectedStream = videoStreamsAdapter!!.getItem(selectedVideoIndex) + val secondary: SecondaryStreamHelper? = videoStreamsAdapter + .getAllSecondary() + .get(wrappedVideoStreams.getStreamsList().indexOf(selectedStream)) + if (secondary != null) { + secondaryStream = secondary.getStream() + if (selectedStream!!.getFormat() == MediaFormat.MPEG_4) { + psName = Postprocessing.Companion.ALGORITHM_MP4_FROM_DASH_MUXER + } else { + psName = Postprocessing.Companion.ALGORITHM_WEBM_MUXER + } + val videoSize: Long = wrappedVideoStreams!!.getSizeInBytes( + selectedStream as VideoStream?) + + // 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 + } + } + } + + 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.Companion.ALGORITHM_TTML_CONVERTER + psArgs = arrayOf( + selectedStream.getFormat()!!.getSuffix(), + "false" // ignore empty frames + ) + } + } + + else -> return + } + if (secondaryStream == null) { + urls = arrayOf( + selectedStream!!.getContent() + ) + recoveryInfo = java.util.List.of(MissionRecoveryInfo((selectedStream))) + } else { + if (secondaryStream.getDeliveryMethod() != DeliveryMethod.PROGRESSIVE_HTTP) { + throw IllegalArgumentException(("Unsupported stream delivery format" + + secondaryStream.getDeliveryMethod())) + } + urls = arrayOf( + selectedStream!!.getContent(), secondaryStream.getContent() + ) + recoveryInfo = java.util.List.of( + MissionRecoveryInfo((selectedStream)), + MissionRecoveryInfo(secondaryStream) + ) + } + DownloadManagerService.Companion.startMission(context, urls, storage, kind, threads, + currentInfo!!.getUrl(), psName, psArgs, nearLength, ArrayList(recoveryInfo)) + Toast.makeText(context, getString(R.string.download_has_started), + Toast.LENGTH_SHORT).show() + dismiss() + } + + companion object { + private val TAG: String = "DialogFragment" + private val DEBUG: Boolean = MainActivity.Companion.DEBUG + } +} diff --git a/app/src/main/java/org/schabi/newpipe/download/LoadingDialog.java b/app/src/main/java/org/schabi/newpipe/download/LoadingDialog.java deleted file mode 100644 index 9e6861908..000000000 --- a/app/src/main/java/org/schabi/newpipe/download/LoadingDialog.java +++ /dev/null @@ -1,87 +0,0 @@ -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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/download/LoadingDialog.kt b/app/src/main/java/org/schabi/newpipe/download/LoadingDialog.kt new file mode 100644 index 000000000..8a644e534 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/download/LoadingDialog.kt @@ -0,0 +1,76 @@ +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.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. + */ +class LoadingDialog +/** + * Create a new LoadingDialog. + * + * + * + * The dialog contains a loading indicator and has a customizable title. + *

+ * Use `show()` to display the dialog to the user. + * + * + * @param title an informative title shown in the dialog's toolbar + */(@field:StringRes @param:StringRes private val title: Int) : DialogFragment() { + private var dialogLoadingBinding: DownloadLoadingDialogBinding? = null + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (DEBUG) { + Log.d(TAG, ("onCreate() called with: " + + "savedInstanceState = [" + savedInstanceState + "]")) + } + setCancelable(false) + } + + public override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?): View? { + if (DEBUG) { + Log.d(TAG, ("onCreateView() called with: " + + "inflater = [" + inflater + "], container = [" + container + "], " + + "savedInstanceState = [" + savedInstanceState + "]")) + } + return inflater.inflate(R.layout.download_loading_dialog, container) + } + + public override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + dialogLoadingBinding = DownloadLoadingDialogBinding.bind(view) + initToolbar(dialogLoadingBinding!!.toolbarLayout.toolbar) + } + + private fun initToolbar(toolbar: Toolbar) { + if (DEBUG) { + Log.d(TAG, "initToolbar() called with: toolbar = [" + toolbar + "]") + } + toolbar.setTitle(requireContext().getString(title)) + toolbar.setNavigationOnClickListener(View.OnClickListener({ v: View? -> dismiss() })) + } + + public override fun onDestroyView() { + dialogLoadingBinding = null + super.onDestroyView() + } + + companion object { + private val TAG: String = "LoadingDialog" + private val DEBUG: Boolean = MainActivity.Companion.DEBUG + } +} diff --git a/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java b/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.kt similarity index 62% rename from app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java rename to app/src/main/java/org/schabi/newpipe/error/AcraReportSender.kt index 4d9966364..e08bdd319 100644 --- a/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java +++ b/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.kt @@ -1,13 +1,11 @@ -package org.schabi.newpipe.error; +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; +import android.content.Context +import org.acra.ReportField +import org.acra.data.CrashReportData +import org.acra.sender.ReportSender +import org.schabi.newpipe.R +import org.schabi.newpipe.error.ErrorUtil.Companion.openActivity /* * Created by Christian Schabesberger on 13.09.16. @@ -28,16 +26,12 @@ import org.schabi.newpipe.R; * 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)}, +class AcraReportSender() : ReportSender { + public override fun send(context: Context, report: CrashReportData) { + openActivity(context, ErrorInfo(arrayOf(report.getString(ReportField.STACK_TRACE)), UserAction.UI_ERROR, ErrorInfo.SERVICE_NONE, "ACRA report", - R.string.app_ui_crash)); + R.string.app_ui_crash)) } } diff --git a/app/src/main/java/org/schabi/newpipe/error/AcraReportSenderFactory.java b/app/src/main/java/org/schabi/newpipe/error/AcraReportSenderFactory.kt similarity index 55% rename from app/src/main/java/org/schabi/newpipe/error/AcraReportSenderFactory.java rename to app/src/main/java/org/schabi/newpipe/error/AcraReportSenderFactory.kt index e63d55063..648884193 100644 --- a/app/src/main/java/org/schabi/newpipe/error/AcraReportSenderFactory.java +++ b/app/src/main/java/org/schabi/newpipe/error/AcraReportSenderFactory.kt @@ -1,15 +1,11 @@ -package org.schabi.newpipe.error; +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; +import android.content.Context +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. @@ -30,15 +26,13 @@ import org.schabi.newpipe.App; * 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. + * Used by ACRA in [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(); +@AutoService(ReportSenderFactory::class) +class AcraReportSenderFactory() : ReportSenderFactory { + public override fun create(context: Context, + config: CoreConfiguration): ReportSender { + return AcraReportSender() } } diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java deleted file mode 100644 index 831a8cc4b..000000000 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java +++ /dev/null @@ -1,348 +0,0 @@ -package org.schabi.newpipe.error; - -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - -import android.app.Activity; -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 android.view.Menu; -import android.view.MenuInflater; -import android.view.MenuItem; - -import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.content.IntentCompat; - -import com.grack.nanojson.JsonWriter; - -import org.schabi.newpipe.BuildConfig; -import org.schabi.newpipe.MainActivity; -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 java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.util.Arrays; -import java.util.stream.Collectors; - -/* - * Created by Christian Schabesberger on 24.10.15. - * - * Copyright (C) Christian Schabesberger 2016 - * ErrorActivity.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 . - */ - -/** - * This activity is used to show error details and allow reporting them in various ways. Use {@link - * ErrorUtil#openActivity(Context, ErrorInfo)} to correctly open this activity. - */ -public class ErrorActivity extends AppCompatActivity { - // LOG TAGS - public static final String TAG = ErrorActivity.class.toString(); - // BUNDLE TAGS - public static final String ERROR_INFO = "error_info"; - - public static final String ERROR_EMAIL_ADDRESS = "crashreport@newpipe.schabi.org"; - public static final String ERROR_EMAIL_SUBJECT = "Exception in "; - - public static final String ERROR_GITHUB_ISSUE_URL = - "https://github.com/TeamNewPipe/NewPipe/issues"; - - public static final DateTimeFormatter CURRENT_TIMESTAMP_FORMATTER = - DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); - - - private ErrorInfo errorInfo; - private String currentTimeStamp; - - private ActivityErrorBinding activityErrorBinding; - - - //////////////////////////////////////////////////////////////////////// - // Activity lifecycle - //////////////////////////////////////////////////////////////////////// - - @Override - protected void onCreate(final Bundle savedInstanceState) { - assureCorrectAppLanguage(this); - super.onCreate(savedInstanceState); - - ThemeHelper.setDayNightMode(this); - ThemeHelper.setTheme(this); - - activityErrorBinding = ActivityErrorBinding.inflate(getLayoutInflater()); - setContentView(activityErrorBinding.getRoot()); - - final Intent intent = getIntent(); - - setSupportActionBar(activityErrorBinding.toolbarLayout.toolbar); - - final ActionBar actionBar = getSupportActionBar(); - if (actionBar != null) { - actionBar.setDisplayHomeAsUpEnabled(true); - actionBar.setTitle(R.string.error_report_title); - actionBar.setDisplayShowTitleEnabled(true); - } - - errorInfo = IntentCompat.getParcelableExtra(intent, ERROR_INFO, ErrorInfo.class); - - // important add guru meditation - addGuruMeditation(); - currentTimeStamp = CURRENT_TIMESTAMP_FORMATTER.format(LocalDateTime.now()); - - activityErrorBinding.errorReportEmailButton.setOnClickListener(v -> - openPrivacyPolicyDialog(this, "EMAIL")); - - activityErrorBinding.errorReportCopyButton.setOnClickListener(v -> - ShareUtils.copyToClipboard(this, buildMarkdown())); - - activityErrorBinding.errorReportGitHubButton.setOnClickListener(v -> - openPrivacyPolicyDialog(this, "GITHUB")); - - // normal bugreport - buildInfo(errorInfo); - activityErrorBinding.errorMessageView.setText(errorInfo.getMessageStringId()); - activityErrorBinding.errorView.setText(formErrorText(errorInfo.getStackTraces())); - - // print stack trace once again for debugging: - for (final String e : errorInfo.getStackTraces()) { - Log.e(TAG, e); - } - } - - @Override - public boolean onCreateOptionsMenu(final Menu menu) { - final MenuInflater inflater = getMenuInflater(); - inflater.inflate(R.menu.error_menu, menu); - return true; - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - onBackPressed(); - return true; - case R.id.menu_item_share_error: - ShareUtils.shareText(getApplicationContext(), - getString(R.string.error_report_title), buildJson()); - return true; - default: - return false; - } - } - - private void openPrivacyPolicyDialog(final Context context, final String action) { - new 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, (dialog, which) -> - ShareUtils.openUrlInApp(context, - context.getString(R.string.privacy_policy_url))) - .setPositiveButton(R.string.accept, (dialog, which) -> { - if (action.equals("EMAIL")) { // send on email - final Intent i = new Intent(Intent.ACTION_SENDTO) - .setData(Uri.parse("mailto:")) // only email apps should handle this - .putExtra(Intent.EXTRA_EMAIL, new String[]{ERROR_EMAIL_ADDRESS}) - .putExtra(Intent.EXTRA_SUBJECT, ERROR_EMAIL_SUBJECT - + getString(R.string.app_name) + " " - + BuildConfig.VERSION_NAME) - .putExtra(Intent.EXTRA_TEXT, buildJson()); - ShareUtils.openIntentInApp(context, i); - } else if (action.equals("GITHUB")) { // open the NewPipe issue page on GitHub - ShareUtils.openUrlInApp(this, ERROR_GITHUB_ISSUE_URL); - } - }) - .setNegativeButton(R.string.decline, null) - .show(); - } - - private String formErrorText(final String[] el) { - final String separator = "-------------------------------------"; - return Arrays.stream(el) - .collect(Collectors.joining(separator + "\n", separator + "\n", separator)); - } - - /** - * Get the checked activity. - * - * @param returnActivity the activity to return to - * @return the casted return activity or null - */ - @Nullable - static Class getReturnActivity(final Class returnActivity) { - Class checkedReturnActivity = null; - if (returnActivity != null) { - if (Activity.class.isAssignableFrom(returnActivity)) { - checkedReturnActivity = returnActivity.asSubclass(Activity.class); - } else { - checkedReturnActivity = MainActivity.class; - } - } - return checkedReturnActivity; - } - - private void buildInfo(final ErrorInfo info) { - String text = ""; - - activityErrorBinding.errorInfoLabelsView.setText(getString(R.string.info_labels) - .replace("\\n", "\n")); - - text += getUserActionString(info.getUserAction()) + "\n" - + info.getRequest() + "\n" - + getContentLanguageString() + "\n" - + getContentCountryString() + "\n" - + getAppLanguage() + "\n" - + info.getServiceName() + "\n" - + currentTimeStamp + "\n" - + getPackageName() + "\n" - + BuildConfig.VERSION_NAME + "\n" - + getOsString(); - - activityErrorBinding.errorInfosView.setText(text); - } - - private String buildJson() { - try { - return JsonWriter.string() - .object() - .value("user_action", getUserActionString(errorInfo.getUserAction())) - .value("request", errorInfo.getRequest()) - .value("content_language", getContentLanguageString()) - .value("content_country", getContentCountryString()) - .value("app_language", getAppLanguage()) - .value("service", errorInfo.getServiceName()) - .value("package", getPackageName()) - .value("version", BuildConfig.VERSION_NAME) - .value("os", getOsString()) - .value("time", currentTimeStamp) - .array("exceptions", Arrays.asList(errorInfo.getStackTraces())) - .value("user_comment", activityErrorBinding.errorCommentBox.getText() - .toString()) - .end() - .done(); - } catch (final Throwable e) { - Log.e(TAG, "Error while erroring: Could not build json"); - e.printStackTrace(); - } - - return ""; - } - - private String buildMarkdown() { - try { - final StringBuilder htmlErrorReport = new StringBuilder(); - - final String userComment = activityErrorBinding.errorCommentBox.getText().toString(); - if (!userComment.isEmpty()) { - htmlErrorReport.append(userComment).append("\n"); - } - - // basic error info - htmlErrorReport - .append("## Exception") - .append("\n* __User Action:__ ") - .append(getUserActionString(errorInfo.getUserAction())) - .append("\n* __Request:__ ").append(errorInfo.getRequest()) - .append("\n* __Content Country:__ ").append(getContentCountryString()) - .append("\n* __Content Language:__ ").append(getContentLanguageString()) - .append("\n* __App Language:__ ").append(getAppLanguage()) - .append("\n* __Service:__ ").append(errorInfo.getServiceName()) - .append("\n* __Version:__ ").append(BuildConfig.VERSION_NAME) - .append("\n* __OS:__ ").append(getOsString()).append("\n"); - - - // Collapse all logs to a single paragraph when there are more than one - // to keep the GitHub issue clean. - if (errorInfo.getStackTraces().length > 1) { - htmlErrorReport - .append("
Exceptions (") - .append(errorInfo.getStackTraces().length) - .append(")

\n"); - } - - // add the logs - for (int i = 0; i < errorInfo.getStackTraces().length; i++) { - htmlErrorReport.append("

Crash log "); - if (errorInfo.getStackTraces().length > 1) { - htmlErrorReport.append(i + 1); - } - htmlErrorReport.append("") - .append("

\n") - .append("\n```\n").append(errorInfo.getStackTraces()[i]).append("\n```\n") - .append("

\n"); - } - - // make sure to close everything - if (errorInfo.getStackTraces().length > 1) { - htmlErrorReport.append("

\n"); - } - htmlErrorReport.append("
\n"); - return htmlErrorReport.toString(); - } catch (final Throwable e) { - Log.e(TAG, "Error while erroring: Could not build markdown"); - e.printStackTrace(); - return ""; - } - } - - private String getUserActionString(final UserAction userAction) { - if (userAction == null) { - return "Your description is in another castle."; - } else { - return userAction.getMessage(); - } - } - - private String getContentCountryString() { - return Localization.getPreferredContentCountry(this).getCountryCode(); - } - - private String getContentLanguageString() { - return Localization.getPreferredLocalization(this).getLocalizationCode(); - } - - private String getAppLanguage() { - return Localization.getAppLocale(getApplicationContext()).toString(); - } - - private String getOsString() { - final String osBase = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M - ? Build.VERSION.BASE_OS : "Android"; - return System.getProperty("os.name") - + " " + (osBase.isEmpty() ? "Android" : osBase) - + " " + Build.VERSION.RELEASE - + " - " + Build.VERSION.SDK_INT; - } - - private void addGuruMeditation() { - //just an easter egg - String text = activityErrorBinding.errorSorryView.getText().toString(); - text += "\n" + getString(R.string.guru_meditation); - activityErrorBinding.errorSorryView.setText(text); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.kt new file mode 100644 index 000000000..52183a00a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.kt @@ -0,0 +1,309 @@ +package org.schabi.newpipe.error + +import android.R +import android.content.Context +import android.net.Uri +import android.os.Build +import android.util.Log +import android.view.Menu +import android.view.MenuItem +import android.view.View +import androidx.appcompat.app.ActionBar +import androidx.appcompat.app.AlertDialog +import com.grack.nanojson.JsonWriter +import org.schabi.newpipe.BuildConfig +import org.schabi.newpipe.databinding.ActivityErrorBinding +import org.schabi.newpipe.util.Localization +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter +import java.util.Arrays +import java.util.stream.Collectors + +/* + * Created by Christian Schabesberger on 24.10.15. + * + * Copyright (C) Christian Schabesberger 2016 + * ErrorActivity.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 . + */ +/** + * 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 var errorInfo: ErrorInfo? = null + private var currentTimeStamp: String? = null + private var activityErrorBinding: ActivityErrorBinding? = null + + //////////////////////////////////////////////////////////////////////// + // Activity lifecycle + //////////////////////////////////////////////////////////////////////// + protected override fun onCreate(savedInstanceState: Bundle?) { + Localization.assureCorrectAppLanguage(this) + super.onCreate(savedInstanceState) + ThemeHelper.setDayNightMode(this) + ThemeHelper.setTheme(this) + activityErrorBinding = ActivityErrorBinding.inflate(getLayoutInflater()) + setContentView(activityErrorBinding!!.getRoot()) + val intent: Intent = getIntent() + setSupportActionBar(activityErrorBinding!!.toolbarLayout.toolbar) + val actionBar: ActionBar? = getSupportActionBar() + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true) + actionBar.setTitle(R.string.error_report_title) + actionBar.setDisplayShowTitleEnabled(true) + } + errorInfo = IntentCompat.getParcelableExtra(intent, ERROR_INFO, ErrorInfo::class.java) + + // important add guru meditation + addGuruMeditation() + currentTimeStamp = CURRENT_TIMESTAMP_FORMATTER.format(LocalDateTime.now()) + activityErrorBinding!!.errorReportEmailButton.setOnClickListener(View.OnClickListener({ v: View? -> openPrivacyPolicyDialog(this, "EMAIL") })) + activityErrorBinding!!.errorReportCopyButton.setOnClickListener(View.OnClickListener({ v: View? -> ShareUtils.copyToClipboard(this, buildMarkdown()) })) + activityErrorBinding!!.errorReportGitHubButton.setOnClickListener(View.OnClickListener({ v: View? -> openPrivacyPolicyDialog(this, "GITHUB") })) + + // normal bugreport + buildInfo(errorInfo) + activityErrorBinding!!.errorMessageView.setText(errorInfo!!.messageStringId) + activityErrorBinding!!.errorView.setText(formErrorText(errorInfo!!.stackTraces)) + + // print stack trace once again for debugging: + for (e: String? in errorInfo!!.stackTraces) { + Log.e(TAG, (e)!!) + } + } + + public override fun onCreateOptionsMenu(menu: Menu): Boolean { + val inflater: MenuInflater = getMenuInflater() + inflater.inflate(R.menu.error_menu, menu) + return true + } + + public override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.getItemId()) { + R.id.home -> { + onBackPressed() + return true + } + + R.id.menu_item_share_error -> { + shareText(getApplicationContext(), + getString(R.string.error_report_title), buildJson()) + return true + } + + else -> return false + } + } + + private fun openPrivacyPolicyDialog(context: Context, action: String) { + AlertDialog.Builder(context) + .setIcon(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, DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> + ShareUtils.openUrlInApp(context, + context.getString(R.string.privacy_policy_url)) + })) + .setPositiveButton(R.string.accept, DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> + if ((action == "EMAIL")) { // send on email + val i: Intent = Intent(Intent.ACTION_SENDTO) + .setData(Uri.parse("mailto:")) // only email apps should handle this + .putExtra(Intent.EXTRA_EMAIL, arrayOf(ERROR_EMAIL_ADDRESS)) + .putExtra(Intent.EXTRA_SUBJECT, (ERROR_EMAIL_SUBJECT + + getString(R.string.app_name) + " " + + BuildConfig.VERSION_NAME)) + .putExtra(Intent.EXTRA_TEXT, buildJson()) + ShareUtils.openIntentInApp(context, i) + } 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(el: Array): String { + val separator: String = "-------------------------------------" + return Arrays.stream(el) + .collect(Collectors.joining(separator + "\n", separator + "\n", separator)) + } + + private fun buildInfo(info: ErrorInfo?) { + var text: String? = "" + activityErrorBinding!!.errorInfoLabelsView.setText(getString(R.string.info_labels) + .replace("\\n", "\n")) + text += ((getUserActionString(info!!.userAction) + "\n" + + info.request + "\n" + + contentLanguageString + "\n" + + contentCountryString + "\n" + + appLanguage + "\n" + + info.serviceName + "\n" + + currentTimeStamp + "\n" + + getPackageName() + "\n" + + BuildConfig.VERSION_NAME).toString() + "\n" + + osString) + activityErrorBinding!!.errorInfosView.setText(text) + } + + private fun buildJson(): String { + try { + return JsonWriter.string() + .`object`() + .value("user_action", getUserActionString(errorInfo!!.userAction)) + .value("request", errorInfo!!.request) + .value("content_language", contentLanguageString) + .value("content_country", contentCountryString) + .value("app_language", appLanguage) + .value("service", errorInfo!!.serviceName) + .value("package", getPackageName()) + .value("version", BuildConfig.VERSION_NAME) + .value("os", osString) + .value("time", currentTimeStamp) + .array("exceptions", Arrays.asList(*errorInfo!!.stackTraces)) + .value("user_comment", activityErrorBinding!!.errorCommentBox.getText() + .toString()) + .end() + .done() + } catch (e: Throwable) { + Log.e(TAG, "Error while erroring: Could not build json") + e.printStackTrace() + } + return "" + } + + private fun buildMarkdown(): String { + try { + val htmlErrorReport: StringBuilder = StringBuilder() + val userComment: String = activityErrorBinding!!.errorCommentBox.getText().toString() + if (!userComment.isEmpty()) { + htmlErrorReport.append(userComment).append("\n") + } + + // basic error info + htmlErrorReport + .append("## Exception") + .append("\n* __User Action:__ ") + .append(getUserActionString(errorInfo!!.userAction)) + .append("\n* __Request:__ ").append(errorInfo!!.request) + .append("\n* __Content Country:__ ").append(contentCountryString) + .append("\n* __Content Language:__ ").append(contentLanguageString) + .append("\n* __App Language:__ ").append(appLanguage) + .append("\n* __Service:__ ").append(errorInfo!!.serviceName) + .append("\n* __Version:__ ").append(BuildConfig.VERSION_NAME) + .append("\n* __OS:__ ").append(osString).append("\n") + + + // Collapse all logs to a single paragraph when there are more than one + // to keep the GitHub issue clean. + if (errorInfo!!.stackTraces.size > 1) { + htmlErrorReport + .append("
Exceptions (") + .append(errorInfo!!.stackTraces.size) + .append(")

\n") + } + + // add the logs + for (i in errorInfo!!.stackTraces.indices) { + htmlErrorReport.append("

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

\n") + .append("\n```\n").append(errorInfo!!.stackTraces.get(i)).append("\n```\n") + .append("

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

\n") + } + htmlErrorReport.append("
\n") + return htmlErrorReport.toString() + } catch (e: Throwable) { + Log.e(TAG, "Error while erroring: Could not build markdown") + e.printStackTrace() + return "" + } + } + + private fun getUserActionString(userAction: UserAction?): String? { + if (userAction == null) { + return "Your description is in another castle." + } else { + return userAction.getMessage() + } + } + + private val contentCountryString: String + private get() { + return Localization.getPreferredContentCountry(this).getCountryCode() + } + private val contentLanguageString: String + private get() { + return Localization.getPreferredLocalization(this).getLocalizationCode() + } + private val appLanguage: String + private get() { + return Localization.getAppLocale(getApplicationContext()).toString() + } + private val osString: String + private get() { + val osBase: String = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) Build.VERSION.BASE_OS else "Android" + return (System.getProperty("os.name") + + " " + (if (osBase.isEmpty()) "Android" else osBase) + + " " + Build.VERSION.RELEASE + + " - " + Build.VERSION.SDK_INT) + } + + private fun addGuruMeditation() { + //just an easter egg + var text: String? = activityErrorBinding!!.errorSorryView.getText().toString() + text += "\n" + getString(R.string.guru_meditation) + activityErrorBinding!!.errorSorryView.setText(text) + } + + companion object { + // LOG TAGS + val TAG: String = ErrorActivity::class.java.toString() + + // BUNDLE TAGS + val ERROR_INFO: String = "error_info" + val ERROR_EMAIL_ADDRESS: String = "crashreport@newpipe.schabi.org" + val ERROR_EMAIL_SUBJECT: String = "Exception in " + val ERROR_GITHUB_ISSUE_URL: String = "https://github.com/TeamNewPipe/NewPipe/issues" + val CURRENT_TIMESTAMP_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") + + /** + * Get the checked activity. + * + * @param returnActivity the activity to return to + * @return the casted return activity or null + */ + @JvmStatic + fun getReturnActivity(returnActivity: Class<*>?): Class? { + var checkedReturnActivity: Class? = null + if (returnActivity != null) { + if (Activity::class.java.isAssignableFrom(returnActivity)) { + checkedReturnActivity = returnActivity.asSubclass(Activity::class.java) + } else { + checkedReturnActivity = MainActivity::class.java + } + } + return checkedReturnActivity + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java b/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java deleted file mode 100644 index 3c14cfe4c..000000000 --- a/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java +++ /dev/null @@ -1,238 +0,0 @@ -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; - -import java.io.UnsupportedEncodingException; - -/* - * 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 - 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 { - String abuseCookie = url.substring(abuseStart + 13, abuseEnd); - abuseCookie = Utils.decodeUrlUtf8(abuseCookie); - handleCookies(abuseCookie); - } catch (UnsupportedEncodingException | StringIndexOutOfBoundsException e) { - if (MainActivity.DEBUG) { - e.printStackTrace(); - Log.d(TAG, "handleCookiesFromUrl: invalid google abuse starting at " - + abuseStart + " and ending at " + abuseEnd + " for url " + url); - } - } - } - } - - 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; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.kt b/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.kt new file mode 100644 index 000000000..9fdb5199a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.kt @@ -0,0 +1,217 @@ +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.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 +import java.io.UnsupportedEncodingException + +/* + * 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 . + */ +class ReCaptchaActivity() : AppCompatActivity() { + private var recaptchaBinding: ActivityRecaptchaBinding? = null + private var foundCookies: String = "" + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + ThemeHelper.setTheme(this) + super.onCreate(savedInstanceState) + recaptchaBinding = ActivityRecaptchaBinding.inflate(getLayoutInflater()) + setContentView(recaptchaBinding!!.getRoot()) + setSupportActionBar(recaptchaBinding!!.toolbar) + val url: String = sanitizeRecaptchaUrl(getIntent().getStringExtra(RECAPTCHA_URL_EXTRA)) + // set return to Cancel by default + setResult(RESULT_CANCELED) + + // enable Javascript + val webSettings: WebSettings = recaptchaBinding!!.reCaptchaWebView.getSettings() + webSettings.setJavaScriptEnabled(true) + webSettings.setUserAgentString(DownloaderImpl.Companion.USER_AGENT) + recaptchaBinding!!.reCaptchaWebView.setWebViewClient(object : WebViewClient() { + public override fun shouldOverrideUrlLoading(view: WebView, + request: WebResourceRequest): Boolean { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "shouldOverrideUrlLoading: url=" + request.getUrl().toString()) + } + handleCookiesFromUrl(request.getUrl().toString()) + return false + } + + public override fun onPageFinished(view: WebView, url: String) { + 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) + } + + public override fun onCreateOptionsMenu(menu: Menu): Boolean { + getMenuInflater().inflate(R.menu.menu_recaptcha, menu) + val 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 + } + + public override fun onBackPressed() { + saveCookiesAndFinish() + } + + public override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.getItemId() == R.id.menu_item_done) { + saveCookiesAndFinish() + return true + } + return false + } + + private fun saveCookiesAndFinish() { + // try to get cookies of unclosed page + handleCookiesFromUrl(recaptchaBinding!!.reCaptchaWebView.getUrl()) + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "saveCookiesAndFinish: foundCookies=" + foundCookies) + } + if (!foundCookies.isEmpty()) { + // save cookies to preferences + val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences( + getApplicationContext()) + val key: String = getApplicationContext().getString(R.string.recaptcha_cookies_key) + prefs.edit().putString(key, foundCookies).apply() + + // give cookies to Downloader class + DownloaderImpl.Companion.getInstance()!!.setCookie(RECAPTCHA_COOKIES_KEY, foundCookies) + setResult(RESULT_OK) + } + + // Navigate to blank page (unloads youtube to prevent background playback) + recaptchaBinding!!.reCaptchaWebView.loadUrl("about:blank") + val intent: Intent = Intent(this, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + NavUtils.navigateUpTo(this, intent) + } + + private fun handleCookiesFromUrl(url: String?) { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "handleCookiesFromUrl: url=" + (if (url == null) "null" else url)) + } + if (url == null) { + return + } + val cookies: String = CookieManager.getInstance().getCookie(url) + handleCookies(cookies) + + // sometimes cookies are inside the url + val abuseStart: Int = url.indexOf("google_abuse=") + if (abuseStart != -1) { + val abuseEnd: Int = url.indexOf("+path") + try { + var abuseCookie: String? = url.substring(abuseStart + 13, abuseEnd) + abuseCookie = Utils.decodeUrlUtf8(abuseCookie) + handleCookies(abuseCookie) + } catch (e: UnsupportedEncodingException) { + if (MainActivity.Companion.DEBUG) { + e.printStackTrace() + Log.d(TAG, ("handleCookiesFromUrl: invalid google abuse starting at " + + abuseStart + " and ending at " + abuseEnd + " for url " + url)) + } + } catch (e: StringIndexOutOfBoundsException) { + if (MainActivity.Companion.DEBUG) { + e.printStackTrace() + Log.d(TAG, ("handleCookiesFromUrl: invalid google abuse starting at " + + abuseStart + " and ending at " + abuseEnd + " for url " + url)) + } + } + } + } + + private fun handleCookies(cookies: String?) { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "handleCookies: cookies=" + (if (cookies == null) "null" else cookies)) + } + if (cookies == null) { + return + } + addYoutubeCookies(cookies) + // add here methods to extract cookies for other services + } + + private fun addYoutubeCookies(cookies: String) { + 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 fun addCookie(cookie: String) { + if (foundCookies.contains(cookie)) { + return + } + if (foundCookies.isEmpty() || foundCookies.endsWith("; ")) { + foundCookies += cookie + } else if (foundCookies.endsWith(";")) { + foundCookies += " " + cookie + } else { + foundCookies += "; " + cookie + } + } + + companion object { + val RECAPTCHA_REQUEST: Int = 10 + val RECAPTCHA_URL_EXTRA: String = "recaptcha_url_extra" + val TAG: String = ReCaptchaActivity::class.java.toString() + val YT_URL: String = "https://www.youtube.com" + val RECAPTCHA_COOKIES_KEY: String = "recaptcha_cookies" + fun sanitizeRecaptchaUrl(url: String?): String { + if (url == null || url.trim({ it <= ' ' }).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", "") + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/error/UserAction.java b/app/src/main/java/org/schabi/newpipe/error/UserAction.kt similarity index 81% rename from app/src/main/java/org/schabi/newpipe/error/UserAction.java rename to app/src/main/java/org/schabi/newpipe/error/UserAction.kt index c8701cd77..2af11bac5 100644 --- a/app/src/main/java/org/schabi/newpipe/error/UserAction.java +++ b/app/src/main/java/org/schabi/newpipe/error/UserAction.kt @@ -1,9 +1,9 @@ -package org.schabi.newpipe.error; +package org.schabi.newpipe.error /** * The user actions that can cause an error. */ -public enum UserAction { +enum class UserAction(val message: String) { USER_REPORT("user report"), UI_ERROR("ui error"), SUBSCRIPTION_CHANGE("subscription change"), @@ -31,15 +31,6 @@ public enum UserAction { 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"); + OPEN_INFO_ITEM_DIALOG("open info item dialog") - private final String message; - - UserAction(final String message) { - this.message = message; - } - - public String getMessage() { - return message; - } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/BackPressable.java b/app/src/main/java/org/schabi/newpipe/fragments/BackPressable.kt similarity index 65% rename from app/src/main/java/org/schabi/newpipe/fragments/BackPressable.java rename to app/src/main/java/org/schabi/newpipe/fragments/BackPressable.kt index 6add5eb09..d3592ce9d 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/BackPressable.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/BackPressable.kt @@ -1,13 +1,13 @@ -package org.schabi.newpipe.fragments; +package org.schabi.newpipe.fragments /** * Indicates that the current fragment can handle back presses. */ -public interface BackPressable { +open interface BackPressable { /** * A back press was delegated to this fragment. * * @return if the back press was handled */ - boolean onBackPressed(); + fun onBackPressed(): Boolean } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java deleted file mode 100644 index a3d3d8b60..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java +++ /dev/null @@ -1,226 +0,0 @@ -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 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; - -import icepick.State; - -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(); - } - - 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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.kt new file mode 100644 index 000000000..6129d7235 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.kt @@ -0,0 +1,197 @@ +package org.schabi.newpipe.fragments + +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.ProgressBar +import android.widget.TextView +import androidx.annotation.StringRes +import icepick.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.error.ErrorUtil.Companion.showSnackbar +import org.schabi.newpipe.ktx.animate +import org.schabi.newpipe.util.InfoCache +import java.util.concurrent.atomic.AtomicBoolean + +abstract class BaseStateFragment() : BaseFragment(), ViewContract { + @State + protected var wasLoading: AtomicBoolean = AtomicBoolean() + protected var isLoading: AtomicBoolean = AtomicBoolean() + protected var emptyStateView: View? = null + protected var emptyStateMessageView: TextView? = null + private var loadingProgressBar: ProgressBar? = null + private var errorPanelHelper: ErrorPanelHelper? = null + + @State + protected var lastPanelError: ErrorInfo? = null + public override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) { + super.onViewCreated(rootView, savedInstanceState) + doInitialLoadLogic() + } + + public override fun onPause() { + super.onPause() + wasLoading.set(isLoading.get()) + } + + public override fun onResume() { + super.onResume() + if (lastPanelError != null) { + showError(lastPanelError!!) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + ////////////////////////////////////////////////////////////////////////// */ + override fun initViews(rootView: View, savedInstanceState: Bundle?) { + 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 = ErrorPanelHelper(this, rootView, Runnable({ onRetryButtonClicked() })) + } + + public override fun onDestroyView() { + super.onDestroyView() + if (errorPanelHelper != null) { + errorPanelHelper!!.dispose() + } + emptyStateView = null + emptyStateMessageView = null + } + + protected fun onRetryButtonClicked() { + reloadContent() + } + + open fun reloadContent() { + startLoading(true) + } + + /*////////////////////////////////////////////////////////////////////////// + // Load + ////////////////////////////////////////////////////////////////////////// */ + protected open fun doInitialLoadLogic() { + startLoading(true) + } + + protected open fun startLoading(forceLoad: Boolean) { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "startLoading() called with: forceLoad = [" + forceLoad + "]") + } + showLoading() + isLoading.set(true) + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + ////////////////////////////////////////////////////////////////////////// */ + public override fun showLoading() { + if (emptyStateView != null) { + emptyStateView!!.animate(false, 150) + } + if (loadingProgressBar != null) { + loadingProgressBar!!.animate(true, 400) + } + hideErrorPanel() + } + + public override fun hideLoading() { + if (emptyStateView != null) { + emptyStateView!!.animate(false, 150) + } + if (loadingProgressBar != null) { + loadingProgressBar!!.animate(false, 0) + } + hideErrorPanel() + } + + public override fun showEmptyState() { + isLoading.set(false) + if (emptyStateView != null) { + emptyStateView!!.animate(true, 200) + } + if (loadingProgressBar != null) { + loadingProgressBar!!.animate(false, 0) + } + hideErrorPanel() + } + + public override fun handleResult(result: I) { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "handleResult() called with: result = [" + result + "]") + } + hideLoading() + } + + public override fun handleError() { + isLoading.set(false) + InfoCache.Companion.getInstance().clearCache() + if (emptyStateView != null) { + emptyStateView!!.animate(false, 150) + } + if (loadingProgressBar != null) { + loadingProgressBar!!.animate(false, 0) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Error handling + ////////////////////////////////////////////////////////////////////////// */ + fun showError(errorInfo: ErrorInfo) { + handleError() + if (isDetached() || isRemoving()) { + if (BaseFragment.Companion.DEBUG) { + Log.w(TAG, "showError() is detached or removing = [" + errorInfo + "]") + } + return + } + errorPanelHelper!!.showError(errorInfo) + lastPanelError = errorInfo + } + + fun showTextError(errorString: String) { + handleError() + if (isDetached() || isRemoving()) { + if (BaseFragment.Companion.DEBUG) { + Log.w(TAG, "showTextError() is detached or removing = [" + errorString + "]") + } + return + } + errorPanelHelper!!.showTextError(errorString) + } + + protected fun setEmptyStateMessage(@StringRes text: Int) { + if (emptyStateMessageView != null) { + emptyStateMessageView!!.setText(text) + } + } + + fun hideErrorPanel() { + errorPanelHelper!!.hide() + lastPanelError = null + } + + val isErrorPanelVisible: Boolean + get() { + return errorPanelHelper!!.isVisible() + } + + /** + * Directly calls [ErrorUtil.showSnackbar], that shows a snackbar if + * a valid view can be found, otherwise creates an error report notification. + * + * @param errorInfo The error information + */ + fun showSnackBarError(errorInfo: ErrorInfo) { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "showSnackBarError() called with: errorInfo = [" + errorInfo + "]") + } + showSnackbar(this, errorInfo) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/BlankFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/BlankFragment.java deleted file mode 100644 index fe4eef37a..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/BlankFragment.java +++ /dev/null @@ -1,30 +0,0 @@ -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 BlankFragment extends BaseFragment { - @Nullable - @Override - public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container, - final Bundle savedInstanceState) { - setTitle("NewPipe"); - return inflater.inflate(R.layout.fragment_blank, container, false); - } - - @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 ... - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/BlankFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/BlankFragment.kt new file mode 100644 index 000000000..007edde3c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/BlankFragment.kt @@ -0,0 +1,24 @@ +package org.schabi.newpipe.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.schabi.newpipe.BaseFragment +import org.schabi.newpipe.R + +class BlankFragment() : BaseFragment() { + public override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + setTitle("NewPipe") + return inflater.inflate(R.layout.fragment_blank, container, false) + } + + public override fun 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 ... + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java deleted file mode 100644 index d4e73bcac..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java +++ /dev/null @@ -1,33 +0,0 @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.kt new file mode 100644 index 000000000..72d7477b7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.kt @@ -0,0 +1,30 @@ +package org.schabi.newpipe.fragments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import org.schabi.newpipe.BaseFragment +import org.schabi.newpipe.R + +class EmptyFragment() : BaseFragment() { + public override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + val showMessage: Boolean = getArguments()!!.getBoolean(SHOW_MESSAGE) + val view: View = inflater.inflate(R.layout.fragment_empty, container, false) + view.findViewById(R.id.empty_state_view).setVisibility( + if (showMessage) View.VISIBLE else View.GONE) + return view + } + + companion object { + private val SHOW_MESSAGE: String = "SHOW_MESSAGE" + fun newInstance(showMessage: Boolean): EmptyFragment { + val emptyFragment: EmptyFragment = EmptyFragment() + val bundle: Bundle = Bundle(1) + bundle.putBoolean(SHOW_MESSAGE, showMessage) + emptyFragment.setArguments(bundle) + return emptyFragment + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java deleted file mode 100644 index 52a41d38f..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java +++ /dev/null @@ -1,342 +0,0 @@ -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.ErrorUtil; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -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 ? R.attr.colorSecondary : R.attr.colorPrimary)); - - @ColorInt final int iconColor = bottom - ? ThemeHelper.resolveColorFromAttr(requireContext(), 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 ExtractionException e) { - ErrorUtil.showUiErrorSnackbar(context, "Getting fragment item", e); - return new BlankFragment(); - } - - 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); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.kt new file mode 100644 index 000000000..24ca2efdb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.kt @@ -0,0 +1,284 @@ +package org.schabi.newpipe.fragments + +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.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 com.google.android.material.tabs.TabLayout.OnTabSelectedListener +import org.schabi.newpipe.BaseFragment +import org.schabi.newpipe.R +import org.schabi.newpipe.databinding.FragmentMainBinding +import org.schabi.newpipe.error.ErrorUtil.Companion.showUiErrorSnackbar +import org.schabi.newpipe.extractor.exceptions.ExtractionException +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.settings.tabs.TabsManager.SavedTabsChangeListener +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.function.Consumer + +class MainFragment() : BaseFragment(), OnTabSelectedListener { + private var binding: FragmentMainBinding? = null + private var pagerAdapter: SelectedTabsPagerAdapter? = null + private val tabsList: MutableList = ArrayList() + private var tabsManager: TabsManager? = null + private var hasTabsChanged: Boolean = false + private var prefs: SharedPreferences? = null + private var youtubeRestrictedModeEnabled: Boolean = false + private var youtubeRestrictedModeEnabledKey: String? = null + private var mainTabsPositionBottom: Boolean = false + private var mainTabsPositionKey: String? = null + + /*////////////////////////////////////////////////////////////////////////// + // Fragment's LifeCycle + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + tabsManager = TabsManager.Companion.getManager((activity)!!) + tabsManager!!.setSavedTabsListener(SavedTabsChangeListener({ + if (BaseFragment.Companion.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) + } + + public override fun onCreateView(inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_main, container, false) + } + + override fun initViews(rootView: View, savedInstanceState: Bundle?) { + super.initViews(rootView, savedInstanceState) + binding = FragmentMainBinding.bind(rootView) + binding!!.mainTabLayout.setupWithViewPager(binding!!.pager) + binding!!.mainTabLayout.addOnTabSelectedListener(this) + setupTabs() + updateTabLayoutPosition() + } + + public override fun onResume() { + super.onResume() + val newYoutubeRestrictedModeEnabled: Boolean = prefs!!.getBoolean(youtubeRestrictedModeEnabledKey, false) + if (youtubeRestrictedModeEnabled != newYoutubeRestrictedModeEnabled || hasTabsChanged) { + youtubeRestrictedModeEnabled = newYoutubeRestrictedModeEnabled + setupTabs() + } + val newMainTabsPosition: Boolean = prefs!!.getBoolean(mainTabsPositionKey, false) + if (mainTabsPositionBottom != newMainTabsPosition) { + mainTabsPositionBottom = newMainTabsPosition + updateTabLayoutPosition() + } + } + + public override fun onDestroy() { + super.onDestroy() + tabsManager!!.unsetSavedTabsListener() + if (binding != null) { + binding!!.pager.setAdapter(null) + binding = null + } + } + + public override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + /*////////////////////////////////////////////////////////////////////////// + // Menu + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreateOptionsMenu(menu: Menu, + inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, ("onCreateOptionsMenu() called with: " + + "menu = [" + menu + "], inflater = [" + inflater + "]")) + } + inflater.inflate(R.menu.menu_main_fragment, menu) + val supportActionBar: ActionBar? = activity!!.getSupportActionBar() + if (supportActionBar != null) { + supportActionBar.setDisplayHomeAsUpEnabled(false) + } + } + + public override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.getItemId() == R.id.action_search) { + try { + NavigationHelper.openSearchFragment(getFM(), + ServiceHelper.getSelectedServiceId((activity)!!), "") + } catch (e: Exception) { + showUiErrorSnackbar(this, "Opening search fragment", e) + } + return true + } + return super.onOptionsItemSelected(item) + } + + /*////////////////////////////////////////////////////////////////////////// + // Tabs + ////////////////////////////////////////////////////////////////////////// */ + private fun setupTabs() { + tabsList.clear() + tabsList.addAll((tabsManager!!.getTabs())!!) + if (pagerAdapter == null || !pagerAdapter!!.sameTabs(tabsList)) { + pagerAdapter = SelectedTabsPagerAdapter(requireContext(), + getChildFragmentManager(), tabsList) + } + binding!!.pager.setAdapter(null) + binding!!.pager.setAdapter(pagerAdapter) + updateTabsIconAndDescription() + updateTitleForTab(binding!!.pager.getCurrentItem()) + hasTabsChanged = false + } + + private fun updateTabsIconAndDescription() { + for (i in tabsList.indices) { + val tabToSet: TabLayout.Tab? = binding!!.mainTabLayout.getTabAt(i) + if (tabToSet != null) { + val tab: Tab? = tabsList.get(i) + tabToSet.setIcon(tab!!.getTabIconRes(requireContext())) + tabToSet.setContentDescription(tab.getTabName(requireContext())) + } + } + } + + private fun updateTitleForTab(tabPosition: Int) { + setTitle(tabsList.get(tabPosition)!!.getTabName(requireContext())) + } + + fun commitPlaylistTabs() { + pagerAdapter!!.getLocalPlaylistFragments() + .stream() + .forEach(Consumer({ obj: LocalPlaylistFragment? -> obj!!.saveImmediate() })) + } + + private fun updateTabLayoutPosition() { + val tabLayout: ScrollableTabLayout = binding!!.mainTabLayout + val viewPager: ViewPager = binding!!.pager + val bottom: Boolean = mainTabsPositionBottom + + // change layout params to make the tab layout appear either at the top or at the bottom + val tabParams: RelativeLayout.LayoutParams = tabLayout.getLayoutParams() as RelativeLayout.LayoutParams + val pagerParams: RelativeLayout.LayoutParams = viewPager.getLayoutParams() as RelativeLayout.LayoutParams + tabParams.removeRule(if (bottom) RelativeLayout.ALIGN_PARENT_TOP else RelativeLayout.ALIGN_PARENT_BOTTOM) + tabParams.addRule(if (bottom) RelativeLayout.ALIGN_PARENT_BOTTOM else RelativeLayout.ALIGN_PARENT_TOP) + pagerParams.removeRule(if (bottom) RelativeLayout.BELOW else RelativeLayout.ABOVE) + pagerParams.addRule(if (bottom) RelativeLayout.ABOVE else RelativeLayout.BELOW, R.id.main_tab_layout) + tabLayout.setSelectedTabIndicatorGravity( + if (bottom) TabLayout.INDICATOR_GRAVITY_TOP else TabLayout.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(), + if (bottom) R.attr.colorSecondary else R.attr.colorPrimary)) + @ColorInt val iconColor: Int = if (bottom) ThemeHelper.resolveColorFromAttr(requireContext(), R.attr.colorAccent) else Color.WHITE + tabLayout.setTabRippleColor(ColorStateList.valueOf(iconColor).withAlpha(32)) + tabLayout.setTabIconTint(ColorStateList.valueOf(iconColor)) + tabLayout.setSelectedTabIndicatorColor(iconColor) + } + + public override fun onTabSelected(selectedTab: TabLayout.Tab) { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "onTabSelected() called with: selectedTab = [" + selectedTab + "]") + } + updateTitleForTab(selectedTab.getPosition()) + } + + public override fun onTabUnselected(tab: TabLayout.Tab) {} + public override fun onTabReselected(tab: TabLayout.Tab) { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "onTabReselected() called with: tab = [" + tab + "]") + } + updateTitleForTab(tab.getPosition()) + } + + class SelectedTabsPagerAdapter(private val context: Context, + fragmentManager: FragmentManager, + tabsList: List) : FragmentStatePagerAdapterMenuWorkaround(fragmentManager, FragmentStatePagerAdapterMenuWorkaround.Companion.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) { + private val internalTabsList: List + + /** + * 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 + * [LocalPlaylistFragment.saveImmediate]. + * The fragments are removed when [LocalPlaylistFragment.onDestroy] is called. + */ + private val localPlaylistFragments: MutableList = ArrayList() + + init { + internalTabsList = ArrayList(tabsList) + } + + public override fun getItem(position: Int): Fragment { + val tab: Tab? = internalTabsList.get(position) + val fragment: Fragment + try { + fragment = tab!!.getFragment(context) + } catch (e: ExtractionException) { + showUiErrorSnackbar(context, "Getting fragment item", e) + return BlankFragment() + } + if (fragment is BaseFragment) { + fragment.useAsFrontPage(true) + } + if (fragment is LocalPlaylistFragment) { + localPlaylistFragments.add(fragment as LocalPlaylistFragment?) + } + return fragment + } + + fun getLocalPlaylistFragments(): MutableList { + return localPlaylistFragments + } + + public override fun getItemPosition(`object`: Any): Int { + // Causes adapter to reload all Fragments when + // notifyDataSetChanged is called + return POSITION_NONE + } + + public override fun getCount(): Int { + return internalTabsList.size + } + + fun sameTabs(tabsToCompare: List): Boolean { + return (internalTabsList == tabsToCompare) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.java b/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.java deleted file mode 100644 index 6b17803c4..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.java +++ /dev/null @@ -1,47 +0,0 @@ -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); -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.kt b/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.kt new file mode 100644 index 000000000..70507e784 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.kt @@ -0,0 +1,43 @@ +package org.schabi.newpipe.fragments + +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.StaggeredGridLayoutManager + +/** + * Recycler view scroll listener which calls the method [.onScrolledDown] + * if the view is scrolled below the last item. + */ +abstract class OnScrollBelowItemsListener() : RecyclerView.OnScrollListener() { + public override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { + super.onScrolled(recyclerView, dx, dy) + if (dy > 0) { + var pastVisibleItems: Int = 0 + val layoutManager: RecyclerView.LayoutManager? = recyclerView.getLayoutManager() + val visibleItemCount: Int = layoutManager!!.getChildCount() + val totalItemCount: Int = layoutManager.getItemCount() + + // Already covers the GridLayoutManager case + if (layoutManager is LinearLayoutManager) { + pastVisibleItems = layoutManager + .findFirstVisibleItemPosition() + } else if (layoutManager is StaggeredGridLayoutManager) { + val positions: IntArray? = layoutManager + .findFirstVisibleItemPositions(null) + if (positions != null && positions.size > 0) { + pastVisibleItems = positions.get(0) + } + } + if ((visibleItemCount + pastVisibleItems) >= totalItemCount) { + onScrolledDown(recyclerView) + } + } + } + + /** + * Called when the recycler view is scrolled below the last item. + * + * @param recyclerView the recycler view + */ + abstract fun onScrolledDown(recyclerView: RecyclerView?) +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/ViewContract.java b/app/src/main/java/org/schabi/newpipe/fragments/ViewContract.java deleted file mode 100644 index 78f644ffb..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/ViewContract.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.schabi.newpipe.fragments; - -public interface ViewContract { - void showLoading(); - - void hideLoading(); - - void showEmptyState(); - - void handleResult(I result); - - void handleError(); -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/ViewContract.kt b/app/src/main/java/org/schabi/newpipe/fragments/ViewContract.kt new file mode 100644 index 000000000..d93ab6313 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/ViewContract.kt @@ -0,0 +1,9 @@ +package org.schabi.newpipe.fragments + +open interface ViewContract { + fun showLoading() + fun hideLoading() + fun showEmptyState() + fun handleResult(result: I) + fun handleError() +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java deleted file mode 100644 index 4789b02e6..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java +++ /dev/null @@ -1,281 +0,0 @@ -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.getHeight())); - urls.append('x'); - urls.append(imageSizeToText(image.getWidth())); - } 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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.kt new file mode 100644 index 000000000..3cae37b95 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.kt @@ -0,0 +1,242 @@ +package org.schabi.newpipe.fragments.detail + +import android.graphics.Typeface +import android.os.Bundle +import android.text.SpannableStringBuilder +import android.text.Spanned +import android.text.TextUtils +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.View.OnLongClickListener +import android.view.ViewGroup +import android.widget.LinearLayout +import androidx.annotation.StringRes +import androidx.appcompat.widget.TooltipCompat +import androidx.core.text.HtmlCompat +import com.google.android.material.chip.Chip +import io.reactivex.rxjava3.disposables.CompositeDisposable +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.Image.ResolutionLevel +import org.schabi.newpipe.extractor.StreamingService +import org.schabi.newpipe.extractor.stream.Description +import org.schabi.newpipe.extractor.utils.Utils +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.function.Consumer + +abstract class BaseDescriptionFragment() : BaseFragment() { + private val descriptionDisposables: CompositeDisposable = CompositeDisposable() + protected var binding: FragmentDescriptionBinding? = null + public override fun onCreateView(inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?): View? { + binding = FragmentDescriptionBinding.inflate(inflater, container, false) + setupDescription() + setupMetadata(inflater, binding!!.detailMetadataLayout) + addTagsMetadataItem(inflater, binding!!.detailMetadataLayout) + return binding!!.getRoot() + } + + public override fun onDestroy() { + descriptionDisposables.clear() + super.onDestroy() + } + + /** + * Get the description to display. + * @return description object, if available + */ + protected abstract fun getDescription(): Description? + + /** + * Get the streaming service. Used for generating description links. + * @return streaming service + */ + protected abstract fun getService(): StreamingService + + /** + * Get the streaming service ID. Used for tag links. + * @return service ID + */ + protected abstract fun getServiceId(): Int + + /** + * Get the URL of the described video or audio, used to generate description links. + * @return stream URL + */ + protected abstract fun getStreamUrl(): String? + + /** + * Get the list of tags to display below the description. + * @return tag list + */ + abstract fun getTags(): List + + /** + * Add additional metadata to display. + * @param inflater LayoutInflater + * @param layout detailMetadataLayout + */ + protected abstract fun setupMetadata(inflater: LayoutInflater?, layout: LinearLayout?) + private fun setupDescription() { + val description: Description? = getDescription() + if (((description == null) || TextUtils.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(View.OnClickListener({ v: View? -> + if (binding!!.detailDescriptionNoteView.getVisibility() == View.VISIBLE) { + disableDescriptionSelection() + } else { + // enable selection only when button is clicked to prevent flickering + enableDescriptionSelection() + } + })) + } + + private fun enableDescriptionSelection() { + binding!!.detailDescriptionNoteView.setVisibility(View.VISIBLE) + binding!!.detailDescriptionView.setTextIsSelectable(true) + val buttonLabel: String = getString(R.string.description_select_disable) + binding!!.detailSelectDescriptionButton.setContentDescription(buttonLabel) + TooltipCompat.setTooltipText(binding!!.detailSelectDescriptionButton, buttonLabel) + binding!!.detailSelectDescriptionButton.setImageResource(R.drawable.ic_close) + } + + private fun disableDescriptionSelection() { + // show description content again, otherwise some links are not clickable + val description: Description? = getDescription() + if (description != null) { + TextLinkifier.fromDescription(binding!!.detailDescriptionView, + description, HtmlCompat.FROM_HTML_MODE_LEGACY, + getService(), getStreamUrl(), + descriptionDisposables, TextLinkifier.SET_LINK_MOVEMENT_METHOD) + } + binding!!.detailDescriptionNoteView.setVisibility(View.GONE) + binding!!.detailDescriptionView.setTextIsSelectable(false) + val buttonLabel: String = getString(R.string.description_select_enable) + binding!!.detailSelectDescriptionButton.setContentDescription(buttonLabel) + TooltipCompat.setTooltipText(binding!!.detailSelectDescriptionButton, buttonLabel) + binding!!.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all) + } + + protected fun addMetadataItem(inflater: LayoutInflater?, + layout: LinearLayout, + linkifyContent: Boolean, + @StringRes type: Int, + content: String) { + if (Utils.isBlank(content)) { + return + } + val itemBinding: ItemMetadataBinding = ItemMetadataBinding.inflate((inflater)!!, layout, false) + itemBinding.metadataTypeView.setText(type) + itemBinding.metadataTypeView.setOnLongClickListener(OnLongClickListener({ v: View? -> + ShareUtils.copyToClipboard(requireContext(), content) + true + })) + if (linkifyContent) { + TextLinkifier.fromPlainText(itemBinding.metadataContentView, content, null, null, + descriptionDisposables, TextLinkifier.SET_LINK_MOVEMENT_METHOD) + } else { + itemBinding.metadataContentView.setText(content) + } + itemBinding.metadataContentView.setClickable(true) + layout.addView(itemBinding.getRoot()) + } + + private fun imageSizeToText(heightOrWidth: Int): String { + if (heightOrWidth < 0) { + return getString(R.string.question_mark) + } else { + return heightOrWidth.toString() + } + } + + protected fun addImagesMetadataItem(inflater: LayoutInflater?, + layout: LinearLayout, + @StringRes type: Int, + images: List) { + val preferredImageUrl: String? = ImageStrategy.choosePreferredImage(images) + if (preferredImageUrl == null) { + return // null will be returned in case there is no image + } + val itemBinding: ItemMetadataBinding = ItemMetadataBinding.inflate((inflater)!!, layout, false) + itemBinding.metadataTypeView.setText(type) + val urls: SpannableStringBuilder = SpannableStringBuilder() + for (image: Image? in images) { + if (urls.length != 0) { + urls.append(", ") + } + val entryBegin: Int = 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() == ResolutionLevel.UNKNOWN)) { + urls.append(imageSizeToText(image.getHeight())) + urls.append('x') + urls.append(imageSizeToText(image.getWidth())) + } else { + when (image.getEstimatedResolutionLevel()) { + ResolutionLevel.LOW -> urls.append(getString(R.string.image_quality_low)) + ResolutionLevel.MEDIUM -> urls.append(getString(R.string.image_quality_medium)) + ResolutionLevel.HIGH -> urls.append(getString(R.string.image_quality_high)) + else -> {} + } + } + urls.setSpan(object : ClickableSpan() { + public override fun onClick(widget: View) { + ShareUtils.openUrlInBrowser(requireContext(), image.getUrl()) + } + }, entryBegin, urls.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + if ((preferredImageUrl == image.getUrl())) { + urls.setSpan(StyleSpan(Typeface.BOLD), entryBegin, urls.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + } + itemBinding.metadataContentView.setText(urls) + itemBinding.metadataContentView.setMovementMethod(LinkMovementMethod.getInstance()) + layout.addView(itemBinding.getRoot()) + } + + private fun addTagsMetadataItem(inflater: LayoutInflater, layout: LinearLayout) { + val tags: List = getTags() + if (!tags.isEmpty()) { + val itemBinding: ItemMetadataTagsBinding = ItemMetadataTagsBinding.inflate(inflater, layout, false) + tags.stream().sorted(java.lang.String.CASE_INSENSITIVE_ORDER).forEach(Consumer({ tag: String? -> + val chip: Chip = inflater.inflate(R.layout.chip, + itemBinding.metadataTagsChips, false) as Chip + chip.setText(tag) + chip.setOnClickListener(View.OnClickListener({ chip: View -> onTagClick(chip) })) + chip.setOnLongClickListener(OnLongClickListener({ chip: View -> onTagLongClick(chip) })) + itemBinding.metadataTagsChips.addView(chip) + })) + layout.addView(itemBinding.getRoot()) + } + } + + private fun onTagClick(chip: View) { + if (getParentFragment() != null) { + NavigationHelper.openSearchFragment(getParentFragment()!!.getParentFragmentManager(), + getServiceId(), (chip as Chip).getText().toString()) + } + } + + private fun onTagLongClick(chip: View): Boolean { + ShareUtils.copyToClipboard(requireContext(), (chip as Chip).getText().toString()) + return true + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java deleted file mode 100644 index 581e54156..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java +++ /dev/null @@ -1,140 +0,0 @@ -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 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; - -import icepick.State; - -public class DescriptionFragment extends BaseDescriptionFragment { - - @State - StreamInfo streamInfo; - - public DescriptionFragment(final StreamInfo streamInfo) { - this.streamInfo = streamInfo; - } - - public DescriptionFragment() { - // keep empty constructor for IcePick 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(getContext()))); - } - - 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)); - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.kt new file mode 100644 index 000000000..55e063356 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.kt @@ -0,0 +1,97 @@ +package org.schabi.newpipe.fragments.detail + +import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout +import androidx.annotation.StringRes +import icepick.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.StreamExtractor +import org.schabi.newpipe.extractor.stream.StreamExtractor.Privacy +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.util.Localization + +class DescriptionFragment : BaseDescriptionFragment { + @State + var streamInfo: StreamInfo? = null + + constructor(streamInfo: StreamInfo?) { + this.streamInfo = streamInfo + } + + constructor() + + override fun getDescription(): Description? { + return streamInfo!!.getDescription() + } + + override fun getService(): StreamingService { + return streamInfo!!.getService() + } + + override fun getServiceId(): Int { + return streamInfo!!.getServiceId() + } + + override fun getStreamUrl(): String { + return streamInfo!!.getUrl() + } + + public override fun getTags(): List { + return streamInfo!!.getTags() + } + + override fun setupMetadata(inflater: LayoutInflater?, + layout: LinearLayout?) { + 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() != StreamExtractor.NO_AGE_LIMIT) { + addMetadataItem(inflater, (layout), false, R.string.metadata_age_limit, streamInfo!!.getAgeLimit().toString()) + } + if (streamInfo!!.getLanguageInfo() != null) { + addMetadataItem(inflater, (layout), false, R.string.metadata_language, + streamInfo!!.getLanguageInfo().getDisplayLanguage(Localization.getAppLocale((getContext())!!))) + } + 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 fun addPrivacyMetadataItem(inflater: LayoutInflater?, layout: LinearLayout?) { + if (streamInfo!!.getPrivacy() != null) { + @StringRes val contentRes: Int + when (streamInfo!!.getPrivacy()) { + Privacy.PUBLIC -> contentRes = R.string.metadata_privacy_public + Privacy.UNLISTED -> contentRes = R.string.metadata_privacy_unlisted + Privacy.PRIVATE -> contentRes = R.string.metadata_privacy_private + Privacy.INTERNAL -> contentRes = R.string.metadata_privacy_internal + Privacy.OTHER -> contentRes = 0 + else -> contentRes = 0 + } + if (contentRes != 0) { + addMetadataItem(inflater, (layout)!!, false, R.string.metadata_privacy, + getString(contentRes)) + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.java deleted file mode 100644 index 5016a49f6..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.java +++ /dev/null @@ -1,56 +0,0 @@ -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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.kt new file mode 100644 index 000000000..439d8c2e3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.kt @@ -0,0 +1,39 @@ +package org.schabi.newpipe.fragments.detail + +import org.schabi.newpipe.player.playqueue.PlayQueue +import java.io.Serializable + +internal class StackItem(private val serviceId: Int, private var url: String?, + private var title: String, private var playQueue: PlayQueue?) : Serializable { + fun setUrl(url: String?) { + this.url = url + } + + fun setPlayQueue(queue: PlayQueue?) { + playQueue = queue + } + + fun getServiceId(): Int { + return serviceId + } + + fun getTitle(): String? { + return title + } + + fun setTitle(title: String) { + this.title = title + } + + fun getUrl(): String? { + return url + } + + fun getPlayQueue(): PlayQueue? { + return playQueue + } + + public override fun toString(): String { + return getServiceId().toString() + ":" + getUrl() + " > " + getTitle() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java deleted file mode 100644 index 1a11836d4..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java +++ /dev/null @@ -1,96 +0,0 @@ -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(); - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.kt new file mode 100644 index 000000000..e2461cc96 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.kt @@ -0,0 +1,76 @@ +package org.schabi.newpipe.fragments.detail + +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentPagerAdapter + +class TabAdapter // 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" +(private val fragmentManager: FragmentManager) : FragmentPagerAdapter(fragmentManager, BEHAVIOR_SET_USER_VISIBLE_HINT) { + private val mFragmentList: MutableList = ArrayList() + private val mFragmentTitleList: MutableList = ArrayList() + public override fun getItem(position: Int): Fragment { + return mFragmentList.get(position) + } + + public override fun getCount(): Int { + return mFragmentList.size + } + + fun addFragment(fragment: Fragment, title: String?) { + mFragmentList.add(fragment) + mFragmentTitleList.add(title) + } + + fun clearAllItems() { + mFragmentList.clear() + mFragmentTitleList.clear() + } + + fun removeItem(position: Int) { + mFragmentList.removeAt(if (position == 0) 0 else position - 1) + mFragmentTitleList.removeAt(if (position == 0) 0 else position - 1) + } + + fun updateItem(position: Int, fragment: Fragment) { + mFragmentList.set(position, fragment) + } + + fun updateItem(title: String?, fragment: Fragment) { + val index: Int = mFragmentTitleList.indexOf(title) + if (index != -1) { + updateItem(index, fragment) + } + } + + public override fun getItemPosition(`object`: Any): Int { + if (mFragmentList.contains(`object`)) { + return mFragmentList.indexOf(`object`) + } else { + return POSITION_NONE + } + } + + fun getItemPositionByTitle(title: String?): Int { + return mFragmentTitleList.indexOf(title) + } + + fun getItemTitle(position: Int): String? { + if (position < 0 || position >= mFragmentTitleList.size) { + return null + } + return mFragmentTitleList.get(position) + } + + fun notifyDataSetUpdate() { + notifyDataSetChanged() + } + + public override fun destroyItem(container: ViewGroup, + position: Int, + `object`: Any) { + fragmentManager.beginTransaction().remove((`object` as Fragment?)!!).commitNowAllowingStateLoss() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java deleted file mode 100644 index 95b54f65a..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ /dev/null @@ -1,2469 +0,0 @@ -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.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.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.PicassoHelper; - -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 icepick.State; -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"; - - private static final String PICASSO_VIDEO_DETAILS_TAG = "PICASSO_VIDEO_DETAILS_TAG"; - - // 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(final Player connectedPlayer, - final PlayerService connectedPlayerService, - final boolean playAfterConnect) { - player = connectedPlayer; - playerService = connectedPlayerService; - - // 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 onServiceDisconnected() { - playerService = null; - player = null; - restoreDefaultBrightness(); - } - - - /*////////////////////////////////////////////////////////////////////////*/ - - public static VideoDetailFragment getInstance(final int serviceId, - @Nullable final String videoUrl, - @NonNull final String name, - @Nullable final PlayQueue queue) { - final VideoDetailFragment instance = new VideoDetailFragment(); - instance.setInitialData(serviceId, videoUrl, 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(); - - 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))); - } - - /*////////////////////////////////////////////////////////////////////////// - // 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 Intent playerIntent = NavigationHelper.getPlayerIntent(requireContext(), - PlayerService.class, queue, true, autoPlayEnabled); - 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 -> Log.e(TAG, "Register view failure: ", error) - )); - } - - 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); - activity.registerReceiver(broadcastReceiver, intentFilter); - } - - - /*////////////////////////////////////////////////////////////////////////// - // 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); - } - } - - PicassoHelper.cancelTag(PICASSO_VIDEO_DETAILS_TAG); - 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); - PicassoHelper.loadDetailsThumbnail(info.getThumbnails()).tag(PICASSO_VIDEO_DETAILS_TAG) - .into(binding.detailThumbnailImageView); - 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, 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); - } - - PicassoHelper.loadAvatar(info.getUploaderAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG) - .into(binding.detailSubChannelThumbnailView); - 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); - } - - PicassoHelper.loadAvatar(info.getSubChannelAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG) - .into(binding.detailSubChannelThumbnailView); - binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); - PicassoHelper.loadAvatar(info.getUploaderAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG) - .into(binding.detailUploaderThumbnailView); - 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 + "], videoUrl = [" + 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() { - 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) { - binding.relatedItemsLayout.setVisibility(fullscreen ? View.GONE : View.VISIBLE); - } - scrollToTop(); - - tryAddVideoPlayerView(); - } - - @Override - public void onScreenRotationButtonClicked() { - // 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.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); - PicassoHelper.loadDetailsThumbnail(thumbnails).tag(PICASSO_VIDEO_DETAILS_TAG) - .into(binding.overlayThumbnail); - } - - 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; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt new file mode 100644 index 000000000..2c3bed53a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.kt @@ -0,0 +1,2214 @@ +package org.schabi.newpipe.fragments.detail + +import android.animation.ValueAnimator +import android.animation.ValueAnimator.AnimatorUpdateListener +import android.annotation.SuppressLint +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.content.IntentFilter +import android.content.SharedPreferences +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +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.text.TextUtils +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.View.OnLongClickListener +import android.view.View.OnTouchListener +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.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.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import androidx.preference.PreferenceManager +import com.google.android.exoplayer2.PlaybackException +import com.google.android.exoplayer2.PlaybackParameters +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.appbar.AppBarLayout.OnOffsetChangedListener +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback +import com.google.android.material.tabs.TabLayout +import icepick.State +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.functions.Action +import io.reactivex.rxjava3.schedulers.Schedulers +import org.schabi.newpipe.App +import org.schabi.newpipe.BaseFragment +import org.schabi.newpipe.R +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.stream.model.StreamStateEntity +import org.schabi.newpipe.databinding.FragmentVideoDetailBinding +import org.schabi.newpipe.download.DownloadDialog +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.ErrorUtil.Companion.showSnackbar +import org.schabi.newpipe.error.ErrorUtil.Companion.showUiErrorSnackbar +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.StreamingService.ServiceInfo.MediaCapability +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.StreamExtractor +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.ktx.animate +import org.schabi.newpipe.ktx.animateRotation +import org.schabi.newpipe.local.dialog.PlaylistDialog +import org.schabi.newpipe.local.feed.FeedFragment.Companion.newInstance +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.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.playqueue.events.PlayQueueEvent +import org.schabi.newpipe.player.ui.MainPlayerUi +import org.schabi.newpipe.player.ui.VideoPlayerUi +import org.schabi.newpipe.util.DependentPreferenceHelper +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.PicassoHelper +import java.util.LinkedList +import java.util.Objects +import java.util.Optional +import java.util.concurrent.TimeUnit +import java.util.function.Function +import java.util.function.IntFunction +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +class VideoDetailFragment() : BaseStateFragment(), BackPressable, PlayerServiceExtendedEventListener, OnKeyDownListener { + // tabs + private var showComments: Boolean = false + private var showRelatedItems: Boolean = false + private var showDescription: Boolean = false + private var selectedTabTag: String? = null + + @AttrRes + val tabIcons: MutableList = ArrayList() + + @StringRes + val tabContentDescriptions: MutableList = ArrayList() + private var tabSettingsChanged: Boolean = false + private var lastAppBarVerticalOffset: Int = Int.MAX_VALUE // prevents useless updates + private val preferenceChangeListener: OnSharedPreferenceChangeListener = OnSharedPreferenceChangeListener({ sharedPreferences: SharedPreferences, key: String? -> + if ((getString(R.string.show_comments_key) == key)) { + showComments = sharedPreferences.getBoolean(key, true) + tabSettingsChanged = true + } else if ((getString(R.string.show_next_video_key) == key)) { + showRelatedItems = sharedPreferences.getBoolean(key, true) + tabSettingsChanged = true + } else if ((getString(R.string.show_description_key) == key)) { + showDescription = sharedPreferences.getBoolean(key, true) + tabSettingsChanged = true + } + }) + + @State + protected var serviceId: Int = NO_SERVICE_ID + + @State + protected var title: String = "" + + @State + protected var url: String? = null + protected var playQueue: PlayQueue? = null + + @State + var bottomSheetState: Int = BottomSheetBehavior.STATE_EXPANDED + + @State + var lastStableBottomSheetState: Int = BottomSheetBehavior.STATE_EXPANDED + + @State + protected var autoPlayEnabled: Boolean = true + private var currentInfo: StreamInfo? = null + private var currentWorker: Disposable? = null + private val disposables: CompositeDisposable = CompositeDisposable() + private var positionSubscriber: Disposable? = null + private var bottomSheetBehavior: BottomSheetBehavior? = null + private var bottomSheetCallback: BottomSheetCallback? = null + private var broadcastReceiver: BroadcastReceiver? = null + + /*////////////////////////////////////////////////////////////////////////// + // Views + ////////////////////////////////////////////////////////////////////////// */ + private var binding: FragmentVideoDetailBinding? = null + private var pageAdapter: TabAdapter? = null + private var settingsContentObserver: ContentObserver? = null + private var playerService: PlayerService? = null + private var player: Player? = null + private val playerHolder: PlayerHolder? = PlayerHolder.Companion.getInstance() + + /*////////////////////////////////////////////////////////////////////////// + // Service management + ////////////////////////////////////////////////////////////////////////// */ + public override fun onServiceConnected(connectedPlayer: Player?, + connectedPlayerService: PlayerService?, + playAfterConnect: Boolean) { + player = connectedPlayer + playerService = connectedPlayerService + + // It will do nothing if the player is not in fullscreen mode + hideSystemUiIfNeeded() + val playerUi: Optional? = player!!.UIs().(get(MainPlayerUi::class.java))!! + 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(Function({ ui: MainPlayerUi? -> 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(java.util.function.Consumer({ obj: MainPlayerUi? -> obj!!.toggleFullscreen() })) + } + if ((playAfterConnect + || (((currentInfo != null + ) && isAutoplayEnabled() + && playerUi!!.isEmpty())))) { + autoPlayEnabled = true // forcefully start playing + openVideoPlayerAutoFullscreen() + } + updateOverlayPlayQueueButtonVisibility() + } + + public override fun onServiceDisconnected() { + playerService = null + player = null + restoreDefaultBrightness() + } + + /*////////////////////////////////////////////////////////////////////////// + // Fragment's Lifecycle + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val prefs: SharedPreferences = 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 = object : ContentObserver(Handler()) { + public override fun onChange(selfChange: Boolean) { + if (activity != null && !PlayerHelper.globalScreenOrientationLocked(activity)) { + activity!!.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) + } + } + } + activity!!.getContentResolver().registerContentObserver( + Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false, + settingsContentObserver) + } + + public override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + binding = FragmentVideoDetailBinding.inflate(inflater, container, false) + return binding!!.getRoot() + } + + public override fun 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() + } + + public override fun onResume() { + super.onResume() + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "onResume() called") + } + activity!!.sendBroadcast(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) + } + } + + public override fun onStop() { + super.onStop() + if (!activity!!.isChangingConfigurations()) { + activity!!.sendBroadcast(Intent(ACTION_VIDEO_FRAGMENT_STOPPED)) + } + } + + public override fun 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 = LinkedList() + } + } + + public override fun onDestroyView() { + super.onDestroyView() + binding = null + } + + public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + when (requestCode) { + ReCaptchaActivity.Companion.RECAPTCHA_REQUEST -> if (resultCode == Activity.RESULT_OK) { + NavigationHelper.openVideoDetailFragment(requireContext(), getFM(), + serviceId, url, title, null, false) + } else { + Log.e(TAG, "ReCaptcha failed") + } + + else -> Log.e(TAG, "Request code from activity not supported [" + requestCode + "]") + } + } + + /*////////////////////////////////////////////////////////////////////////// + // OnClick + ////////////////////////////////////////////////////////////////////////// */ + private fun setOnClickListeners() { + binding!!.detailTitleRootLayout.setOnClickListener(View.OnClickListener({ v: View? -> toggleTitleAndSecondaryControls() })) + binding!!.detailUploaderRootLayout.setOnClickListener(makeOnClickListener(java.util.function.Consumer({ info: StreamInfo -> + if (TextUtils.isEmpty(info.getSubChannelUrl())) { + if (!TextUtils.isEmpty(info.getUploaderUrl())) { + openChannel(info.getUploaderUrl(), info.getUploaderName()) + } + if (BaseFragment.Companion.DEBUG) { + Log.i(TAG, "Can't open sub-channel because we got no channel URL") + } + } else { + openChannel(info.getSubChannelUrl(), info.getSubChannelName()) + } + }))) + binding!!.detailThumbnailRootLayout.setOnClickListener(View.OnClickListener({ v: View? -> + autoPlayEnabled = true // forcefully start playing + // FIXME Workaround #7427 + if (isPlayerAvailable()) { + player!!.setRecovery() + } + openVideoPlayerAutoFullscreen() + })) + binding!!.detailControlsBackground.setOnClickListener(View.OnClickListener({ v: View? -> openBackgroundPlayer(false) })) + binding!!.detailControlsPopup.setOnClickListener(View.OnClickListener({ v: View? -> openPopupPlayer(false) })) + binding!!.detailControlsPlaylistAppend.setOnClickListener(makeOnClickListener(java.util.function.Consumer({ info: StreamInfo? -> + if (getFM() != null && currentInfo != null) { + val fragment: Fragment? = getParentFragmentManager().findFragmentById(R.id.fragment_holder) + + // commit previous pending changes to database + if (fragment is LocalPlaylistFragment) { + fragment.saveImmediate() + } else if (fragment is MainFragment) { + fragment.commitPlaylistTabs() + } + disposables.add(PlaylistDialog.Companion.createCorrespondingDialog(requireContext(), + java.util.List.of(StreamEntity((info)!!)), + java.util.function.Consumer({ dialog: PlaylistDialog -> dialog.show(getParentFragmentManager(), TAG) }))) + } + }))) + binding!!.detailControlsDownload.setOnClickListener(View.OnClickListener({ v: View? -> + if (PermissionHelper.checkStoragePermissions(activity, + PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { + openDownloadDialog() + } + })) + binding!!.detailControlsShare.setOnClickListener(makeOnClickListener(java.util.function.Consumer({ info: StreamInfo -> + ShareUtils.shareText(requireContext(), info.getName(), info.getUrl(), + info.getThumbnails()) + }))) + binding!!.detailControlsOpenInBrowser.setOnClickListener(makeOnClickListener(java.util.function.Consumer({ info: StreamInfo -> ShareUtils.openUrlInBrowser(requireContext(), info.getUrl()) }))) + binding!!.detailControlsPlayWithKodi.setOnClickListener(makeOnClickListener(java.util.function.Consumer({ info: StreamInfo -> KoreUtils.playWithKore(requireContext(), Uri.parse(info.getUrl())) }))) + if (BaseFragment.Companion.DEBUG) { + binding!!.detailControlsCrashThePlayer.setOnClickListener(View.OnClickListener({ v: View? -> VideoDetailPlayerCrasher.onCrashThePlayer(requireContext(), player) })) + } + val overlayListener: View.OnClickListener = View.OnClickListener({ v: View? -> + bottomSheetBehavior + .setState(BottomSheetBehavior.STATE_EXPANDED) + }) + binding!!.overlayThumbnail.setOnClickListener(overlayListener) + binding!!.overlayMetadataLayout.setOnClickListener(overlayListener) + binding!!.overlayButtonsLayout.setOnClickListener(overlayListener) + binding!!.overlayCloseButton.setOnClickListener(View.OnClickListener({ v: View? -> + bottomSheetBehavior + .setState(BottomSheetBehavior.STATE_HIDDEN) + })) + binding!!.overlayPlayQueueButton.setOnClickListener(View.OnClickListener({ v: View? -> NavigationHelper.openPlayQueue(requireContext()) })) + binding!!.overlayPlayPauseButton.setOnClickListener(View.OnClickListener({ v: View? -> + if (playerIsNotStopped()) { + player!!.playPause() + player!!.UIs().get((VideoPlayerUi::class.java)).ifPresent(java.util.function.Consumer({ ui: VideoPlayerUi? -> ui!!.hideControls(0, 0) })) + showSystemUi() + } else { + autoPlayEnabled = true // forcefully start playing + openVideoPlayer(false) + } + setOverlayPlayPauseImage(isPlayerAvailable() && player!!.isPlaying()) + })) + } + + private fun makeOnClickListener(consumer: java.util.function.Consumer): View.OnClickListener { + return View.OnClickListener({ v: View? -> + if (!isLoading.get() && currentInfo != null) { + consumer.accept(currentInfo!!) + } + }) + } + + private fun setOnLongClickListeners() { + binding!!.detailTitleRootLayout.setOnLongClickListener(makeOnLongClickListener(java.util.function.Consumer({ info: StreamInfo? -> + ShareUtils.copyToClipboard(requireContext(), + binding!!.detailVideoTitleView.getText().toString()) + }))) + binding!!.detailUploaderRootLayout.setOnLongClickListener(makeOnLongClickListener(java.util.function.Consumer({ info: StreamInfo -> + if (TextUtils.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(java.util.function.Consumer({ info: StreamInfo? -> openBackgroundPlayer(true) }) + )) + binding!!.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(java.util.function.Consumer({ info: StreamInfo? -> openPopupPlayer(true) }) + )) + binding!!.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(java.util.function.Consumer({ info: StreamInfo? -> NavigationHelper.openDownloads((activity)!!) }))) + val overlayListener: OnLongClickListener = makeOnLongClickListener(java.util.function.Consumer({ info: StreamInfo -> openChannel(info.getUploaderUrl(), info.getUploaderName()) })) + binding!!.overlayThumbnail.setOnLongClickListener(overlayListener) + binding!!.overlayMetadataLayout.setOnLongClickListener(overlayListener) + } + + private fun makeOnLongClickListener(consumer: java.util.function.Consumer): OnLongClickListener { + return OnLongClickListener({ v: View? -> + if (isLoading.get() || currentInfo == null) { + return@OnLongClickListener false + } + consumer.accept(currentInfo!!) + true + }) + } + + private fun openChannel(subChannelUrl: String, subChannelName: String) { + try { + NavigationHelper.openChannelFragment(getFM(), currentInfo!!.getServiceId(), + subChannelUrl, subChannelName) + } catch (e: Exception) { + showUiErrorSnackbar(this, "Opening channel fragment", e) + } + } + + private fun toggleTitleAndSecondaryControls() { + if (binding!!.detailSecondaryControlPanel.getVisibility() == View.GONE) { + binding!!.detailVideoTitleView.setMaxLines(10) + binding!!.detailToggleSecondaryControlsView.animateRotation(VideoPlayerUi.Companion.DEFAULT_CONTROLS_DURATION, 180) + binding!!.detailSecondaryControlPanel.setVisibility(View.VISIBLE) + } else { + binding!!.detailVideoTitleView.setMaxLines(1) + binding!!.detailToggleSecondaryControlsView.animateRotation(VideoPlayerUi.Companion.DEFAULT_CONTROLS_DURATION, 0) + binding!!.detailSecondaryControlPanel.setVisibility(View.GONE) + } + // view pager height has changed, update the tab layout + updateTabLayoutVisibility() + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + ////////////////////////////////////////////////////////////////////////// */ + // called from onViewCreated in {@link BaseFragment#onViewCreated} + override fun initViews(rootView: View, savedInstanceState: Bundle?) { + super.initViews(rootView, savedInstanceState) + pageAdapter = TabAdapter(getChildFragmentManager()) + binding!!.viewPager.setAdapter(pageAdapter) + binding!!.tabLayout.setupWithViewPager(binding!!.viewPager) + binding!!.detailThumbnailRootLayout.requestFocus() + binding!!.detailControlsPlayWithKodi.setVisibility( + if (KoreUtils.shouldShowPlayWithKodi(requireContext(), serviceId)) View.VISIBLE else View.GONE + ) + binding!!.detailControlsCrashThePlayer.setVisibility( + if (BaseFragment.Companion.DEBUG && PreferenceManager.getDefaultSharedPreferences((getContext())!!) + .getBoolean(getString(R.string.show_crash_the_player_key), false)) View.VISIBLE else View.GONE + ) + accommodateForTvAndDesktopMode() + } + + @SuppressLint("ClickableViewAccessibility") + override fun initListeners() { + super.initListeners() + setOnClickListeners() + setOnLongClickListeners() + val controlsTouchListener: OnTouchListener = OnTouchListener({ view: View?, motionEvent: MotionEvent -> + if ((motionEvent.getAction() == MotionEvent.ACTION_DOWN + && PlayButtonHelper.shouldShowHoldToAppendTip((activity)!!))) { + binding!!.touchAppendDetail.animate(true, 250, AnimationType.ALPHA, 0, Runnable({ binding!!.touchAppendDetail.animate(false, 1500, AnimationType.ALPHA, 1000) })) + } + false + }) + binding!!.detailControlsBackground.setOnTouchListener(controlsTouchListener) + binding!!.detailControlsPopup.setOnTouchListener(controlsTouchListener) + binding!!.appBarLayout.addOnOffsetChangedListener(OnOffsetChangedListener({ layout: AppBarLayout?, verticalOffset: Int -> + // 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) + } + } + + public override fun onKeyDown(keyCode: Int): Boolean { + return (isPlayerAvailable() + && player!!.UIs().get((VideoPlayerUi::class.java)) + .map(Function({ playerUi: VideoPlayerUi? -> playerUi!!.onKeyDown(keyCode) })).orElse(false)) + } + + public override fun onBackPressed(): Boolean { + if (BaseFragment.Companion.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 fun setupFromHistoryItem(item: StackItem) { + setAutoPlay(false) + hideMainPlayerOnLoadingNewStream() + setInitialData(item.getServiceId(), item.getUrl(), + (if (item.getTitle() == null) "" else item.getTitle())!!, item.getPlayQueue()) + startLoading(false) + + // Maybe an item was deleted in background activity + if (item.getPlayQueue()!!.getItem() == null) { + return + } + val playQueueItem: PlayQueueItem? = item.getPlayQueue()!!.getItem() + // Update title, url, uploader from the last item in the stack (it's current now) + val isPlayerStopped: Boolean = !isPlayerAvailable() || player!!.isStopped() + if (playQueueItem != null && isPlayerStopped) { + updateOverlayData(playQueueItem.getTitle(), + playQueueItem.getUploader(), playQueueItem.getThumbnails()) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Info loading and handling + ////////////////////////////////////////////////////////////////////////// */ + override fun doInitialLoadLogic() { + if (wasCleared()) { + return + } + if (currentInfo == null) { + prepareAndLoadInfo() + } else { + prepareAndHandleInfoIfNeededAfterDelay(currentInfo, false, 50) + } + } + + fun selectAndLoadVideo(newServiceId: Int, + newUrl: String?, + newTitle: String, + newQueue: PlayQueue?) { + if (isPlayerAvailable() && (newQueue != null) && (playQueue != null + ) && (playQueue!!.getItem() != null) && !(playQueue!!.getItem().getUrl() == newUrl)) { + // Preloading can be disabled since playback is surely being replaced. + player!!.disablePreloadingOfCurrentTrack() + } + setInitialData(newServiceId, newUrl, newTitle, newQueue) + startLoading(false, true) + } + + private fun prepareAndHandleInfoIfNeededAfterDelay(info: StreamInfo?, + scrollToTop: Boolean, + delay: Long) { + Handler(Looper.getMainLooper()).postDelayed(Runnable({ + if (activity == null) { + return@postDelayed + } + // Data can already be drawn, don't spend time twice + if ((info!!.getName() == binding!!.detailVideoTitleView.getText().toString())) { + return@postDelayed + } + prepareAndHandleInfo(info, scrollToTop) + }), delay) + } + + private fun prepareAndHandleInfo(info: StreamInfo?, scrollToTop: Boolean) { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, ("prepareAndHandleInfo() called with: " + + "info = [" + info + "], scrollToTop = [" + scrollToTop + "]")) + } + showLoading() + initTabs() + if (scrollToTop) { + scrollToTop() + } + handleResult((info)!!) + showContent() + } + + protected fun prepareAndLoadInfo() { + scrollToTop() + startLoading(false) + } + + public override fun startLoading(forceLoad: Boolean) { + super.startLoading(forceLoad) + initTabs() + currentInfo = null + if (currentWorker != null) { + currentWorker!!.dispose() + } + runWorker(forceLoad, stack.isEmpty()) + } + + private fun startLoading(forceLoad: Boolean, addToBackStack: Boolean) { + super.startLoading(forceLoad) + initTabs() + currentInfo = null + if (currentWorker != null) { + currentWorker!!.dispose() + } + runWorker(forceLoad, addToBackStack) + } + + private fun runWorker(forceLoad: Boolean, addToBackStack: Boolean) { + val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences((activity)!!) + currentWorker = ExtractorHelper.getStreamInfo(serviceId, url, forceLoad) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(io.reactivex.rxjava3.functions.Consumer({ result: StreamInfo -> + isLoading.set(false) + hideMainPlayerOnLoadingNewStream() + if (result.getAgeLimit() != StreamExtractor.NO_AGE_LIMIT && !prefs.getBoolean( + getString(R.string.show_age_restricted_content), false)) { + hideAgeRestrictedContent() + } else { + handleResult(result) + showContent() + if (addToBackStack) { + if (playQueue == null) { + playQueue = SinglePlayQueue(result) + } + if (stack.isEmpty() || !stack.peek().getPlayQueue() + .equalStreams(playQueue)) { + stack.push(StackItem(serviceId, url, title, playQueue)) + } + } + if (isAutoplayEnabled()) { + openVideoPlayerAutoFullscreen() + } + } + }), io.reactivex.rxjava3.functions.Consumer({ throwable: Throwable? -> + showError(ErrorInfo((throwable)!!, UserAction.REQUESTED_STREAM, + (if (url == null) "no url" else url)!!, serviceId)) + })) + } + + /*////////////////////////////////////////////////////////////////////////// + // Tabs + ////////////////////////////////////////////////////////////////////////// */ + private fun initTabs() { + if (pageAdapter!!.getCount() != 0) { + selectedTabTag = pageAdapter!!.getItemTitle(binding!!.viewPager.getCurrentItem()) + } + pageAdapter!!.clearAllItems() + tabIcons.clear() + tabContentDescriptions.clear() + if (shouldShowComments()) { + pageAdapter!!.addFragment( + CommentsFragment.Companion.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.Companion.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.Companion.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.Companion.newInstance(true), EMPTY_TAB_TAG) + } + pageAdapter!!.notifyDataSetUpdate() + if (pageAdapter!!.getCount() >= 2) { + val position: Int = 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 [.pageAdapter] is modified, since that triggers a refresh in + * [FragmentVideoDetailBinding.tabLayout] resetting all tab's icons and content + * descriptions. This reads icons from [.tabIcons] and content descriptions from + * [.tabContentDescriptions], which are all set in [.initTabs]. + */ + private fun updateTabIconsAndContentDescriptions() { + for (i in tabIcons.indices) { + val tab: TabLayout.Tab? = binding!!.tabLayout.getTabAt(i) + if (tab != null) { + tab.setIcon(tabIcons.get(i)) + tab.setContentDescription(tabContentDescriptions.get(i)) + } + } + } + + private fun updateTabs(info: StreamInfo) { + if (showRelatedItems) { + if (binding!!.relatedItemsLayout == null) { // phone + pageAdapter!!.updateItem(RELATED_TAB_TAG, RelatedItemsFragment.Companion.getInstance(info)) + } else { // tablet + TV + getChildFragmentManager().beginTransaction() + .replace(R.id.relatedItemsLayout, RelatedItemsFragment.Companion.getInstance(info)) + .commitAllowingStateLoss() + binding!!.relatedItemsLayout!!.setVisibility(if (isFullscreen()) View.GONE else View.VISIBLE) + } + } + if (showDescription) { + pageAdapter!!.updateItem(DESCRIPTION_TAB_TAG, DescriptionFragment(info)) + } + binding!!.viewPager.setVisibility(View.VISIBLE) + // make sure the tab layout is visible + updateTabLayoutVisibility() + pageAdapter!!.notifyDataSetUpdate() + updateTabIconsAndContentDescriptions() + } + + private fun shouldShowComments(): Boolean { + try { + return showComments && NewPipe.getService(serviceId) + .getServiceInfo() + .getMediaCapabilities() + .contains(MediaCapability.COMMENTS) + } catch (e: ExtractionException) { + return false + } + } + + fun 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(Runnable({ + val activity: FragmentActivity? = getActivity() + if (activity != null) { + val pagerHitRect: Rect = Rect() + binding!!.viewPager.getHitRect(pagerHitRect) + val height: Int = DeviceUtils.getWindowHeight(activity.getWindowManager()) + val viewPagerVisibleHeight: Int = height - pagerHitRect.top + // see TabLayout.DEFAULT_HEIGHT, which is equal to 48dp + val tabLayoutHeight: Float = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, 48f, getResources().getDisplayMetrics()) + if (viewPagerVisibleHeight > tabLayoutHeight * 2) { + // no translation at all when viewPagerVisibleHeight > tabLayout.height * 3 + binding!!.tabLayout.setTranslationY(max(0.0, (tabLayoutHeight * 3 - viewPagerVisibleHeight).toDouble()).toFloat()) + binding!!.tabLayout.setVisibility(View.VISIBLE) + } else { + // view pager is not visible enough + binding!!.tabLayout.setVisibility(View.GONE) + } + } + })) + } + } + + fun scrollToTop() { + binding!!.appBarLayout.setExpanded(true, true) + // notify tab layout of scrolling + updateTabLayoutVisibility() + } + + fun scrollToComment(comment: CommentsInfoItem?) { + val commentsTabPos: Int = pageAdapter!!.getItemPositionByTitle(COMMENTS_TAB_TAG) + val fragment: Fragment = pageAdapter!!.getItem(commentsTabPos) + if (!(fragment is CommentsFragment)) { + return + } + + // unexpand the app bar only if scrolling to the comment succeeded + if (fragment.scrollToComment(comment)) { + binding!!.appBarLayout.setExpanded(false, false) + binding!!.viewPager.setCurrentItem(commentsTabPos, false) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Play Utils + ////////////////////////////////////////////////////////////////////////// */ + private fun 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.java)).ifPresent(java.util.function.Consumer({ playerUi: MainPlayerUi? -> + if (playerUi!!.isFullscreen()) { + playerUi.toggleFullscreen() + } + })) + } + } + + private fun openBackgroundPlayer(append: Boolean) { + val useExternalAudioPlayer: Boolean = 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 fun openPopupPlayer(append: Boolean) { + 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() + val queue: PlayQueue = setupPlayQueueForIntent(append) + if (append) { //resumePlayback: false + NavigationHelper.enqueueOnPlayer((activity)!!, queue, PlayerType.POPUP) + } else { + replaceQueueIfUserConfirms(Runnable({ 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 + */ + fun openVideoPlayer(directlyFullscreenIfApplicable: Boolean) { + 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(Runnable({ openMainPlayer() })) + } + } + + /** + * If the option to start directly fullscreen is enabled, calls + * [.openVideoPlayer] with `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 [.openVideoPlayer] with `directlyFullscreenIfApplicable + * = false`, hence preventing it from going directly fullscreen. + */ + fun openVideoPlayerAutoFullscreen() { + openVideoPlayer(PlayerHelper.isStartMainPlayerFullscreenEnabled(requireContext())) + } + + private fun openNormalBackgroundPlayer(append: Boolean) { + // See UI changes while remote playQueue changes + if (!isPlayerAvailable()) { + playerHolder!!.startService(false, this) + } + val queue: PlayQueue = setupPlayQueueForIntent(append) + if (append) { + NavigationHelper.enqueueOnPlayer((activity)!!, queue, PlayerType.AUDIO) + } else { + replaceQueueIfUserConfirms(Runnable({ NavigationHelper.playOnBackgroundPlayer(activity, queue, true) })) + } + } + + private fun openMainPlayer() { + if (!isPlayerServiceAvailable()) { + playerHolder!!.startService(autoPlayEnabled, this) + return + } + if (currentInfo == null) { + return + } + val queue: PlayQueue = setupPlayQueueForIntent(false) + tryAddVideoPlayerView() + val playerIntent: Intent = NavigationHelper.getPlayerIntent(requireContext(), + PlayerService::class.java, queue, true, autoPlayEnabled) + 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 fun hideMainPlayerOnLoadingNewStream() { + val root: Optional = getRoot() + if (!isPlayerServiceAvailable() || root.isEmpty() || !player!!.videoPlayerSelected()) { + return + } + removeVideoPlayerView() + if (isAutoplayEnabled()) { + playerService!!.stopForImmediateReusing() + root.ifPresent(java.util.function.Consumer({ view: View -> view.setVisibility(View.GONE) })) + } else { + playerHolder!!.stopService() + } + } + + private fun setupPlayQueueForIntent(append: Boolean): PlayQueue { + if (append) { + return SinglePlayQueue(currentInfo) + } + var queue: PlayQueue? = playQueue + // Size can be 0 because queue removes bad stream automatically when error occurs + if (queue == null || queue.isEmpty()) { + queue = SinglePlayQueue(currentInfo) + } + return queue + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + ////////////////////////////////////////////////////////////////////////// */ + fun setAutoPlay(autoPlay: Boolean) { + autoPlayEnabled = autoPlay + } + + private fun startOnExternalPlayer(context: Context, + info: StreamInfo, + selectedStream: Stream) { + NavigationHelper.playOnExternalPlayer(context, currentInfo!!.getName(), + currentInfo!!.getSubChannelName(), selectedStream) + val recordManager: HistoryRecordManager = HistoryRecordManager(requireContext()) + disposables.add(recordManager.onViewed(info).onErrorComplete() + .subscribe( + io.reactivex.rxjava3.functions.Consumer({ ignored: Long? -> }), + io.reactivex.rxjava3.functions.Consumer({ error: Throwable? -> Log.e(TAG, "Register view failure: ", error) }) + )) + } + + private fun isExternalPlayerEnabled(): Boolean { + 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 fun isAutoplayEnabled(): Boolean { + return (autoPlayEnabled + && !isExternalPlayerEnabled() + && (!isPlayerAvailable() || player!!.videoPlayerSelected()) + && (bottomSheetState != BottomSheetBehavior.STATE_HIDDEN + ) && PlayerHelper.isAutoplayAllowedByUser(requireContext())) + } + + private fun 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 + Handler(Looper.getMainLooper()).post(Runnable({ + if (!isPlayerAvailable() || getView() == null) { + return@post + } + + // setup the surface view height, so that it fits the video correctly + setHeightThumbnail() + player!!.UIs().get((MainPlayerUi::class.java)).ifPresent(java.util.function.Consumer({ playerUi: MainPlayerUi? -> + // 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 fun removeVideoPlayerView() { + makeDefaultHeightForVideoPlaceholder() + if (player != null) { + player!!.UIs().get((VideoPlayerUi::class.java)).ifPresent(java.util.function.Consumer({ obj: VideoPlayerUi? -> obj!!.removeViewFromParent() })) + } + } + + private fun makeDefaultHeightForVideoPlaceholder() { + if (getView() == null) { + return + } + binding!!.playerPlaceholder.getLayoutParams().height = FrameLayout.LayoutParams.MATCH_PARENT + binding!!.playerPlaceholder.requestLayout() + } + + private val preDrawListener: ViewTreeObserver.OnPreDrawListener = object : ViewTreeObserver.OnPreDrawListener { + public override fun onPreDraw(): Boolean { + val metrics: DisplayMetrics = getResources().getDisplayMetrics() + if (getView() != null) { + val height: Int = (if (DeviceUtils.isInMultiWindow((activity)!!)) requireView() else 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, + * [.MAX_PLAYER_HEIGHT]) + */ + private fun setHeightThumbnail() { + val metrics: DisplayMetrics = getResources().getDisplayMetrics() + val isPortrait: Boolean = metrics.heightPixels > metrics.widthPixels + requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener) + if (isFullscreen()) { + val height: Int = (if (DeviceUtils.isInMultiWindow((activity)!!)) requireView() else 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 { + val height: Int = (if (isPortrait) metrics.widthPixels / (16.0f / 9.0f) else metrics.heightPixels / 2.0f).toInt() + setHeightThumbnail(height, metrics) + } + } + + private fun setHeightThumbnail(newHeight: Int, metrics: DisplayMetrics) { + binding!!.detailThumbnailImageView.setLayoutParams( + FrameLayout.LayoutParams( + RelativeLayout.LayoutParams.MATCH_PARENT, newHeight)) + binding!!.detailThumbnailImageView.setMinimumHeight(newHeight) + if (isPlayerAvailable()) { + val maxHeight: Int = (metrics.heightPixels * MAX_PLAYER_HEIGHT).toInt() + player!!.UIs().get((VideoPlayerUi::class.java)).ifPresent(java.util.function.Consumer({ ui: VideoPlayerUi? -> + ui.getBinding().surfaceView.setHeights(newHeight, + if (ui!!.isFullscreen()) newHeight else maxHeight) + })) + } + } + + private fun showContent() { + binding!!.detailContentRootHiding.setVisibility(View.VISIBLE) + } + + protected fun setInitialData(newServiceId: Int, + newUrl: String?, + newTitle: String, + newPlayQueue: PlayQueue?) { + serviceId = newServiceId + url = newUrl + title = newTitle + playQueue = newPlayQueue + } + + private fun setErrorImage(imageResource: Int) { + if (binding == null || activity == null) { + return + } + binding!!.detailThumbnailImageView.setImageDrawable( + AppCompatResources.getDrawable(requireContext(), imageResource)) + binding!!.detailThumbnailImageView.animate(false, 0, AnimationType.ALPHA, 0, Runnable({ binding!!.detailThumbnailImageView.animate(true, 500) })) + } + + public override fun 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 fun hideAgeRestrictedContent() { + showTextError(getString(R.string.restricted_video, + getString(R.string.show_age_restricted_content_title))) + } + + private fun setupBroadcastReceiver() { + broadcastReceiver = object : BroadcastReceiver() { + public override fun onReceive(context: Context, intent: Intent) { + when (intent.getAction()) { + ACTION_SHOW_MAIN_PLAYER -> bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_EXPANDED) + ACTION_HIDE_MAIN_PLAYER -> bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_HIDDEN) + 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, this@VideoDetailFragment) + } + } + } + } + } + val intentFilter: IntentFilter = IntentFilter() + intentFilter.addAction(ACTION_SHOW_MAIN_PLAYER) + intentFilter.addAction(ACTION_HIDE_MAIN_PLAYER) + intentFilter.addAction(ACTION_PLAYER_STARTED) + activity!!.registerReceiver(broadcastReceiver, intentFilter) + } + + /*////////////////////////////////////////////////////////////////////////// + // Orientation listener + ////////////////////////////////////////////////////////////////////////// */ + private fun 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 + ////////////////////////////////////////////////////////////////////////// */ + public override fun 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) + } + binding!!.detailThumbnailPlayButton.animate(false, 50) + binding!!.detailDurationView.animate(false, 100) + binding!!.detailPositionView.setVisibility(View.GONE) + binding!!.positionView.setVisibility(View.GONE) + binding!!.detailVideoTitleView.setText(title) + binding!!.detailVideoTitleView.setMaxLines(1) + binding!!.detailVideoTitleView.animate(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( + if (isFullscreen()) View.GONE else View.INVISIBLE) + } else { + binding!!.relatedItemsLayout!!.setVisibility(View.GONE) + } + } + PicassoHelper.cancelTag(PICASSO_VIDEO_DETAILS_TAG) + binding!!.detailThumbnailImageView.setImageBitmap(null) + binding!!.detailSubChannelThumbnailView.setImageBitmap(null) + } + + public override fun handleResult(info: StreamInfo) { + super.handleResult(info) + currentInfo = info + setInitialData(info.getServiceId(), info.getOriginalUrl(), info.getName(), playQueue) + updateTabs(info) + binding!!.detailThumbnailPlayButton.animate(true, 200) + binding!!.detailVideoTitleView.setText(title) + binding!!.detailSubChannelThumbnailView.setVisibility(View.GONE) + if (!TextUtils.isEmpty(info.getSubChannelName())) { + displayBothUploaderAndSubChannel(info) + } else { + displayUploaderAsSubChannel(info) + } + if (info.getViewCount() >= 0) { + if ((info.getStreamType() == StreamType.AUDIO_LIVE_STREAM)) { + binding!!.detailViewCountView.setText(Localization.listeningCount((activity)!!, + info.getViewCount())) + } else if ((info.getStreamType() == 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() == -1L && info.getLikeCount() == -1L) { + 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)) + binding!!.detailDurationView.animate(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)) + binding!!.detailDurationView.animate(true, 100) + } else { + binding!!.detailDurationView.setVisibility(View.GONE) + } + binding!!.detailTitleRootLayout.setClickable(true) + binding!!.detailToggleSecondaryControlsView.setRotation(0f) + binding!!.detailToggleSecondaryControlsView.setVisibility(View.VISIBLE) + binding!!.detailSecondaryControlPanel.setVisibility(View.GONE) + checkUpdateProgressInfo(info) + PicassoHelper.loadDetailsThumbnail(info.getThumbnails()).tag(PICASSO_VIDEO_DETAILS_TAG) + .into(binding!!.detailThumbnailImageView) + ExtractorHelper.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 (throwable: Throwable in info.getErrors()) { + if ((throwable is ContentNotSupportedException + && ("Fan pages are not supported" == throwable.message))) { + info.getErrors().remove(throwable) + } + } + if (!info.getErrors().isEmpty()) { + showSnackBarError(ErrorInfo(info.getErrors(), + UserAction.REQUESTED_STREAM, info.getUrl(), info)) + } + } + binding!!.detailControlsDownload.setVisibility( + if (StreamTypeUtil.isLiveStream(info.getStreamType())) View.GONE else View.VISIBLE) + binding!!.detailControlsBackground.setVisibility( + if (info.getAudioStreams().isEmpty() && info.getVideoStreams().isEmpty()) View.GONE else View.VISIBLE) + val noVideoStreams: Boolean = info.getVideoStreams().isEmpty() && info.getVideoOnlyStreams().isEmpty() + binding!!.detailControlsPopup.setVisibility(if (noVideoStreams) View.GONE else View.VISIBLE) + binding!!.detailThumbnailPlayButton.setImageResource( + if (noVideoStreams) R.drawable.ic_headset_shadow else R.drawable.ic_play_arrow_shadow) + } + + private fun displayUploaderAsSubChannel(info: StreamInfo) { + 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) + } + PicassoHelper.loadAvatar(info.getUploaderAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG) + .into(binding!!.detailSubChannelThumbnailView) + binding!!.detailSubChannelThumbnailView.setVisibility(View.VISIBLE) + binding!!.detailUploaderThumbnailView.setVisibility(View.GONE) + } + + private fun displayBothUploaderAndSubChannel(info: StreamInfo) { + binding!!.detailSubChannelTextView.setText(info.getSubChannelName()) + binding!!.detailSubChannelTextView.setVisibility(View.VISIBLE) + binding!!.detailSubChannelTextView.setSelected(true) + val subText: StringBuilder = StringBuilder() + if (!TextUtils.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) + } + PicassoHelper.loadAvatar(info.getSubChannelAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG) + .into(binding!!.detailSubChannelThumbnailView) + binding!!.detailSubChannelThumbnailView.setVisibility(View.VISIBLE) + PicassoHelper.loadAvatar(info.getUploaderAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG) + .into(binding!!.detailUploaderThumbnailView) + binding!!.detailUploaderThumbnailView.setVisibility(View.VISIBLE) + } + + fun openDownloadDialog() { + if (currentInfo == null) { + return + } + try { + val downloadDialog: DownloadDialog = DownloadDialog((activity)!!, currentInfo!!) + downloadDialog.show(activity!!.getSupportFragmentManager(), "downloadDialog") + } catch (e: Exception) { + showSnackbar((activity)!!, ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG, + "Showing download dialog", currentInfo)) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Stream Results + ////////////////////////////////////////////////////////////////////////// */ + private fun checkUpdateProgressInfo(info: StreamInfo) { + if (positionSubscriber != null) { + positionSubscriber!!.dispose() + } + if (!DependentPreferenceHelper.getResumePlaybackEnabled((activity)!!)) { + binding!!.positionView.setVisibility(View.GONE) + binding!!.detailPositionView.setVisibility(View.GONE) + return + } + val recordManager: HistoryRecordManager = HistoryRecordManager(requireContext()) + positionSubscriber = recordManager.loadStreamState(info) + .subscribeOn(Schedulers.io()) + .onErrorComplete() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(io.reactivex.rxjava3.functions.Consumer({ state: StreamStateEntity? -> + updatePlaybackProgress( + state!!.getProgressMillis(), info.getDuration() * 1000) + }), io.reactivex.rxjava3.functions.Consumer({ e: Throwable? -> }), Action({ + binding!!.positionView.setVisibility(View.GONE) + binding!!.detailPositionView.setVisibility(View.GONE) + })) + } + + private fun updatePlaybackProgress(progress: Long, duration: Long) { + if (!DependentPreferenceHelper.getResumePlaybackEnabled((activity)!!)) { + return + } + val progressSeconds: Int = TimeUnit.MILLISECONDS.toSeconds(progress).toInt() + val durationSeconds: Int = TimeUnit.MILLISECONDS.toSeconds(duration).toInt() + // If the old and the new progress values have a big difference then use animation. + // Otherwise don't because it affects CPU + val progressDifference: Int = abs((binding!!.positionView.getProgress() + - progressSeconds).toDouble()).toInt() + binding!!.positionView.setMax(durationSeconds) + if (progressDifference > 2) { + binding!!.positionView.setProgressAnimated(progressSeconds) + } else { + binding!!.positionView.setProgress(progressSeconds) + } + val position: String? = Localization.getDurationString(progressSeconds.toLong()) + if (position !== binding!!.detailPositionView.getText()) { + binding!!.detailPositionView.setText(position) + } + if (binding!!.positionView.getVisibility() != View.VISIBLE) { + binding!!.positionView.animate(true, 100) + binding!!.detailPositionView.animate(true, 100) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Player event listener + ////////////////////////////////////////////////////////////////////////// */ + public override fun onViewCreated() { + tryAddVideoPlayerView() + } + + public override fun onQueueUpdate(queue: PlayQueue?) { + playQueue = queue + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, ("onQueueUpdate() called with: serviceId = [" + + serviceId + "], videoUrl = [" + 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( + io.reactivex.rxjava3.functions.Consumer({ event: PlayQueueEvent? -> 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 + val stackPeek: StackItem? = stack.peek() + if (stackPeek != null && !stackPeek.getPlayQueue()!!.equalStreams(queue)) { + val playQueueItem: PlayQueueItem? = queue!!.getItem() + if (playQueueItem != null) { + stack.push(StackItem(playQueueItem.getServiceId(), playQueueItem.getUrl(), + playQueueItem.getTitle(), queue)) + return + } // else continue below + } + val stackWithQueue: StackItem? = 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) + } + } + + public override fun onPlaybackUpdate(state: Int, + repeatMode: Int, + shuffled: Boolean, + parameters: PlaybackParameters?) { + setOverlayPlayPauseImage(player != null && player!!.isPlaying()) + when (state) { + Player.Companion.STATE_PLAYING -> if ((binding!!.positionView.getAlpha() != 1.0f + ) && (player!!.getPlayQueue() != null + ) && (player!!.getPlayQueue()!!.getItem() != null + ) && (player!!.getPlayQueue()!!.getItem().getUrl() == url)) { + binding!!.positionView.animate(true, 100) + binding!!.detailPositionView.animate(true, 100) + } + } + } + + public override fun onProgressUpdate(currentProgress: Int, + duration: Int, + bufferPercent: Int) { + // 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() == url)) { + updatePlaybackProgress(currentProgress.toLong(), duration.toLong()) + } + } + + public override fun onMetadataUpdate(info: StreamInfo?, queue: PlayQueue?) { + val item: StackItem? = 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() == 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) + } + + public override fun onPlayerError(error: PlaybackException?, isCatchableException: Boolean) { + if (!isCatchableException) { + // Properly exit from fullscreen + toggleFullscreenIfInFullscreenMode() + hideMainPlayerOnLoadingNewStream() + } + } + + public override fun onServiceStopped() { + setOverlayPlayPauseImage(false) + if (currentInfo != null) { + updateOverlayData(currentInfo!!.getName(), + currentInfo!!.getUploaderName(), + currentInfo!!.getThumbnails()) + } + updateOverlayPlayQueueButtonVisibility() + } + + public override fun onFullscreenStateChanged(fullscreen: Boolean) { + setupBrightness() + if ((!isPlayerAndPlayerServiceAvailable() + || player!!.UIs().get((MainPlayerUi::class.java)).isEmpty() + || getRoot().map(Function({ obj: View -> obj.getParent() })).isEmpty())) { + return + } + if (fullscreen) { + hideSystemUiIfNeeded() + binding!!.overlayPlayPauseButton.requestFocus() + } else { + showSystemUi() + } + if (binding!!.relatedItemsLayout != null) { + binding!!.relatedItemsLayout!!.setVisibility(if (fullscreen) View.GONE else View.VISIBLE) + } + scrollToTop() + tryAddVideoPlayerView() + } + + public override fun onScreenRotationButtonClicked() { + // 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 + val isLandscape: Boolean = DeviceUtils.isLandscape(requireContext()) + if ((DeviceUtils.isTablet((activity)!!) + && (!PlayerHelper.globalScreenOrientationLocked(activity) || isLandscape))) { + player!!.UIs().get((MainPlayerUi::class.java)).ifPresent(java.util.function.Consumer({ obj: MainPlayerUi? -> obj!!.toggleFullscreen() })) + return + } + val newOrientation: Int = if (isLandscape) ActivityInfo.SCREEN_ORIENTATION_PORTRAIT else ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + activity!!.setRequestedOrientation(newOrientation) + } + + /* + * Will scroll down to description view after long click on moreOptionsButton + * */ + public override fun onMoreOptionsLongClicked() { + val params: CoordinatorLayout.LayoutParams = binding!!.appBarLayout.getLayoutParams() as CoordinatorLayout.LayoutParams + val behavior: AppBarLayout.Behavior? = params.getBehavior() as AppBarLayout.Behavior? + val valueAnimator: ValueAnimator = ValueAnimator + .ofInt(0, -binding!!.playerPlaceholder.getHeight()) + valueAnimator.setInterpolator(DecelerateInterpolator()) + valueAnimator.addUpdateListener(AnimatorUpdateListener({ animation: ValueAnimator -> + behavior!!.setTopAndBottomOffset(animation.getAnimatedValue() as Int) + binding!!.appBarLayout.requestLayout() + })) + valueAnimator.setInterpolator(DecelerateInterpolator()) + valueAnimator.setDuration(500) + valueAnimator.start() + } + + /*////////////////////////////////////////////////////////////////////////// + // Player related utils + ////////////////////////////////////////////////////////////////////////// */ + private fun showSystemUi() { + if (BaseFragment.Companion.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 fun hideSystemUi() { + if (BaseFragment.Companion.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 + } + var visibility: Int = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + or 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 + val isInMultiWindow: Boolean = DeviceUtils.isInMultiWindow(activity!!) + if (!isInMultiWindow) { + visibility = visibility or 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 + public override fun hideSystemUiIfNeeded() { + if ((isFullscreen() + && bottomSheetBehavior!!.getState() == BottomSheetBehavior.STATE_EXPANDED)) { + hideSystemUi() + } + } + + private fun isFullscreen(): Boolean { + return isPlayerAvailable() && player!!.UIs().get((VideoPlayerUi::class.java)) + .map(Function({ obj: VideoPlayerUi? -> obj!!.isFullscreen() })).orElse(false) + } + + private fun playerIsNotStopped(): Boolean { + return isPlayerAvailable() && !player!!.isStopped() + } + + private fun restoreDefaultBrightness() { + val lp: WindowManager.LayoutParams = activity!!.getWindow().getAttributes() + if (lp.screenBrightness == -1f) { + return + } + + // Restore the old brightness when fragment.onPause() called or + // when a player is in portrait + lp.screenBrightness = -1f + activity!!.getWindow().setAttributes(lp) + } + + private fun setupBrightness() { + if (activity == null) { + return + } + val lp: WindowManager.LayoutParams = 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!!) + == getString(R.string.brightness_control_key)) + && !(PlayerHelper.getActionForLeftGestureSide(activity!!) + == getString(R.string.brightness_control_key)))) { + return + } + // Restore already saved brightness level + val brightnessLevel: Float = 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 fun accommodateForTvAndDesktopMode() { + if (DeviceUtils.isTv(getContext())) { + // remove ripple effects from detail controls + val transparent: Int = 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 fun checkLandscape() { + if (((!player!!.isPlaying() && player!!.getPlayQueue() !== playQueue) + || player!!.getPlayQueue() == null)) { + setAutoPlay(true) + } + player!!.UIs().get((MainPlayerUi::class.java)).ifPresent(java.util.function.Consumer({ obj: MainPlayerUi? -> obj!!.checkLandscape() })) + // Let's give a user time to look at video information page if video is not playing + if (PlayerHelper.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 fun wasCleared(): Boolean { + return url == null + } + + private fun findQueueInStack(queue: PlayQueue?): StackItem? { + var item: StackItem? = null + val iterator: Iterator = stack.descendingIterator() + while (iterator.hasNext()) { + val next: StackItem = iterator.next() + if (next.getPlayQueue()!!.equalStreams(queue)) { + item = next + break + } + } + return item + } + + private fun replaceQueueIfUserConfirms(onAllow: Runnable) { + val activeQueue: PlayQueue? = if (isPlayerAvailable()) player!!.getPlayQueue() else null + + // Player will have STATE_IDLE when a user pressed back button + if ((PlayerHelper.isClearingQueueConfirmationRequired((activity)!!) + && playerIsNotStopped() + && (activeQueue != null + ) && !activeQueue.equalStreams(playQueue))) { + showClearingQueueConfirmation(onAllow) + } else { + onAllow.run() + } + } + + private fun showClearingQueueConfirmation(onAllow: Runnable) { + AlertDialog.Builder((activity)!!) + .setTitle(R.string.clear_queue_confirmation_description) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.ok, DialogInterface.OnClickListener({ dialog: DialogInterface, which: Int -> + onAllow.run() + dialog.dismiss() + })) + .show() + } + + private fun showExternalVideoPlaybackDialog() { + if (currentInfo == null) { + return + } + val builder: AlertDialog.Builder = AlertDialog.Builder((activity)!!) + builder.setTitle(R.string.select_quality_external_players) + builder.setNeutralButton(R.string.open_in_browser, DialogInterface.OnClickListener({ dialog: DialogInterface?, i: Int -> ShareUtils.openUrlInBrowser(requireActivity(), url) })) + val videoStreamsForExternalPlayers: List = ListHelper.getSortedStreamVideosList( + (activity)!!, + ListHelper.getUrlAndNonTorrentStreams(currentInfo!!.getVideoStreams()), + ListHelper.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 { + val selectedVideoStreamIndexForExternalPlayers: Int = ListHelper.getDefaultResolutionIndex((activity)!!, videoStreamsForExternalPlayers) + val resolutions: Array = videoStreamsForExternalPlayers.stream() + .map(Function({ obj: VideoStream? -> obj!!.getResolution() })).toArray(IntFunction>({ _Dummy_.__Array__() })) + builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndexForExternalPlayers, + null) + builder.setNegativeButton(R.string.cancel, null) + builder.setPositiveButton(R.string.ok, DialogInterface.OnClickListener({ dialog: DialogInterface, i: Int -> + val index: Int = (dialog as AlertDialog).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 fun showExternalAudioPlaybackDialog() { + if (currentInfo == null) { + return + } + val audioStreams: List = ListHelper.getUrlAndNonTorrentStreams( + currentInfo!!.getAudioStreams()) + val audioTracks: List? = 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 { + val selectedAudioStream: Int = ListHelper.getDefaultAudioFormat(activity, audioTracks) + val trackNames: Array = audioTracks.stream() + .map(Function({ audioStream: AudioStream? -> Localization.audioTrackName((activity)!!, audioStream) })) + .toArray(IntFunction>({ _Dummy_.__Array__() })) + AlertDialog.Builder((activity)!!) + .setTitle(R.string.select_audio_track_external_players) + .setNeutralButton(R.string.open_in_browser, DialogInterface.OnClickListener({ dialog: DialogInterface?, i: Int -> ShareUtils.openUrlInBrowser(requireActivity(), url) })) + .setSingleChoiceItems(trackNames, selectedAudioStream, null) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.ok, DialogInterface.OnClickListener({ dialog: DialogInterface, i: Int -> + val index: Int = (dialog as AlertDialog).getListView() + .getCheckedItemPosition() + startOnExternalPlayer((activity)!!, currentInfo!!, (audioTracks.get(index))!!) + })) + .show() + } + } + + /* + * Remove unneeded information while waiting for a next task + * */ + private fun cleanUp() { + // New beginning + stack.clear() + if (currentWorker != null) { + currentWorker!!.dispose() + } + playerHolder!!.stopService() + setInitialData(0, null, "", null) + currentInfo = null + updateOverlayData(null, null, listOf()) + } + /*////////////////////////////////////////////////////////////////////////// + // 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 fun moveFocusToMainFragment(toMain: Boolean) { + setupBrightness() + val mainFragment: ViewGroup = requireActivity().findViewById(R.id.fragment_holder) + // Hamburger button steels a focus even under bottomSheet + val toolbar: Toolbar = requireActivity().findViewById(R.id.toolbar) + val afterDescendants: Int = ViewGroup.FOCUS_AFTER_DESCENDANTS + val blockDescendants: Int = ViewGroup.FOCUS_BLOCK_DESCENDANTS + if (toMain) { + mainFragment.setDescendantFocusability(afterDescendants) + toolbar.setDescendantFocusability(afterDescendants) + (requireView() as ViewGroup).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) + (requireView() as ViewGroup).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 fun manageSpaceAtTheBottom(showMore: Boolean) { + val peekHeight: Int = getResources().getDimensionPixelSize(R.dimen.mini_player_height) + val holder: ViewGroup = requireActivity().findViewById(R.id.fragment_holder) + val newBottomPadding: Int + if (showMore) { + newBottomPadding = 0 + } else { + newBottomPadding = peekHeight + } + if (holder.getPaddingBottom() == newBottomPadding) { + return + } + holder.setPadding(holder.getPaddingLeft(), + holder.getPaddingTop(), + holder.getPaddingRight(), + newBottomPadding) + } + + private fun setupBottomPlayer() { + val params: CoordinatorLayout.LayoutParams = binding!!.appBarLayout.getLayoutParams() as CoordinatorLayout.LayoutParams + val behavior: AppBarLayout.Behavior? = params.getBehavior() as AppBarLayout.Behavior? + val bottomSheetLayout: FrameLayout = activity!!.findViewById(R.id.fragment_player_holder) + bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout) + bottomSheetBehavior!!.setState(lastStableBottomSheetState) + updateBottomSheetState(lastStableBottomSheetState) + val peekHeight: Int = 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(0f) + setOverlayElementsClickable(false) + } + } + bottomSheetCallback = object : BottomSheetCallback() { + public override fun onStateChanged(bottomSheet: View, newState: Int) { + updateBottomSheetState(newState) + when (newState) { + BottomSheetBehavior.STATE_HIDDEN -> { + moveFocusToMainFragment(true) + manageSpaceAtTheBottom(true) + bottomSheetBehavior!!.setPeekHeight(0) + cleanUp() + } + + 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.java)) + .ifPresent(java.util.function.Consumer({ obj: MainPlayerUi? -> obj!!.toggleFullscreen() })) + } + setOverlayLook(binding!!.appBarLayout, behavior, 1f) + } + + BottomSheetBehavior.STATE_COLLAPSED -> { + moveFocusToMainFragment(true) + manageSpaceAtTheBottom(false) + bottomSheetBehavior!!.setPeekHeight(peekHeight) + + // Re-enable clicks + setOverlayElementsClickable(true) + if (isPlayerAvailable()) { + player!!.UIs().get((MainPlayerUi::class.java)) + .ifPresent(java.util.function.Consumer({ obj: MainPlayerUi? -> obj!!.closeItemsList() })) + } + setOverlayLook(binding!!.appBarLayout, behavior, 0f) + } + + BottomSheetBehavior.STATE_DRAGGING, BottomSheetBehavior.STATE_SETTLING -> { + if (isFullscreen()) { + showSystemUi() + } + if (isPlayerAvailable()) { + player!!.UIs().get((MainPlayerUi::class.java)).ifPresent(java.util.function.Consumer({ ui: MainPlayerUi? -> + if (ui!!.isControlsVisible()) { + ui.hideControls(0, 0) + } + })) + } + } + + BottomSheetBehavior.STATE_HALF_EXPANDED -> {} + } + } + + public override fun onSlide(bottomSheet: View, slideOffset: Float) { + setOverlayLook(binding!!.appBarLayout, behavior, slideOffset) + } + } + bottomSheetBehavior!!.addBottomSheetCallback(bottomSheetCallback) + + // User opened a new page and the player will hide itself + activity!!.getSupportFragmentManager().addOnBackStackChangedListener(FragmentManager.OnBackStackChangedListener({ + if (bottomSheetBehavior!!.getState() == BottomSheetBehavior.STATE_EXPANDED) { + bottomSheetBehavior!!.setState(BottomSheetBehavior.STATE_COLLAPSED) + } + })) + } + + private fun updateOverlayPlayQueueButtonVisibility() { + val isPlayQueueEmpty: Boolean = (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( + if (isPlayQueueEmpty) View.GONE else View.VISIBLE) + } + } + + private fun updateOverlayData(overlayTitle: String?, + uploader: String?, + thumbnails: List) { + binding!!.overlayTitleTextView.setText(if (TextUtils.isEmpty(overlayTitle)) "" else overlayTitle) + binding!!.overlayChannelTextView.setText(if (TextUtils.isEmpty(uploader)) "" else uploader) + binding!!.overlayThumbnail.setImageDrawable(null) + PicassoHelper.loadDetailsThumbnail(thumbnails).tag(PICASSO_VIDEO_DETAILS_TAG) + .into(binding!!.overlayThumbnail) + } + + private fun setOverlayPlayPauseImage(playerIsPlaying: Boolean) { + val drawable: Int = if (playerIsPlaying) R.drawable.ic_pause else R.drawable.ic_play_arrow + binding!!.overlayPlayPauseButton.setImageResource(drawable) + } + + private fun setOverlayLook(appBar: AppBarLayout, + behavior: AppBarLayout.Behavior?, + slideOffset: Float) { + // 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(min(MAX_OVERLAY_ALPHA.toDouble(), (1 - slideOffset).toDouble()).toFloat()) + // These numbers are not special. They just do a cool transition + behavior.setTopAndBottomOffset((-binding!!.detailThumbnailImageView.getHeight() * 2 * (1 - slideOffset) / 3).toInt()) + appBar.requestLayout() + } + + private fun setOverlayElementsClickable(enable: Boolean) { + 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 + fun isPlayerAvailable(): Boolean { + return player != null + } + + fun isPlayerServiceAvailable(): Boolean { + return playerService != null + } + + fun isPlayerAndPlayerServiceAvailable(): Boolean { + return player != null && playerService != null + } + + fun getRoot(): Optional { + return Optional.ofNullable(player) + .flatMap(Function?>({ player1: Player -> player1.UIs().get(VideoPlayerUi::class.java) })) + .map(Function({ playerUi: VideoPlayerUi? -> playerUi.getBinding().getRoot() })) + } + + private fun updateBottomSheetState(newState: Int) { + bottomSheetState = newState + if ((newState != BottomSheetBehavior.STATE_DRAGGING + && newState != BottomSheetBehavior.STATE_SETTLING)) { + lastStableBottomSheetState = newState + } + } + + companion object { + val KEY_SWITCHING_PLAYERS: String = "switching_players" + private val MAX_OVERLAY_ALPHA: Float = 0.9f + private val MAX_PLAYER_HEIGHT: Float = 0.7f + val ACTION_SHOW_MAIN_PLAYER: String = App.Companion.PACKAGE_NAME + ".VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER" + val ACTION_HIDE_MAIN_PLAYER: String = App.Companion.PACKAGE_NAME + ".VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER" + val ACTION_PLAYER_STARTED: String = App.Companion.PACKAGE_NAME + ".VideoDetailFragment.ACTION_PLAYER_STARTED" + val ACTION_VIDEO_FRAGMENT_RESUMED: String = App.Companion.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED" + val ACTION_VIDEO_FRAGMENT_STOPPED: String = App.Companion.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED" + private val COMMENTS_TAB_TAG: String = "COMMENTS" + private val RELATED_TAB_TAG: String = "NEXT VIDEO" + private val DESCRIPTION_TAB_TAG: String = "DESCRIPTION TAB" + private val EMPTY_TAB_TAG: String = "EMPTY TAB" + private val PICASSO_VIDEO_DETAILS_TAG: String = "PICASSO_VIDEO_DETAILS_TAG" + + /*//////////////////////////////////////////////////////////////////////// */ + fun getInstance(serviceId: Int, + videoUrl: String?, + name: String, + queue: PlayQueue?): VideoDetailFragment { + val instance: VideoDetailFragment = VideoDetailFragment() + instance.setInitialData(serviceId, videoUrl, name, queue) + return instance + } + + fun getInstanceInCollapsedState(): VideoDetailFragment { + val instance: VideoDetailFragment = VideoDetailFragment() + instance.updateBottomSheetState(BottomSheetBehavior.STATE_COLLAPSED) + return instance + } + /*////////////////////////////////////////////////////////////////////////// + // OwnStack + ////////////////////////////////////////////////////////////////////////// */ + /** + * Stack that contains the "navigation history".

+ * The peek is the current video. + */ + private var stack: LinkedList = LinkedList() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java deleted file mode 100644 index c816723ff..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java +++ /dev/null @@ -1,146 +0,0 @@ -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); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.kt b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.kt new file mode 100644 index 000000000..47be9546e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.kt @@ -0,0 +1,126 @@ +package org.schabi.newpipe.fragments.detail + +import android.content.Context +import android.util.Log +import android.util.Pair +import android.view.ContextThemeWrapper +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.RadioButton +import android.widget.RadioGroup +import android.widget.Toast +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.function.Supplier + +/** + * Outsourced logic for crashing the player in the [VideoDetailFragment]. + */ +object 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 val TAG: String = "VideoDetPlayerCrasher" + private val DEFAULT_MSG: String = "Dummy" + private val AVAILABLE_EXCEPTION_TYPES: List>> = java.util.List.of( + Pair("Source", Supplier({ + ExoPlaybackException.createForSource( + IOException(DEFAULT_MSG), + PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW + ) + })), + Pair("Renderer", Supplier({ + ExoPlaybackException.createForRenderer( + Exception(DEFAULT_MSG), + "Dummy renderer", + 0, + null, + C.FORMAT_HANDLED, /*isRecoverable=*/ + false, + PlaybackException.ERROR_CODE_DECODING_FAILED + ) + })), + Pair("Unexpected", Supplier({ + ExoPlaybackException.createForUnexpected( + RuntimeException(DEFAULT_MSG), + PlaybackException.ERROR_CODE_UNSPECIFIED + ) + })), + Pair("Remote", Supplier({ ExoPlaybackException.createForRemote(DEFAULT_MSG) })) + ) + + private fun getThemeWrapperContext(context: Context): Context { + return ContextThemeWrapper( + context, + if (ThemeHelper.isLightThemeSelected(context)) R.style.LightTheme else R.style.DarkTheme) + } + + fun onCrashThePlayer( + context: Context, + 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 -- + val themeWrapperContext: Context = getThemeWrapperContext(context) + val inflater: LayoutInflater = LayoutInflater.from(themeWrapperContext) + val binding: SingleChoiceDialogViewBinding = SingleChoiceDialogViewBinding.inflate(inflater) + val alertDialog: AlertDialog = AlertDialog.Builder(themeWrapperContext) + .setTitle("Choose an exception") + .setView(binding.getRoot()) + .setCancelable(true) + .setNegativeButton(R.string.cancel, null) + .create() + for (entry: Pair> in AVAILABLE_EXCEPTION_TYPES) { + val radioButton: RadioButton = ListRadioIconItemBinding.inflate(inflater).getRoot() + radioButton.setText(entry.first) + radioButton.setChecked(false) + radioButton.setLayoutParams( + RadioGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + ) + radioButton.setOnClickListener(View.OnClickListener({ v: View? -> + 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 [Player.onPlayerError]. + * @param player + * @param exception + */ + private fun tryCrashPlayerWith( + player: Player, + exception: ExoPlaybackException + ) { + Log.d(TAG, "Crashing the player using player.onPlayerError(ex)") + try { + player.onPlayerError(exception) + } catch (exPlayer: Exception) { + Log.e(TAG, + "Run into an exception while crashing the player:", + exPlayer) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java deleted file mode 100644 index 8a117a47a..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java +++ /dev/null @@ -1,489 +0,0 @@ -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()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.kt new file mode 100644 index 000000000..b63ef707f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.kt @@ -0,0 +1,431 @@ +package org.schabi.newpipe.fragments.list + +import android.content.Context +import android.content.SharedPreferences +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +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.appcompat.app.ActionBar +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView +import org.schabi.newpipe.BaseFragment +import org.schabi.newpipe.R +import org.schabi.newpipe.error.ErrorUtil.Companion.showUiErrorSnackbar +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.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.ktx.animate +import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.OnClickGesture +import org.schabi.newpipe.util.StateSaver +import org.schabi.newpipe.util.StateSaver.WriteRead +import org.schabi.newpipe.util.ThemeHelper +import org.schabi.newpipe.views.SuperScrollLayoutManager +import java.util.Queue +import java.util.function.Supplier + +abstract class BaseListFragment() : BaseStateFragment(), ListViewContract, WriteRead, OnSharedPreferenceChangeListener { + protected var savedState: org.schabi.newpipe.util.SavedState? = null + private var useDefaultStateSaving: Boolean = true + private var updateFlags: Int = 0 + + /*////////////////////////////////////////////////////////////////////////// + // Views + ////////////////////////////////////////////////////////////////////////// */ + protected var infoListAdapter: InfoListAdapter? = null + protected var itemsList: RecyclerView? = null + private var focusedPosition: Int = -1 + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + ////////////////////////////////////////////////////////////////////////// */ + public override fun onAttach(context: Context) { + super.onAttach(context) + if (infoListAdapter == null) { + infoListAdapter = InfoListAdapter((activity)!!) + } + } + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + PreferenceManager.getDefaultSharedPreferences((activity)!!) + .registerOnSharedPreferenceChangeListener(this) + } + + public override fun onDestroy() { + super.onDestroy() + if (useDefaultStateSaving) { + StateSaver.onDestroy(savedState) + } + PreferenceManager.getDefaultSharedPreferences((activity)!!) + .unregisterOnSharedPreferenceChangeListener(this) + } + + public override fun onResume() { + super.onResume() + if (updateFlags != 0) { + if ((updateFlags and LIST_MODE_UPDATE_FLAG) != 0) { + refreshItemViewMode() + } + updateFlags = 0 + } + } + /*////////////////////////////////////////////////////////////////////////// + // State Saving + ////////////////////////////////////////////////////////////////////////// */ + /** + * If the default implementation of [StateSaver.WriteRead] should be used. + * + * @param useDefaultStateSaving Whether the default implementation should be used + * @see StateSaver + */ + fun setUseDefaultStateSaving(useDefaultStateSaving: Boolean) { + this.useDefaultStateSaving = useDefaultStateSaving + } + + public override fun generateSuffix(): String? { + // Naive solution, but it's good for now (the items don't change) + return "." + infoListAdapter!!.getItemsList().size + ".list" + } + + private fun getFocusedPosition(): Int { + try { + val focusedItem: View = itemsList!!.getFocusedChild() + val itemHolder: RecyclerView.ViewHolder? = itemsList!!.findContainingViewHolder(focusedItem) + return itemHolder!!.getBindingAdapterPosition() + } catch (e: NullPointerException) { + return -1 + } + } + + public override fun writeTo(objectsToSave: Queue) { + if (!useDefaultStateSaving) { + return + } + objectsToSave.add(infoListAdapter!!.getItemsList()) + objectsToSave.add(getFocusedPosition()) + } + + @Throws(Exception::class) + public override fun readFrom(savedObjects: Queue) { + if (!useDefaultStateSaving) { + return + } + infoListAdapter!!.getItemsList().clear() + infoListAdapter!!.getItemsList().addAll((savedObjects.poll() as List?)!!) + restoreFocus(savedObjects.poll() as Int?) + } + + private fun restoreFocus(position: Int?) { + if (position == null || position < 0) { + return + } + itemsList!!.post(Runnable({ + val focusedHolder: RecyclerView.ViewHolder? = itemsList!!.findViewHolderForAdapterPosition(position) + if (focusedHolder != null) { + focusedHolder.itemView.requestFocus() + } + })) + } + + public override fun onSaveInstanceState(bundle: Bundle) { + super.onSaveInstanceState(bundle) + if (useDefaultStateSaving) { + savedState = StateSaver.tryToSave(activity!!.isChangingConfigurations(), savedState, bundle, this) + } + } + + override fun onRestoreInstanceState(bundle: Bundle) { + super.onRestoreInstanceState(bundle) + if (useDefaultStateSaving) { + savedState = StateSaver.tryToRestore(bundle, this) + } + } + + public override fun onStop() { + focusedPosition = getFocusedPosition() + super.onStop() + } + + public override fun onStart() { + super.onStart() + restoreFocus(focusedPosition) + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + ////////////////////////////////////////////////////////////////////////// */ + protected open fun getListHeaderSupplier(): Supplier? { + return null + } + + protected fun getListLayoutManager(): RecyclerView.LayoutManager { + return SuperScrollLayoutManager(activity) + } + + protected fun getGridLayoutManager(): RecyclerView.LayoutManager { + val resources: Resources = activity!!.getResources() + var width: Int = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width) + width = (width + (24 * resources.getDisplayMetrics().density)).toInt() + val spanCount: Int = Math.floorDiv(resources.getDisplayMetrics().widthPixels, width) + val lm: GridLayoutManager = GridLayoutManager(activity, spanCount) + lm.setSpanSizeLookup(infoListAdapter!!.getSpanSizeLookup(spanCount)) + return lm + } + + /** + * Updates the item view mode based on user preference. + */ + private fun refreshItemViewMode() { + val itemViewMode: ItemViewMode? = getItemViewMode() + itemsList!!.setLayoutManager(if ((itemViewMode == ItemViewMode.GRID)) getGridLayoutManager() else getListLayoutManager()) + infoListAdapter!!.setItemViewMode(itemViewMode) + infoListAdapter!!.notifyDataSetChanged() + } + + override fun initViews(rootView: View, savedInstanceState: Bundle?) { + super.initViews(rootView, savedInstanceState) + itemsList = rootView.findViewById(R.id.items_list) + refreshItemViewMode() + val listHeaderSupplier: Supplier? = getListHeaderSupplier() + if (listHeaderSupplier != null) { + infoListAdapter!!.setHeaderSupplier(listHeaderSupplier) + } + itemsList.setAdapter(infoListAdapter) + } + + protected open fun onItemSelected(selectedItem: InfoItem) { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "onItemSelected() called with: selectedItem = [" + selectedItem + "]") + } + } + + override fun initListeners() { + super.initListeners() + infoListAdapter!!.setOnStreamSelectedListener(object : OnClickGesture { + public override fun selected(selectedItem: StreamInfoItem) { + onStreamSelected(selectedItem) + } + + public override fun held(selectedItem: StreamInfoItem) { + showInfoItemDialog(selectedItem) + } + }) + infoListAdapter!!.setOnChannelSelectedListener(OnClickGesture({ selectedItem: ChannelInfoItem -> + try { + onItemSelected(selectedItem) + NavigationHelper.openChannelFragment(getFM(), selectedItem.getServiceId(), + selectedItem.getUrl(), selectedItem.getName()) + } catch (e: Exception) { + showUiErrorSnackbar(this, "Opening channel fragment", e) + } + })) + infoListAdapter!!.setOnPlaylistSelectedListener(OnClickGesture({ selectedItem: PlaylistInfoItem -> + try { + onItemSelected(selectedItem) + NavigationHelper.openPlaylistFragment(getFM(), selectedItem.getServiceId(), + selectedItem.getUrl(), selectedItem.getName()) + } catch (e: Exception) { + showUiErrorSnackbar(this, "Opening playlist fragment", e) + } + })) + infoListAdapter!!.setOnCommentsSelectedListener(OnClickGesture({ selectedItem: T -> onItemSelected(selectedItem) })) + + // 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 [.itemsList]. + */ + protected fun useNormalItemListScrollListener() { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "useNormalItemListScrollListener called") + } + itemsList!!.clearOnScrollListeners() + itemsList!!.addOnScrollListener(DefaultItemListOnScrolledDownListener()) + } + + /** + * Removes all listeners and adds the initial scroll listener to the [.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 fun useInitialItemListLoadScrollListener() { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "useInitialItemListLoadScrollListener called") + } + itemsList!!.clearOnScrollListeners() + itemsList!!.addOnScrollListener(object : DefaultItemListOnScrolledDownListener() { + public override fun onScrolled(recyclerView: RecyclerView, + dx: Int, dy: Int) { + 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 fun log(msg: String) { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "initItemListLoadScrollListener - " + msg) + } + } + }) + } + + internal open inner class DefaultItemListOnScrolledDownListener() : OnScrollBelowItemsListener() { + public override fun onScrolledDown(recyclerView: RecyclerView?) { + onScrollToBottom() + } + } + + private fun onStreamSelected(selectedItem: StreamInfoItem) { + onItemSelected(selectedItem) + NavigationHelper.openVideoDetailFragment(requireContext(), getFM(), + selectedItem.getServiceId(), selectedItem.getUrl(), selectedItem.getName(), + null, false) + } + + protected fun onScrollToBottom() { + if (hasMoreItems() && !isLoading.get()) { + loadMoreItems() + } + } + + protected open fun showInfoItemDialog(item: StreamInfoItem) { + try { + InfoItemDialog.Builder((getActivity())!!, (getContext())!!, this, item).create().show() + } catch (e: IllegalArgumentException) { + InfoItemDialog.Builder.Companion.reportErrorDuringInitialization(e, item) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Menu + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreateOptionsMenu(menu: Menu, + inflater: MenuInflater) { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, ("onCreateOptionsMenu() called with: " + + "menu = [" + menu + "], inflater = [" + inflater + "]")) + } + super.onCreateOptionsMenu(menu, inflater) + val supportActionBar: ActionBar? = activity!!.getSupportActionBar() + if (supportActionBar != null) { + supportActionBar.setDisplayShowTitleEnabled(true) + supportActionBar.setDisplayHomeAsUpEnabled(!useAsFrontPage) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Load and handle + ////////////////////////////////////////////////////////////////////////// */ + override fun startLoading(forceLoad: Boolean) { + useInitialItemListLoadScrollListener() + super.startLoading(forceLoad) + } + + protected abstract fun loadMoreItems() + protected abstract fun hasMoreItems(): Boolean + + /*////////////////////////////////////////////////////////////////////////// + // Contract + ////////////////////////////////////////////////////////////////////////// */ + public override fun showLoading() { + super.showLoading() + itemsList!!.animateHideRecyclerViewAllowingScrolling() + } + + public override fun hideLoading() { + super.hideLoading() + itemsList!!.animate(true, 300) + } + + public override fun showEmptyState() { + super.showEmptyState() + showListFooter(false) + itemsList!!.animateHideRecyclerViewAllowingScrolling() + } + + public override fun showListFooter(show: Boolean) { + itemsList!!.post(Runnable({ + if (infoListAdapter != null && itemsList != null) { + infoListAdapter!!.showFooter(show) + } + })) + } + + public override fun handleNextItems(result: N) { + isLoading.set(false) + } + + public override fun handleError() { + super.handleError() + showListFooter(false) + itemsList!!.animateHideRecyclerViewAllowingScrolling() + } + + public override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, + key: String?) { + if ((getString(R.string.list_view_mode_key) == key)) { + updateFlags = updateFlags or LIST_MODE_UPDATE_FLAG + } + } + + /** + * Returns preferred item view mode. + * @return ItemViewMode + */ + protected open fun getItemViewMode(): ItemViewMode? { + return ThemeHelper.getItemViewMode(requireContext()) + } + + companion object { + private val LIST_MODE_UPDATE_FLAG: Int = 0x32 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java deleted file mode 100644 index dd5eb6c8a..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java +++ /dev/null @@ -1,288 +0,0 @@ -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 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 icepick.State; -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; - 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 L result) -> { - isLoading.set(false); - currentInfo = result; - currentNextPage = result.getNextPage(); - handleResult(result); - }, throwable -> - showError(new ErrorInfo(throwable, errorUserAction, - "Start loading: " + url, serviceId))); - } - - /** - * 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))); - } - - 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)); - } - } - - @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)); - } - } - } - - @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); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.kt new file mode 100644 index 000000000..175be1fcc --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.kt @@ -0,0 +1,250 @@ +package org.schabi.newpipe.fragments.list + +import android.os.Bundle +import android.text.TextUtils +import android.util.Log +import android.view.View +import icepick.State +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.functions.Action +import io.reactivex.rxjava3.functions.Consumer +import io.reactivex.rxjava3.schedulers.Schedulers +import org.schabi.newpipe.BaseFragment +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.ListExtractor.InfoItemsPage +import org.schabi.newpipe.extractor.ListInfo +import org.schabi.newpipe.extractor.Page +import org.schabi.newpipe.extractor.ServiceList +import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException +import org.schabi.newpipe.views.NewPipeRecyclerView +import java.util.Queue +import java.util.function.Predicate + +abstract class BaseListInfoFragment?> protected constructor(private val errorUserAction: UserAction) : BaseListFragment?>() { + @State + protected var serviceId: Int = NO_SERVICE_ID + + @State + protected var name: String? = null + + @State + protected var url: String? = null + protected var currentInfo: L? = null + protected var currentNextPage: Page? = null + protected var currentWorker: Disposable? = null + override fun initViews(rootView: View, savedInstanceState: Bundle?) { + super.initViews(rootView, savedInstanceState) + setTitle(name) + showListFooter(hasMoreItems()) + } + + public override fun onPause() { + super.onPause() + if (currentWorker != null) { + currentWorker!!.dispose() + } + } + + public override fun 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() + } + } + } + + public override fun onDestroy() { + super.onDestroy() + if (currentWorker != null) { + currentWorker!!.dispose() + currentWorker = null + } + } + + /*////////////////////////////////////////////////////////////////////////// + // State Saving + ////////////////////////////////////////////////////////////////////////// */ + public override fun writeTo(objectsToSave: Queue) { + super.writeTo(objectsToSave) + objectsToSave.add(currentInfo) + objectsToSave.add(currentNextPage) + } + + @Throws(Exception::class) + public override fun readFrom(savedObjects: Queue) { + super.readFrom(savedObjects) + currentInfo = savedObjects.poll() as L + currentNextPage = savedObjects.poll() as Page? + } + + /*////////////////////////////////////////////////////////////////////////// + // Load and handle + ////////////////////////////////////////////////////////////////////////// */ + override fun doInitialLoadLogic() { + if (BaseFragment.Companion.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 [org.schabi.newpipe.util.ExtractorHelper]. + * + * @param forceLoad allow or disallow the result to come from the cache + * @return Rx [Single] containing the [ListInfo] + */ + protected abstract fun loadResult(forceLoad: Boolean): Single? + public override fun startLoading(forceLoad: Boolean) { + super.startLoading(forceLoad) + showListFooter(false) + infoListAdapter!!.clearStreamItemList() + currentInfo = null + if (currentWorker != null) { + currentWorker!!.dispose() + } + currentWorker = loadResult(forceLoad) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer({ result: L -> + isLoading.set(false) + currentInfo = result + currentNextPage = result!!.getNextPage() + handleResult(result) + }), Consumer({ throwable: Throwable? -> + showError(ErrorInfo((throwable)!!, errorUserAction, + "Start loading: " + url, serviceId)) + })) + } + + /** + * Implement the logic to load more items. + * + * You can use the default implementations + * from [org.schabi.newpipe.util.ExtractorHelper]. + * + * @return Rx [Single] containing the [ListExtractor.InfoItemsPage] + */ + protected abstract fun loadMoreItemsLogic(): Single?>? + override fun loadMoreItems() { + isLoading.set(true) + if (currentWorker != null) { + currentWorker!!.dispose() + } + forbidDownwardFocusScroll() + currentWorker = loadMoreItemsLogic() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doFinally(Action({ allowDownwardFocusScroll() })) + .subscribe(Consumer({ infoItemsPage: InfoItemsPage? -> + isLoading.set(false) + handleNextItems(infoItemsPage) + }), Consumer({ throwable: Throwable -> + dynamicallyShowErrorPanelOrSnackbar(ErrorInfo(throwable, + errorUserAction, "Loading more items: " + url, serviceId)) + })) + } + + private fun forbidDownwardFocusScroll() { + if (itemsList is NewPipeRecyclerView) { + (itemsList as NewPipeRecyclerView).setFocusScrollAllowed(false) + } + } + + private fun allowDownwardFocusScroll() { + if (itemsList is NewPipeRecyclerView) { + (itemsList as NewPipeRecyclerView).setFocusScrollAllowed(true) + } + } + + public override fun handleNextItems(result: InfoItemsPage?) { + super.handleNextItems(result) + currentNextPage = result!!.getNextPage() + infoListAdapter!!.addInfoItemList(result.getItems()) + showListFooter(hasMoreItems()) + if (!result.getErrors().isEmpty()) { + dynamicallyShowErrorPanelOrSnackbar(ErrorInfo(result.getErrors(), errorUserAction, + "Get next items of: " + url, serviceId)) + } + } + + override fun hasMoreItems(): Boolean { + return Page.isValid(currentNextPage) + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + ////////////////////////////////////////////////////////////////////////// */ + public override fun handleResult(result: L) { + 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()) { + val errors: MutableList = 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(Predicate({ obj: Throwable? -> ContentNotSupportedException::class.java.isInstance(obj) })) + if (!errors.isEmpty()) { + dynamicallyShowErrorPanelOrSnackbar(ErrorInfo(result.getErrors(), + errorUserAction, "Start loading: " + url, serviceId)) + } + } + } + + public override fun showEmptyState() { + // show "no streams" for SoundCloud; otherwise "no videos" + // showing "no live streams" is handled in KioskFragment + if (emptyStateView != null) { + if (currentInfo!!.getService() === ServiceList.SoundCloud) { + setEmptyStateMessage(R.string.no_streams) + } else { + setEmptyStateMessage(R.string.no_videos) + } + } + super.showEmptyState() + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + ////////////////////////////////////////////////////////////////////////// */ + protected fun setInitialData(sid: Int, u: String?, title: String?) { + serviceId = sid + url = u + name = if (!TextUtils.isEmpty(title)) title else "" + } + + private fun dynamicallyShowErrorPanelOrSnackbar(errorInfo: ErrorInfo) { + if (infoListAdapter!!.getItemCount() == 0) { + // show error panel only if no items already visible + showError(errorInfo) + } else { + isLoading.set(false) + showSnackBarError(errorInfo) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/ListViewContract.java b/app/src/main/java/org/schabi/newpipe/fragments/list/ListViewContract.java deleted file mode 100644 index 161d5d524..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/ListViewContract.java +++ /dev/null @@ -1,9 +0,0 @@ -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); -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/ListViewContract.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/ListViewContract.kt new file mode 100644 index 000000000..a4264a109 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/ListViewContract.kt @@ -0,0 +1,8 @@ +package org.schabi.newpipe.fragments.list + +import org.schabi.newpipe.fragments.ViewContract + +open interface ListViewContract : ViewContract { + fun showListFooter(show: Boolean) + fun handleNextItems(result: N) +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java deleted file mode 100644 index 0dc2fb65a..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java +++ /dev/null @@ -1,94 +0,0 @@ -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 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; - -import icepick.State; - -public class ChannelAboutFragment extends BaseDescriptionFragment { - @State - protected ChannelInfo channelInfo; - - ChannelAboutFragment(@NonNull final ChannelInfo channelInfo) { - this.channelInfo = channelInfo; - } - - public ChannelAboutFragment() { - // keep empty constructor for IcePick 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( - requireContext(), - channelInfo.getSubscriberCount())); - } - - addImagesMetadataItem(inflater, layout, R.string.metadata_avatars, - channelInfo.getAvatars()); - addImagesMetadataItem(inflater, layout, R.string.metadata_banners, - channelInfo.getBanners()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.kt new file mode 100644 index 000000000..891695de4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.kt @@ -0,0 +1,70 @@ +package org.schabi.newpipe.fragments.list.channel + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout +import icepick.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.extractor.stream.StreamExtractor +import org.schabi.newpipe.fragments.detail.BaseDescriptionFragment +import org.schabi.newpipe.util.DeviceUtils +import org.schabi.newpipe.util.Localization + +class ChannelAboutFragment : BaseDescriptionFragment { + @State + protected var channelInfo: ChannelInfo? = null + + internal constructor(channelInfo: ChannelInfo) { + this.channelInfo = channelInfo + } + + constructor() + + override fun initViews(rootView: View, savedInstanceState: Bundle?) { + super.initViews(rootView, savedInstanceState) + binding!!.constraintLayout.setPadding(0, DeviceUtils.dpToPx(8, requireContext()), 0, 0) + } + + override fun getDescription(): Description? { + return Description(channelInfo!!.getDescription(), Description.PLAIN_TEXT) + } + + override fun getService(): StreamingService { + return channelInfo!!.getService() + } + + override fun getServiceId(): Int { + return channelInfo!!.getServiceId() + } + + override fun getStreamUrl(): String? { + return null + } + + public override fun getTags(): List { + return channelInfo!!.getTags() + } + + override fun setupMetadata(inflater: LayoutInflater?, + layout: LinearLayout?) { + // 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() != StreamExtractor.UNKNOWN_SUBSCRIBER_COUNT) { + addMetadataItem(inflater, (layout)!!, false, R.string.metadata_subscribers, + Localization.localizeNumber( + requireContext(), + channelInfo!!.getSubscriberCount())) + } + addImagesMetadataItem(inflater, (layout)!!, R.string.metadata_avatars, + channelInfo!!.getAvatars()) + addImagesMetadataItem(inflater, (layout), R.string.metadata_banners, + channelInfo!!.getBanners()) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java deleted file mode 100644 index 7e83d9958..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java +++ /dev/null @@ -1,647 +0,0 @@ -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.preference.PreferenceManager; - -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.image.ImageStrategy; -import org.schabi.newpipe.util.image.PicassoHelper; -import org.schabi.newpipe.util.ThemeHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; - -import java.util.List; -import java.util.Queue; -import java.util.concurrent.TimeUnit; - -import icepick.State; -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; - private static final String PICASSO_CHANNEL_TAG = "PICASSO_CHANNEL_TAG"; - - @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; - - 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 onCreate(final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setHasOptionsMenu(true); - } - - @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 // 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 onDestroy() { - super.onDestroy(); - if (currentWorker != null) { - currentWorker.dispose(); - } - disposables.clear(); - binding = null; - } - - - /*////////////////////////////////////////////////////////////////////////// - // Menu - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreateOptionsMenu(@NonNull final Menu menu, - @NonNull final MenuInflater inflater) { - super.onCreateOptionsMenu(menu, inflater); - inflater.inflate(R.menu.menu_channel, menu); - - if (DEBUG) { - Log.d(TAG, "onCreateOptionsMenu() called with: " - + "menu = [" + menu + "], inflater = [" + inflater + "]"); - } - } - - @Override - public void onPrepareOptionsMenu(@NonNull final Menu menu) { - super.onPrepareOptionsMenu(menu); - menuRssButton = menu.findItem(R.id.menu_item_rss); - menuNotifyButton = menu.findItem(R.id.menu_item_notify); - updateNotifyButton(channelSubscription); - } - - @Override - public boolean onOptionsItemSelected(@NonNull final MenuItem item) { - switch (item.getItemId()) { - case R.id.menu_item_notify: - final boolean value = !item.isChecked(); - item.setEnabled(false); - setNotify(value); - break; - case R.id.action_settings: - NavigationHelper.openSettings(requireContext()); - break; - case R.id.menu_item_rss: - if (currentInfo != null) { - ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl()); - } - break; - case R.id.menu_item_openInBrowser: - if (currentInfo != null) { - ShareUtils.openUrlInBrowser(requireContext(), currentInfo.getOriginalUrl()); - } - break; - case R.id.menu_item_share: - if (currentInfo != null) { - ShareUtils.shareText(requireContext(), name, currentInfo.getOriginalUrl(), - currentInfo.getAvatars()); - } - break; - default: - return super.onOptionsItemSelected(item); - } - return true; - } - - - /*////////////////////////////////////////////////////////////////////////// - // Channel Subscription - //////////////////////////////////////////////////////////////////////////*/ - - private void monitorSubscription(final ChannelInfo info) { - final Consumer onError = (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 Object o) -> { - subscriptionManager.insertSubscription(subscription); - return o; - }; - } - - private Function mapOnUnsubscribe(final SubscriptionEntity subscription) { - return (@NonNull 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 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 (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.setData(info.getName(), - ImageStrategy.imageListToDbUrl(info.getAvatars()), - info.getDescription(), - 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 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))); - } - - @Override - public void showLoading() { - super.showLoading(); - PicassoHelper.cancelTag(PICASSO_CHANNEL_TAG); - 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()) { - PicassoHelper.loadBanner(result.getBanners()).tag(PICASSO_CHANNEL_TAG) - .into(binding.channelBannerImage); - } else { - // do not waste space for the banner, if the user disabled images or there is not one - binding.channelBannerImage.setImageDrawable(null); - } - - PicassoHelper.loadAvatar(result.getAvatars()).tag(PICASSO_CHANNEL_TAG) - .into(binding.channelAvatarView); - PicassoHelper.loadAvatar(result.getParentChannelAvatars()).tag(PICASSO_CHANNEL_TAG) - .into(binding.subChannelAvatarView); - - 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); - } - - if (menuRssButton != null) { - menuRssButton.setVisible(!TextUtils.isEmpty(result.getFeedUrl())); - } - - 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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.kt new file mode 100644 index 000000000..3913f055b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.kt @@ -0,0 +1,564 @@ +package org.schabi.newpipe.fragments.list.channel + +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.core.content.ContextCompat +import androidx.core.graphics.ColorUtils +import androidx.preference.PreferenceManager +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.tabs.TabLayout +import com.jakewharton.rxbinding4.view.clicks +import icepick.State +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.functions.Predicate +import io.reactivex.rxjava3.schedulers.Schedulers +import org.schabi.newpipe.BaseFragment +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.Companion.showUiErrorSnackbar +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.ktx.animate +import org.schabi.newpipe.ktx.animateBackgroundColor +import org.schabi.newpipe.ktx.animateTextColor +import org.schabi.newpipe.local.feed.notifications.NotificationHelper.Companion.areNewStreamsNotificationsEnabled +import org.schabi.newpipe.local.subscription.SubscriptionManager +import org.schabi.newpipe.local.subscription.SubscriptionManager.updateNotificationMode +import org.schabi.newpipe.util.ChannelTabHelper +import org.schabi.newpipe.util.ExtractorHelper +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.StateSaver.WriteRead +import org.schabi.newpipe.util.ThemeHelper +import org.schabi.newpipe.util.external_communication.ShareUtils +import org.schabi.newpipe.util.image.ImageStrategy +import org.schabi.newpipe.util.image.PicassoHelper +import java.util.Queue +import java.util.concurrent.TimeUnit + +class ChannelFragment() : BaseStateFragment(), WriteRead { + @State + protected var serviceId: Int = NO_SERVICE_ID + + @State + protected var name: String? = null + + @State + protected var url: String? = null + private var currentInfo: ChannelInfo? = null + private var currentWorker: Disposable? = null + private val disposables: CompositeDisposable = CompositeDisposable() + private var subscribeButtonMonitor: Disposable? = null + private var subscriptionManager: SubscriptionManager? = null + private var lastTab: Int = 0 + private var channelContentNotSupported: Boolean = false + + /*////////////////////////////////////////////////////////////////////////// + // Views + ////////////////////////////////////////////////////////////////////////// */ + private var binding: FragmentChannelBinding? = null + private var tabAdapter: TabAdapter? = null + private var menuRssButton: MenuItem? = null + private var menuNotifyButton: MenuItem? = null + private var channelSubscription: SubscriptionEntity? = null + private fun setInitialData(sid: Int, u: String?, title: String?) { + serviceId = sid + url = u + name = if (!TextUtils.isEmpty(title)) title else "" + } + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + } + + public override fun onAttach(context: Context) { + super.onAttach(context) + subscriptionManager = SubscriptionManager((activity)!!) + } + + public override fun onCreateView(inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?): View? { + binding = FragmentChannelBinding.inflate(inflater, container, false) + return binding!!.getRoot() + } + + // called from onViewCreated in BaseFragment.onViewCreated + override fun initViews(rootView: View, savedInstanceState: Bundle?) { + super.initViews(rootView, savedInstanceState) + tabAdapter = 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 fun initListeners() { + super.initListeners() + val openSubChannel: View.OnClickListener = View.OnClickListener({ v: View? -> + if (!TextUtils.isEmpty(currentInfo!!.getParentChannelUrl())) { + try { + NavigationHelper.openChannelFragment(getFM(), currentInfo!!.getServiceId(), + currentInfo!!.getParentChannelUrl(), + currentInfo!!.getParentChannelName()) + } catch (e: Exception) { + showUiErrorSnackbar(this, "Opening channel fragment", e) + } + } else if (BaseFragment.Companion.DEBUG) { + Log.i(TAG, "Can't open parent channel because we got no channel URL") + } + }) + binding!!.subChannelAvatarView.setOnClickListener(openSubChannel) + binding!!.subChannelTitleView.setOnClickListener(openSubChannel) + } + + public override fun onDestroy() { + super.onDestroy() + if (currentWorker != null) { + currentWorker!!.dispose() + } + disposables.clear() + binding = null + } + + /*////////////////////////////////////////////////////////////////////////// + // Menu + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreateOptionsMenu(menu: Menu, + inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.menu_channel, menu) + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, ("onCreateOptionsMenu() called with: " + + "menu = [" + menu + "], inflater = [" + inflater + "]")) + } + } + + public override fun onPrepareOptionsMenu(menu: Menu) { + super.onPrepareOptionsMenu(menu) + menuRssButton = menu.findItem(R.id.menu_item_rss) + menuNotifyButton = menu.findItem(R.id.menu_item_notify) + updateNotifyButton(channelSubscription) + } + + public override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.getItemId()) { + R.id.menu_item_notify -> { + val value: Boolean = !item.isChecked() + item.setEnabled(false) + setNotify(value) + } + + R.id.action_settings -> NavigationHelper.openSettings(requireContext()) + R.id.menu_item_rss -> if (currentInfo != null) { + ShareUtils.openUrlInApp(requireContext(), currentInfo!!.getFeedUrl()) + } + + R.id.menu_item_openInBrowser -> if (currentInfo != null) { + ShareUtils.openUrlInBrowser(requireContext(), currentInfo!!.getOriginalUrl()) + } + + R.id.menu_item_share -> if (currentInfo != null) { + ShareUtils.shareText(requireContext(), (name)!!, currentInfo!!.getOriginalUrl(), + currentInfo!!.getAvatars()) + } + + else -> return super.onOptionsItemSelected(item) + } + return true + } + + /*////////////////////////////////////////////////////////////////////////// + // Channel Subscription + ////////////////////////////////////////////////////////////////////////// */ + private fun monitorSubscription(info: ChannelInfo) { + val onError: Consumer = Consumer({ throwable: Throwable? -> + binding!!.channelSubscribeButton.animate(false, 100) + showSnackBarError(ErrorInfo((throwable)!!, UserAction.SUBSCRIPTION_GET, + "Get subscription status", currentInfo)) + }) + val observable: Observable> = subscriptionManager + .subscriptionTable() + .getSubscriptionFlowable(info.getServiceId(), info.getUrl()) + .toObservable() + disposables.add(observable + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getSubscribeUpdateMonitor(info), onError)) + disposables.add(observable + .map(Function, Boolean>({ obj: List -> obj.isEmpty() })) + .distinctUntilChanged() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer({ isEmpty: Boolean? -> updateSubscribeButton(!isEmpty!!) }), onError)) + disposables.add(observable + .map(Function, Boolean>({ obj: List -> obj.isEmpty() })) + .distinctUntilChanged() + .skip(1) // channel has just been opened + .filter(Predicate({ x: Boolean? -> areNewStreamsNotificationsEnabled(requireContext()) })) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer({ isEmpty: Boolean? -> + if (!isEmpty!!) { + showNotifySnackbar() + } + }), onError)) + } + + private fun mapOnSubscribe(subscription: SubscriptionEntity): Function { + return Function({ o: Any -> + subscriptionManager!!.insertSubscription(subscription) + o + }) + } + + private fun mapOnUnsubscribe(subscription: SubscriptionEntity?): Function { + return Function({ o: Any -> + subscriptionManager!!.deleteSubscription((subscription)!!) + o + }) + } + + private fun updateSubscription(info: ChannelInfo) { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "updateSubscription() called with: info = [" + info + "]") + } + val onComplete: Action = Action({ + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "Updated subscription: " + info.getUrl()) + } + }) + val onError: Consumer = Consumer({ throwable: Throwable -> + showSnackBarError(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 fun monitorSubscribeButton(action: Function): Disposable { + val onNext: Consumer = Consumer({ o: Any -> + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "Changed subscription status to this channel!") + } + }) + val onError: Consumer = Consumer({ throwable: Throwable -> + showSnackBarError(ErrorInfo(throwable, UserAction.SUBSCRIPTION_CHANGE, + "Changing subscription for " + currentInfo!!.getUrl(), currentInfo)) + }) + + /* Emit clicks from main thread unto io thread */return binding!!.channelSubscribeButton.clicks() + .subscribeOn(AndroidSchedulers.mainThread()) + .observeOn(Schedulers.io()) + .debounce(BUTTON_DEBOUNCE_INTERVAL.toLong(), TimeUnit.MILLISECONDS) // Ignore rapid clicks + .map(action) + .subscribe(onNext, onError) + } + + private fun getSubscribeUpdateMonitor(info: ChannelInfo): Consumer> { + return Consumer>({ subscriptionEntities: List -> + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, ("subscriptionManager.subscriptionTable.doOnNext() called with: " + + "subscriptionEntities = [" + subscriptionEntities + "]")) + } + if (subscribeButtonMonitor != null) { + subscribeButtonMonitor!!.dispose() + } + if (subscriptionEntities.isEmpty()) { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "No subscription to this channel!") + } + val channel: SubscriptionEntity = SubscriptionEntity() + channel.setServiceId(info.getServiceId()) + channel.setUrl(info.getUrl()) + channel.setData(info.getName(), + ImageStrategy.imageListToDbUrl(info.getAvatars()), + info.getDescription(), + info.getSubscriberCount()) + channelSubscription = null + updateNotifyButton(null) + subscribeButtonMonitor = monitorSubscribeButton(mapOnSubscribe(channel)) + } else { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "Found subscription to this channel!") + } + channelSubscription = subscriptionEntities.get(0) + updateNotifyButton(channelSubscription) + subscribeButtonMonitor = monitorSubscribeButton(mapOnUnsubscribe(channelSubscription)) + } + }) + } + + private fun updateSubscribeButton(isSubscribed: Boolean) { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, ("updateSubscribeButton() called with: " + + "isSubscribed = [" + isSubscribed + "]")) + } + val isButtonVisible: Boolean = (binding!!.channelSubscribeButton.getVisibility() + == View.VISIBLE) + val backgroundDuration: Int = if (isButtonVisible) 300 else 0 + val textDuration: Int = if (isButtonVisible) 200 else 0 + val subscribedBackground: Int = ContextCompat + .getColor((activity)!!, R.color.subscribed_background_color) + val subscribedText: Int = ContextCompat.getColor((activity)!!, R.color.subscribed_text_color) + val subscribeBackground: Int = ColorUtils.blendARGB(ThemeHelper.resolveColorFromAttr((activity)!!, R.attr.colorPrimary), subscribedBackground, 0.35f) + val subscribeText: Int = ContextCompat.getColor((activity)!!, R.color.subscribe_text_color) + if (isSubscribed) { + binding!!.channelSubscribeButton.setText(R.string.subscribed_button_title) + binding!!.channelSubscribeButton.animateBackgroundColor(backgroundDuration.toLong(), subscribeBackground, subscribedBackground) + binding!!.channelSubscribeButton.animateTextColor(textDuration.toLong(), subscribeText, subscribedText) + } else { + binding!!.channelSubscribeButton.setText(R.string.subscribe_button_title) + binding!!.channelSubscribeButton.animateBackgroundColor(backgroundDuration.toLong(), subscribedBackground, subscribeBackground) + binding!!.channelSubscribeButton.animateTextColor(textDuration.toLong(), subscribedText, subscribeText) + } + binding!!.channelSubscribeButton.animate(true, 100, AnimationType.LIGHT_SCALE_AND_ALPHA) + } + + private fun updateNotifyButton(subscription: SubscriptionEntity?) { + if (menuNotifyButton == null) { + return + } + if (subscription != null) { + menuNotifyButton!!.setEnabled( + areNewStreamsNotificationsEnabled(requireContext()) + ) + menuNotifyButton!!.setChecked( + subscription.getNotificationMode() == NotificationMode.Companion.ENABLED + ) + } + menuNotifyButton!!.setVisible(subscription != null) + } + + private fun setNotify(isEnabled: Boolean) { + disposables.add( + subscriptionManager + .updateNotificationMode( + currentInfo!!.getServiceId(), + currentInfo!!.getUrl(), + if (isEnabled) NotificationMode.Companion.ENABLED else NotificationMode.Companion.DISABLED) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe() + ) + } + + /** + * Show a snackbar with the option to enable notifications on new streams for this channel. + */ + private fun showNotifySnackbar() { + Snackbar.make(binding!!.getRoot(), R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG) + .setAction(R.string.get_notified, View.OnClickListener({ v: View? -> setNotify(true) })) + .setActionTextColor(Color.YELLOW) + .show() + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + ////////////////////////////////////////////////////////////////////////// */ + private fun updateTabs() { + tabAdapter!!.clearAllItems() + if (currentInfo != null && !channelContentNotSupported) { + val context: Context = requireContext() + val preferences: SharedPreferences = PreferenceManager + .getDefaultSharedPreferences(context) + for (linkHandler: ListLinkHandler in currentInfo!!.getTabs()) { + val tab: String = linkHandler.getContentFilters().get(0) + if (ChannelTabHelper.showChannelTab(context, preferences, tab)) { + val channelTabFragment: ChannelTabFragment = ChannelTabFragment.Companion.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( + ChannelAboutFragment(currentInfo!!), + context.getString(R.string.channel_tab_about)) + } + } + tabAdapter!!.notifyDataSetUpdate() + for (i in 0 until tabAdapter!!.getCount()) { + binding!!.tabLayout.getTabAt(i)!!.setText(tabAdapter!!.getItemTitle(i)) + } + + // Restore previously selected tab + val ltab: TabLayout.Tab? = binding!!.tabLayout.getTabAt(lastTab) + if (ltab != null) { + binding!!.tabLayout.selectTab(ltab) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // State Saving + ////////////////////////////////////////////////////////////////////////// */ + public override fun generateSuffix(): String? { + return null + } + + public override fun writeTo(objectsToSave: Queue) { + objectsToSave.add(currentInfo) + objectsToSave.add(if (binding == null) 0 else binding!!.tabLayout.getSelectedTabPosition()) + } + + public override fun readFrom(savedObjects: Queue) { + currentInfo = savedObjects.poll() as ChannelInfo? + lastTab = savedObjects.poll() as Int + } + + public override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + if (binding != null) { + outState.putInt("LastTab", binding!!.tabLayout.getSelectedTabPosition()) + } + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle) { + super.onRestoreInstanceState(savedInstanceState) + lastTab = savedInstanceState.getInt("LastTab", 0) + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + ////////////////////////////////////////////////////////////////////////// */ + override fun doInitialLoadLogic() { + if (currentInfo == null) { + startLoading(false) + } else { + handleResult(currentInfo!!) + } + } + + public override fun startLoading(forceLoad: Boolean) { + super.startLoading(forceLoad) + currentInfo = null + updateTabs() + if (currentWorker != null) { + currentWorker!!.dispose() + } + runWorker(forceLoad) + } + + private fun runWorker(forceLoad: Boolean) { + currentWorker = ExtractorHelper.getChannelInfo(serviceId, url, forceLoad) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer({ result: ChannelInfo? -> + isLoading.set(false) + handleResult((result)!!) + }), Consumer({ throwable: Throwable? -> + showError(ErrorInfo((throwable)!!, UserAction.REQUESTED_CHANNEL, + (if (url == null) "No URL" else url)!!, serviceId)) + })) + } + + public override fun showLoading() { + super.showLoading() + PicassoHelper.cancelTag(PICASSO_CHANNEL_TAG) + binding!!.channelSubscribeButton.animate(false, 100) + } + + public override fun handleResult(result: ChannelInfo) { + super.handleResult(result) + currentInfo = result + setInitialData(result.getServiceId(), result.getOriginalUrl(), result.getName()) + if (ImageStrategy.shouldLoadImages() && !result.getBanners().isEmpty()) { + PicassoHelper.loadBanner(result.getBanners()).tag(PICASSO_CHANNEL_TAG) + .into(binding!!.channelBannerImage) + } else { + // do not waste space for the banner, if the user disabled images or there is not one + binding!!.channelBannerImage.setImageDrawable(null) + } + PicassoHelper.loadAvatar(result.getAvatars()).tag(PICASSO_CHANNEL_TAG) + .into(binding!!.channelAvatarView) + PicassoHelper.loadAvatar(result.getParentChannelAvatars()).tag(PICASSO_CHANNEL_TAG) + .into(binding!!.subChannelAvatarView) + 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) + } + if (menuRssButton != null) { + menuRssButton!!.setVisible(!TextUtils.isEmpty(result.getFeedUrl())) + } + channelContentNotSupported = false + for (throwable: Throwable? in result.getErrors()) { + if (throwable is ContentNotSupportedException) { + channelContentNotSupported = true + showContentNotSupportedIfNeeded() + break + } + } + disposables.clear() + if (subscribeButtonMonitor != null) { + subscribeButtonMonitor!!.dispose() + } + updateTabs() + updateSubscription(result) + monitorSubscription(result) + } + + private fun 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) + } + + companion object { + private val BUTTON_DEBOUNCE_INTERVAL: Int = 100 + private val PICASSO_CHANNEL_TAG: String = "PICASSO_CHANNEL_TAG" + fun getInstance(serviceId: Int, url: String?, + name: String?): ChannelFragment { + val instance: ChannelFragment = ChannelFragment() + instance.setInitialData(serviceId, url, name) + return instance + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java deleted file mode 100644 index 95ac42eed..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java +++ /dev/null @@ -1,168 +0,0 @@ -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 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 icepick.State; -import io.reactivex.rxjava3.core.Single; - -public class ChannelTabFragment extends BaseListInfoFragment - implements PlaylistControlViewHolder { - - // states must be protected and not private for IcePick 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); - } - } - - 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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.kt new file mode 100644 index 000000000..e29eb6ac0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.kt @@ -0,0 +1,146 @@ +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 icepick.State +import io.reactivex.rxjava3.core.Single +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.InfoItemsPage +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.function.Function +import java.util.function.Predicate +import java.util.function.Supplier +import java.util.stream.Collectors + +class ChannelTabFragment() : BaseListInfoFragment(UserAction.REQUESTED_CHANNEL), PlaylistControlViewHolder { + // states must be protected and not private for IcePick being able to access them + @State + protected var tabHandler: ListLinkHandler? = null + + @State + protected var channelName: String? = null + private var playlistControlBinding: PlaylistControlBinding? = null + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(false) + } + + public override fun onCreateView(inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_channel_tab, container, false) + } + + public override fun onDestroyView() { + super.onDestroyView() + playlistControlBinding = null + } + + override fun getListHeaderSupplier(): Supplier? { + if (ChannelTabHelper.isStreamsTab(tabHandler)) { + playlistControlBinding = PlaylistControlBinding + .inflate(activity!!.getLayoutInflater(), itemsList, false) + return Supplier({ playlistControlBinding!!.getRoot() }) + } + return null + } + + override fun loadResult(forceLoad: Boolean): Single? { + return ExtractorHelper.getChannelTab(serviceId, tabHandler, forceLoad) + } + + override fun loadMoreItemsLogic(): Single?>? { + return ExtractorHelper.getMoreChannelTabItems(serviceId, tabHandler, currentNextPage) + } + + public override fun setTitle(title: String?) { + // 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) + } + + public override fun handleResult(result: ChannelTabInfo) { + 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 is 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 + val channelTabLHFactory: ListLinkHandlerFactory? = result.getService() + .getChannelTabLHFactory() + if (channelTabLHFactory != null) { + // some services do not not have a ChannelTabLHFactory + tabHandler = channelTabLHFactory.fromQuery(tabHandler.getId(), + tabHandler.getContentFilters(), tabHandler.getSortFilter()) + } + } catch (e: ParsingException) { + // 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) + } + } + + public override fun getPlayQueue(): PlayQueue { + val streamItems: List = infoListAdapter!!.getItemsList().stream() + .filter(Predicate({ obj: InfoItem? -> StreamInfoItem::class.java.isInstance(obj) })) + .map(Function({ obj: InfoItem? -> StreamInfoItem::class.java.cast(obj) })) + .collect(Collectors.toList()) + return ChannelTabPlayQueue(currentInfo!!.getServiceId(), tabHandler, + currentInfo!!.getNextPage(), streamItems, 0) + } + + companion object { + fun getInstance(serviceId: Int, + tabHandler: ListLinkHandler?, + channelName: String?): ChannelTabFragment { + val instance: ChannelTabFragment = ChannelTabFragment() + instance.serviceId = serviceId + instance.tabHandler = tabHandler + instance.channelName = channelName + return instance + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.java deleted file mode 100644 index a816b149f..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.java +++ /dev/null @@ -1,168 +0,0 @@ -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 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.ImageStrategy; -import org.schabi.newpipe.util.image.PicassoHelper; -import org.schabi.newpipe.util.text.TextLinkifier; - -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(); - - private 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 - PicassoHelper.loadAvatar(item.getUploaderAvatars()).into(binding.authorAvatar); - 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); - - 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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.kt new file mode 100644 index 000000000..c4e6abc61 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.kt @@ -0,0 +1,140 @@ +package org.schabi.newpipe.fragments.list.comments + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.text.HtmlCompat +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import org.schabi.newpipe.R +import org.schabi.newpipe.databinding.CommentRepliesHeaderBinding +import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage +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.ServiceHelper +import org.schabi.newpipe.util.image.ImageStrategy +import org.schabi.newpipe.util.image.PicassoHelper +import org.schabi.newpipe.util.text.TextLinkifier +import java.util.Queue +import java.util.concurrent.Callable +import java.util.function.Supplier + +class CommentRepliesFragment /*////////////////////////////////////////////////////////////////////////// + // Constructors and lifecycle + ////////////////////////////////////////////////////////////////////////// */ +// only called by the Android framework, after which readFrom is called and restores all data +() : BaseListInfoFragment(UserAction.REQUESTED_COMMENT_REPLIES) { + private var commentsInfoItem: CommentsInfoItem? = null // the comment to show replies of + private val disposables: CompositeDisposable = CompositeDisposable() + + constructor(commentsInfoItem: CommentsInfoItem) : this() { + this.commentsInfoItem = commentsInfoItem + // setting "" as title since the title will be properly set right after + setInitialData(commentsInfoItem.getServiceId(), commentsInfoItem.getUrl(), "") + } + + public override fun onCreateView(inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_comments, container, false) + } + + public override fun onDestroyView() { + disposables.clear() + super.onDestroyView() + } + + override fun getListHeaderSupplier(): Supplier? { + return Supplier({ + val binding: CommentRepliesHeaderBinding = CommentRepliesHeaderBinding + .inflate(activity!!.getLayoutInflater(), itemsList, false) + val item: CommentsInfoItem? = commentsInfoItem + + // load the author avatar + PicassoHelper.loadAvatar(item!!.getUploaderAvatars()).into(binding.authorAvatar) + binding.authorAvatar.setVisibility(if (ImageStrategy.shouldLoadImages()) View.VISIBLE else 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( + View.OnClickListener({ v: View? -> 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 + (binding.thumbsUpCount.getLayoutParams() as ConstraintLayout.LayoutParams) + .setMarginEnd(DeviceUtils.dpToPx( + (if (item.isHeartedByUploader() || item.isPinned()) 8 else 16), + requireContext())) + binding.heartImage.setVisibility(if (item.isHeartedByUploader()) View.VISIBLE else View.GONE) + binding.pinnedImage.setVisibility(if (item.isPinned()) View.VISIBLE else View.GONE) + + // setup comment content + TextLinkifier.fromDescription(binding.commentContent, item.getCommentText(), + HtmlCompat.FROM_HTML_MODE_LEGACY, ServiceHelper.getServiceById(item.getServiceId()), + item.getUrl(), disposables, null) + binding.getRoot() + }) + } + + /*////////////////////////////////////////////////////////////////////////// + // State saving + ////////////////////////////////////////////////////////////////////////// */ + public override fun writeTo(objectsToSave: Queue) { + super.writeTo(objectsToSave) + objectsToSave.add(commentsInfoItem) + } + + @Throws(Exception::class) + public override fun readFrom(savedObjects: Queue) { + super.readFrom(savedObjects) + commentsInfoItem = savedObjects.poll() as CommentsInfoItem? + } + + /*////////////////////////////////////////////////////////////////////////// + // Data loading + ////////////////////////////////////////////////////////////////////////// */ + override fun loadResult(forceLoad: Boolean): Single? { + return Single.fromCallable(Callable({ + CommentRepliesInfo(commentsInfoItem, // the reply count string will be shown as the activity title + Localization.replyCount(requireContext(), commentsInfoItem!!.getReplyCount())) + })) + } + + override fun loadMoreItemsLogic(): Single?>? { + // 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 fun getItemViewMode(): ItemViewMode? { + return ItemViewMode.LIST + } + + /** + * @return the comment to which the replies are shown + */ + fun getCommentsInfoItem(): CommentsInfoItem? { + return commentsInfoItem + } + + companion object { + val TAG: String = CommentRepliesFragment::class.java.getSimpleName() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.java b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.java deleted file mode 100644 index cc160c395..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.java +++ /dev/null @@ -1,22 +0,0 @@ -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 - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.kt new file mode 100644 index 000000000..3f58ee947 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.kt @@ -0,0 +1,19 @@ +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 + +class CommentRepliesInfo(comment: CommentsInfoItem?, name: String?) : ListInfo(comment!!.getServiceId(), + ListLinkHandler("", "", "", emptyList(), null), name) { + /** + * 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 + */ + init { + setNextPage(comment!!.getReplies()) + setRelatedItems(emptyList()) // since it must be non-null + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java deleted file mode 100644 index e25e02794..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java +++ /dev/null @@ -1,123 +0,0 @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.kt new file mode 100644 index 000000000..2c6bf91ed --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.kt @@ -0,0 +1,95 @@ +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 io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import org.schabi.newpipe.R +import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage +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.slideUp +import org.schabi.newpipe.util.ExtractorHelper + +class CommentsFragment() : BaseListInfoFragment(UserAction.REQUESTED_COMMENTS) { + private val disposables: CompositeDisposable = CompositeDisposable() + private var emptyStateDesc: TextView? = null + override fun initViews(rootView: View, savedInstanceState: Bundle?) { + super.initViews(rootView, savedInstanceState) + emptyStateDesc = rootView.findViewById(R.id.empty_state_desc) + } + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreateView(inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_comments, container, false) + } + + public override fun onDestroy() { + super.onDestroy() + disposables.clear() + } + + /*////////////////////////////////////////////////////////////////////////// + // Load and handle + ////////////////////////////////////////////////////////////////////////// */ + override fun loadMoreItemsLogic(): Single?>? { + return ExtractorHelper.getMoreCommentItems(serviceId, currentInfo, currentNextPage) + } + + override fun loadResult(forceLoad: Boolean): Single? { + return ExtractorHelper.getCommentsInfo(serviceId, (url)!!, forceLoad) + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + ////////////////////////////////////////////////////////////////////////// */ + public override fun handleResult(result: CommentsInfo) { + super.handleResult(result) + emptyStateDesc!!.setText( + if (result.isCommentsDisabled()) R.string.comments_are_disabled else R.string.no_comments) + requireView().slideUp(120, 150, 0.06f) + disposables.clear() + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + ////////////////////////////////////////////////////////////////////////// */ + public override fun setTitle(title: String?) {} + public override fun onCreateOptionsMenu(menu: Menu, + inflater: MenuInflater) { + } + + override fun getItemViewMode(): ItemViewMode? { + return ItemViewMode.LIST + } + + fun scrollToComment(comment: CommentsInfoItem?): Boolean { + val position: Int = infoListAdapter!!.getItemsList().indexOf(comment) + if (position < 0) { + return false + } + itemsList!!.scrollToPosition(position) + return true + } + + companion object { + fun getInstance(serviceId: Int, url: String?, + name: String?): CommentsFragment { + val instance: CommentsFragment = CommentsFragment() + instance.setInitialData(serviceId, url, name) + return instance + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/DefaultKioskFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/DefaultKioskFragment.java deleted file mode 100644 index d0b9e3a3d..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/DefaultKioskFragment.java +++ /dev/null @@ -1,55 +0,0 @@ -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")); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/DefaultKioskFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/DefaultKioskFragment.kt new file mode 100644 index 000000000..0f03f8259 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/DefaultKioskFragment.kt @@ -0,0 +1,46 @@ +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 + +class DefaultKioskFragment() : KioskFragment() { + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (serviceId < 0) { + updateSelectedDefaultKiosk() + } + } + + public override fun onResume() { + super.onResume() + if (serviceId != ServiceHelper.getSelectedServiceId(requireContext())) { + if (currentWorker != null) { + currentWorker!!.dispose() + } + updateSelectedDefaultKiosk() + reloadContent() + } + } + + private fun updateSelectedDefaultKiosk() { + try { + serviceId = ServiceHelper.getSelectedServiceId(requireContext()) + val 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 (e: ExtractionException) { + showError(ErrorInfo(e, UserAction.REQUESTED_KIOSK, + "Loading default kiosk for selected service")) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java deleted file mode 100644 index b90dccb17..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java +++ /dev/null @@ -1,176 +0,0 @@ -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 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 icepick.State; -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); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.kt new file mode 100644 index 000000000..a2b24bbbf --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.kt @@ -0,0 +1,158 @@ +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.appcompat.app.ActionBar +import icepick.State +import io.reactivex.rxjava3.core.Single +import org.schabi.newpipe.R +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage +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 + +/** + * Created by Christian Schabesberger on 23.09.17. + * + * + * Copyright (C) Christian Schabesberger 2017 @mailbox.org> + * 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 //www.gnu.org/licenses/>. + * + */ +open class KioskFragment() : BaseListInfoFragment(UserAction.REQUESTED_KIOSK) { + @State + var kioskId: String? = "" + var kioskTranslatedName: String? = null + + @State + var contentCountry: ContentCountry? = null + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + kioskTranslatedName = KioskTranslator.getTranslatedKioskName(kioskId, activity) + name = kioskTranslatedName + contentCountry = Localization.getPreferredContentCountry(requireContext()) + } + + public override fun onResume() { + super.onResume() + if (!(Localization.getPreferredContentCountry(requireContext()) == contentCountry)) { + reloadContent() + } + if (useAsFrontPage && activity != null) { + try { + setTitle(kioskTranslatedName) + } catch (e: Exception) { + showSnackBarError(ErrorInfo(e, UserAction.UI_ERROR, "Setting kiosk title")) + } + } + } + + public override fun onCreateView(inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_kiosk, container, false) + } + + /*////////////////////////////////////////////////////////////////////////// + // Menu + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreateOptionsMenu(menu: Menu, + inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + val supportActionBar: ActionBar? = activity!!.getSupportActionBar() + if (supportActionBar != null && useAsFrontPage) { + supportActionBar.setDisplayHomeAsUpEnabled(false) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Load and handle + ////////////////////////////////////////////////////////////////////////// */ + public override fun loadResult(forceReload: Boolean): Single? { + contentCountry = Localization.getPreferredContentCountry(requireContext()) + return ExtractorHelper.getKioskInfo(serviceId, (url)!!, forceReload) + } + + public override fun loadMoreItemsLogic(): Single?>? { + return ExtractorHelper.getMoreKioskItems(serviceId, url, currentNextPage) + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + ////////////////////////////////////////////////////////////////////////// */ + public override fun handleResult(result: KioskInfo) { + super.handleResult(result) + name = kioskTranslatedName + setTitle(kioskTranslatedName) + } + + public override fun showEmptyState() { + // show "no live streams" for live stream kiosk + super.showEmptyState() + if (((MediaCCCLiveStreamKiosk.KIOSK_ID == currentInfo!!.getId()) && ServiceList.MediaCCC.getServiceId() == currentInfo!!.getServiceId())) { + setEmptyStateMessage(R.string.no_live_streams) + } + } + + companion object { + /*////////////////////////////////////////////////////////////////////////// + // Views + ////////////////////////////////////////////////////////////////////////// */ + @Throws(ExtractionException::class) + fun getInstance(serviceId: Int): KioskFragment { + return getInstance(serviceId, NewPipe.getService(serviceId) + .getKioskList().getDefaultKioskId()) + } + + @Throws(ExtractionException::class) + fun getInstance(serviceId: Int, kioskId: String?): KioskFragment { + val instance: KioskFragment = KioskFragment() + val service: StreamingService = NewPipe.getService(serviceId) + val kioskLinkHandlerFactory: ListLinkHandlerFactory = service.getKioskList() + .getListLinkHandlerFactoryByType(kioskId) + instance.setInitialData(serviceId, + kioskLinkHandlerFactory.fromId(kioskId).getUrl(), kioskId) + instance.kioskId = kioskId + return instance + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistControlViewHolder.java b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistControlViewHolder.java deleted file mode 100644 index e4705bb71..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistControlViewHolder.java +++ /dev/null @@ -1,11 +0,0 @@ -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(); -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistControlViewHolder.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistControlViewHolder.kt new file mode 100644 index 000000000..27e19d9d1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistControlViewHolder.kt @@ -0,0 +1,11 @@ +package org.schabi.newpipe.fragments.list.playlist + +import org.schabi.newpipe.player.playqueue.PlayQueue + +/** + * Interface for `R.layout.playlist_control` view holders + * to give access to the play queue. + */ +open interface PlaylistControlViewHolder { + val playQueue: PlayQueue +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java deleted file mode 100644 index 998ea0624..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java +++ /dev/null @@ -1,514 +0,0 @@ -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.PicassoHelper; -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 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 static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG"; - - 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) { - switch (item.getItemId()) { - case R.id.action_settings: - NavigationHelper.openSettings(requireContext()); - break; - case R.id.menu_item_openInBrowser: - ShareUtils.openUrlInBrowser(requireContext(), url); - break; - case R.id.menu_item_share: - ShareUtils.shareText(requireContext(), name, url, - currentInfo == null ? List.of() : currentInfo.getThumbnails()); - break; - case R.id.menu_item_bookmark: - onBookmarkClicked(); - break; - case 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) - )); - } - break; - default: - return super.onOptionsItemSelected(item); - } - return true; - } - - - /*////////////////////////////////////////////////////////////////////////// - // Contract - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void showLoading() { - super.showLoading(); - animate(headerBinding.getRoot(), false, 200); - animateHideRecyclerViewAllowingScrolling(itemsList); - - PicassoHelper.cancelTag(PICASSO_PLAYLIST_TAG); - 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 { - PicassoHelper.loadAvatar(result.getUploaderAvatars()).tag(PICASSO_PLAYLIST_TAG) - .into(headerBinding.uploaderAvatarView); - } - - 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()); - } 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)) - ); - } - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.kt new file mode 100644 index 000000000..3c5c5fca4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.kt @@ -0,0 +1,443 @@ +package org.schabi.newpipe.fragments.list.playlist + +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.appcompat.content.res.AppCompatResources +import androidx.fragment.app.Fragment +import com.google.android.material.shape.CornerFamily +import com.google.android.material.shape.ShapeAppearanceModel +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 +import io.reactivex.rxjava3.functions.Action +import io.reactivex.rxjava3.functions.BiFunction +import org.reactivestreams.Publisher +import org.reactivestreams.Subscriber +import org.reactivestreams.Subscription +import org.schabi.newpipe.BaseFragment +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.Companion.showUiErrorSnackbar +import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.extractor.InfoItem +import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage +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.extractor.utils.Utils +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.info_list.dialog.StreamDialogEntry.StreamDialogEntryAction +import org.schabi.newpipe.ktx.animate +import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling +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.PlayQueueItem +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.ServiceHelper +import org.schabi.newpipe.util.external_communication.ShareUtils +import org.schabi.newpipe.util.image.PicassoHelper +import org.schabi.newpipe.util.text.TextEllipsizer +import java.util.concurrent.atomic.AtomicBoolean +import java.util.function.Supplier +import java.util.function.ToLongFunction +import java.util.stream.Collectors +import kotlin.math.max + +class PlaylistFragment() : BaseListInfoFragment(UserAction.REQUESTED_PLAYLIST), PlaylistControlViewHolder { + private var disposables: CompositeDisposable? = null + private var bookmarkReactor: Subscription? = null + private var isBookmarkButtonReady: AtomicBoolean? = null + private var remotePlaylistManager: RemotePlaylistManager? = null + private var playlistEntity: PlaylistRemoteEntity? = null + + /*////////////////////////////////////////////////////////////////////////// + // Views + ////////////////////////////////////////////////////////////////////////// */ + private var headerBinding: PlaylistHeaderBinding? = null + private var playlistControlBinding: PlaylistControlBinding? = null + private var playlistBookmarkButton: MenuItem? = null + private var streamCount: Long = 0 + private var playlistOverallDurationSeconds: Long = 0 + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + disposables = CompositeDisposable() + isBookmarkButtonReady = AtomicBoolean(false) + remotePlaylistManager = RemotePlaylistManager(NewPipeDatabase.getInstance(requireContext())) + } + + public override fun onCreateView(inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_playlist, container, false) + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + ////////////////////////////////////////////////////////////////////////// */ + override fun getListHeaderSupplier(): Supplier? { + headerBinding = PlaylistHeaderBinding + .inflate(activity!!.getLayoutInflater(), itemsList, false) + playlistControlBinding = headerBinding!!.playlistControl + return Supplier({ headerBinding!!.getRoot() }) + } + + override fun initViews(rootView: View, savedInstanceState: Bundle?) { + super.initViews(rootView, savedInstanceState) + + // Is mini variant still relevant? + // Only the remote playlist screen uses it now + infoListAdapter!!.setUseMiniVariant(true) + } + + private fun getPlayQueueStartingAt(infoItem: StreamInfoItem): PlayQueue { + return getPlayQueue(max(infoListAdapter!!.getItemsList().indexOf(infoItem).toDouble(), 0.0).toInt()) + } + + override fun showInfoItemDialog(item: StreamInfoItem) { + val context: Context? = getContext() + try { + val dialogBuilder: InfoItemDialog.Builder = InfoItemDialog.Builder((getActivity())!!, (context)!!, this, item) + dialogBuilder + .setAction( + StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND, + StreamDialogEntryAction({ f: Fragment?, infoItem: StreamInfoItem -> + NavigationHelper.playOnBackgroundPlayer( + context, getPlayQueueStartingAt(infoItem), true) + })) + .create() + .show() + } catch (e: IllegalArgumentException) { + InfoItemDialog.Builder.Companion.reportErrorDuringInitialization(e, item) + } + } + + public override fun onCreateOptionsMenu(menu: Menu, + inflater: MenuInflater) { + if (BaseFragment.Companion.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() + } + + public override fun 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 + } + + public override fun onDestroy() { + super.onDestroy() + if (disposables != null) { + disposables!!.dispose() + } + disposables = null + remotePlaylistManager = null + playlistEntity = null + isBookmarkButtonReady = null + } + + /*////////////////////////////////////////////////////////////////////////// + // Load and handle + ////////////////////////////////////////////////////////////////////////// */ + override fun loadMoreItemsLogic(): Single?>? { + return ExtractorHelper.getMorePlaylistItems(serviceId, url, currentNextPage) + } + + override fun loadResult(forceLoad: Boolean): Single? { + return ExtractorHelper.getPlaylistInfo(serviceId, url, forceLoad) + } + + public override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.getItemId()) { + R.id.action_settings -> NavigationHelper.openSettings(requireContext()) + R.id.menu_item_openInBrowser -> ShareUtils.openUrlInBrowser(requireContext(), url) + R.id.menu_item_share -> ShareUtils.shareText(requireContext(), (name)!!, url, + if (currentInfo == null) listOf() else currentInfo!!.getThumbnails()) + + R.id.menu_item_bookmark -> onBookmarkClicked() + R.id.menu_item_append_playlist -> if (currentInfo != null) { + disposables!!.add(PlaylistDialog.Companion.createCorrespondingDialog( + getContext(), + getPlayQueue() + .getStreams() + .stream() + .map(java.util.function.Function({ item: PlayQueueItem? -> StreamEntity((item)!!) })) + .collect(Collectors.toList()), + java.util.function.Consumer({ dialog: PlaylistDialog -> dialog.show(getFM(), TAG) }) + )) + } + + else -> return super.onOptionsItemSelected(item) + } + return true + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + ////////////////////////////////////////////////////////////////////////// */ + public override fun showLoading() { + super.showLoading() + headerBinding!!.getRoot().animate(false, 200) + itemsList!!.animateHideRecyclerViewAllowingScrolling() + PicassoHelper.cancelTag(PICASSO_PLAYLIST_TAG) + headerBinding!!.uploaderLayout.animate(false, 200) + } + + public override fun handleNextItems(result: InfoItemsPage<*>) { + super.handleNextItems(result) + setStreamCountAndOverallDuration(result.getItems(), !result.hasNextPage()) + } + + public override fun handleResult(result: PlaylistInfo) { + super.handleResult(result) + headerBinding!!.getRoot().animate(true, 100) + headerBinding!!.uploaderLayout.animate(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(View.OnClickListener({ v: View? -> + try { + NavigationHelper.openChannelFragment(getFM(), result.getServiceId(), + result.getUploaderUrl(), result.getUploaderName()) + } catch (e: Exception) { + 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 + val model: ShapeAppearanceModel = 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 { + PicassoHelper.loadAvatar(result.getUploaderAvatars()).tag(PICASSO_PLAYLIST_TAG) + .into(headerBinding!!.uploaderAvatarView) + } + streamCount = result.getStreamCount() + setStreamCountAndOverallDuration(result.getRelatedItems(), !result.hasNextPage()) + val description: Description? = result.getDescription() + if ((description != null) && (description !== Description.EMPTY_DESCRIPTION + ) && !Utils.isBlank(description.getContent())) { + val ellipsizer: TextEllipsizer = TextEllipsizer( + headerBinding!!.playlistDescription, 5, ServiceHelper.getServiceById(result.getServiceId())) + ellipsizer.setStateChangeListener(java.util.function.Consumer({ isEllipsized: Boolean -> + headerBinding!!.playlistDescriptionReadMore.setText( + if ((java.lang.Boolean.TRUE == isEllipsized)) R.string.show_more else R.string.show_less + ) + })) + ellipsizer.setOnContentChanged(java.util.function.Consumer({ canBeEllipsized: Boolean -> + headerBinding!!.playlistDescriptionReadMore.setVisibility( + if ((java.lang.Boolean.TRUE == canBeEllipsized)) View.VISIBLE else View.GONE) + if ((java.lang.Boolean.TRUE == canBeEllipsized)) { + ellipsizer.ellipsize() + } + })) + ellipsizer.setContent(description) + headerBinding!!.playlistDescriptionReadMore.setOnClickListener(View.OnClickListener({ v: View? -> ellipsizer.toggle() })) + } else { + headerBinding!!.playlistDescription.setVisibility(View.GONE) + headerBinding!!.playlistDescriptionReadMore.setVisibility(View.GONE) + } + if (!result.getErrors().isEmpty()) { + showSnackBarError(ErrorInfo(result.getErrors(), UserAction.REQUESTED_PLAYLIST, + result.getUrl(), result)) + } + remotePlaylistManager!!.getPlaylist(result) + .flatMap>(io.reactivex.rxjava3.functions.Function, Publisher>({ lists: List -> getUpdateProcessor(lists, result) }), BiFunction, Int, List?>({ lists: List?, id: Int? -> lists })) + .onBackpressureLatest() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getPlaylistBookmarkSubscriber()) + PlayButtonHelper.initPlaylistControlClickListener((activity)!!, (playlistControlBinding)!!, this) + } + + public override fun getPlayQueue(): PlayQueue { + return getPlayQueue(0) + } + + private fun getPlayQueue(index: Int): PlayQueue { + val infoItems: MutableList = ArrayList() + for (i: InfoItem? in infoListAdapter!!.getItemsList()) { + if (i is StreamInfoItem) { + infoItems.add(i) + } + } + return PlaylistPlayQueue( + currentInfo!!.getServiceId(), + currentInfo!!.getUrl(), + currentInfo!!.getNextPage(), + infoItems, + index + ) + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + ////////////////////////////////////////////////////////////////////////// */ + private fun getUpdateProcessor( + playlists: List, + result: PlaylistInfo): Flowable { + val noItemToUpdate: Flowable = Flowable.just( /*noItemToUpdate=*/-1) + if (playlists.isEmpty()) { + return noItemToUpdate + } + val playlistRemoteEntity: PlaylistRemoteEntity = playlists.get(0) + if (playlistRemoteEntity.isIdenticalTo(result)) { + return noItemToUpdate + } + return remotePlaylistManager!!.onUpdate(playlists.get(0).getUid(), result).toFlowable() + } + + private fun getPlaylistBookmarkSubscriber(): Subscriber> { + return object : Subscriber> { + public override fun onSubscribe(s: Subscription) { + if (bookmarkReactor != null) { + bookmarkReactor!!.cancel() + } + bookmarkReactor = s + bookmarkReactor!!.request(1) + } + + public override fun onNext(playlist: List) { + playlistEntity = if (playlist.isEmpty()) null else playlist.get(0) + updateBookmarkButtons() + isBookmarkButtonReady!!.set(true) + if (bookmarkReactor != null) { + bookmarkReactor!!.request(1) + } + } + + public override fun onError(throwable: Throwable) { + showError(ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, + "Get playlist bookmarks")) + } + + public override fun onComplete() {} + } + } + + public override fun setTitle(title: String?) { + super.setTitle(title) + if (headerBinding != null) { + headerBinding!!.playlistTitleView.setText(title) + } + } + + private fun onBookmarkClicked() { + if (((isBookmarkButtonReady == null) || !isBookmarkButtonReady!!.get() + || (remotePlaylistManager == null))) { + return + } + val action: Disposable + if (currentInfo != null && playlistEntity == null) { + action = remotePlaylistManager!!.onBookmark(currentInfo!!) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(io.reactivex.rxjava3.functions.Consumer({ ignored: Long? -> }), io.reactivex.rxjava3.functions.Consumer({ throwable: Throwable? -> + showError(ErrorInfo((throwable)!!, UserAction.REQUESTED_BOOKMARK, + "Adding playlist bookmark")) + })) + } else if (playlistEntity != null) { + action = remotePlaylistManager!!.deletePlaylist(playlistEntity!!.getUid()) + .observeOn(AndroidSchedulers.mainThread()) + .doFinally(Action({ playlistEntity = null })) + .subscribe(io.reactivex.rxjava3.functions.Consumer({ ignored: Int? -> }), io.reactivex.rxjava3.functions.Consumer({ throwable: Throwable? -> + showError(ErrorInfo((throwable)!!, UserAction.REQUESTED_BOOKMARK, + "Deleting playlist bookmark")) + })) + } else { + action = Disposable.empty() + } + disposables!!.add(action) + } + + private fun updateBookmarkButtons() { + if (playlistBookmarkButton == null || activity == null) { + return + } + val drawable: Int = if (playlistEntity == null) R.drawable.ic_playlist_add else R.drawable.ic_playlist_add_check + val titleRes: Int = if (playlistEntity == null) R.string.bookmark_playlist else R.string.unbookmark_playlist + playlistBookmarkButton!!.setIcon(drawable) + playlistBookmarkButton!!.setTitle(titleRes) + } + + private fun setStreamCountAndOverallDuration(list: List, + isDurationComplete: Boolean) { + if (activity != null && headerBinding != null) { + playlistOverallDurationSeconds += list.stream() + .mapToLong(ToLongFunction({ x: StreamInfoItem -> x.getDuration() })) + .sum() + headerBinding!!.playlistStreamCount.setText( + Localization.concatenateStrings( + Localization.localizeStreamCount(activity!!, streamCount), + Localization.getDurationString(playlistOverallDurationSeconds, + isDurationComplete)) + ) + } + } + + companion object { + private val PICASSO_PLAYLIST_TAG: String = "PICASSO_PLAYLIST_TAG" + fun getInstance(serviceId: Int, url: String?, + name: String?): PlaylistFragment { + val instance: PlaylistFragment = PlaylistFragment() + instance.setInitialData(serviceId, url, name) + return instance + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java deleted file mode 100644 index eef3455ae..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java +++ /dev/null @@ -1,1113 +0,0 @@ -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 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.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 icepick.State; -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; - 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); - 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((View v, 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( - (TextView v, int actionId, 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)); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - private void changeContentFilter(final MenuItem item, final List theContentFilter) { - filterItemCheckedId = item.getItemId(); - item.setChecked(true); - - 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, - () -> 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)); - } - - 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()); - nextPage = result.getNextPage(); - - if (!result.getErrors().isEmpty()) { - showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED, - "\"" + searchString + "\" → pageUrl: " + nextPage.getUrl() + ", " - + "pageIds: " + nextPage.getIds() + ", " - + "pageCookies: " + nextPage.getCookies(), - serviceId)); - } - 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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.kt new file mode 100644 index 000000000..f30bcd213 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.kt @@ -0,0 +1,1013 @@ +package org.schabi.newpipe.fragments.list.search + +import android.app.Activity +import android.content.Context +import android.content.DialogInterface +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.View.OnFocusChangeListener +import android.view.View.OnLongClickListener +import android.view.ViewGroup +import android.view.animation.DecelerateInterpolator +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import android.widget.TextView +import android.widget.TextView.OnEditorActionListener +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 icepick.State +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Notification +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.core.ObservableSource +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.functions.BiConsumer +import io.reactivex.rxjava3.functions.BiFunction +import io.reactivex.rxjava3.functions.Consumer +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.PublishSubject +import org.schabi.newpipe.BaseFragment +import org.schabi.newpipe.R +import org.schabi.newpipe.databinding.FragmentSearchBinding +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.ErrorUtil.Companion.showUiErrorSnackbar +import org.schabi.newpipe.error.ReCaptchaActivity +import org.schabi.newpipe.error.UserAction +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.StreamingService +import org.schabi.newpipe.extractor.search.SearchExtractor.NothingFoundException +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.extractor.utils.Utils +import org.schabi.newpipe.fragments.BackPressable +import org.schabi.newpipe.fragments.list.BaseListFragment +import org.schabi.newpipe.fragments.list.search.SuggestionListAdapter.OnSuggestionItemSelected +import org.schabi.newpipe.ktx.AnimationType +import org.schabi.newpipe.ktx.animate +import org.schabi.newpipe.ktx.isInterruptedCaused +import org.schabi.newpipe.local.history.HistoryRecordManager +import org.schabi.newpipe.settings.NewPipeSettings +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.Arrays +import java.util.Queue +import java.util.concurrent.Callable +import java.util.concurrent.TimeUnit +import java.util.function.Predicate +import java.util.stream.Collectors + +class SearchFragment() : BaseListFragment?>(), BackPressable { + private val suggestionPublisher: PublishSubject = PublishSubject.create() + + @State + var filterItemCheckedId: Int = -1 + + @State + protected var serviceId: Int = NO_SERVICE_ID + + // these three represents the current search query + @State + var searchString: String? = null + + /** + * No content filter should add like contentFilter = all + * be aware of this when implementing an extractor. + */ + @State + var contentFilter: Array = arrayOfNulls(0) + + @State + var sortFilter: String? = null + + // these represents the last search + @State + var lastSearchedString: String? = null + + @State + var searchSuggestion: String? = null + + @State + var isCorrectedSearch: Boolean = false + + @State + var metaInfo: Array? + + @State + var wasSearchFocused: Boolean = false + private val menuItemToFilterName: SparseArrayCompat = SparseArrayCompat() + private var service: StreamingService? = null + private var nextPage: Page? = null + private var showLocalSuggestions: Boolean = true + private var showRemoteSuggestions: Boolean = true + private var searchDisposable: Disposable? = null + private var suggestionDisposable: Disposable? = null + private val disposables: CompositeDisposable = CompositeDisposable() + private var suggestionListAdapter: SuggestionListAdapter? = null + private var historyRecordManager: HistoryRecordManager? = null + + /*////////////////////////////////////////////////////////////////////////// + // Views + ////////////////////////////////////////////////////////////////////////// */ + private var searchBinding: FragmentSearchBinding? = null + private var searchToolbarContainer: View? = null + private var searchEditText: EditText? = null + private var searchClear: View? = null + private var suggestionsPanelVisible: Boolean = false + /*//////////////////////////////////////////////////////////////////////// */ + /** + * TextWatcher to remove rich-text formatting on the search EditText when pasting content + * from the clipboard. + */ + private var textWatcher: TextWatcher? = null + + /** + * Set wasLoading to true so when the fragment onResume is called, the initial search is done. + */ + private fun setSearchOnResume() { + wasLoading.set(true) + } + + /*////////////////////////////////////////////////////////////////////////// + // Fragment's LifeCycle + ////////////////////////////////////////////////////////////////////////// */ + public override fun onAttach(context: Context) { + super.onAttach(context) + val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences((activity)!!) + showLocalSuggestions = NewPipeSettings.showLocalSearchSuggestions((activity)!!, prefs) + showRemoteSuggestions = NewPipeSettings.showRemoteSearchSuggestions((activity)!!, prefs) + suggestionListAdapter = SuggestionListAdapter() + historyRecordManager = HistoryRecordManager(context) + } + + public override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_search, container, false) + } + + public override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) { + searchBinding = FragmentSearchBinding.bind(rootView) + super.onViewCreated(rootView, savedInstanceState) + showSearchOnStart() + initSearchListeners() + } + + private fun updateService() { + try { + service = NewPipe.getService(serviceId) + } catch (e: Exception) { + showUiErrorSnackbar(this, "Getting service for id " + serviceId, e) + } + } + + public override fun onStart() { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "onStart() called") + } + super.onStart() + updateService() + } + + public override fun onPause() { + super.onPause() + wasSearchFocused = searchEditText!!.hasFocus() + if (searchDisposable != null) { + searchDisposable!!.dispose() + } + if (suggestionDisposable != null) { + suggestionDisposable!!.dispose() + } + disposables.clear() + hideKeyboardSearch() + } + + public override fun onResume() { + if (BaseFragment.Companion.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() + ExtractorHelper.showMetaInfoInTextView(if (metaInfo == null) null else Arrays.asList(*metaInfo), + searchBinding!!.searchMetaInfoTextView, searchBinding!!.searchMetaInfoSeparator, + disposables) + if (TextUtils.isEmpty(searchString) || wasSearchFocused) { + showKeyboardSearch() + showSuggestionsPanel() + } else { + hideKeyboardSearch() + hideSuggestionsPanel() + } + wasSearchFocused = false + } + + public override fun onDestroyView() { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "onDestroyView() called") + } + unsetSearchListeners() + searchBinding = null + super.onDestroyView() + } + + public override fun onDestroy() { + super.onDestroy() + if (searchDisposable != null) { + searchDisposable!!.dispose() + } + if (suggestionDisposable != null) { + suggestionDisposable!!.dispose() + } + disposables.clear() + } + + public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + if (requestCode == ReCaptchaActivity.Companion.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 fun initViews(rootView: View, savedInstanceState: Bundle?) { + 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) + ItemTouchHelper(object : ItemTouchHelper.Callback() { + public override fun getMovementFlags(recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder): Int { + return getSuggestionMovementFlags(viewHolder) + } + + public override fun onMove(recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + viewHolder1: RecyclerView.ViewHolder): Boolean { + return false + } + + public override fun onSwiped(viewHolder: RecyclerView.ViewHolder, i: Int) { + 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 + ////////////////////////////////////////////////////////////////////////// */ + public override fun writeTo(objectsToSave: Queue) { + super.writeTo(objectsToSave) + objectsToSave.add(nextPage) + } + + @Throws(Exception::class) + public override fun readFrom(savedObjects: Queue) { + super.readFrom(savedObjects) + nextPage = savedObjects.poll() as Page? + } + + public override fun onSaveInstanceState(bundle: Bundle) { + searchString = if (searchEditText != null) getSearchEditString().trim({ it <= ' ' }) else searchString + super.onSaveInstanceState(bundle) + } + + /*////////////////////////////////////////////////////////////////////////// + // Init's + ////////////////////////////////////////////////////////////////////////// */ + public override fun reloadContent() { + if (!TextUtils.isEmpty(searchString) || ((searchEditText != null + && !isSearchEditBlank()))) { + search((if (!TextUtils.isEmpty(searchString)) searchString else getSearchEditString())!!, contentFilter, "") + } else { + if (searchEditText != null) { + searchEditText!!.setText("") + showKeyboardSearch() + } + hideErrorPanel() + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Menu + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreateOptionsMenu(menu: Menu, + inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + val supportActionBar: ActionBar? = activity!!.getSupportActionBar() + if (supportActionBar != null) { + supportActionBar.setDisplayShowTitleEnabled(false) + supportActionBar.setDisplayHomeAsUpEnabled(true) + } + var itemId: Int = 0 + var isFirstItem: Boolean = true + val c: Context? = getContext() + if (service == null) { + Log.w(TAG, "onCreateOptionsMenu() called with null service") + updateService() + } + for (filter: String in service!!.getSearchQHFactory().getAvailableContentFilter()) { + if ((filter == YoutubeSearchQueryHandlerFactory.MUSIC_SONGS)) { + val musicItem: MenuItem = menu.add(2, + itemId++, + 0, + "YouTube Music") + musicItem.setEnabled(false) + } else if ((filter == PeertubeSearchQueryHandlerFactory.SEPIA_VIDEOS)) { + val sepiaItem: MenuItem = menu.add(2, + itemId++, + 0, + "Sepia Search") + sepiaItem.setEnabled(false) + } + menuItemToFilterName.put(itemId, filter) + val item: MenuItem = menu.add(1, + itemId++, + 0, + ServiceHelper.getTranslatedFilterString(filter, c)) + if (isFirstItem) { + item.setChecked(true) + isFirstItem = false + } + } + menu.setGroupCheckable(1, true, true) + restoreFilterChecked(menu, filterItemCheckedId) + } + + public override fun onOptionsItemSelected(item: MenuItem): Boolean { + val filter: List = listOf(menuItemToFilterName.get(item.getItemId())) + changeContentFilter(item, filter) + return true + } + + private fun restoreFilterChecked(menu: Menu, itemId: Int) { + if (itemId != -1) { + val item: MenuItem? = menu.findItem(itemId) + if (item == null) { + return + } + item.setChecked(true) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Search + ////////////////////////////////////////////////////////////////////////// */ + private fun showSearchOnStart() { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, ("showSearchOnStart() called, searchQuery → " + + searchString + + ", lastSearchedQuery → " + + lastSearchedString)) + } + searchEditText!!.setText(searchString) + if ((TextUtils.isEmpty(searchString) + || isSearchEditBlank())) { + searchToolbarContainer!!.setTranslationX(100f) + searchToolbarContainer!!.setAlpha(0.0f) + searchToolbarContainer!!.setVisibility(View.VISIBLE) + searchToolbarContainer!!.animate() + .translationX(0f) + .alpha(1.0f) + .setDuration(200) + .setInterpolator(DecelerateInterpolator()).start() + } else { + searchToolbarContainer!!.setTranslationX(0f) + searchToolbarContainer!!.setAlpha(1.0f) + searchToolbarContainer!!.setVisibility(View.VISIBLE) + } + } + + private fun initSearchListeners() { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "initSearchListeners() called") + } + searchClear!!.setOnClickListener(View.OnClickListener({ v: View -> + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "onClick() called with: v = [" + v + "]") + } + if (isSearchEditBlank()) { + NavigationHelper.gotoMainFragment(getFM()) + return@setOnClickListener + } + searchBinding!!.correctSuggestion.setVisibility(View.GONE) + searchEditText!!.setText("") + suggestionListAdapter!!.submitList(null) + showKeyboardSearch() + })) + TooltipCompat.setTooltipText((searchClear)!!, getString(R.string.clear)) + searchEditText!!.setOnClickListener(View.OnClickListener({ v: View -> + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "onClick() called with: v = [" + v + "]") + } + if ((showLocalSuggestions || showRemoteSuggestions) && !isErrorPanelVisible()) { + showSuggestionsPanel() + } + if (DeviceUtils.isTv(getContext())) { + showKeyboardSearch() + } + })) + searchEditText!!.setOnFocusChangeListener(OnFocusChangeListener({ v: View, hasFocus: Boolean -> + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, ("onFocusChange() called with: " + + "v = [" + v + "], hasFocus = [" + hasFocus + "]")) + } + if (((showLocalSuggestions || showRemoteSuggestions) + && hasFocus && !isErrorPanelVisible())) { + showSuggestionsPanel() + } + })) + suggestionListAdapter!!.setListener(object : OnSuggestionItemSelected { + public override fun onSuggestionItemSelected(item: SuggestionItem?) { + search((item!!.query)!!, arrayOfNulls(0), "") + searchEditText!!.setText(item.query) + } + + public override fun onSuggestionItemInserted(item: SuggestionItem?) { + searchEditText!!.setText(item!!.query) + searchEditText!!.setSelection(searchEditText!!.getText().length) + } + + public override fun onSuggestionItemLongClick(item: SuggestionItem?) { + if (item!!.fromHistory) { + showDeleteSuggestionDialog(item) + } + } + }) + if (textWatcher != null) { + searchEditText!!.removeTextChangedListener(textWatcher) + } + textWatcher = object : TextWatcher { + public override fun beforeTextChanged(s: CharSequence, start: Int, + count: Int, after: Int) { + // Do nothing, old text is already clean + } + + public override fun onTextChanged(s: CharSequence, start: Int, + before: Int, count: Int) { + // Changes are handled in afterTextChanged; CharSequence cannot be changed here. + } + + public override fun afterTextChanged(s: Editable) { + // Remove rich text formatting + for (span: CharacterStyle? in s.getSpans(0, s.length, CharacterStyle::class.java)) { + s.removeSpan(span) + } + val newText: String = getSearchEditString().trim({ it <= ' ' }) + suggestionPublisher.onNext(newText) + } + } + searchEditText!!.addTextChangedListener(textWatcher) + searchEditText!!.setOnEditorActionListener( + OnEditorActionListener({ v: TextView, actionId: Int, event: KeyEvent? -> + if (BaseFragment.Companion.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({ it <= ' ' })) + search(getSearchEditString(), arrayOfNulls(0), "") + return@setOnEditorActionListener true + } + false + })) + if (suggestionDisposable == null || suggestionDisposable!!.isDisposed()) { + initSuggestionObserver() + } + } + + private fun unsetSearchListeners() { + if (BaseFragment.Companion.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 fun showSuggestionsPanel() { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "showSuggestionsPanel() called") + } + suggestionsPanelVisible = true + searchBinding!!.suggestionsPanel.animate(true, 200, AnimationType.LIGHT_SLIDE_AND_ALPHA) + } + + private fun hideSuggestionsPanel() { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "hideSuggestionsPanel() called") + } + suggestionsPanelVisible = false + searchBinding!!.suggestionsPanel.animate(false, 200, AnimationType.LIGHT_SLIDE_AND_ALPHA) + } + + private fun showKeyboardSearch() { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "showKeyboardSearch() called") + } + KeyboardUtil.showKeyboard(activity, searchEditText) + } + + private fun hideKeyboardSearch() { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "hideKeyboardSearch() called") + } + KeyboardUtil.hideKeyboard(activity, searchEditText) + } + + private fun showDeleteSuggestionDialog(item: SuggestionItem?) { + if ((activity == null) || (historyRecordManager == null) || (searchEditText == null)) { + return + } + val query: String? = item!!.query + AlertDialog.Builder(activity!!) + .setTitle(query) + .setMessage(R.string.delete_item_search_history) + .setCancelable(true) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.delete, DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> + val onDelete: Disposable = historyRecordManager!!.deleteSearchHistory(query) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + Consumer({ howManyDeleted: Int? -> + suggestionPublisher + .onNext(getSearchEditString()) + }), + Consumer({ throwable: Throwable? -> + showSnackBarError(ErrorInfo((throwable)!!, + UserAction.DELETE_FROM_HISTORY, + "Deleting item failed")) + })) + disposables.add(onDelete) + })) + .show() + } + + public override fun onBackPressed(): Boolean { + if ((suggestionsPanelVisible + && !infoListAdapter!!.getItemsList().isEmpty() + && !isLoading.get())) { + hideSuggestionsPanel() + hideKeyboardSearch() + searchEditText!!.setText(lastSearchedString) + return true + } + return false + } + + private fun getLocalSuggestionsObservable( + query: String, similarQueryLimit: Int): Observable> { + return historyRecordManager + .getRelatedSearches(query, similarQueryLimit, 25) + .toObservable() + .map(io.reactivex.rxjava3.functions.Function?, MutableList>({ searchHistoryEntries: List? -> + searchHistoryEntries!!.stream() + .map(java.util.function.Function({ entry: String? -> SuggestionItem(true, entry) })) + .collect(Collectors.toList()) + })) + } + + private fun getRemoteSuggestionsObservable(query: String): Observable> { + return ExtractorHelper.suggestionsFor(serviceId, query) + .toObservable() + .map(io.reactivex.rxjava3.functions.Function?, MutableList>({ strings: List? -> + val result: MutableList = ArrayList() + for (entry: String? in strings!!) { + result.add(SuggestionItem(false, entry)) + } + result + })) + } + + private fun initSuggestionObserver() { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "initSuggestionObserver() called") + } + if (suggestionDisposable != null) { + suggestionDisposable!!.dispose() + } + suggestionDisposable = suggestionPublisher + .debounce(SUGGESTIONS_DEBOUNCE.toLong(), TimeUnit.MILLISECONDS) + .startWithItem(if (searchString == null) "" else searchString) + .switchMap>>(io.reactivex.rxjava3.functions.Function>>>({ query: String -> + // Only show remote suggestions if they are enabled in settings and + // the query length is at least THRESHOLD_NETWORK_SUGGESTION + val shallShowRemoteSuggestionsNow: Boolean = (showRemoteSuggestions + && query.length >= THRESHOLD_NETWORK_SUGGESTION) + if (showLocalSuggestions && shallShowRemoteSuggestionsNow) { + return@switchMap Observable.zip, MutableList, List>( + getLocalSuggestionsObservable(query, 3), + getRemoteSuggestionsObservable(query), + BiFunction, MutableList, List>({ local: MutableList, remote: MutableList -> + remote.removeIf(Predicate({ remoteItem: SuggestionItem -> + local.stream().anyMatch( + Predicate({ localItem: SuggestionItem -> (localItem == remoteItem) })) + })) + local.addAll(remote) + local + })) + .materialize() + } else if (showLocalSuggestions) { + return@switchMap getLocalSuggestionsObservable(query, 25) + .materialize() + } else if (shallShowRemoteSuggestionsNow) { + return@switchMap getRemoteSuggestionsObservable(query) + .materialize() + } else { + return@switchMap Single.fromCallable>(Callable>({ emptyList() })) + .toObservable() + .materialize() + } + })) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + Consumer>>({ listNotification: Notification> -> + if (listNotification.isOnNext()) { + if (listNotification.getValue() != null) { + handleSuggestions(listNotification.getValue()!!) + } + } else if ((listNotification.isOnError() + && (listNotification.getError() != null + ) && !listNotification.getError()!!.isInterruptedCaused)) { + showSnackBarError(ErrorInfo(listNotification.getError()!!, + UserAction.GET_SUGGESTIONS, (searchString)!!, serviceId)) + } + }), Consumer({ throwable: Throwable? -> + showSnackBarError(ErrorInfo( + (throwable)!!, UserAction.GET_SUGGESTIONS, (searchString)!!, serviceId)) + })) + } + + override fun 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 fun search(theSearchString: String, + theContentFilter: Array, + theSortFilter: String?) { + if (BaseFragment.Companion.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 { + val streamingService: StreamingService = NewPipe.getServiceByUrl(theSearchString) + showLoading() + disposables.add(Observable + .fromCallable(Callable({ + NavigationHelper.getIntentByLink((activity)!!, + streamingService, theSearchString) + })) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer({ intent: Intent? -> + getFM().popBackStackImmediate() + activity!!.startActivity(intent) + }), Consumer({ throwable: Throwable? -> showTextError(getString(R.string.unsupported_url)) }))) + return + } catch (ignored: Exception) { + // Exception occurred, it's not a url + } + + // prepare search + lastSearchedString = searchString + searchString = theSearchString + infoListAdapter!!.clearStreamItemList() + hideSuggestionsPanel() + ExtractorHelper.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( + Consumer({ ignored: Long? -> }), + Consumer({ throwable: Throwable? -> + showSnackBarError(ErrorInfo((throwable)!!, UserAction.SEARCHED, + theSearchString, serviceId)) + }) + )) + + // load search results + suggestionPublisher.onNext(theSearchString) + startLoading(false) + } + + public override fun startLoading(forceLoad: Boolean) { + 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(BiConsumer({ searchResult: SearchInfo?, throwable: Throwable? -> isLoading.set(false) })) + .subscribe(Consumer({ result: SearchInfo? -> this.handleResult(result) }), Consumer({ exception: Throwable -> onItemError(exception) })) + } + + override fun loadMoreItems() { + if (!Page.isValid(nextPage)) { + return + } + isLoading.set(true) + showListFooter(true) + if (searchDisposable != null) { + searchDisposable!!.dispose() + } + searchDisposable = ExtractorHelper.getMoreSearchItems( + serviceId, + searchString, + Arrays.asList(*contentFilter), + sortFilter, + nextPage) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnEvent(BiConsumer({ nextItemsResult: InfoItemsPage?, throwable: Throwable? -> isLoading.set(false) })) + .subscribe(Consumer?>({ result: InfoItemsPage? -> handleNextItems(result) }), Consumer({ exception: Throwable -> onItemError(exception) })) + } + + override fun hasMoreItems(): Boolean { + return Page.isValid(nextPage) + } + + override fun onItemSelected(selectedItem: InfoItem) { + super.onItemSelected(selectedItem) + hideKeyboardSearch() + } + + private fun onItemError(exception: Throwable) { + if (exception is NothingFoundException) { + infoListAdapter!!.clearStreamItemList() + showEmptyState() + } else { + showError(ErrorInfo(exception, UserAction.SEARCHED, (searchString)!!, serviceId)) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + ////////////////////////////////////////////////////////////////////////// */ + private fun changeContentFilter(item: MenuItem, theContentFilter: List) { + filterItemCheckedId = item.getItemId() + item.setChecked(true) + contentFilter = theContentFilter.toTypedArray() + if (!TextUtils.isEmpty(searchString)) { + search((searchString)!!, contentFilter, sortFilter) + } + } + + private fun setQuery(theServiceId: Int, + theSearchString: String?, + theContentFilter: Array, + theSortFilter: String) { + serviceId = theServiceId + searchString = theSearchString + contentFilter = theContentFilter + sortFilter = theSortFilter + } + + private fun getSearchEditString(): String { + return searchEditText!!.getText().toString() + } + + private fun isSearchEditBlank(): Boolean { + return Utils.isBlank(getSearchEditString()) + } + + /*////////////////////////////////////////////////////////////////////////// + // Suggestion Results + ////////////////////////////////////////////////////////////////////////// */ + fun handleSuggestions(suggestions: List) { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, "handleSuggestions() called with: suggestions = [" + suggestions + "]") + } + suggestionListAdapter!!.submitList(suggestions, + Runnable({ searchBinding!!.suggestionsList.scrollToPosition(0) })) + if (suggestionsPanelVisible && isErrorPanelVisible()) { + hideLoading() + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + ////////////////////////////////////////////////////////////////////////// */ + public override fun hideLoading() { + super.hideLoading() + showListFooter(false) + } + + /*////////////////////////////////////////////////////////////////////////// + // Search Results + ////////////////////////////////////////////////////////////////////////// */ + public override fun handleResult(result: SearchInfo) { + val exceptions: List = result.getErrors() + if ((!exceptions.isEmpty() + && !(exceptions.size == 1 + && exceptions.get(0) is NothingFoundException))) { + showSnackBarError(ErrorInfo(result.getErrors(), UserAction.SEARCHED, + (searchString)!!, serviceId)) + } + searchSuggestion = result.getSearchSuggestion() + if (searchSuggestion != null) { + searchSuggestion = searchSuggestion!!.trim({ it <= ' ' }) + } + isCorrectedSearch = result.isCorrectedSearch() + + // List cannot be bundled without creating some containers + metaInfo = result.getMetaInfo().toTypedArray() + ExtractorHelper.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 fun handleSearchSuggestion() { + if (TextUtils.isEmpty(searchSuggestion)) { + searchBinding!!.correctSuggestion.setVisibility(View.GONE) + } else { + val helperText: String = getString(if (isCorrectedSearch) R.string.search_showing_result_for else R.string.did_you_mean) + val highlightedSearchSuggestion: String = "" + Html.escapeHtml(searchSuggestion) + "" + val text: String = String.format(helperText, highlightedSearchSuggestion) + searchBinding!!.correctSuggestion.setText(HtmlCompat.fromHtml(text, + HtmlCompat.FROM_HTML_MODE_LEGACY)) + searchBinding!!.correctSuggestion.setOnClickListener(View.OnClickListener({ v: View? -> + searchBinding!!.correctSuggestion.setVisibility(View.GONE) + search((searchSuggestion)!!, contentFilter, sortFilter) + searchEditText!!.setText(searchSuggestion) + })) + searchBinding!!.correctSuggestion.setOnLongClickListener(OnLongClickListener({ v: View? -> + searchEditText!!.setText(searchSuggestion) + searchEditText!!.setSelection(searchSuggestion!!.length) + showKeyboardSearch() + true + })) + searchBinding!!.correctSuggestion.setVisibility(View.VISIBLE) + } + } + + public override fun handleNextItems(result: InfoItemsPage<*>?) { + showListFooter(false) + infoListAdapter!!.addInfoItemList(result!!.getItems()) + nextPage = result.getNextPage() + if (!result.getErrors().isEmpty()) { + showSnackBarError(ErrorInfo(result.getErrors(), UserAction.SEARCHED, + ("\"" + searchString + "\" → pageUrl: " + nextPage.getUrl() + ", " + + "pageIds: " + nextPage.getIds() + ", " + + "pageCookies: " + nextPage.getCookies()), + serviceId)) + } + super.handleNextItems(result) + } + + public override fun handleError() { + super.handleError() + hideSuggestionsPanel() + hideKeyboardSearch() + } + + /*////////////////////////////////////////////////////////////////////////// + // Suggestion item touch helper + ////////////////////////////////////////////////////////////////////////// */ + fun getSuggestionMovementFlags(viewHolder: RecyclerView.ViewHolder): Int { + val position: Int = viewHolder.getBindingAdapterPosition() + if (position == RecyclerView.NO_POSITION) { + return 0 + } + val item: SuggestionItem = (suggestionListAdapter!!.getCurrentList().get(position))!! + return if (item.fromHistory) ItemTouchHelper.Callback.makeMovementFlags(0, + ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) else 0 + } + + fun onSuggestionItemSwiped(viewHolder: RecyclerView.ViewHolder) { + val position: Int = viewHolder.getBindingAdapterPosition() + val query: String? = suggestionListAdapter!!.getCurrentList().get(position)!!.query + val onDelete: Disposable = historyRecordManager!!.deleteSearchHistory(query) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + Consumer({ howManyDeleted: Int? -> + suggestionPublisher + .onNext(getSearchEditString()) + }), + Consumer({ throwable: Throwable? -> + showSnackBarError(ErrorInfo((throwable)!!, + UserAction.DELETE_FROM_HISTORY, "Deleting item failed")) + })) + disposables.add(onDelete) + } + + companion object { + /*////////////////////////////////////////////////////////////////////////// + // 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 val THRESHOLD_NETWORK_SUGGESTION: Int = 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 val SUGGESTIONS_DEBOUNCE: Int = 120 //ms + fun getInstance(serviceId: Int, searchString: String?): SearchFragment { + val searchFragment: SearchFragment = SearchFragment() + searchFragment.setQuery(serviceId, searchString, arrayOfNulls(0), "") + if (!TextUtils.isEmpty(searchString)) { + searchFragment.setSearchOnResume() + } + return searchFragment + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionItem.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionItem.java deleted file mode 100644 index 83f68dbb5..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionItem.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.schabi.newpipe.fragments.list.search; - -import androidx.annotation.NonNull; - -public class SuggestionItem { - final boolean fromHistory; - public final String query; - - public SuggestionItem(final boolean fromHistory, final String query) { - this.fromHistory = fromHistory; - this.query = query; - } - - @Override - public boolean equals(final Object o) { - if (o instanceof SuggestionItem) { - return query.equals(((SuggestionItem) o).query); - } - return false; - } - - @Override - public int hashCode() { - return query.hashCode(); - } - - @NonNull - @Override - public String toString() { - return "[" + fromHistory + "→" + query + "]"; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionItem.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionItem.kt new file mode 100644 index 000000000..52166e191 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionItem.kt @@ -0,0 +1,18 @@ +package org.schabi.newpipe.fragments.list.search + +class SuggestionItem(val fromHistory: Boolean, val query: String?) { + public override fun equals(o: Any?): Boolean { + if (o is SuggestionItem) { + return (query == o.query) + } + return false + } + + public override fun hashCode(): Int { + return query.hashCode() + } + + public override fun toString(): String { + return "[" + fromHistory + "→" + query + "]" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java deleted file mode 100644 index 856ba22f1..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java +++ /dev/null @@ -1,93 +0,0 @@ -package org.schabi.newpipe.fragments.list.search; - -import android.view.LayoutInflater; -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.R; -import org.schabi.newpipe.databinding.ItemSearchSuggestionBinding; - -public class SuggestionListAdapter - extends ListAdapter { - private OnSuggestionItemSelected listener; - - public SuggestionListAdapter() { - super(new SuggestionItemCallback()); - } - - public void setListener(final OnSuggestionItemSelected listener) { - this.listener = listener; - } - - @NonNull - @Override - public SuggestionItemHolder onCreateViewHolder(@NonNull final ViewGroup parent, - final int viewType) { - return new SuggestionItemHolder(ItemSearchSuggestionBinding - .inflate(LayoutInflater.from(parent.getContext()), parent, false)); - } - - @Override - public void onBindViewHolder(final SuggestionItemHolder holder, final int position) { - final SuggestionItem currentItem = getItem(position); - holder.updateFrom(currentItem); - holder.itemBinding.suggestionSearch.setOnClickListener(v -> { - if (listener != null) { - listener.onSuggestionItemSelected(currentItem); - } - }); - holder.itemBinding.suggestionSearch.setOnLongClickListener(v -> { - if (listener != null) { - listener.onSuggestionItemLongClick(currentItem); - } - return true; - }); - holder.itemBinding.suggestionInsert.setOnClickListener(v -> { - if (listener != null) { - listener.onSuggestionItemInserted(currentItem); - } - }); - } - - public interface OnSuggestionItemSelected { - void onSuggestionItemSelected(SuggestionItem item); - - void onSuggestionItemInserted(SuggestionItem item); - - void onSuggestionItemLongClick(SuggestionItem item); - } - - public static final class SuggestionItemHolder extends RecyclerView.ViewHolder { - private final ItemSearchSuggestionBinding itemBinding; - - private SuggestionItemHolder(final ItemSearchSuggestionBinding binding) { - super(binding.getRoot()); - this.itemBinding = binding; - } - - private void updateFrom(final SuggestionItem item) { - itemBinding.itemSuggestionIcon.setImageResource(item.fromHistory ? R.drawable.ic_history - : R.drawable.ic_search); - itemBinding.itemSuggestionQuery.setText(item.query); - } - } - - private static class SuggestionItemCallback extends DiffUtil.ItemCallback { - @Override - public boolean areItemsTheSame(@NonNull final SuggestionItem oldItem, - @NonNull final SuggestionItem newItem) { - return oldItem.fromHistory == newItem.fromHistory - && oldItem.query.equals(newItem.query); - } - - @Override - public boolean areContentsTheSame(@NonNull final SuggestionItem oldItem, - @NonNull final SuggestionItem newItem) { - return true; // items' contents never change; the list of items themselves does - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.kt new file mode 100644 index 000000000..1c7597294 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.kt @@ -0,0 +1,72 @@ +package org.schabi.newpipe.fragments.list.search + +import android.view.LayoutInflater +import android.view.View +import android.view.View.OnLongClickListener +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()) { + private var listener: OnSuggestionItemSelected? = null + fun setListener(listener: OnSuggestionItemSelected?) { + this.listener = listener + } + + public override fun onCreateViewHolder(parent: ViewGroup, + viewType: Int): SuggestionItemHolder { + return SuggestionItemHolder(ItemSearchSuggestionBinding + .inflate(LayoutInflater.from(parent.getContext()), parent, false)) + } + + public override fun onBindViewHolder(holder: SuggestionItemHolder, position: Int) { + val currentItem: SuggestionItem? = getItem(position) + holder.updateFrom(currentItem) + holder.itemBinding.suggestionSearch.setOnClickListener(View.OnClickListener({ v: View? -> + if (listener != null) { + listener!!.onSuggestionItemSelected(currentItem) + } + })) + holder.itemBinding.suggestionSearch.setOnLongClickListener(OnLongClickListener({ v: View? -> + if (listener != null) { + listener!!.onSuggestionItemLongClick(currentItem) + } + true + })) + holder.itemBinding.suggestionInsert.setOnClickListener(View.OnClickListener({ v: View? -> + if (listener != null) { + listener!!.onSuggestionItemInserted(currentItem) + } + })) + } + + open interface OnSuggestionItemSelected { + fun onSuggestionItemSelected(item: SuggestionItem?) + fun onSuggestionItemInserted(item: SuggestionItem?) + fun onSuggestionItemLongClick(item: SuggestionItem?) + } + + class SuggestionItemHolder(val itemBinding: ItemSearchSuggestionBinding) : RecyclerView.ViewHolder(itemBinding.getRoot()) { + fun updateFrom(item: SuggestionItem?) { + itemBinding.itemSuggestionIcon.setImageResource(if (item!!.fromHistory) R.drawable.ic_history else R.drawable.ic_search) + itemBinding.itemSuggestionQuery.setText(item.query) + } + } + + private class SuggestionItemCallback() : DiffUtil.ItemCallback() { + public override fun areItemsTheSame(oldItem: SuggestionItem, + newItem: SuggestionItem): Boolean { + return (oldItem.fromHistory == newItem.fromHistory + && (oldItem.query == newItem.query)) + } + + public override fun areContentsTheSame(oldItem: SuggestionItem, + newItem: SuggestionItem): Boolean { + return true // items' contents never change; the list of items themselves does + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java deleted file mode 100644 index e46937ede..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java +++ /dev/null @@ -1,176 +0,0 @@ -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.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.fragments.list.BaseListInfoFragment; -import org.schabi.newpipe.info_list.ItemViewMode; -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.kt new file mode 100644 index 000000000..b9a9d657c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.kt @@ -0,0 +1,148 @@ +package org.schabi.newpipe.fragments.list.videos + +import android.content.SharedPreferences +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +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.CompoundButton +import androidx.preference.PreferenceManager +import io.reactivex.rxjava3.core.Single +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.InfoItemsPage +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.fragments.list.BaseListInfoFragment +import org.schabi.newpipe.info_list.ItemViewMode +import org.schabi.newpipe.ktx.slideUp +import java.io.Serializable +import java.util.concurrent.Callable +import java.util.function.Supplier + +class RelatedItemsFragment() : BaseListInfoFragment(UserAction.REQUESTED_STREAM), OnSharedPreferenceChangeListener { + private var relatedItemsInfo: RelatedItemsInfo? = null + + /*////////////////////////////////////////////////////////////////////////// + // Views + ////////////////////////////////////////////////////////////////////////// */ + private var headerBinding: RelatedItemsHeaderBinding? = null + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreateView(inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_related_items, container, false) + } + + public override fun onDestroyView() { + headerBinding = null + super.onDestroyView() + } + + override fun getListHeaderSupplier(): Supplier? { + if (relatedItemsInfo == null || relatedItemsInfo!!.getRelatedItems() == null) { + return null + } + headerBinding = RelatedItemsHeaderBinding + .inflate(activity!!.getLayoutInflater(), itemsList, false) + val pref: SharedPreferences = PreferenceManager + .getDefaultSharedPreferences(requireContext()) + val autoplay: Boolean = pref.getBoolean(getString(R.string.auto_queue_key), false) + headerBinding!!.autoplaySwitch.setChecked(autoplay) + headerBinding!!.autoplaySwitch.setOnCheckedChangeListener(CompoundButton.OnCheckedChangeListener({ compoundButton: CompoundButton?, b: Boolean -> + PreferenceManager.getDefaultSharedPreferences(requireContext()).edit() + .putBoolean(getString(R.string.auto_queue_key), b).apply() + })) + return Supplier({ headerBinding!!.getRoot() }) + } + + override fun loadMoreItemsLogic(): Single>? { + return Single.fromCallable?>(Callable({ InfoItemsPage.emptyPage() })) + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + ////////////////////////////////////////////////////////////////////////// */ + override fun loadResult(forceLoad: Boolean): Single? { + return Single.fromCallable((Callable({ relatedItemsInfo }))) + } + + public override fun showLoading() { + super.showLoading() + if (headerBinding != null) { + headerBinding!!.getRoot().setVisibility(View.INVISIBLE) + } + } + + public override fun handleResult(result: RelatedItemsInfo) { + super.handleResult(result) + if (headerBinding != null) { + headerBinding!!.getRoot().setVisibility(View.VISIBLE) + } + requireView().slideUp(120, 96, 0.06f) + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + ////////////////////////////////////////////////////////////////////////// */ + public override fun setTitle(title: String?) { + // Nothing to do - override parent + } + + public override fun onCreateOptionsMenu(menu: Menu, + inflater: MenuInflater) { + // Nothing to do - override parent + } + + private fun setInitialData(info: StreamInfo) { + super.setInitialData(info.getServiceId(), info.getUrl(), info.getName()) + if (relatedItemsInfo == null) { + relatedItemsInfo = RelatedItemsInfo(info) + } + } + + public override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + outState.putSerializable(INFO_KEY, relatedItemsInfo) + } + + override fun onRestoreInstanceState(savedState: Bundle) { + super.onRestoreInstanceState(savedState) + val serializable: Serializable? = savedState.getSerializable(INFO_KEY) + if (serializable is RelatedItemsInfo) { + relatedItemsInfo = serializable + } + } + + public override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, + key: String?) { + if (headerBinding != null && (getString(R.string.auto_queue_key) == key)) { + headerBinding!!.autoplaySwitch.setChecked(sharedPreferences.getBoolean(key, false)) + } + } + + override fun getItemViewMode(): ItemViewMode? { + var mode: ItemViewMode? = 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 + } + + companion object { + private val INFO_KEY: String = "related_info_key" + fun getInstance(info: StreamInfo): RelatedItemsFragment { + val instance: RelatedItemsFragment = RelatedItemsFragment() + instance.setInitialData(info) + return instance + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsInfo.java b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsInfo.java deleted file mode 100644 index bbc7e1ed0..000000000 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsInfo.java +++ /dev/null @@ -1,22 +0,0 @@ -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())); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsInfo.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsInfo.kt new file mode 100644 index 000000000..ac8254a7f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsInfo.kt @@ -0,0 +1,18 @@ +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 + +class RelatedItemsInfo(info: StreamInfo) : ListInfo(info.getServiceId(), ListLinkHandler(info.getOriginalUrl(), info.getUrl(), + info.getId(), emptyList(), null), info.getName()) { + /** + * 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 + */ + init { + setRelatedItems(ArrayList(info.getRelatedItems())) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java deleted file mode 100644 index d959c6327..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java +++ /dev/null @@ -1,131 +0,0 @@ -package org.schabi.newpipe.info_list; - -import android.content.Context; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; - -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.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.PlaylistInfoItemHolder; -import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder; -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.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 InfoItemBuilder { - private final Context context; - - private OnClickGesture onStreamSelectedListener; - private OnClickGesture onChannelSelectedListener; - private OnClickGesture onPlaylistSelectedListener; - private OnClickGesture onCommentsSelectedListener; - - public InfoItemBuilder(final Context context) { - this.context = context; - } - - public View buildView(@NonNull final ViewGroup parent, @NonNull final InfoItem infoItem, - final HistoryRecordManager historyRecordManager) { - return buildView(parent, infoItem, historyRecordManager, false); - } - - public View buildView(@NonNull final ViewGroup parent, @NonNull final InfoItem infoItem, - final HistoryRecordManager historyRecordManager, - final boolean useMiniVariant) { - final InfoItemHolder holder = - holderFromInfoType(parent, infoItem.getInfoType(), useMiniVariant); - holder.updateFromItem(infoItem, historyRecordManager); - return holder.itemView; - } - - private InfoItemHolder holderFromInfoType(@NonNull final ViewGroup parent, - @NonNull final InfoItem.InfoType infoType, - final boolean useMiniVariant) { - switch (infoType) { - case STREAM: - return useMiniVariant ? new StreamMiniInfoItemHolder(this, parent) - : new StreamInfoItemHolder(this, parent); - case CHANNEL: - return useMiniVariant ? new ChannelMiniInfoItemHolder(this, parent) - : new ChannelInfoItemHolder(this, parent); - case PLAYLIST: - return useMiniVariant ? new PlaylistMiniInfoItemHolder(this, parent) - : new PlaylistInfoItemHolder(this, parent); - case COMMENT: - return new CommentInfoItemHolder(this, parent); - default: - throw new RuntimeException("InfoType not expected = " + infoType.name()); - } - } - - public Context getContext() { - return context; - } - - public OnClickGesture getOnStreamSelectedListener() { - return onStreamSelectedListener; - } - - public void setOnStreamSelectedListener(final OnClickGesture listener) { - this.onStreamSelectedListener = listener; - } - - public OnClickGesture getOnChannelSelectedListener() { - return onChannelSelectedListener; - } - - public void setOnChannelSelectedListener(final OnClickGesture listener) { - this.onChannelSelectedListener = listener; - } - - public OnClickGesture getOnPlaylistSelectedListener() { - return onPlaylistSelectedListener; - } - - public void setOnPlaylistSelectedListener(final OnClickGesture listener) { - this.onPlaylistSelectedListener = listener; - } - - public OnClickGesture getOnCommentsSelectedListener() { - return onCommentsSelectedListener; - } - - public void setOnCommentsSelectedListener( - final OnClickGesture onCommentsSelectedListener) { - this.onCommentsSelectedListener = onCommentsSelectedListener; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.kt b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.kt new file mode 100644 index 000000000..0cf0b08f7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.kt @@ -0,0 +1,108 @@ +package org.schabi.newpipe.info_list + +import android.content.Context +import android.view.View +import android.view.ViewGroup +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.comments.CommentsInfoItem +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem +import org.schabi.newpipe.extractor.stream.StreamInfoItem +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.PlaylistInfoItemHolder +import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder +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.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 . +*

+*/ +class InfoItemBuilder(private val context: Context) { + private var onStreamSelectedListener: OnClickGesture? = null + private var onChannelSelectedListener: OnClickGesture? = null + private var onPlaylistSelectedListener: OnClickGesture? = null + private var onCommentsSelectedListener: OnClickGesture? = null + @JvmOverloads + fun buildView(parent: ViewGroup, infoItem: InfoItem, + historyRecordManager: HistoryRecordManager, + useMiniVariant: Boolean = false): View { + val holder: InfoItemHolder = holderFromInfoType(parent, infoItem.getInfoType(), useMiniVariant) + holder.updateFromItem(infoItem, historyRecordManager) + return holder.itemView + } + + private fun holderFromInfoType(parent: ViewGroup, + infoType: InfoType, + useMiniVariant: Boolean): InfoItemHolder { + when (infoType) { + InfoType.STREAM -> return if (useMiniVariant) StreamMiniInfoItemHolder(this, parent) else StreamInfoItemHolder(this, parent) + InfoType.CHANNEL -> return if (useMiniVariant) ChannelMiniInfoItemHolder(this, parent) else ChannelInfoItemHolder(this, parent) + InfoType.PLAYLIST -> return if (useMiniVariant) PlaylistMiniInfoItemHolder(this, parent) else PlaylistInfoItemHolder(this, parent) + InfoType.COMMENT -> return CommentInfoItemHolder(this, parent) + else -> throw RuntimeException("InfoType not expected = " + infoType.name) + } + } + + fun getContext(): Context { + return context + } + + fun getOnStreamSelectedListener(): OnClickGesture? { + return onStreamSelectedListener + } + + fun setOnStreamSelectedListener(listener: OnClickGesture?) { + onStreamSelectedListener = listener + } + + fun getOnChannelSelectedListener(): OnClickGesture? { + return onChannelSelectedListener + } + + fun setOnChannelSelectedListener(listener: OnClickGesture?) { + onChannelSelectedListener = listener + } + + fun getOnPlaylistSelectedListener(): OnClickGesture? { + return onPlaylistSelectedListener + } + + fun setOnPlaylistSelectedListener(listener: OnClickGesture?) { + onPlaylistSelectedListener = listener + } + + fun getOnCommentsSelectedListener(): OnClickGesture? { + return onCommentsSelectedListener + } + + fun setOnCommentsSelectedListener( + onCommentsSelectedListener: OnClickGesture?) { + this.onCommentsSelectedListener = onCommentsSelectedListener + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java b/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java deleted file mode 100644 index 575568c00..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java +++ /dev/null @@ -1,358 +0,0 @@ -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); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.kt b/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.kt new file mode 100644 index 000000000..3de7249fe --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.kt @@ -0,0 +1,309 @@ +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.recyclerview.widget.GridLayoutManager.SpanSizeLookup +import androidx.recyclerview.widget.RecyclerView +import org.schabi.newpipe.databinding.PignateFooterBinding +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.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.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 . +*/ +class InfoListAdapter(context: Context) : RecyclerView.Adapter() { + private val layoutInflater: LayoutInflater + private val infoItemBuilder: InfoItemBuilder + private val infoItemList: MutableList + private val recordManager: HistoryRecordManager + private var useMiniVariant: Boolean = false + private var showFooter: Boolean = false + private var itemMode: ItemViewMode? = ItemViewMode.LIST + private var headerSupplier: Supplier? = null + + init { + layoutInflater = LayoutInflater.from(context) + recordManager = HistoryRecordManager(context) + infoItemBuilder = InfoItemBuilder(context) + infoItemList = ArrayList() + } + + fun setOnStreamSelectedListener(listener: OnClickGesture?) { + infoItemBuilder.setOnStreamSelectedListener(listener) + } + + fun setOnChannelSelectedListener(listener: OnClickGesture?) { + infoItemBuilder.setOnChannelSelectedListener(listener) + } + + fun setOnPlaylistSelectedListener(listener: OnClickGesture?) { + infoItemBuilder.setOnPlaylistSelectedListener(listener) + } + + fun setOnCommentsSelectedListener(listener: OnClickGesture?) { + infoItemBuilder.setOnCommentsSelectedListener(listener) + } + + fun setUseMiniVariant(useMiniVariant: Boolean) { + this.useMiniVariant = useMiniVariant + } + + fun setItemViewMode(itemViewMode: ItemViewMode?) { + itemMode = itemViewMode + } + + fun addInfoItemList(data: List?) { + if (data == null) { + return + } + if (DEBUG) { + Log.d(TAG, ("addInfoItemList() before > infoItemList.size() = " + + infoItemList.size + ", data.size() = " + data.size)) + } + val offsetStart: Int = 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) { + val footerNow: Int = sizeConsideringHeaderOffset() + notifyItemMoved(offsetStart, footerNow) + if (DEBUG) { + Log.d(TAG, ("addInfoItemList() footer from " + offsetStart + + " to " + footerNow)) + } + } + } + + fun clearStreamItemList() { + if (infoItemList.isEmpty()) { + return + } + infoItemList.clear() + notifyDataSetChanged() + } + + fun setHeaderSupplier(headerSupplier: Supplier?) { + val changed: Boolean = headerSupplier !== this.headerSupplier + this.headerSupplier = headerSupplier + if (changed) { + notifyDataSetChanged() + } + } + + protected fun hasHeader(): Boolean { + return headerSupplier != null + } + + fun showFooter(show: Boolean) { + if (DEBUG) { + Log.d(TAG, "showFooter() called with: show = [" + show + "]") + } + if (show == showFooter) { + return + } + showFooter = show + if (show) { + notifyItemInserted(sizeConsideringHeaderOffset()) + } else { + notifyItemRemoved(sizeConsideringHeaderOffset()) + } + } + + private fun sizeConsideringHeaderOffset(): Int { + val i: Int = infoItemList.size + (if (hasHeader()) 1 else 0) + if (DEBUG) { + Log.d(TAG, "sizeConsideringHeaderOffset() called → " + i) + } + return i + } + + fun getItemsList(): MutableList { + return infoItemList + } + + public override fun getItemCount(): Int { + var count: Int = 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 + } + + public override fun getItemViewType(position: Int): Int { + var position: 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 + } + val item: InfoItem? = infoItemList.get(position) + when (item!!.getInfoType()) { + InfoType.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 + } + + InfoType.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 + } + + InfoType.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 + } + + InfoType.COMMENT -> return COMMENT_HOLDER_TYPE + else -> return -1 + } + } + + public override fun onCreateViewHolder(parent: ViewGroup, + type: Int): RecyclerView.ViewHolder { + if (DEBUG) { + Log.d(TAG, ("onCreateViewHolder() called with: " + + "parent = [" + parent + "], type = [" + type + "]")) + } + when (type) { + HEADER_TYPE -> return HFHolder(headerSupplier!!.get()) + FOOTER_TYPE -> return HFHolder(PignateFooterBinding + .inflate(layoutInflater, parent, false) + .getRoot() + ) + + MINI_STREAM_HOLDER_TYPE -> return StreamMiniInfoItemHolder(infoItemBuilder, parent) + STREAM_HOLDER_TYPE -> return StreamInfoItemHolder(infoItemBuilder, parent) + GRID_STREAM_HOLDER_TYPE -> return StreamGridInfoItemHolder(infoItemBuilder, parent) + CARD_STREAM_HOLDER_TYPE -> return StreamCardInfoItemHolder(infoItemBuilder, parent) + MINI_CHANNEL_HOLDER_TYPE -> return ChannelMiniInfoItemHolder(infoItemBuilder, parent) + CHANNEL_HOLDER_TYPE -> return ChannelInfoItemHolder(infoItemBuilder, parent) + CARD_CHANNEL_HOLDER_TYPE -> return ChannelCardInfoItemHolder(infoItemBuilder, parent) + GRID_CHANNEL_HOLDER_TYPE -> return ChannelGridInfoItemHolder(infoItemBuilder, parent) + MINI_PLAYLIST_HOLDER_TYPE -> return PlaylistMiniInfoItemHolder(infoItemBuilder, parent) + PLAYLIST_HOLDER_TYPE -> return PlaylistInfoItemHolder(infoItemBuilder, parent) + GRID_PLAYLIST_HOLDER_TYPE -> return PlaylistGridInfoItemHolder(infoItemBuilder, parent) + CARD_PLAYLIST_HOLDER_TYPE -> return PlaylistCardInfoItemHolder(infoItemBuilder, parent) + COMMENT_HOLDER_TYPE -> return CommentInfoItemHolder(infoItemBuilder, parent) + else -> return FallbackViewHolder(View(parent.getContext())) + } + } + + public override fun onBindViewHolder(holder: RecyclerView.ViewHolder, + position: Int) { + if (DEBUG) { + Log.d(TAG, ("onBindViewHolder() called with: " + + "holder = [" + holder.javaClass.getSimpleName() + "], " + + "position = [" + position + "]")) + } + if (holder is InfoItemHolder) { + holder.updateFromItem( // If header is present, offset the items by -1 + infoItemList.get(if (hasHeader()) position - 1 else position), recordManager) + } + } + + fun getSpanSizeLookup(spanCount: Int): SpanSizeLookup { + return object : SpanSizeLookup() { + public override fun getSpanSize(position: Int): Int { + val type: Int = getItemViewType(position) + return if (type == HEADER_TYPE || type == FOOTER_TYPE) spanCount else 1 + } + } + } + + internal class HFHolder(v: View?) : RecyclerView.ViewHolder((v)!!) + companion object { + private val TAG: String = InfoListAdapter::class.java.getSimpleName() + private val DEBUG: Boolean = false + private val HEADER_TYPE: Int = 0 + private val FOOTER_TYPE: Int = 1 + private val MINI_STREAM_HOLDER_TYPE: Int = 0x100 + private val STREAM_HOLDER_TYPE: Int = 0x101 + private val GRID_STREAM_HOLDER_TYPE: Int = 0x102 + private val CARD_STREAM_HOLDER_TYPE: Int = 0x103 + private val MINI_CHANNEL_HOLDER_TYPE: Int = 0x200 + private val CHANNEL_HOLDER_TYPE: Int = 0x201 + private val GRID_CHANNEL_HOLDER_TYPE: Int = 0x202 + private val CARD_CHANNEL_HOLDER_TYPE: Int = 0x203 + private val MINI_PLAYLIST_HOLDER_TYPE: Int = 0x300 + private val PLAYLIST_HOLDER_TYPE: Int = 0x301 + private val GRID_PLAYLIST_HOLDER_TYPE: Int = 0x302 + private val CARD_PLAYLIST_HOLDER_TYPE: Int = 0x303 + private val COMMENT_HOLDER_TYPE: Int = 0x400 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.java b/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.kt similarity index 84% rename from app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.java rename to app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.kt index 447c540a0..429581013 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.kt @@ -1,21 +1,24 @@ -package org.schabi.newpipe.info_list; +package org.schabi.newpipe.info_list /** * Item view mode for streams & playlist listing screens. */ -public enum ItemViewMode { +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. */ diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java deleted file mode 100644 index 0c69557bf..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java +++ /dev/null @@ -1,356 +0,0 @@ -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.getApp().getBaseContext(), new ErrorInfo( - throwable, - UserAction.OPEN_INFO_ITEM_DIALOG, - "none", - item.getServiceId())); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.kt b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.kt new file mode 100644 index 000000000..243dc908e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.kt @@ -0,0 +1,350 @@ +package org.schabi.newpipe.info_list.dialog + +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.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import androidx.preference.PreferenceManager +import org.schabi.newpipe.App +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.R +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.ErrorUtil.Companion.showSnackbar +import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.extractor.InfoItem +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.info_list.dialog.StreamDialogEntry.StreamDialogEntryAction +import org.schabi.newpipe.player.helper.PlayerHolder +import org.schabi.newpipe.util.StreamTypeUtil +import org.schabi.newpipe.util.external_communication.KoreUtils +import java.lang.IllegalArgumentException +import java.util.function.Consumer +import java.util.function.Function +import java.util.function.IntFunction +import java.util.stream.Stream + +/** + * Dialog for a [StreamInfoItem]. + * The dialog's content are actions that can be performed on the [StreamInfoItem]. + * This dialog is mostly used for longpress context menus. + */ +class InfoItemDialog private constructor(activity: Activity, + fragment: Fragment, + info: StreamInfoItem, + entries: List) { + /** + * Ideally, [InfoItemDialog] would extend [AlertDialog]. + * However, extending [AlertDialog] requires many additional lines + * and brings more complexity to this class, especially the constructor. + * To circumvent this, an [AlertDialog.Builder] is used in the constructor. + * Its result is stored in this class variable to allow access via the [.show] method. + */ + private val dialog: AlertDialog + + init { + + // Create the dialog's title + val bannerView: View = View.inflate(activity, R.layout.dialog_title, null) + bannerView.setSelected(true) + val titleView: TextView = bannerView.findViewById(R.id.itemTitleView) + titleView.setText(info.getName()) + val detailsView: TextView = 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 + val items: Array = entries.stream() + .map(Function({ entry: StreamDialogEntry -> entry.getString(activity) })).toArray(IntFunction>({ _Dummy_.__Array__() })) + + // Call an entry's action / onClick method when the entry is selected. + val action: DialogInterface.OnClickListener = DialogInterface.OnClickListener({ d: DialogInterface?, index: Int -> entries.get(index).action.onClick(fragment, info) }) + dialog = AlertDialog.Builder(activity) + .setCustomTitle(bannerView) + .setItems(items, action) + .create() + } + + fun show() { + dialog.show() + } + + /** + * + * Builder to generate a [InfoItemDialog] for a [StreamInfoItem]. + * Use [.addEntry] + * and [.addAllEntries] to add options to the dialog. + *

+ * Custom actions for entries can be set using + * [.setAction]. + */ + class Builder @JvmOverloads constructor(activity: Activity, + context: Context, + fragment: Fragment, + infoItem: StreamInfoItem, + addDefaultEntriesAutomatically: Boolean = true) { + private val activity: Activity + private val context: Context + private val infoItem: StreamInfoItem + private val fragment: Fragment + private val entries: MutableList = ArrayList() + private val addDefaultEntriesAutomatically: Boolean + /** + * + * Create an instance of this [Builder] for a [StreamInfoItem]. + * + * If `addDefaultEntriesAutomatically` is set to `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 [StreamType] and the current player state. + * + * @param activity + * @param context + * @param fragment + * @param infoItem + * @param addDefaultEntriesAutomatically + * whether default entries added with [.addDefaultBeginningEntries] + * and [.addDefaultEndEntries] are added automatically when generating + * the [InfoItemDialog]. + *

+ * Entries added with [.addEntry] and + * [.addAllEntries] are added in between. + * @throws IllegalArgumentException if `activity, context` + * or resources is `null` + */ + /** + * + * Create a [builder][Builder] instance for a [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 [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 [StreamInfoItem] + * @throws IllegalArgumentException if `activity, context` + * or resources is `null` + */ + init { + if ((activity == null) || (context == null) || (context.getResources() == null)) { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, ("activity, context or resources is null: activity = " + + activity + ", context = " + context)) + } + throw 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 [Builder] instance + */ + fun addEntry(entry: StreamDialogDefaultEntry): Builder { + 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 [Builder] instance + */ + fun addAllEntries(vararg newEntries: StreamDialogDefaultEntry): Builder { + Stream.of(*newEntries).forEach(Consumer({ entry: StreamDialogDefaultEntry -> addEntry(entry) })) + 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 [Builder] instance + */ + fun setAction(entry: StreamDialogDefaultEntry, + action: StreamDialogEntryAction): Builder { + for (i in entries.indices) { + if (entries.get(i).resource == entry.resource) { + entries.set(i, StreamDialogEntry(entry.resource, action)) + return this + } + } + return this + } + + /** + * Adds [StreamDialogDefaultEntry.ENQUEUE] if the player is open and + * [StreamDialogDefaultEntry.ENQUEUE_NEXT] if there are multiple streams + * in the play queue. + * @return the current [Builder] instance + */ + fun addEnqueueEntriesIfNeeded(): Builder { + val holder: PlayerHolder? = PlayerHolder.Companion.getInstance() + if (holder!!.isPlayQueueReady()) { + addEntry(StreamDialogDefaultEntry.ENQUEUE) + if (holder.getQueuePosition() < holder.getQueueSize() - 1) { + addEntry(StreamDialogDefaultEntry.ENQUEUE_NEXT) + } + } + return this + } + + /** + * Adds the [StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND]. + * If the [.infoItem] is not a pure audio (live) stream, + * [StreamDialogDefaultEntry.START_HERE_ON_POPUP] is added, too. + * @return the current [Builder] instance + */ + fun addStartHereEntries(): Builder { + addEntry(StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND) + if (!StreamTypeUtil.isAudio(infoItem.getStreamType())) { + addEntry(StreamDialogDefaultEntry.START_HERE_ON_POPUP) + } + return this + } + + /** + * Adds [StreamDialogDefaultEntry.MARK_AS_WATCHED] if the watch history is enabled + * and the stream is not a livestream. + * @return the current [Builder] instance + */ + fun addMarkAsWatchedEntryIfNeeded(): Builder { + val isWatchHistoryEnabled: Boolean = 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 [StreamDialogDefaultEntry.PLAY_WITH_KODI] entry if it is needed. + * @return the current [Builder] instance + */ + fun addPlayWithKodiEntryIfNeeded(): Builder { + 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 [.addEnqueueEntriesIfNeeded]) + * and "start here" (see [.addStartHereEntries] entries. + * @return the current [Builder] instance + */ + fun addDefaultBeginningEntries(): Builder { + addEnqueueEntriesIfNeeded() + addStartHereEntries() + return this + } + + /** + * Add the entries which are usually at the bottom of the action list. + * @return the current [Builder] instance + */ + fun addDefaultEndEntries(): Builder { + addAllEntries( + StreamDialogDefaultEntry.DOWNLOAD, + StreamDialogDefaultEntry.APPEND_PLAYLIST, + StreamDialogDefaultEntry.SHARE, + StreamDialogDefaultEntry.OPEN_IN_BROWSER + ) + addPlayWithKodiEntryIfNeeded() + addMarkAsWatchedEntryIfNeeded() + addEntry(StreamDialogDefaultEntry.SHOW_CHANNEL_DETAILS) + return this + } + + /** + * Creates the [InfoItemDialog]. + * @return a new instance of [InfoItemDialog] + */ + fun create(): InfoItemDialog { + if (addDefaultEntriesAutomatically) { + addDefaultEndEntries() + } + return InfoItemDialog(activity, fragment, infoItem, entries) + } + + companion object { + fun reportErrorDuringInitialization(throwable: Throwable?, + item: InfoItem) { + showSnackbar(App.Companion.getApp().getBaseContext(), ErrorInfo( + (throwable)!!, + UserAction.OPEN_INFO_ITEM_DIALOG, + "none", + item.getServiceId())) + } + } + } + + companion object { + private val TAG: String = Build::class.java.getSimpleName() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java deleted file mode 100644 index 948a8274c..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java +++ /dev/null @@ -1,154 +0,0 @@ -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.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 -> { - if (fragment.getContext() != null) { - 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) - .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); - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.kt b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.kt new file mode 100644 index 000000000..3be3a6ac7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.kt @@ -0,0 +1,130 @@ +package org.schabi.newpipe.info_list.dialog + +import android.net.Uri +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import org.schabi.newpipe.R +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.download.DownloadDialog +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.info_list.dialog.StreamDialogEntry.StreamDialogEntryAction +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.player.playqueue.SinglePlayQueue +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.SparseItemUtil +import org.schabi.newpipe.util.external_communication.KoreUtils +import org.schabi.newpipe.util.external_communication.ShareUtils +import java.util.List +import java.util.function.Consumer + +/** + * + * + * This enum provides entries that are accepted + * by the [InfoItemDialog.Builder]. + * + * + * + * These entries contain a String [.resource] which is displayed in the dialog and + * a default [.action] that is executed + * when the entry is selected (via `onClick()`). + *

+ * They action can be overridden by using the Builder's + * [InfoItemDialog.Builder.setAction] + * method. + * + */ +enum class StreamDialogDefaultEntry(@field:StringRes @param:StringRes val resource: Int, + val action: StreamDialogEntryAction) { + SHOW_CHANNEL_DETAILS(R.string.show_channel_details, StreamDialogEntryAction({ fragment: Fragment, item: StreamInfoItem -> + SparseItemUtil.fetchUploaderUrlIfSparse(fragment.requireContext(), item.getServiceId(), item.getUrl(), + item.getUploaderUrl(), Consumer({ url: String? -> NavigationHelper.openChannelFragment(fragment, item, url) })) + }) + ), + + /** + * Enqueues the stream automatically to the current PlayerType. + */ + ENQUEUE(R.string.enqueue_stream, StreamDialogEntryAction({ fragment: Fragment, item: StreamInfoItem -> SparseItemUtil.fetchItemInfoIfSparse(fragment.requireContext(), item, Consumer({ singlePlayQueue: 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, StreamDialogEntryAction({ fragment: Fragment, item: StreamInfoItem -> SparseItemUtil.fetchItemInfoIfSparse(fragment.requireContext(), item, Consumer({ singlePlayQueue: SinglePlayQueue? -> NavigationHelper.enqueueNextOnPlayer(fragment.getContext(), singlePlayQueue) })) }) + ), + START_HERE_ON_BACKGROUND(R.string.start_here_on_background, StreamDialogEntryAction({ fragment: Fragment, item: StreamInfoItem -> + SparseItemUtil.fetchItemInfoIfSparse(fragment.requireContext(), item, Consumer({ singlePlayQueue: SinglePlayQueue? -> + NavigationHelper.playOnBackgroundPlayer( + fragment.getContext(), singlePlayQueue, true) + })) + })), + START_HERE_ON_POPUP(R.string.start_here_on_popup, StreamDialogEntryAction({ fragment: Fragment, item: StreamInfoItem -> SparseItemUtil.fetchItemInfoIfSparse(fragment.requireContext(), item, Consumer({ singlePlayQueue: SinglePlayQueue? -> NavigationHelper.playOnPopupPlayer(fragment.getContext(), singlePlayQueue, true) })) })), + SET_AS_PLAYLIST_THUMBNAIL(R.string.set_as_playlist_thumbnail, StreamDialogEntryAction({ fragment: Fragment?, item: StreamInfoItem? -> + throw UnsupportedOperationException(("This needs to be implemented manually " + + "by using InfoItemDialog.Builder.setAction()")) + })), + DELETE(R.string.delete, StreamDialogEntryAction({ fragment: Fragment?, item: StreamInfoItem? -> + throw UnsupportedOperationException(("This needs to be implemented manually " + + "by using InfoItemDialog.Builder.setAction()")) + })), + + /** + * Opens a [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, StreamDialogEntryAction({ fragment: Fragment, item: StreamInfoItem? -> + PlaylistDialog.Companion.createCorrespondingDialog( + fragment.getContext(), + List.of(StreamEntity((item)!!)), + Consumer({ dialog: PlaylistDialog -> + dialog.show( + fragment.getParentFragmentManager(), + ("StreamDialogEntry@" + + (if (dialog is PlaylistAppendDialog) "append" else "create") + + "_playlist") + ) + }) + ) + }) + ), + PLAY_WITH_KODI(R.string.play_with_kodi_title, StreamDialogEntryAction({ fragment: Fragment, item: StreamInfoItem -> KoreUtils.playWithKore(fragment.requireContext(), Uri.parse(item.getUrl())) })), + SHARE(R.string.share, StreamDialogEntryAction({ fragment: Fragment, item: StreamInfoItem -> + ShareUtils.shareText(fragment.requireContext(), item.getName(), item.getUrl(), + item.getThumbnails()) + })), + + /** + * Opens a [DownloadDialog] after fetching some stream info. + * If the user quits the current fragment, it will not open a DownloadDialog. + */ + DOWNLOAD(R.string.download, StreamDialogEntryAction({ fragment: Fragment, item: StreamInfoItem -> + SparseItemUtil.fetchStreamInfoAndSaveToDatabase(fragment.requireContext(), item.getServiceId(), + item.getUrl(), Consumer({ info: StreamInfo -> + if (fragment.getContext() != null) { + val downloadDialog: DownloadDialog = DownloadDialog(fragment.requireContext(), info) + downloadDialog.show(fragment.getChildFragmentManager(), + "downloadDialog") + } + })) + }) + ), + OPEN_IN_BROWSER(R.string.open_in_browser, StreamDialogEntryAction({ fragment: Fragment, item: StreamInfoItem -> ShareUtils.openUrlInBrowser(fragment.requireContext(), item.getUrl()) })), + MARK_AS_WATCHED(R.string.mark_as_watched, StreamDialogEntryAction({ fragment: Fragment, item: StreamInfoItem -> + HistoryRecordManager(fragment.getContext()) + .markAsWatched(item) + .onErrorComplete() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe() + }) + ); + + fun toStreamDialogEntry(): StreamDialogEntry { + return StreamDialogEntry(resource, action) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java deleted file mode 100644 index 9d82e3b58..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.java +++ /dev/null @@ -1,31 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.kt b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.kt new file mode 100644 index 000000000..bef42e044 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogEntry.kt @@ -0,0 +1,17 @@ +package org.schabi.newpipe.info_list.dialog + +import android.content.Context +import androidx.annotation.StringRes +import androidx.fragment.app.Fragment +import org.schabi.newpipe.extractor.stream.StreamInfoItem + +class StreamDialogEntry(@field:StringRes @param:StringRes val resource: Int, + val action: StreamDialogEntryAction) { + fun getString(context: Context): String { + return context.getString(resource) + } + + open interface StreamDialogEntryAction { + fun onClick(fragment: Fragment?, infoItem: StreamInfoItem?) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelCardInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelCardInfoItemHolder.java deleted file mode 100644 index 29fc50be0..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelCardInfoItemHolder.java +++ /dev/null @@ -1,22 +0,0 @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelCardInfoItemHolder.kt b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelCardInfoItemHolder.kt new file mode 100644 index 000000000..c49847f20 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelCardInfoItemHolder.kt @@ -0,0 +1,14 @@ +package org.schabi.newpipe.info_list.holder + +import android.view.ViewGroup +import org.schabi.newpipe.R +import org.schabi.newpipe.info_list.InfoItemBuilder + +class ChannelCardInfoItemHolder(infoItemBuilder: InfoItemBuilder, + parent: ViewGroup?) : ChannelMiniInfoItemHolder(infoItemBuilder, R.layout.list_channel_card_item, parent) { + override fun getDescriptionMaxLineCount(content: String?): Int { + // Based on `list_channel_card_item` left side content (thumbnail 100dp + // + additional details), Right side description can grow up to 8 lines. + return 8 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelGridInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelGridInfoItemHolder.java deleted file mode 100644 index a4755052e..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelGridInfoItemHolder.java +++ /dev/null @@ -1,13 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelGridInfoItemHolder.kt b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelGridInfoItemHolder.kt new file mode 100644 index 000000000..c03a458f1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelGridInfoItemHolder.kt @@ -0,0 +1,8 @@ +package org.schabi.newpipe.info_list.holder + +import android.view.ViewGroup +import org.schabi.newpipe.R +import org.schabi.newpipe.info_list.InfoItemBuilder + +class ChannelGridInfoItemHolder(infoItemBuilder: InfoItemBuilder, + parent: ViewGroup?) : ChannelMiniInfoItemHolder(infoItemBuilder, R.layout.list_channel_grid_item, parent) diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelInfoItemHolder.kt b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelInfoItemHolder.kt new file mode 100644 index 000000000..c0c796bc9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelInfoItemHolder.kt @@ -0,0 +1,26 @@ +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 . +*/ +class ChannelInfoItemHolder(infoItemBuilder: InfoItemBuilder, parent: ViewGroup?) : ChannelMiniInfoItemHolder(infoItemBuilder, R.layout.list_channel_item, parent) diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java deleted file mode 100644 index 7afc05c6c..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java +++ /dev/null @@ -1,117 +0,0 @@ -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.image.PicassoHelper; -import org.schabi.newpipe.util.Localization; - -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)); - } - - PicassoHelper.loadAvatar(item.getThumbnails()).into(itemThumbnailView); - - 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; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.kt b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.kt new file mode 100644 index 000000000..67b001379 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.kt @@ -0,0 +1,103 @@ +package org.schabi.newpipe.info_list.holder + +import android.view.View +import android.view.View.OnLongClickListener +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.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.PicassoHelper + +open class ChannelMiniInfoItemHolder internal constructor(infoItemBuilder: InfoItemBuilder, layoutId: Int, + parent: ViewGroup?) : InfoItemHolder(infoItemBuilder, layoutId, parent) { + private val itemThumbnailView: ImageView + private val itemTitleView: TextView + private val itemAdditionalDetailView: TextView + private val itemChannelDescriptionView: TextView? + + init { + itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView) + itemTitleView = itemView.findViewById(R.id.itemTitleView) + itemAdditionalDetailView = itemView.findViewById(R.id.itemAdditionalDetails) + itemChannelDescriptionView = itemView.findViewById(R.id.itemChannelDescriptionView) + } + + constructor(infoItemBuilder: InfoItemBuilder, + parent: ViewGroup?) : this(infoItemBuilder, R.layout.list_channel_mini_item, parent) + + public override fun updateFromItem(infoItem: InfoItem?, + historyRecordManager: HistoryRecordManager) { + if (!(infoItem is ChannelInfoItem)) { + return + } + val item: ChannelInfoItem = infoItem + itemTitleView.setText(item.getName()) + itemTitleView.setSelected(true) + val detailLine: String? = getDetailLine(item) + if (detailLine == null) { + itemAdditionalDetailView.setVisibility(View.GONE) + } else { + itemAdditionalDetailView.setVisibility(View.VISIBLE) + itemAdditionalDetailView.setText(getDetailLine(item)) + } + PicassoHelper.loadAvatar(item.getThumbnails()).into(itemThumbnailView) + itemView.setOnClickListener(View.OnClickListener({ view: View? -> + if (itemBuilder.getOnChannelSelectedListener() != null) { + itemBuilder.getOnChannelSelectedListener()!!.selected(item) + } + })) + itemView.setOnLongClickListener(OnLongClickListener({ view: View? -> + if (itemBuilder.getOnChannelSelectedListener() != null) { + itemBuilder.getOnChannelSelectedListener()!!.held(item) + } + 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 open fun getDescriptionMaxLineCount(content: String?): Int { + return if (content == null) 3 else 2 + } + + private fun getDetailLine(item: ChannelInfoItem): String? { + 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 + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java deleted file mode 100644 index a3f0384ad..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java +++ /dev/null @@ -1,188 +0,0 @@ -package org.schabi.newpipe.info_list.holder; - -import static org.schabi.newpipe.util.ServiceHelper.getServiceById; - -import android.text.method.LinkMovementMethod; -import android.text.style.URLSpan; -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.ImageStrategy; -import org.schabi.newpipe.util.image.PicassoHelper; -import org.schabi.newpipe.util.text.CommentTextOnTouchListener; -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)) { - return; - } - final CommentsInfoItem item = (CommentsInfoItem) infoItem; - - - // load the author avatar - PicassoHelper.loadAvatar(item.getUploaderAvatars()).into(itemThumbnailView); - 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); - itemTitleView.setText(Localization.concatenateStrings(item.getUploaderName(), - 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(CommentTextOnTouchListener.INSTANCE); - - 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(); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.kt b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.kt new file mode 100644 index 000000000..9c9e7ee11 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.kt @@ -0,0 +1,170 @@ +package org.schabi.newpipe.info_list.holder + +import android.text.method.LinkMovementMethod +import android.text.style.URLSpan +import android.view.View +import android.view.View.OnLongClickListener +import android.view.ViewGroup +import android.widget.Button +import android.widget.ImageView +import android.widget.RelativeLayout +import android.widget.TextView +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.ServiceHelper +import org.schabi.newpipe.util.external_communication.ShareUtils +import org.schabi.newpipe.util.image.ImageStrategy +import org.schabi.newpipe.util.image.PicassoHelper +import org.schabi.newpipe.util.text.CommentTextOnTouchListener +import org.schabi.newpipe.util.text.TextEllipsizer +import java.util.function.Consumer + +class CommentInfoItemHolder(infoItemBuilder: InfoItemBuilder, + parent: ViewGroup?) : InfoItemHolder(infoItemBuilder, R.layout.list_comment_item, parent) { + private val commentHorizontalPadding: Int + private val commentVerticalPadding: Int + private val itemRoot: RelativeLayout + private val itemThumbnailView: ImageView + private val itemContentView: TextView + private val itemThumbsUpView: ImageView + private val itemLikesCountView: TextView + private val itemTitleView: TextView + private val itemHeartView: ImageView + private val itemPinnedView: ImageView + private val repliesButton: Button + private val textEllipsizer: TextEllipsizer + + init { + 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 = infoItemBuilder.getContext() + .getResources().getDimension(R.dimen.comments_horizontal_padding).toInt() + commentVerticalPadding = infoItemBuilder.getContext() + .getResources().getDimension(R.dimen.comments_vertical_padding).toInt() + textEllipsizer = TextEllipsizer(itemContentView, COMMENT_DEFAULT_LINES, null) + textEllipsizer.setStateChangeListener(Consumer({ isEllipsized: Boolean -> + if ((java.lang.Boolean.TRUE == isEllipsized)) { + denyLinkFocus() + } else { + determineMovementMethod() + } + })) + } + + public override fun updateFromItem(infoItem: InfoItem?, + historyRecordManager: HistoryRecordManager) { + if (!(infoItem is CommentsInfoItem)) { + return + } + val item: CommentsInfoItem = infoItem + + + // load the author avatar + PicassoHelper.loadAvatar(item.getUploaderAvatars()).into(itemThumbnailView) + 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.OnClickListener({ view: View? -> openCommentAuthor(item) })) + + + // setup the top row, with pinned icon, author name and comment date + itemPinnedView.setVisibility(if (item.isPinned()) View.VISIBLE else View.GONE) + itemTitleView.setText(Localization.concatenateStrings(item.getUploaderName(), + 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(if (item.isHeartedByUploader()) View.VISIBLE else View.GONE) + val hasReplies: Boolean = item.getReplies() != null + repliesButton.setOnClickListener(if (hasReplies) View.OnClickListener({ v: View? -> openCommentReplies(item) }) else null) + repliesButton.setVisibility(if (hasReplies) View.VISIBLE else View.GONE) + repliesButton.setText(if (hasReplies) Localization.replyCount(itemBuilder.getContext(), item.getReplyCount()) else "") + (itemThumbsUpView.getLayoutParams() as RelativeLayout.LayoutParams).topMargin = if (hasReplies) 0 else DeviceUtils.dpToPx(6, itemBuilder.getContext()) + + + // setup comment content and click listeners to expand/ellipsize it + textEllipsizer.setStreamingService(ServiceHelper.getServiceById(item.getServiceId())) + textEllipsizer.setStreamUrl(item.getUrl()) + textEllipsizer.setContent(item.getCommentText()) + textEllipsizer.ellipsize() + itemContentView.setOnTouchListener(CommentTextOnTouchListener.Companion.INSTANCE) + itemView.setOnClickListener(View.OnClickListener({ view: View? -> + textEllipsizer.toggle() + if (itemBuilder.getOnCommentsSelectedListener() != null) { + itemBuilder.getOnCommentsSelectedListener()!!.selected(item) + } + })) + itemView.setOnLongClickListener(OnLongClickListener({ view: View? -> + if (DeviceUtils.isTv(itemBuilder.getContext())) { + openCommentAuthor(item) + } else { + val text: CharSequence? = itemContentView.getText() + if (text != null) { + ShareUtils.copyToClipboard(itemBuilder.getContext(), text.toString()) + } + } + true + })) + } + + private fun openCommentAuthor(item: CommentsInfoItem) { + NavigationHelper.openCommentAuthorIfPresent((itemBuilder.getContext() as FragmentActivity?)!!, + item) + } + + private fun openCommentReplies(item: CommentsInfoItem) { + NavigationHelper.openCommentRepliesFragment((itemBuilder.getContext() as FragmentActivity?)!!, + item) + } + + private fun allowLinkFocus() { + itemContentView.setMovementMethod(LinkMovementMethod.getInstance()) + } + + private fun denyLinkFocus() { + itemContentView.setMovementMethod(null) + } + + private fun shouldFocusLinks(): Boolean { + if (itemView.isInTouchMode()) { + return false + } + val urls: Array? = itemContentView.getUrls() + return urls != null && urls.size != 0 + } + + private fun determineMovementMethod() { + if (shouldFocusLinks()) { + allowLinkFocus() + } else { + denyLinkFocus() + } + } + + companion object { + private val COMMENT_DEFAULT_LINES: Int = 2 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/InfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/InfoItemHolder.java deleted file mode 100644 index 9e1561786..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/InfoItemHolder.java +++ /dev/null @@ -1,46 +0,0 @@ -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) { } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/InfoItemHolder.kt b/app/src/main/java/org/schabi/newpipe/info_list/holder/InfoItemHolder.kt new file mode 100644 index 000000000..2aeedd5f5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/InfoItemHolder.kt @@ -0,0 +1,37 @@ +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 . +*/ +abstract class InfoItemHolder(protected val itemBuilder: InfoItemBuilder, layoutId: Int, + parent: ViewGroup?) : RecyclerView.ViewHolder(LayoutInflater.from(itemBuilder.getContext()).inflate(layoutId, parent, false)) { + abstract fun updateFromItem(infoItem: InfoItem?, + historyRecordManager: HistoryRecordManager) + + open fun updateState(infoItem: InfoItem, + historyRecordManager: HistoryRecordManager) { + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistCardInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistCardInfoItemHolder.java deleted file mode 100644 index f1682b4e4..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistCardInfoItemHolder.java +++ /dev/null @@ -1,17 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistCardInfoItemHolder.kt b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistCardInfoItemHolder.kt new file mode 100644 index 000000000..b91121b51 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistCardInfoItemHolder.kt @@ -0,0 +1,11 @@ +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. + */ +class PlaylistCardInfoItemHolder(infoItemBuilder: InfoItemBuilder, + parent: ViewGroup?) : PlaylistMiniInfoItemHolder(infoItemBuilder, R.layout.list_playlist_card_item, parent) diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistGridInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistGridInfoItemHolder.java deleted file mode 100644 index 1cb69208b..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistGridInfoItemHolder.java +++ /dev/null @@ -1,13 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistGridInfoItemHolder.kt b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistGridInfoItemHolder.kt new file mode 100644 index 000000000..aa42cd206 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistGridInfoItemHolder.kt @@ -0,0 +1,8 @@ +package org.schabi.newpipe.info_list.holder + +import android.view.ViewGroup +import org.schabi.newpipe.R +import org.schabi.newpipe.info_list.InfoItemBuilder + +class PlaylistGridInfoItemHolder(infoItemBuilder: InfoItemBuilder, + parent: ViewGroup?) : PlaylistMiniInfoItemHolder(infoItemBuilder, R.layout.list_playlist_grid_item, parent) diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistInfoItemHolder.java deleted file mode 100644 index 7691a377d..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistInfoItemHolder.java +++ /dev/null @@ -1,12 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistInfoItemHolder.kt b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistInfoItemHolder.kt new file mode 100644 index 000000000..44b5056f1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistInfoItemHolder.kt @@ -0,0 +1,7 @@ +package org.schabi.newpipe.info_list.holder + +import android.view.ViewGroup +import org.schabi.newpipe.R +import org.schabi.newpipe.info_list.InfoItemBuilder + +class PlaylistInfoItemHolder(infoItemBuilder: InfoItemBuilder, parent: ViewGroup?) : PlaylistMiniInfoItemHolder(infoItemBuilder, R.layout.list_playlist_item, parent) diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java deleted file mode 100644 index c9216d9a9..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java +++ /dev/null @@ -1,65 +0,0 @@ -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.image.PicassoHelper; -import org.schabi.newpipe.util.Localization; - -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()); - - PicassoHelper.loadPlaylistThumbnail(item.getThumbnails()).into(itemThumbnailView); - - 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; - }); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.kt b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.kt new file mode 100644 index 000000000..f73419cd8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.kt @@ -0,0 +1,56 @@ +package org.schabi.newpipe.info_list.holder + +import android.view.View +import android.view.View.OnLongClickListener +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.PicassoHelper + +open class PlaylistMiniInfoItemHolder(infoItemBuilder: InfoItemBuilder, layoutId: Int, + parent: ViewGroup?) : InfoItemHolder(infoItemBuilder, layoutId, parent) { + val itemThumbnailView: ImageView + private val itemStreamCountView: TextView + val itemTitleView: TextView + val itemUploaderView: TextView + + init { + itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView) + itemTitleView = itemView.findViewById(R.id.itemTitleView) + itemStreamCountView = itemView.findViewById(R.id.itemStreamCountView) + itemUploaderView = itemView.findViewById(R.id.itemUploaderView) + } + + constructor(infoItemBuilder: InfoItemBuilder, + parent: ViewGroup?) : this(infoItemBuilder, R.layout.list_playlist_mini_item, parent) + + public override fun updateFromItem(infoItem: InfoItem?, + historyRecordManager: HistoryRecordManager) { + if (!(infoItem is PlaylistInfoItem)) { + return + } + val item: PlaylistInfoItem = infoItem + itemTitleView.setText(item.getName()) + itemStreamCountView.setText(Localization.localizeStreamCountMini(itemStreamCountView.getContext(), item.getStreamCount())) + itemUploaderView.setText(item.getUploaderName()) + PicassoHelper.loadPlaylistThumbnail(item.getThumbnails()).into(itemThumbnailView) + itemView.setOnClickListener(View.OnClickListener({ view: View? -> + if (itemBuilder.getOnPlaylistSelectedListener() != null) { + itemBuilder.getOnPlaylistSelectedListener()!!.selected(item) + } + })) + itemView.setLongClickable(true) + itemView.setOnLongClickListener(OnLongClickListener({ view: View? -> + if (itemBuilder.getOnPlaylistSelectedListener() != null) { + itemBuilder.getOnPlaylistSelectedListener()!!.held(item) + } + true + })) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamCardInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamCardInfoItemHolder.java deleted file mode 100644 index 807bad6e0..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamCardInfoItemHolder.java +++ /dev/null @@ -1,16 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamCardInfoItemHolder.kt b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamCardInfoItemHolder.kt new file mode 100644 index 000000000..44a854e0e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamCardInfoItemHolder.kt @@ -0,0 +1,10 @@ +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. + */ +class StreamCardInfoItemHolder(infoItemBuilder: InfoItemBuilder, parent: ViewGroup?) : StreamInfoItemHolder(infoItemBuilder, R.layout.list_stream_card_item, parent) diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamGridInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamGridInfoItemHolder.java deleted file mode 100644 index 8e4a1914e..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamGridInfoItemHolder.java +++ /dev/null @@ -1,12 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamGridInfoItemHolder.kt b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamGridInfoItemHolder.kt new file mode 100644 index 000000000..b4ed38c99 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamGridInfoItemHolder.kt @@ -0,0 +1,7 @@ +package org.schabi.newpipe.info_list.holder + +import android.view.ViewGroup +import org.schabi.newpipe.R +import org.schabi.newpipe.info_list.InfoItemBuilder + +class StreamGridInfoItemHolder(infoItemBuilder: InfoItemBuilder, parent: ViewGroup?) : StreamInfoItemHolder(infoItemBuilder, R.layout.list_stream_grid_item, parent) diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java deleted file mode 100644 index 80f62eed3..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java +++ /dev/null @@ -1,93 +0,0 @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.kt b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.kt new file mode 100644 index 000000000..4fdc0c667 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.kt @@ -0,0 +1,78 @@ +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 . +*

+*/ +open class StreamInfoItemHolder(infoItemBuilder: InfoItemBuilder, layoutId: Int, + parent: ViewGroup?) : StreamMiniInfoItemHolder(infoItemBuilder, layoutId, parent) { + val itemAdditionalDetails: TextView + + constructor(infoItemBuilder: InfoItemBuilder, parent: ViewGroup?) : this(infoItemBuilder, R.layout.list_stream_item, parent) + + init { + itemAdditionalDetails = itemView.findViewById(R.id.itemAdditionalDetails) + } + + public override fun updateFromItem(infoItem: InfoItem?, + historyRecordManager: HistoryRecordManager) { + super.updateFromItem(infoItem, historyRecordManager) + if (!(infoItem is StreamInfoItem)) { + return + } + itemAdditionalDetails.setText(getStreamInfoDetailLine(infoItem)) + } + + private fun getStreamInfoDetailLine(infoItem: StreamInfoItem): String? { + var viewsAndDate: String? = "" + if (infoItem.getViewCount() >= 0) { + if ((infoItem.getStreamType() == StreamType.AUDIO_LIVE_STREAM)) { + viewsAndDate = Localization.listeningCount(itemBuilder.getContext(), infoItem.getViewCount()) + } else if ((infoItem.getStreamType() == StreamType.LIVE_STREAM)) { + viewsAndDate = Localization.shortWatchingCount(itemBuilder.getContext(), infoItem.getViewCount()) + } else { + viewsAndDate = Localization.shortViewCount(itemBuilder.getContext(), infoItem.getViewCount()) + } + } + val uploadDate: String? = Localization.relativeTimeOrTextual(itemBuilder.getContext(), + infoItem.getUploadDate(), + infoItem.getTextualUploadDate()) + if (!TextUtils.isEmpty(uploadDate)) { + if (viewsAndDate!!.isEmpty()) { + return uploadDate + } + return Localization.concatenateStrings(viewsAndDate, uploadDate) + } + return viewsAndDate + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java deleted file mode 100644 index 01f3be6b3..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java +++ /dev/null @@ -1,155 +0,0 @@ -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.image.PicassoHelper; -import org.schabi.newpipe.util.StreamTypeUtil; -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 - PicassoHelper.loadThumbnail(item.getThumbnails()).into(itemThumbnailView); - - 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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.kt b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.kt new file mode 100644 index 000000000..29bb3437d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.kt @@ -0,0 +1,132 @@ +package org.schabi.newpipe.info_list.holder + +import android.view.View +import android.view.View.OnLongClickListener +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.extractor.stream.StreamType +import org.schabi.newpipe.info_list.InfoItemBuilder +import org.schabi.newpipe.ktx.animate +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.PicassoHelper +import org.schabi.newpipe.views.AnimatedProgressBar +import java.util.concurrent.TimeUnit + +open class StreamMiniInfoItemHolder internal constructor(infoItemBuilder: InfoItemBuilder, layoutId: Int, + parent: ViewGroup?) : InfoItemHolder(infoItemBuilder, layoutId, parent) { + val itemThumbnailView: ImageView + val itemVideoTitleView: TextView + val itemUploaderView: TextView + val itemDurationView: TextView + private val itemProgressView: AnimatedProgressBar + + init { + 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) + } + + constructor(infoItemBuilder: InfoItemBuilder, parent: ViewGroup?) : this(infoItemBuilder, R.layout.list_stream_mini_item, parent) + + public override fun updateFromItem(infoItem: InfoItem?, + historyRecordManager: HistoryRecordManager) { + if (!(infoItem is StreamInfoItem)) { + return + } + val 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) + var state2: StreamStateEntity? = null + if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())) { + state2 = historyRecordManager.loadStreamState(infoItem) + .blockingGet().get(0) + } + if (state2 != null) { + itemProgressView.setVisibility(View.VISIBLE) + itemProgressView.setMax(item.getDuration().toInt()) + itemProgressView.setProgress(TimeUnit.MILLISECONDS + .toSeconds(state2.getProgressMillis()).toInt()) + } 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 + PicassoHelper.loadThumbnail(item.getThumbnails()).into(itemThumbnailView) + itemView.setOnClickListener(View.OnClickListener({ view: View? -> + if (itemBuilder.getOnStreamSelectedListener() != null) { + itemBuilder.getOnStreamSelectedListener()!!.selected(item) + } + })) + when (item.getStreamType()) { + StreamType.AUDIO_STREAM, StreamType.VIDEO_STREAM, StreamType.LIVE_STREAM, StreamType.AUDIO_LIVE_STREAM, StreamType.POST_LIVE_STREAM, StreamType.POST_LIVE_AUDIO_STREAM -> enableLongClick(item) + StreamType.NONE -> disableLongClick() + else -> disableLongClick() + } + } + + public override fun updateState(infoItem: InfoItem, + historyRecordManager: HistoryRecordManager) { + val item: StreamInfoItem = infoItem as StreamInfoItem + var state: StreamStateEntity? = null + if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())) { + state = historyRecordManager + .loadStreamState(infoItem) + .blockingGet().get(0) + } + if ((state != null) && (item.getDuration() > 0 + ) && !StreamTypeUtil.isLiveStream(item.getStreamType())) { + itemProgressView.setMax(item.getDuration().toInt()) + if (itemProgressView.getVisibility() == View.VISIBLE) { + itemProgressView.setProgressAnimated(TimeUnit.MILLISECONDS + .toSeconds(state.getProgressMillis()).toInt()) + } else { + itemProgressView.setProgress(TimeUnit.MILLISECONDS + .toSeconds(state.getProgressMillis()).toInt()) + itemProgressView.animate(true, 500) + } + } else if (itemProgressView.getVisibility() == View.VISIBLE) { + itemProgressView.animate(false, 500) + } + } + + private fun enableLongClick(item: StreamInfoItem) { + itemView.setLongClickable(true) + itemView.setOnLongClickListener(OnLongClickListener({ view: View? -> + if (itemBuilder.getOnStreamSelectedListener() != null) { + itemBuilder.getOnStreamSelectedListener()!!.held(item) + } + true + })) + } + + private fun disableLongClick() { + itemView.setLongClickable(false) + itemView.setOnLongClickListener(null) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java b/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java deleted file mode 100644 index 53fe1677b..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java +++ /dev/null @@ -1,268 +0,0 @@ -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; - -/** - * 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 ViewBinding getListHeader() { - 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(); - - headerRootBinding = getListHeader(); - if (headerRootBinding != null) { - itemListAdapter.setHeader(headerRootBinding.getRoot()); - } - 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); - } - if (headerRootBinding != null) { - animate(headerRootBinding.getRoot(), false, 200); - } - } - - @Override - public void hideLoading() { - super.hideLoading(); - if (itemsList != null) { - animate(itemsList, true, 200); - } - if (headerRootBinding != null) { - animate(headerRootBinding.getRoot(), true, 200); - } - } - - @Override - public void showEmptyState() { - super.showEmptyState(); - showListFooter(false); - } - - @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); - } - if (headerRootBinding != null) { - animate(headerRootBinding.getRoot(), false, 200); - } - } - - @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; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.kt b/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.kt new file mode 100644 index 000000000..9dfac0804 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.kt @@ -0,0 +1,234 @@ +package org.schabi.newpipe.local + +import android.content.SharedPreferences +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +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.appcompat.app.ActionBar +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.BaseFragment +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 org.schabi.newpipe.ktx.animate +import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling +import org.schabi.newpipe.util.ThemeHelper + +/** + * This fragment is design to be used with persistent data such as + * [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 [Fragment.onDestroyView] is + * called and is memory efficient when in backstack. + * + * + * @param List of [org.schabi.newpipe.database.LocalItem]s + * @param [Void] + */ +abstract class BaseLocalListFragment() : BaseStateFragment(), ListViewContract, OnSharedPreferenceChangeListener { + private var headerRootBinding: ViewBinding? = null + private var footerRootBinding: ViewBinding? = null + protected var itemListAdapter: LocalItemListAdapter? = null + protected var itemsList: RecyclerView? = null + private var updateFlags: Int = 0 + + /*////////////////////////////////////////////////////////////////////////// + // Lifecycle - Creation + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setHasOptionsMenu(true) + PreferenceManager.getDefaultSharedPreferences((activity)!!) + .registerOnSharedPreferenceChangeListener(this) + } + + public override fun onDestroy() { + super.onDestroy() + PreferenceManager.getDefaultSharedPreferences((activity)!!) + .unregisterOnSharedPreferenceChangeListener(this) + } + + public override fun onResume() { + super.onResume() + if (updateFlags != 0) { + if ((updateFlags and LIST_MODE_UPDATE_FLAG) != 0) { + refreshItemViewMode() + } + updateFlags = 0 + } + } + + /** + * Updates the item view mode based on user preference. + */ + private fun refreshItemViewMode() { + val itemViewMode: ItemViewMode? = ThemeHelper.getItemViewMode(requireContext()) + itemsList!!.setLayoutManager(if ((itemViewMode == ItemViewMode.GRID)) gridLayoutManager else listLayoutManager) + itemListAdapter!!.setItemViewMode(itemViewMode) + itemListAdapter!!.notifyDataSetChanged() + } + + protected open val listHeader: ViewBinding? + /*////////////////////////////////////////////////////////////////////////// + // Lifecycle - View + ////////////////////////////////////////////////////////////////////////// */protected get() { + return null + } + protected val listFooter: ViewBinding + protected get() { + return PignateFooterBinding.inflate(activity!!.getLayoutInflater(), itemsList, false) + } + protected val gridLayoutManager: RecyclerView.LayoutManager + protected get() { + val resources: Resources = activity!!.getResources() + var width: Int = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width) + width = (width + (24 * resources.getDisplayMetrics().density)).toInt() + val spanCount: Int = Math.floorDiv(resources.getDisplayMetrics().widthPixels, width) + val lm: GridLayoutManager = GridLayoutManager(activity, spanCount) + lm.setSpanSizeLookup(itemListAdapter!!.getSpanSizeLookup(spanCount)) + return lm + } + protected val listLayoutManager: RecyclerView.LayoutManager + protected get() { + return LinearLayoutManager(activity) + } + + override fun initViews(rootView: View, savedInstanceState: Bundle?) { + super.initViews(rootView, savedInstanceState) + itemListAdapter = LocalItemListAdapter(activity) + itemsList = rootView.findViewById(R.id.items_list) + refreshItemViewMode() + headerRootBinding = listHeader + if (headerRootBinding != null) { + itemListAdapter!!.setHeader(headerRootBinding!!.getRoot()) + } + footerRootBinding = listFooter + itemListAdapter!!.setFooter(footerRootBinding!!.getRoot()) + itemsList.setAdapter(itemListAdapter) + } + + override fun initListeners() { + super.initListeners() + } + + /*////////////////////////////////////////////////////////////////////////// + // Lifecycle - Menu + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreateOptionsMenu(menu: Menu, + inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, ("onCreateOptionsMenu() called with: " + + "menu = [" + menu + "], inflater = [" + inflater + "]")) + } + val supportActionBar: ActionBar? = activity!!.getSupportActionBar() + if (supportActionBar == null) { + return + } + supportActionBar.setDisplayShowTitleEnabled(true) + } + + /*////////////////////////////////////////////////////////////////////////// + // Lifecycle - Destruction + ////////////////////////////////////////////////////////////////////////// */ + public override fun onDestroyView() { + super.onDestroyView() + itemsList = null + itemListAdapter = null + } + + /*////////////////////////////////////////////////////////////////////////// + // Contract + ////////////////////////////////////////////////////////////////////////// */ + public override fun startLoading(forceLoad: Boolean) { + super.startLoading(forceLoad) + resetFragment() + } + + public override fun showLoading() { + super.showLoading() + if (itemsList != null) { + itemsList!!.animateHideRecyclerViewAllowingScrolling() + } + if (headerRootBinding != null) { + headerRootBinding!!.getRoot().animate(false, 200) + } + } + + public override fun hideLoading() { + super.hideLoading() + if (itemsList != null) { + itemsList!!.animate(true, 200) + } + if (headerRootBinding != null) { + headerRootBinding!!.getRoot().animate(true, 200) + } + } + + public override fun showEmptyState() { + super.showEmptyState() + showListFooter(false) + } + + public override fun showListFooter(show: Boolean) { + if (itemsList == null) { + return + } + itemsList!!.post(Runnable({ + if (itemListAdapter != null) { + itemListAdapter!!.showFooter(show) + } + })) + } + + public override fun handleNextItems(result: N) { + isLoading.set(false) + } + + /*////////////////////////////////////////////////////////////////////////// + // Error handling + ////////////////////////////////////////////////////////////////////////// */ + protected open fun resetFragment() { + if (itemListAdapter != null) { + itemListAdapter!!.clearStreamItemList() + } + } + + public override fun handleError() { + super.handleError() + resetFragment() + showListFooter(false) + if (itemsList != null) { + itemsList!!.animateHideRecyclerViewAllowingScrolling() + } + if (headerRootBinding != null) { + headerRootBinding!!.getRoot().animate(false, 200) + } + } + + public override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, + key: String?) { + if ((getString(R.string.list_view_mode_key) == key)) { + updateFlags = updateFlags or LIST_MODE_UPDATE_FLAG + } + } + + companion object { + /*////////////////////////////////////////////////////////////////////////// + // Views + ////////////////////////////////////////////////////////////////////////// */ + private val LIST_MODE_UPDATE_FLAG: Int = 0x32 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/HeaderFooterHolder.java b/app/src/main/java/org/schabi/newpipe/local/HeaderFooterHolder.java deleted file mode 100644 index 5aac75119..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/HeaderFooterHolder.java +++ /dev/null @@ -1,14 +0,0 @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/HeaderFooterHolder.kt b/app/src/main/java/org/schabi/newpipe/local/HeaderFooterHolder.kt new file mode 100644 index 000000000..0e8f807cb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/HeaderFooterHolder.kt @@ -0,0 +1,6 @@ +package org.schabi.newpipe.local + +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +class HeaderFooterHolder(var view: View?) : RecyclerView.ViewHolder((view)!!) diff --git a/app/src/main/java/org/schabi/newpipe/local/LocalItemBuilder.java b/app/src/main/java/org/schabi/newpipe/local/LocalItemBuilder.java deleted file mode 100644 index 041d16d43..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/LocalItemBuilder.java +++ /dev/null @@ -1,48 +0,0 @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/LocalItemBuilder.kt similarity index 58% rename from app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelInfoItemHolder.java rename to app/src/main/java/org/schabi/newpipe/local/LocalItemBuilder.kt index f8133d3de..1bb5c480e 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/LocalItemBuilder.kt @@ -1,32 +1,29 @@ -package org.schabi.newpipe.info_list.holder; +package org.schabi.newpipe.local -import android.view.ViewGroup; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.info_list.InfoItemBuilder; +import android.content.Context +import org.schabi.newpipe.database.LocalItem +import org.schabi.newpipe.util.OnClickGesture /* - * Created by Christian Schabesberger on 12.02.17. - * + * Created by Christian Schabesberger on 26.09.16. + *

* Copyright (C) Christian Schabesberger 2016 - * ChannelInfoItemHolder .java is part of NewPipe. - * + * 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 . */ +class LocalItemBuilder(val context: Context?) { + var onItemSelectedListener: OnClickGesture? = null -public class ChannelInfoItemHolder extends ChannelMiniInfoItemHolder { - public ChannelInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { - super(infoItemBuilder, R.layout.list_channel_item, parent); - } } diff --git a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java deleted file mode 100644 index b33619dea..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java +++ /dev/null @@ -1,410 +0,0 @@ -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; - -/* - * 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 View header = 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 = " + header + ", 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 + (header != null ? 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 setHeader(final View header) { - final boolean changed = header != this.header; - this.header = header; - if (changed) { - notifyDataSetChanged(); - } - } - - public void setFooter(final View view) { - this.footer = view; - } - - 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(sizeConsideringHeader()); - } else { - notifyItemRemoved(sizeConsideringHeader()); - } - } - - private int adapterOffsetWithoutHeader(final int offset) { - return offset - (header != null ? 1 : 0); - } - - private int sizeConsideringHeader() { - return localItems.size() + (header != null ? 1 : 0); - } - - public ArrayList getItemsList() { - return localItems; - } - - @Override - public int getItemCount() { - int count = localItems.size(); - if (header != null) { - count++; - } - if (footer != null && showFooter) { - count++; - } - - if (DEBUG) { - Log.d(TAG, "getItemCount() called, count = " + count + ", " - + "localItems.size() = " + localItems.size() + ", " - + "header = " + header + ", 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 (header != null && position == 0) { - return HEADER_TYPE; - } else if (header != null) { - 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(header); - 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 (header != null) { - position--; - } - - ((LocalItemHolder) holder) - .updateFromItem(localItems.get(position), recordManager, dateTimeFormatter); - } else if (holder instanceof HeaderFooterHolder && position == 0 && header != null) { - ((HeaderFooterHolder) holder).view = header; - } 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(header == null ? position : position - 1), recordManager); - } else if (payload instanceof Boolean) { - ((LocalItemHolder) holder).updateState(localItems - .get(header == null ? position : position - 1), 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; - } - }; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.kt b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.kt new file mode 100644 index 000000000..aacef8622 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.kt @@ -0,0 +1,364 @@ +package org.schabi.newpipe.local + +import android.content.Context +import android.util.Log +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup +import androidx.recyclerview.widget.RecyclerView +import org.schabi.newpipe.database.LocalItem +import org.schabi.newpipe.database.LocalItem.LocalItemType +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 + +/* + * 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 . + */ +class LocalItemListAdapter(context: Context?) : RecyclerView.Adapter() { + private val localItemBuilder: LocalItemBuilder + val itemsList: ArrayList + private val recordManager: HistoryRecordManager + private val dateTimeFormatter: DateTimeFormatter + private var showFooter: Boolean = false + private var header: View? = null + private var footer: View? = null + private var itemViewMode: ItemViewMode? = ItemViewMode.LIST + private var useItemHandle: Boolean = false + + init { + recordManager = HistoryRecordManager(context) + localItemBuilder = LocalItemBuilder(context) + itemsList = ArrayList() + dateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.SHORT) + .withLocale(Localization.getPreferredLocale((context)!!)) + } + + fun setSelectedListener(listener: OnClickGesture?) { + localItemBuilder.setOnItemSelectedListener(listener) + } + + fun unsetSelectedListener() { + localItemBuilder.setOnItemSelectedListener(null) + } + + fun addItems(data: List?) { + if (data == null) { + return + } + if (DEBUG) { + Log.d(TAG, ("addItems() before > localItems.size() = " + + itemsList.size + ", data.size() = " + data.size)) + } + val offsetStart: Int = sizeConsideringHeader() + itemsList.addAll(data) + if (DEBUG) { + Log.d(TAG, ("addItems() after > offsetStart = " + offsetStart + ", " + + "localItems.size() = " + itemsList.size + ", " + + "header = " + header + ", footer = " + footer + ", " + + "showFooter = " + showFooter)) + } + notifyItemRangeInserted(offsetStart, data.size) + if (footer != null && showFooter) { + val footerNow: Int = sizeConsideringHeader() + notifyItemMoved(offsetStart, footerNow) + if (DEBUG) { + Log.d(TAG, ("addItems() footer from " + offsetStart + + " to " + footerNow)) + } + } + } + + fun removeItem(data: LocalItem?) { + val index: Int = itemsList.indexOf(data) + if (index != -1) { + itemsList.removeAt(index) + notifyItemRemoved(index + (if (header != null) 1 else 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() + } + } + + fun swapItems(fromAdapterPosition: Int, toAdapterPosition: Int): Boolean { + val actualFrom: Int = adapterOffsetWithoutHeader(fromAdapterPosition) + val actualTo: Int = adapterOffsetWithoutHeader(toAdapterPosition) + if (actualFrom < 0 || actualTo < 0) { + return false + } + if (actualFrom >= itemsList.size || actualTo >= itemsList.size) { + return false + } + itemsList.add(actualTo, itemsList.removeAt(actualFrom)) + notifyItemMoved(fromAdapterPosition, toAdapterPosition) + return true + } + + fun clearStreamItemList() { + if (itemsList.isEmpty()) { + return + } + itemsList.clear() + notifyDataSetChanged() + } + + fun setItemViewMode(itemViewMode: ItemViewMode?) { + this.itemViewMode = itemViewMode + } + + fun setUseItemHandle(useItemHandle: Boolean) { + this.useItemHandle = useItemHandle + } + + fun setHeader(header: View) { + val changed: Boolean = header !== this.header + this.header = header + if (changed) { + notifyDataSetChanged() + } + } + + fun setFooter(view: View?) { + footer = view + } + + fun showFooter(show: Boolean) { + if (DEBUG) { + Log.d(TAG, "showFooter() called with: show = [" + show + "]") + } + if (show == showFooter) { + return + } + showFooter = show + if (show) { + notifyItemInserted(sizeConsideringHeader()) + } else { + notifyItemRemoved(sizeConsideringHeader()) + } + } + + private fun adapterOffsetWithoutHeader(offset: Int): Int { + return offset - (if (header != null) 1 else 0) + } + + private fun sizeConsideringHeader(): Int { + return itemsList.size + (if (header != null) 1 else 0) + } + + public override fun getItemCount(): Int { + var count: Int = itemsList.size + if (header != null) { + count++ + } + if (footer != null && showFooter) { + count++ + } + if (DEBUG) { + Log.d(TAG, ("getItemCount() called, count = " + count + ", " + + "localItems.size() = " + itemsList.size + ", " + + "header = " + header + ", footer = " + footer + ", " + + "showFooter = " + showFooter)) + } + return count + } + + public override fun getItemViewType(position: Int): Int { + var position: Int = position + if (DEBUG) { + Log.d(TAG, "getItemViewType() called with: position = [" + position + "]") + } + if (header != null && position == 0) { + return HEADER_TYPE + } else if (header != null) { + position-- + } + if ((footer != null) && (position == itemsList.size) && showFooter) { + return FOOTER_TYPE + } + val item: LocalItem? = itemsList.get(position) + when (item!!.getLocalItemType()) { + LocalItemType.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 + } + + LocalItemType.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 + } + + LocalItemType.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 + } + + LocalItemType.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 + } + + else -> { + Log.e(TAG, ("No holder type has been considered for item: [" + + item.getLocalItemType() + "]")) + return -1 + } + } + } + + public override fun onCreateViewHolder(parent: ViewGroup, + type: Int): RecyclerView.ViewHolder { + if (DEBUG) { + Log.d(TAG, ("onCreateViewHolder() called with: " + + "parent = [" + parent + "], type = [" + type + "]")) + } + when (type) { + HEADER_TYPE -> return HeaderFooterHolder(header) + FOOTER_TYPE -> return HeaderFooterHolder(footer) + LOCAL_PLAYLIST_HOLDER_TYPE -> return LocalPlaylistItemHolder(localItemBuilder, parent) + LOCAL_PLAYLIST_GRID_HOLDER_TYPE -> return LocalPlaylistGridItemHolder(localItemBuilder, parent) + LOCAL_PLAYLIST_CARD_HOLDER_TYPE -> return LocalPlaylistCardItemHolder(localItemBuilder, parent) + LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE -> return LocalBookmarkPlaylistItemHolder(localItemBuilder, parent) + REMOTE_PLAYLIST_HOLDER_TYPE -> return RemotePlaylistItemHolder(localItemBuilder, parent) + REMOTE_PLAYLIST_GRID_HOLDER_TYPE -> return RemotePlaylistGridItemHolder(localItemBuilder, parent) + REMOTE_PLAYLIST_CARD_HOLDER_TYPE -> return RemotePlaylistCardItemHolder(localItemBuilder, parent) + REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE -> return RemoteBookmarkPlaylistItemHolder(localItemBuilder, parent) + STREAM_PLAYLIST_HOLDER_TYPE -> return LocalPlaylistStreamItemHolder(localItemBuilder, parent) + STREAM_PLAYLIST_GRID_HOLDER_TYPE -> return LocalPlaylistStreamGridItemHolder(localItemBuilder, parent) + STREAM_PLAYLIST_CARD_HOLDER_TYPE -> return LocalPlaylistStreamCardItemHolder(localItemBuilder, parent) + STREAM_STATISTICS_HOLDER_TYPE -> return LocalStatisticStreamItemHolder(localItemBuilder, parent) + STREAM_STATISTICS_GRID_HOLDER_TYPE -> return LocalStatisticStreamGridItemHolder(localItemBuilder, parent) + STREAM_STATISTICS_CARD_HOLDER_TYPE -> return LocalStatisticStreamCardItemHolder(localItemBuilder, parent) + else -> { + Log.e(TAG, "No view type has been considered for holder: [" + type + "]") + return FallbackViewHolder(View(parent.getContext())) + } + } + } + + public override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + var position: Int = position + if (DEBUG) { + Log.d(TAG, ("onBindViewHolder() called with: " + + "holder = [" + holder.javaClass.getSimpleName() + "], " + + "position = [" + position + "]")) + } + if (holder is LocalItemHolder) { + // If header isn't null, offset the items by -1 + if (header != null) { + position-- + } + holder + .updateFromItem(itemsList.get(position), recordManager, dateTimeFormatter) + } else if (holder is HeaderFooterHolder && (position == 0) && (header != null)) { + holder.view = header + } else if (holder is HeaderFooterHolder && (position == sizeConsideringHeader() + ) && (footer != null) && showFooter) { + holder.view = footer + } + } + + public override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int, + payloads: List) { + if (!payloads.isEmpty() && holder is LocalItemHolder) { + for (payload: Any? in payloads) { + if (payload is StreamStateEntity) { + holder.updateState(itemsList + .get(if (header == null) position else position - 1), recordManager) + } else if (payload is Boolean) { + holder.updateState(itemsList + .get(if (header == null) position else position - 1), recordManager) + } + } + } else { + onBindViewHolder(holder, position) + } + } + + fun getSpanSizeLookup(spanCount: Int): SpanSizeLookup { + return object : SpanSizeLookup() { + public override fun getSpanSize(position: Int): Int { + val type: Int = getItemViewType(position) + return if (type == HEADER_TYPE || type == FOOTER_TYPE) spanCount else 1 + } + } + } + + companion object { + private val TAG: String = LocalItemListAdapter::class.java.getSimpleName() + private val DEBUG: Boolean = false + private val HEADER_TYPE: Int = 0 + private val FOOTER_TYPE: Int = 1 + private val STREAM_STATISTICS_HOLDER_TYPE: Int = 0x1000 + private val STREAM_PLAYLIST_HOLDER_TYPE: Int = 0x1001 + private val STREAM_STATISTICS_GRID_HOLDER_TYPE: Int = 0x1002 + private val STREAM_STATISTICS_CARD_HOLDER_TYPE: Int = 0x1003 + private val STREAM_PLAYLIST_GRID_HOLDER_TYPE: Int = 0x1004 + private val STREAM_PLAYLIST_CARD_HOLDER_TYPE: Int = 0x1005 + private val LOCAL_PLAYLIST_HOLDER_TYPE: Int = 0x2000 + private val LOCAL_PLAYLIST_GRID_HOLDER_TYPE: Int = 0x2001 + private val LOCAL_PLAYLIST_CARD_HOLDER_TYPE: Int = 0x2002 + private val LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE: Int = 0x2003 + private val REMOTE_PLAYLIST_HOLDER_TYPE: Int = 0x3000 + private val REMOTE_PLAYLIST_GRID_HOLDER_TYPE: Int = 0x3001 + private val REMOTE_PLAYLIST_CARD_HOLDER_TYPE: Int = 0x3002 + private val REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE: Int = 0x3003 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java b/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java deleted file mode 100644 index a366723e0..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java +++ /dev/null @@ -1,556 +0,0 @@ -package org.schabi.newpipe.local.bookmark; - -import static org.schabi.newpipe.local.bookmark.MergedPlaylistManager.getMergedOrderedPlaylists; - -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 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.debounce.DebounceSavable; -import org.schabi.newpipe.util.debounce.DebounceSaver; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.OnClickGesture; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; - -import icepick.State; -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.name); - - } else if (selectedItem instanceof PlaylistRemoteEntity) { - final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem); - NavigationHelper.openPlaylistFragment( - fragmentManager, - entry.getServiceId(), - entry.getUrl(), - entry.getName()); - } - } - - @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(i); - localItemsUpdate.add((PlaylistMetadataEntry) item); - } else if (item instanceof PlaylistRemoteEntity - && ((PlaylistRemoteEntity) item).getDisplayIndex() != i) { - ((PlaylistRemoteEntity) item).setDisplayIndex(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() { - // if adding grid layout, also include ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT - // with an `if (shouldUseGridLayout()) ...` - return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, - 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.getName(), 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.name, 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.name); - - 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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.kt b/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.kt new file mode 100644 index 000000000..a60f0d729 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.kt @@ -0,0 +1,482 @@ +package org.schabi.newpipe.local.bookmark + +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.appcompat.app.AlertDialog +import androidx.fragment.app.FragmentManager +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import icepick.State +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +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 org.reactivestreams.Subscriber +import org.reactivestreams.Subscription +import org.schabi.newpipe.BaseFragment +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.LocalItem.LocalItemType +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.concurrent.atomic.AtomicBoolean +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.sign + +class BookmarkFragment() : BaseLocalListFragment?, Void?>(), DebounceSavable { + @State + var itemsListState: Parcelable? = null + private var databaseSubscription: Subscription? = null + private var disposables: CompositeDisposable? = CompositeDisposable() + private var localPlaylistManager: LocalPlaylistManager? = null + private var remotePlaylistManager: RemotePlaylistManager? = null + private var itemTouchHelper: ItemTouchHelper? = null + + /* Have the bookmarked playlists been fully loaded from db */ + private var isLoadingComplete: AtomicBoolean? = null + + /* Gives enough time to avoid interrupting user sorting operations */ + private var debounceSaver: DebounceSaver? = null + private var deletedItems: MutableList>? = null + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Creation + /////////////////////////////////////////////////////////////////////////// + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (activity == null) { + return + } + val database: AppDatabase = NewPipeDatabase.getInstance(activity!!) + localPlaylistManager = LocalPlaylistManager(database) + remotePlaylistManager = RemotePlaylistManager(database) + disposables = CompositeDisposable() + isLoadingComplete = AtomicBoolean() + debounceSaver = DebounceSaver(3000, this) + deletedItems = ArrayList() + } + + public override fun onCreateView(inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?): View? { + if (!useAsFrontPage) { + setTitle(activity!!.getString(R.string.tab_bookmarks)) + } + return inflater.inflate(R.layout.fragment_bookmarks, container, false) + } + + public override fun onResume() { + super.onResume() + if (activity != null) { + setTitle(activity!!.getString(R.string.tab_bookmarks)) + } + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Views + /////////////////////////////////////////////////////////////////////////// + override fun initViews(rootView: View, savedInstanceState: Bundle?) { + super.initViews(rootView, savedInstanceState) + itemListAdapter!!.setUseItemHandle(true) + } + + override fun initListeners() { + super.initListeners() + itemTouchHelper = ItemTouchHelper(itemTouchCallback) + itemTouchHelper!!.attachToRecyclerView(itemsList) + itemListAdapter!!.setSelectedListener(object : OnClickGesture { + public override fun selected(selectedItem: LocalItem?) { + val fragmentManager: FragmentManager? = getFM() + if (selectedItem is PlaylistMetadataEntry) { + val entry: PlaylistMetadataEntry = selectedItem + NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.getUid(), + entry.name) + } else if (selectedItem is PlaylistRemoteEntity) { + val entry: PlaylistRemoteEntity = selectedItem + NavigationHelper.openPlaylistFragment( + fragmentManager, + entry.getServiceId(), + entry.getUrl(), + entry.getName()) + } + } + + public override fun held(selectedItem: LocalItem?) { + if (selectedItem is PlaylistMetadataEntry) { + showLocalDialog(selectedItem) + } else if (selectedItem is PlaylistRemoteEntity) { + showRemoteDeleteDialog(selectedItem) + } + } + + public override fun drag(selectedItem: LocalItem?, + viewHolder: RecyclerView.ViewHolder?) { + if (itemTouchHelper != null) { + itemTouchHelper!!.startDrag((viewHolder)!!) + } + } + }) + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Loading + /////////////////////////////////////////////////////////////////////////// + public override fun startLoading(forceLoad: Boolean) { + super.startLoading(forceLoad) + if (debounceSaver != null) { + disposables!!.add(debounceSaver.getDebouncedSaver()) + debounceSaver!!.setNoChangesToSave() + } + isLoadingComplete!!.set(false) + MergedPlaylistManager.getMergedOrderedPlaylists(localPlaylistManager, remotePlaylistManager) + .onBackpressureLatest() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(playlistsSubscriber) + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Destruction + /////////////////////////////////////////////////////////////////////////// + public override fun onPause() { + super.onPause() + itemsListState = itemsList!!.getLayoutManager()!!.onSaveInstanceState() + + // Save on exit + saveImmediate() + } + + public override fun onDestroyView() { + super.onDestroyView() + if (disposables != null) { + disposables!!.clear() + } + if (databaseSubscription != null) { + databaseSubscription!!.cancel() + } + databaseSubscription = null + itemTouchHelper = null + } + + public override fun 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 + } + + private val playlistsSubscriber: Subscriber?> + /////////////////////////////////////////////////////////////////////////// + private get() { + return object : Subscriber?> { + public override fun onSubscribe(s: Subscription) { + showLoading() + isLoadingComplete!!.set(false) + if (databaseSubscription != null) { + databaseSubscription!!.cancel() + } + databaseSubscription = s + databaseSubscription!!.request(1) + } + + public override fun onNext(subscriptions: List?) { + if (debounceSaver == null || !debounceSaver!!.getIsModified()) { + handleResult(subscriptions) + isLoadingComplete!!.set(true) + } + if (databaseSubscription != null) { + databaseSubscription!!.request(1) + } + } + + public override fun onError(exception: Throwable) { + showError(ErrorInfo(exception, + UserAction.REQUESTED_BOOKMARK, "Loading playlists")) + } + + public override fun onComplete() {} + } + } + + public override fun handleResult(result: List) { + 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 fun resetFragment() { + super.resetFragment() + if (disposables != null) { + disposables!!.clear() + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Playlist Metadata Manipulation + ////////////////////////////////////////////////////////////////////////// */ + private fun changeLocalPlaylistName(id: Long, name: String) { + if (localPlaylistManager == null) { + return + } + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, ("Updating playlist id=[" + id + "] " + + "with new name=[" + name + "] items")) + } + val disposable: Disposable = localPlaylistManager!!.renamePlaylist(id, name) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer({ longs: Int? -> }), Consumer({ throwable: Throwable? -> + showError( + ErrorInfo((throwable)!!, + UserAction.REQUESTED_BOOKMARK, + "Changing playlist name")) + })) + disposables!!.add(disposable) + } + + private fun deleteItem(item: PlaylistLocalItem) { + if (itemListAdapter == null) { + return + } + itemListAdapter!!.removeItem(item) + if (item is PlaylistMetadataEntry) { + deletedItems!!.add(Pair(item.getUid(), + LocalItemType.PLAYLIST_LOCAL_ITEM)) + } else if (item is PlaylistRemoteEntity) { + deletedItems!!.add(Pair(item.getUid(), + LocalItemType.PLAYLIST_REMOTE_ITEM)) + } + if (debounceSaver != null) { + debounceSaver!!.setHasChangesToSave() + saveImmediate() + } + } + + public override fun 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 + } + val items: List? = itemListAdapter.getItemsList() + val localItemsUpdate: MutableList = ArrayList() + val localItemsDeleteUid: MutableList = ArrayList() + val remoteItemsUpdate: MutableList = ArrayList() + val remoteItemsDeleteUid: MutableList = ArrayList() + + // Calculate display index + for (i in items!!.indices) { + val item: LocalItem? = items.get(i) + if ((item is PlaylistMetadataEntry + && item.getDisplayIndex() != i.toLong())) { + item.setDisplayIndex(i.toLong()) + localItemsUpdate.add(item) + } else if ((item is PlaylistRemoteEntity + && item.getDisplayIndex() != i.toLong())) { + item.setDisplayIndex(i.toLong()) + remoteItemsUpdate.add(item) + } + } + + // Find deleted items + for (item: Pair in deletedItems!!) { + if ((item.second == LocalItemType.PLAYLIST_LOCAL_ITEM)) { + localItemsDeleteUid.add(item.first) + } else if ((item.second == 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(Action({ + if (debounceSaver != null) { + debounceSaver!!.setNoChangesToSave() + } + }), + Consumer({ throwable: Throwable? -> + showError(ErrorInfo((throwable)!!, + UserAction.REQUESTED_BOOKMARK, "Saving playlist")) + }) + )) + } + + private val itemTouchCallback: ItemTouchHelper.SimpleCallback + private get() { + // if adding grid layout, also include ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT + // with an `if (shouldUseGridLayout()) ...` + return object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, + ItemTouchHelper.ACTION_STATE_IDLE) { + public override fun interpolateOutOfBoundsScroll(recyclerView: RecyclerView, + viewSize: Int, + viewSizeOutOfBounds: Int, + totalSize: Int, + msSinceStartScroll: Long): Int { + val standardSpeed: Int = super.interpolateOutOfBoundsScroll(recyclerView, + viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll) + val minimumAbsVelocity: Int = max(MINIMUM_INITIAL_DRAG_VELOCITY.toDouble(), abs(standardSpeed.toDouble())).toInt() + return minimumAbsVelocity * sign(viewSizeOutOfBounds.toDouble()).toInt() + } + + public override fun onMove(recyclerView: RecyclerView, + source: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder): Boolean { + + // Allow swap LocalBookmarkPlaylistItemHolder and RemoteBookmarkPlaylistItemHolder. + if ((itemListAdapter == null + || source.getItemViewType() != target.getItemViewType() + && !(((source is LocalBookmarkPlaylistItemHolder) + || (source is RemoteBookmarkPlaylistItemHolder)) + && ((target is LocalBookmarkPlaylistItemHolder) + || (target is RemoteBookmarkPlaylistItemHolder))))) { + return false + } + val sourceIndex: Int = source.getBindingAdapterPosition() + val targetIndex: Int = target.getBindingAdapterPosition() + val isSwapped: Boolean = itemListAdapter!!.swapItems(sourceIndex, targetIndex) + if (isSwapped && debounceSaver != null) { + debounceSaver!!.setHasChangesToSave() + } + return isSwapped + } + + public override fun isLongPressDragEnabled(): Boolean { + return false + } + + public override fun isItemViewSwipeEnabled(): Boolean { + return false + } + + public override fun onSwiped(viewHolder: RecyclerView.ViewHolder, + swipeDir: Int) { + // Do nothing. + } + } + } + + /////////////////////////////////////////////////////////////////////////// + // Utils + /////////////////////////////////////////////////////////////////////////// + private fun showRemoteDeleteDialog(item: PlaylistRemoteEntity) { + showDeleteDialog(item.getName(), item) + } + + private fun showLocalDialog(selectedItem: PlaylistMetadataEntry) { + val rename: String = getString(R.string.rename) + val delete: String = getString(R.string.delete) + val unsetThumbnail: String = getString(R.string.unset_playlist_thumbnail) + val isThumbnailPermanent: Boolean = localPlaylistManager + .getIsPlaylistThumbnailPermanent(selectedItem.getUid()) + val items: ArrayList = ArrayList() + items.add(rename) + items.add(delete) + if (isThumbnailPermanent) { + items.add(unsetThumbnail) + } + val action: DialogInterface.OnClickListener = DialogInterface.OnClickListener({ d: DialogInterface?, index: Int -> + if ((items.get(index) == rename)) { + showRenameDialog(selectedItem) + } else if ((items.get(index) == delete)) { + showDeleteDialog(selectedItem.name, selectedItem) + } else if (isThumbnailPermanent && (items.get(index) == unsetThumbnail)) { + val thumbnailStreamId: Long = localPlaylistManager + .getAutomaticPlaylistThumbnailStreamId(selectedItem.getUid()) + localPlaylistManager + .changePlaylistThumbnail(selectedItem.getUid(), thumbnailStreamId, false) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe() + } + }) + AlertDialog.Builder((activity)!!) + .setItems(items.toTypedArray(), action) + .show() + } + + private fun showRenameDialog(selectedItem: PlaylistMetadataEntry) { + val dialogBinding: DialogEditTextBinding = DialogEditTextBinding.inflate(getLayoutInflater()) + dialogBinding.dialogEditText.setHint(R.string.name) + dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT) + dialogBinding.dialogEditText.setText(selectedItem.name) + AlertDialog.Builder((activity)!!) + .setView(dialogBinding.getRoot()) + .setPositiveButton(R.string.rename_playlist, DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> + changeLocalPlaylistName( + selectedItem.getUid(), + dialogBinding.dialogEditText.getText().toString()) + })) + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun showDeleteDialog(name: String?, item: PlaylistLocalItem) { + if (activity == null || disposables == null) { + return + } + AlertDialog.Builder(activity!!) + .setTitle(name) + .setMessage(R.string.delete_playlist_prompt) + .setCancelable(true) + .setPositiveButton(R.string.delete, DialogInterface.OnClickListener({ dialog: DialogInterface?, i: Int -> deleteItem(item) })) + .setNegativeButton(R.string.cancel, null) + .show() + } + + companion object { + private val MINIMUM_INITIAL_DRAG_VELOCITY: Int = 12 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java deleted file mode 100644 index 25eb2f652..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.java +++ /dev/null @@ -1,95 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.kt b/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.kt new file mode 100644 index 000000000..2c0c7c4dd --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/bookmark/MergedPlaylistManager.kt @@ -0,0 +1,85 @@ +package org.schabi.newpipe.local.bookmark + +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.functions.BiFunction +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.lang.String +import java.util.Collections +import java.util.function.Function + +/** + * Takes care of remote and local playlists at once, hence "merged". + */ +object MergedPlaylistManager { + fun getMergedOrderedPlaylists( + localPlaylistManager: LocalPlaylistManager?, + remotePlaylistManager: RemotePlaylistManager?): Flowable> { + return Flowable.combineLatest( + localPlaylistManager.getPlaylists(), + remotePlaylistManager.getPlaylists(), BiFunction, List, List>({ obj: List?, localPlaylists: List? -> merge(localPlaylists) })) + } + + /** + * Merge localPlaylists and remotePlaylists by the display index. + * If two items have the same display index, sort them in `CASE_INSENSITIVE_ORDER`. + * + * @param localPlaylists local playlists, already sorted by display index + * @param remotePlaylists remote playlists, already sorted by display index + * @return merged playlists + */ + fun merge( + localPlaylists: List, + remotePlaylists: List): List { + + // This algorithm is similar to the merge operation in merge sort. + val result: MutableList = ArrayList( + localPlaylists.size + remotePlaylists.size) + val itemsWithSameIndex: MutableList = ArrayList() + var i: Int = 0 + var j: Int = 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 fun addItem(result: MutableList, + item: PlaylistLocalItem, + itemsWithSameIndex: MutableList) { + 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 fun addItemsWithSameIndex(result: MutableList, + itemsWithSameIndex: List) { + Collections.sort(itemsWithSameIndex, + Comparator.comparing(Function({ obj: PlaylistLocalItem -> obj.getOrderingName() }), + Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER))) + result.addAll(itemsWithSameIndex) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java deleted file mode 100644 index e7f73079f..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java +++ /dev/null @@ -1,174 +0,0 @@ -package org.schabi.newpipe.local.dialog; - -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.model.PlaylistEntity; -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.timesStreamIsContained > 0); - } - - private void onPlaylistSelected(@NonNull final LocalPlaylistManager manager, - @NonNull final PlaylistDuplicatesEntry playlist, - @NonNull final List streams) { - - final String toastText; - if (playlist.timesStreamIsContained > 0) { - toastText = getString(R.string.playlist_add_stream_success_duplicate, - playlist.timesStreamIsContained); - } 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.thumbnailUrl.equals(PlaylistEntity.DEFAULT_THUMBNAIL)) { - playlistDisposables.add(manager - .changePlaylistThumbnail(playlist.getUid(), streams.get(0).getUid(), - false) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(ignore -> successToast.show())); - } - })); - - requireDialog().dismiss(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.kt b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.kt new file mode 100644 index 000000000..859f176b5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.kt @@ -0,0 +1,150 @@ +package org.schabi.newpipe.local.dialog + +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.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.functions.Consumer +import org.schabi.newpipe.NewPipeDatabase +import org.schabi.newpipe.R +import org.schabi.newpipe.database.LocalItem +import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry +import org.schabi.newpipe.database.playlist.model.PlaylistEntity +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.local.LocalItemListAdapter +import org.schabi.newpipe.local.feed.FeedFragment.Companion.newInstance +import org.schabi.newpipe.local.playlist.LocalPlaylistManager +import org.schabi.newpipe.util.OnClickGesture +import java.util.function.Predicate + +class PlaylistAppendDialog() : PlaylistDialog() { + private var playlistRecyclerView: RecyclerView? = null + private var playlistAdapter: LocalItemListAdapter? = null + private var playlistDuplicateIndicator: TextView? = null + private val playlistDisposables: CompositeDisposable = CompositeDisposable() + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle - Creation + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.dialog_playlists, container) + } + + public override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + val playlistManager: LocalPlaylistManager = LocalPlaylistManager(NewPipeDatabase.getInstance(requireContext())) + playlistAdapter = LocalItemListAdapter(getActivity()) + playlistAdapter!!.setSelectedListener(OnClickGesture({ selectedItem: LocalItem? -> + val entities: List? = getStreamEntities() + if (selectedItem is PlaylistDuplicatesEntry && entities != null) { + onPlaylistSelected(playlistManager, + selectedItem, entities) + } + })) + playlistRecyclerView = view.findViewById(R.id.playlist_list) + playlistRecyclerView.setLayoutManager(LinearLayoutManager(requireContext())) + playlistRecyclerView.setAdapter(playlistAdapter) + playlistDuplicateIndicator = view.findViewById(R.id.playlist_duplicate) + val newPlaylistButton: View = view.findViewById(R.id.newPlaylist) + newPlaylistButton.setOnClickListener(View.OnClickListener({ ignored: View? -> openCreatePlaylistDialog() })) + playlistDisposables.add(playlistManager + .getPlaylistDuplicates(getStreamEntities().get(0).url) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer?>({ playlists: List -> onPlaylistsReceived(playlists) }))) + } + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle - Destruction + ////////////////////////////////////////////////////////////////////////// */ + public override fun onDestroyView() { + super.onDestroyView() + playlistDisposables.dispose() + if (playlistAdapter != null) { + playlistAdapter!!.unsetSelectedListener() + } + playlistDisposables.clear() + playlistRecyclerView = null + playlistAdapter = null + } + /*////////////////////////////////////////////////////////////////////////// + // Helper + ////////////////////////////////////////////////////////////////////////// */ + /** Display create playlist dialog. */ + fun openCreatePlaylistDialog() { + if (getStreamEntities() == null || !isAdded()) { + return + } + val playlistCreationDialog: PlaylistCreationDialog = PlaylistCreationDialog.Companion.newInstance(getStreamEntities()) + // Move the dismissListener to the new dialog. + playlistCreationDialog.setOnDismissListener(getOnDismissListener()) + setOnDismissListener(null) + playlistCreationDialog.show(getParentFragmentManager(), TAG) + requireDialog().dismiss() + } + + private fun onPlaylistsReceived(playlists: List) { + if ((playlistAdapter != null + ) && (playlistRecyclerView != null + ) && (playlistDuplicateIndicator != null)) { + playlistAdapter!!.clearStreamItemList() + playlistAdapter!!.addItems(playlists) + playlistRecyclerView!!.setVisibility(View.VISIBLE) + playlistDuplicateIndicator!!.setVisibility( + if (anyPlaylistContainsDuplicates(playlists)) View.VISIBLE else View.GONE) + } + } + + private fun anyPlaylistContainsDuplicates(playlists: List): Boolean { + return playlists.stream() + .anyMatch(Predicate({ playlist: PlaylistDuplicatesEntry? -> playlist!!.timesStreamIsContained > 0 })) + } + + private fun onPlaylistSelected(manager: LocalPlaylistManager, + playlist: PlaylistDuplicatesEntry, + streams: List) { + val toastText: String + if (playlist.timesStreamIsContained > 0) { + toastText = getString(R.string.playlist_add_stream_success_duplicate, + playlist.timesStreamIsContained) + } else { + toastText = getString(R.string.playlist_add_stream_success) + } + val successToast: Toast = Toast.makeText(getContext(), toastText, Toast.LENGTH_SHORT) + playlistDisposables.add(manager.appendToPlaylist(playlist.getUid(), streams) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer?>({ ignored: List? -> + successToast.show() + if ((playlist.thumbnailUrl == PlaylistEntity.Companion.DEFAULT_THUMBNAIL)) { + playlistDisposables.add(manager + .changePlaylistThumbnail(playlist.getUid(), streams.get(0)!!.uid, + false) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer({ ignore: Int? -> successToast.show() }))) + } + }))) + requireDialog().dismiss() + } + + companion object { + private val TAG: String = PlaylistAppendDialog::class.java.getCanonicalName() + + /** + * Create a new instance of [PlaylistAppendDialog]. + * + * @param streamEntities a list of [StreamEntity] to be added to playlists + * @return a new instance of [PlaylistAppendDialog] + */ + fun newInstance(streamEntities: List?): PlaylistAppendDialog { + val dialog: PlaylistAppendDialog = PlaylistAppendDialog() + dialog.setStreamEntities(streamEntities) + return dialog + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.java deleted file mode 100644 index 0d5cfac23..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.java +++ /dev/null @@ -1,74 +0,0 @@ -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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.kt b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.kt new file mode 100644 index 000000000..6677f5ff9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.kt @@ -0,0 +1,62 @@ +package org.schabi.newpipe.local.dialog + +import android.app.Dialog +import android.content.DialogInterface +import android.os.Bundle +import android.text.InputType +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.functions.Consumer +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 + +class PlaylistCreationDialog() : PlaylistDialog() { + /*////////////////////////////////////////////////////////////////////////// + // Dialog + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + if (getStreamEntities() == null) { + return super.onCreateDialog(savedInstanceState) + } + val dialogBinding: DialogEditTextBinding = DialogEditTextBinding.inflate(getLayoutInflater()) + dialogBinding.getRoot().getContext().setTheme(ThemeHelper.getDialogTheme(requireContext())) + dialogBinding.dialogEditText.setHint(R.string.name) + dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT) + val dialogBuilder: AlertDialog.Builder = AlertDialog.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.OnClickListener({ dialogInterface: DialogInterface?, i: Int -> + val name: String = dialogBinding.dialogEditText.getText().toString() + val playlistManager: LocalPlaylistManager = LocalPlaylistManager(NewPipeDatabase.getInstance(requireContext())) + val successToast: Toast = Toast.makeText(getActivity(), + R.string.playlist_creation_success, + Toast.LENGTH_SHORT) + playlistManager.createPlaylist(name, getStreamEntities()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer?>({ longs: List? -> successToast.show() })) + })) + return dialogBuilder.create() + } + + companion object { + /** + * Create a new instance of [PlaylistCreationDialog]. + * + * @param streamEntities a list of [StreamEntity] to be added to playlists + * @return a new instance of [PlaylistCreationDialog] + */ + fun newInstance(streamEntities: List?): PlaylistCreationDialog { + val dialog: PlaylistCreationDialog = PlaylistCreationDialog() + dialog.setStreamEntities(streamEntities) + return dialog + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java deleted file mode 100644 index 612c38181..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java +++ /dev/null @@ -1,181 +0,0 @@ -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")); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.kt b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.kt new file mode 100644 index 000000000..4fd9ab998 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.kt @@ -0,0 +1,138 @@ +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.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.Disposable +import org.schabi.newpipe.NewPipeDatabase +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.local.feed.FeedFragment.Companion.newInstance +import org.schabi.newpipe.local.playlist.LocalPlaylistManager +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.StateSaver +import org.schabi.newpipe.util.StateSaver.WriteRead +import java.util.Objects +import java.util.Queue +import java.util.function.Function +import java.util.function.Predicate +import java.util.stream.Collectors +import java.util.stream.Stream + +abstract class PlaylistDialog() : DialogFragment(), WriteRead { + /*////////////////////////////////////////////////////////////////////////// + // Getter + Setter + ////////////////////////////////////////////////////////////////////////// */ var onDismissListener: DialogInterface.OnDismissListener? = null + var streamEntities: List? = null + protected set + private var savedState: org.schabi.newpipe.util.SavedState? = null + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + savedState = StateSaver.tryToRestore(savedInstanceState, this) + } + + public override fun onDestroy() { + super.onDestroy() + StateSaver.onDestroy(savedState) + } + + public override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val dialog: Dialog = super.onCreateDialog(savedInstanceState) + //remove title + val window: Window? = dialog.getWindow() + if (window != null) { + window.requestFeature(Window.FEATURE_NO_TITLE) + } + return dialog + } + + public override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + if (onDismissListener != null) { + onDismissListener!!.onDismiss(dialog) + } + } + + /*////////////////////////////////////////////////////////////////////////// + // State Saving + ////////////////////////////////////////////////////////////////////////// */ + public override fun generateSuffix(): String? { + val size: Int = if (streamEntities == null) 0 else streamEntities!!.size + return "." + size + ".list" + } + + public override fun writeTo(objectsToSave: Queue) { + objectsToSave.add(streamEntities) + } + + public override fun readFrom(savedObjects: Queue) { + streamEntities = savedObjects.poll() as List? + } + + public override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + if (getActivity() != null) { + savedState = StateSaver.tryToSave(getActivity()!!.isChangingConfigurations(), + savedState, outState, this) + } + } + + companion object { + /*////////////////////////////////////////////////////////////////////////// + // Dialog creation + ////////////////////////////////////////////////////////////////////////// */ + /** + * Creates a [PlaylistAppendDialog] when playlists exists, + * otherwise a [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 + */ + fun createCorrespondingDialog( + context: Context?, + streamEntities: List?, + onExec: java.util.function.Consumer): Disposable { + return LocalPlaylistManager(NewPipeDatabase.getInstance((context)!!)) + .hasPlaylists() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(io.reactivex.rxjava3.functions.Consumer({ hasPlaylists: Boolean? -> onExec.accept(if ((hasPlaylists)!!) PlaylistAppendDialog.Companion.newInstance(streamEntities) else PlaylistCreationDialog.Companion.newInstance(streamEntities)) }) + ) + } + + /** + * Creates a [PlaylistAppendDialog] when playlists exists, + * otherwise a [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 + */ + fun showForPlayQueue( + player: Player?, + fragmentManager: FragmentManager): Disposable { + val streamEntities: List = Stream.of(player!!.getPlayQueue()) + .filter(Predicate({ obj: PlayQueue? -> Objects.nonNull(obj) })) + .flatMap(Function>({ playQueue: PlayQueue? -> playQueue!!.getStreams().stream() })) + .map(Function({ item: PlayQueueItem? -> StreamEntity((item)!!) })) + .collect(Collectors.toList()) + if (streamEntities.isEmpty()) { + return Disposable.empty() + } + return createCorrespondingDialog(player.getContext(), streamEntities, + java.util.function.Consumer({ dialog: PlaylistDialog -> dialog.show(fragmentManager, "PlaylistDialog") })) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.java b/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.java deleted file mode 100644 index 709a16b68..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.java +++ /dev/null @@ -1,108 +0,0 @@ -package org.schabi.newpipe.local.history; - -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; - -import org.schabi.newpipe.util.Localization; - -import java.text.DateFormat; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Date; - - -/** - * This is an adapter for history entries. - * - * @param the type of the entries - * @param the type of the view holder - */ -public abstract class HistoryEntryAdapter - extends RecyclerView.Adapter { - private final ArrayList mEntries; - private final DateFormat mDateFormat; - private final Context mContext; - private OnHistoryItemClickListener onHistoryItemClickListener = null; - - public HistoryEntryAdapter(final Context context) { - super(); - mContext = context; - mEntries = new ArrayList<>(); - mDateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM, - Localization.getPreferredLocale(context)); - } - - public void setEntries(@NonNull final Collection historyEntries) { - mEntries.clear(); - mEntries.addAll(historyEntries); - notifyDataSetChanged(); - } - - public Collection getItems() { - return mEntries; - } - - public void clear() { - mEntries.clear(); - notifyDataSetChanged(); - } - - protected String getFormattedDate(final Date date) { - return mDateFormat.format(date); - } - - protected String getFormattedViewString(final long viewCount) { - return Localization.shortViewCount(mContext, viewCount); - } - - @Override - public int getItemCount() { - return mEntries.size(); - } - - @Override - public void onBindViewHolder(final VH holder, final int position) { - final E entry = mEntries.get(position); - holder.itemView.setOnClickListener(v -> { - if (onHistoryItemClickListener != null) { - onHistoryItemClickListener.onHistoryItemClick(entry); - } - }); - - holder.itemView.setOnLongClickListener(view -> { - if (onHistoryItemClickListener != null) { - onHistoryItemClickListener.onHistoryItemLongClick(entry); - return true; - } - return false; - }); - - onBindViewHolder(holder, entry, position); - } - - @Override - public void onViewRecycled(@NonNull final VH holder) { - super.onViewRecycled(holder); - holder.itemView.setOnClickListener(null); - } - - abstract void onBindViewHolder(VH holder, E entry, int position); - - public void setOnHistoryItemClickListener( - @Nullable final OnHistoryItemClickListener onHistoryItemClickListener) { - this.onHistoryItemClickListener = onHistoryItemClickListener; - } - - public boolean isEmpty() { - return mEntries.isEmpty(); - } - - public interface OnHistoryItemClickListener { - void onHistoryItemClick(E item); - - void onHistoryItemLongClick(E item); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.kt b/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.kt new file mode 100644 index 000000000..7befdd117 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/history/HistoryEntryAdapter.kt @@ -0,0 +1,93 @@ +package org.schabi.newpipe.local.history + +import android.content.Context +import android.view.View +import android.view.View.OnLongClickListener +import androidx.recyclerview.widget.RecyclerView +import org.schabi.newpipe.util.Localization +import java.text.DateFormat +import java.util.Date + +/** + * This is an adapter for history entries. + * + * @param the type of the entries + * @param the type of the view holder + */ +abstract class HistoryEntryAdapter(private val mContext: Context) : RecyclerView.Adapter() { + private val mEntries: ArrayList + private val mDateFormat: DateFormat + private var onHistoryItemClickListener: OnHistoryItemClickListener? = null + + init { + mEntries = ArrayList() + mDateFormat = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.MEDIUM, + Localization.getPreferredLocale(mContext)) + } + + fun setEntries(historyEntries: Collection) { + mEntries.clear() + mEntries.addAll(historyEntries) + notifyDataSetChanged() + } + + val items: Collection + get() { + return mEntries + } + + fun clear() { + mEntries.clear() + notifyDataSetChanged() + } + + protected fun getFormattedDate(date: Date?): String { + return mDateFormat.format(date) + } + + protected fun getFormattedViewString(viewCount: Long): String? { + return Localization.shortViewCount(mContext, viewCount) + } + + public override fun getItemCount(): Int { + return mEntries.size + } + + public override fun onBindViewHolder(holder: VH, position: Int) { + val entry: E = mEntries.get(position) + holder!!.itemView.setOnClickListener(View.OnClickListener({ v: View? -> + if (onHistoryItemClickListener != null) { + onHistoryItemClickListener!!.onHistoryItemClick(entry) + } + })) + holder.itemView.setOnLongClickListener(OnLongClickListener({ view: View? -> + if (onHistoryItemClickListener != null) { + onHistoryItemClickListener!!.onHistoryItemLongClick(entry) + return@setOnLongClickListener true + } + false + })) + onBindViewHolder(holder, entry, position) + } + + public override fun onViewRecycled(holder: VH) { + super.onViewRecycled(holder) + holder!!.itemView.setOnClickListener(null) + } + + abstract fun onBindViewHolder(holder: VH, entry: E, position: Int) + fun setOnHistoryItemClickListener( + onHistoryItemClickListener: OnHistoryItemClickListener?) { + this.onHistoryItemClickListener = onHistoryItemClickListener + } + + val isEmpty: Boolean + get() { + return mEntries.isEmpty() + } + + open interface OnHistoryItemClickListener { + fun onHistoryItemClick(item: E) + fun onHistoryItemLongClick(item: E) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java deleted file mode 100644 index ed3cf548f..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java +++ /dev/null @@ -1,318 +0,0 @@ -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()); - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.kt b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.kt new file mode 100644 index 000000000..346d7647f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.kt @@ -0,0 +1,313 @@ +package org.schabi.newpipe.local.history + +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.PreferenceManager +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Maybe +import io.reactivex.rxjava3.core.MaybeSource +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.functions.Action +import io.reactivex.rxjava3.functions.Function +import io.reactivex.rxjava3.functions.Predicate +import io.reactivex.rxjava3.schedulers.Schedulers +import org.reactivestreams.Publisher +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.player.playqueue.PlayQueueItem +import org.schabi.newpipe.util.ExtractorHelper +import java.time.OffsetDateTime +import java.time.ZoneOffset +import java.util.concurrent.Callable + +/* + * 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 . + */ +class HistoryRecordManager(context: Context?) { + private val database: AppDatabase + private val streamTable: StreamDAO? + private val streamHistoryTable: StreamHistoryDAO? + private val searchHistoryTable: SearchHistoryDAO? + private val streamStateTable: StreamStateDAO? + private val sharedPreferences: SharedPreferences + private val searchHistoryKey: String + private val streamHistoryKey: String + + init { + 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 + */ + fun markAsWatched(info: StreamInfoItem): Maybe { + if (!isStreamHistoryEnabled) { + return Maybe.empty() + } + val currentTime: OffsetDateTime = OffsetDateTime.now(ZoneOffset.UTC) + return Maybe.fromCallable(Callable({ + database.runInTransaction(Callable({ + val streamId: Long + val duration: Long + // Duration will not exist if the item was loaded with fast mode, so fetch it if empty + if (info.getDuration() < 0) { + val completeInfo: StreamInfo = ExtractorHelper.getStreamInfo( + info.getServiceId(), + info.getUrl(), + false + ) + .subscribeOn(Schedulers.io()) + .blockingGet() + duration = completeInfo.getDuration() + streamId = streamTable!!.upsert(StreamEntity(completeInfo)) + } else { + duration = info.getDuration() + streamId = streamTable!!.upsert(StreamEntity(info)) + } + + // Update the stream progress to the full duration of the video + val entity: StreamStateEntity = StreamStateEntity( + streamId, + duration * 1000 + ) + streamStateTable!!.upsert(entity) + + // Add a history entry + val latestEntry: StreamHistoryEntity? = streamHistoryTable!!.getLatestEntry(streamId) + if (latestEntry == null) { + // never actually viewed: add history entry but with 0 views + return@runInTransaction streamHistoryTable.insert(StreamHistoryEntity(streamId, currentTime, 0)) + } else { + return@runInTransaction 0L + } + })) + })).subscribeOn(Schedulers.io()) + } + + fun onViewed(info: StreamInfo?): Maybe { + if (!isStreamHistoryEnabled) { + return Maybe.empty() + } + val currentTime: OffsetDateTime = OffsetDateTime.now(ZoneOffset.UTC) + return Maybe.fromCallable(Callable({ + database.runInTransaction(Callable({ + val streamId: Long = streamTable!!.upsert(StreamEntity((info)!!)) + val latestEntry: StreamHistoryEntity? = streamHistoryTable!!.getLatestEntry(streamId) + if (latestEntry != null) { + streamHistoryTable.delete(latestEntry) + latestEntry.setAccessDate(currentTime) + latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1) + return@runInTransaction streamHistoryTable.insert(latestEntry) + } else { + // just viewed for the first time: set 1 view + return@runInTransaction streamHistoryTable.insert(StreamHistoryEntity(streamId, currentTime, 1)) + } + })) + })).subscribeOn(Schedulers.io()) + } + + fun deleteStreamHistoryAndState(streamId: Long): Completable { + return Completable.fromAction(Action({ + streamStateTable!!.deleteState(streamId) + streamHistoryTable!!.deleteStreamHistory(streamId) + })).subscribeOn(Schedulers.io()) + } + + fun deleteWholeStreamHistory(): Single { + return Single.fromCallable(Callable({ streamHistoryTable!!.deleteAll() })) + .subscribeOn(Schedulers.io()) + } + + fun deleteCompleteStreamStateHistory(): Single { + return Single.fromCallable(Callable({ streamStateTable!!.deleteAll() })) + .subscribeOn(Schedulers.io()) + } + + val streamHistorySortedById: Flowable?> + get() { + return streamHistoryTable!!.getHistorySortedById().subscribeOn(Schedulers.io()) + } + val streamStatistics: Flowable?> + get() { + return streamHistoryTable!!.getStatistics().subscribeOn(Schedulers.io()) + } + private val isStreamHistoryEnabled: Boolean + private get() { + return sharedPreferences.getBoolean(streamHistoryKey, false) + } + + /////////////////////////////////////////////////////// + // Search History + /////////////////////////////////////////////////////// + fun onSearched(serviceId: Int, search: String?): Maybe { + if (!isSearchHistoryEnabled) { + return Maybe.empty() + } + val currentTime: OffsetDateTime = OffsetDateTime.now(ZoneOffset.UTC) + val newEntry: SearchHistoryEntry = SearchHistoryEntry(currentTime, serviceId, search) + return Maybe.fromCallable(Callable({ + database.runInTransaction(Callable({ + val latestEntry: SearchHistoryEntry? = searchHistoryTable!!.getLatestEntry() + if (latestEntry != null && latestEntry.hasEqualValues(newEntry)) { + latestEntry.creationDate = currentTime + return@runInTransaction searchHistoryTable.update(latestEntry).toLong() + } else { + return@runInTransaction searchHistoryTable.insert(newEntry) + } + })) + })).subscribeOn(Schedulers.io()) + } + + fun deleteSearchHistory(search: String?): Single { + return Single.fromCallable(Callable({ searchHistoryTable!!.deleteAllWhereQuery(search) })) + .subscribeOn(Schedulers.io()) + } + + fun deleteCompleteSearchHistory(): Single { + return Single.fromCallable(Callable({ searchHistoryTable!!.deleteAll() })) + .subscribeOn(Schedulers.io()) + } + + fun getRelatedSearches(query: String, + similarQueryLimit: Int, + uniqueQueryLimit: Int): Flowable?>? { + return if (query.length > 0) searchHistoryTable!!.getSimilarEntries(query, similarQueryLimit) else searchHistoryTable!!.getUniqueEntries(uniqueQueryLimit) + } + + private val isSearchHistoryEnabled: Boolean + private get() { + return sharedPreferences.getBoolean(searchHistoryKey, false) + } + + /////////////////////////////////////////////////////// + // Stream State History + /////////////////////////////////////////////////////// + fun loadStreamState(queueItem: PlayQueueItem?): Maybe { + return queueItem.getStream() + .map(Function({ info: StreamInfo? -> streamTable!!.upsert(StreamEntity((info)!!)) })) + .flatMapPublisher?>(Function?>?>({ streamId: Long -> streamStateTable!!.getState(streamId) })) + .firstElement() + .flatMap(Function?, MaybeSource>({ list: List? -> if (list!!.isEmpty()) Maybe.empty() else Maybe.just(list.get(0)) })) + .filter(Predicate({ state: StreamStateEntity? -> state!!.isValid(queueItem.getDuration()) })) + .subscribeOn(Schedulers.io()) + } + + fun loadStreamState(info: StreamInfo): Maybe { + return Single.fromCallable(Callable({ streamTable!!.upsert(StreamEntity(info)) })) + .flatMapPublisher?>(Function?>?>({ streamId: Long -> streamStateTable!!.getState(streamId) })) + .firstElement() + .flatMap(Function?, MaybeSource>({ list: List? -> if (list!!.isEmpty()) Maybe.empty() else Maybe.just(list.get(0)) })) + .filter(Predicate({ state: StreamStateEntity? -> state!!.isValid(info.getDuration()) })) + .subscribeOn(Schedulers.io()) + } + + fun saveStreamState(info: StreamInfo, progressMillis: Long): Completable { + return Completable.fromAction(Action({ + database.runInTransaction(Runnable({ + val streamId: Long = streamTable!!.upsert(StreamEntity(info)) + val state: StreamStateEntity = StreamStateEntity(streamId, progressMillis) + if (state.isValid(info.getDuration())) { + streamStateTable!!.upsert(state) + } + })) + })).subscribeOn(Schedulers.io()) + } + + fun loadStreamState(info: InfoItem): Single> { + return Single.fromCallable>(Callable>({ + val entities: List = streamTable + .getStream(info.getServiceId().toLong(), info.getUrl()).blockingFirst() + if (entities.isEmpty()) { + return@fromCallable arrayOf(null) + } + val states: List? = streamStateTable + .getState(entities.get(0).uid).blockingFirst() + if (states!!.isEmpty()) { + return@fromCallable arrayOf(null) + } + arrayOf(states.get(0)) + })).subscribeOn(Schedulers.io()) + } + + fun loadLocalStreamStateBatch( + items: List?): Single> { + return Single.fromCallable(Callable>({ + val result: MutableList = ArrayList(items!!.size) + for (item: LocalItem? in items) { + val streamId: Long + if (item is StreamStatisticsEntry) { + streamId = item.streamId + } else if (item is PlaylistStreamEntity) { + streamId = (item as PlaylistStreamEntity).getStreamUid() + } else if (item is PlaylistStreamEntry) { + streamId = item.streamId + } else { + result.add(null) + continue + } + val states: List? = streamStateTable!!.getState(streamId) + .blockingFirst() + if (states!!.isEmpty()) { + result.add(null) + } else { + result.add(states.get(0)) + } + } + result + })).subscribeOn(Schedulers.io()) + } + + /////////////////////////////////////////////////////// + // Utility + /////////////////////////////////////////////////////// + fun removeOrphanedRecords(): Single { + return Single.fromCallable(Callable({ streamTable!!.deleteOrphans() })).subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java deleted file mode 100644 index 1fea7e155..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java +++ /dev/null @@ -1,395 +0,0 @@ -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 androidx.viewbinding.ViewBinding; - -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 icepick.State; -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 ViewBinding getListHeader() { - headerBinding = StatisticPlaylistControlBinding.inflate(activity.getLayoutInflater(), - itemsList, false); - playlistControlBinding = headerBinding.playlistControl; - - return headerBinding; - } - - @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))) - .setAction( - StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND, - (f, i) -> NavigationHelper.playOnBackgroundPlayer( - context, getPlayQueueStartingAt(item), true)) - .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); - } - } - - 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, - } -} - diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.kt b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.kt new file mode 100644 index 000000000..f91de7fb7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.kt @@ -0,0 +1,337 @@ +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.fragment.app.Fragment +import androidx.viewbinding.ViewBinding +import com.google.android.material.snackbar.Snackbar +import icepick.State +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +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 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.info_list.dialog.StreamDialogEntry.StreamDialogEntryAction +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.Collections +import java.util.Objects +import kotlin.math.max + +class StatisticsPlaylistFragment() : BaseLocalListFragment?, Void?>(), PlaylistControlViewHolder { + private val disposables: CompositeDisposable = CompositeDisposable() + + @State + var itemsListState: Parcelable? = null + private var sortMode: StatisticSortMode = StatisticSortMode.LAST_PLAYED + private var headerBinding: StatisticPlaylistControlBinding? = null + private var playlistControlBinding: PlaylistControlBinding? = null + + /* Used for independent events */ + private var databaseSubscription: Subscription? = null + private var recordManager: HistoryRecordManager? = null + private fun processResult(results: List): List? { + val comparator: Comparator + when (sortMode) { + StatisticSortMode.LAST_PLAYED -> comparator = Comparator.comparing(StreamStatisticsEntry::latestAccessDate) + StatisticSortMode.MOST_PLAYED -> comparator = Comparator.comparingLong(StreamStatisticsEntry::watchCount) + else -> return null + } + Collections.sort(results, comparator.reversed()) + return results + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Creation + /////////////////////////////////////////////////////////////////////////// + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + recordManager = HistoryRecordManager(getContext()) + } + + public override fun onCreateView(inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_playlist, container, false) + } + + public override fun onResume() { + super.onResume() + if (activity != null) { + setTitle(activity!!.getString(R.string.title_activity_history)) + } + } + + public override fun onCreateOptionsMenu(menu: Menu, + inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.menu_history, menu) + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Views + /////////////////////////////////////////////////////////////////////////// + override fun initViews(rootView: View, savedInstanceState: Bundle?) { + super.initViews(rootView, savedInstanceState) + if (!useAsFrontPage) { + setTitle(getString(R.string.title_last_played)) + } + } + + protected override val listHeader: ViewBinding? + protected get() { + headerBinding = StatisticPlaylistControlBinding.inflate(activity!!.getLayoutInflater(), + itemsList, false) + playlistControlBinding = headerBinding!!.playlistControl + return headerBinding + } + + override fun initListeners() { + super.initListeners() + itemListAdapter!!.setSelectedListener(object : OnClickGesture { + public override fun selected(selectedItem: LocalItem?) { + if (selectedItem is StreamStatisticsEntry) { + val item: StreamEntity = selectedItem.streamEntity + NavigationHelper.openVideoDetailFragment(requireContext(), getFM(), + item.serviceId, item.url, item.title, null, false) + } + } + + public override fun held(selectedItem: LocalItem?) { + if (selectedItem is StreamStatisticsEntry) { + showInfoItemDialog(selectedItem) + } + } + }) + } + + public override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.getItemId() == R.id.action_history_clear) { + HistorySettingsFragment.Companion.openDeleteWatchHistoryDialog(requireContext(), recordManager, disposables) + } else { + return super.onOptionsItemSelected(item) + } + return true + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Loading + /////////////////////////////////////////////////////////////////////////// + public override fun startLoading(forceLoad: Boolean) { + super.startLoading(forceLoad) + recordManager.getStreamStatistics() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(historyObserver) + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Destruction + /////////////////////////////////////////////////////////////////////////// + public override fun onPause() { + super.onPause() + itemsListState = Objects.requireNonNull(itemsList!!.getLayoutManager()).onSaveInstanceState() + } + + public override fun onDestroyView() { + super.onDestroyView() + if (itemListAdapter != null) { + itemListAdapter!!.unsetSelectedListener() + } + headerBinding = null + playlistControlBinding = null + if (databaseSubscription != null) { + databaseSubscription!!.cancel() + } + databaseSubscription = null + } + + public override fun onDestroy() { + super.onDestroy() + recordManager = null + itemsListState = null + } + + private val historyObserver: Subscriber?> + /////////////////////////////////////////////////////////////////////////// + private get() { + return object : Subscriber?> { + public override fun onSubscribe(s: Subscription) { + showLoading() + if (databaseSubscription != null) { + databaseSubscription!!.cancel() + } + databaseSubscription = s + databaseSubscription!!.request(1) + } + + public override fun onNext(streams: List?) { + handleResult(streams) + if (databaseSubscription != null) { + databaseSubscription!!.request(1) + } + } + + public override fun onError(exception: Throwable) { + showError( + ErrorInfo(exception, UserAction.SOMETHING_ELSE, "History Statistics")) + } + + public override fun onComplete() {} + } + } + + public override fun handleResult(result: List) { + 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.OnClickListener({ view: View? -> toggleSortMode() })) + hideLoading() + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Error Handling + /////////////////////////////////////////////////////////////////////////// + override fun resetFragment() { + super.resetFragment() + if (databaseSubscription != null) { + databaseSubscription!!.cancel() + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + ////////////////////////////////////////////////////////////////////////// */ + private fun 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 fun getPlayQueueStartingAt(infoItem: StreamStatisticsEntry): PlayQueue { + return getPlayQueue(max(itemListAdapter.getItemsList().indexOf(infoItem).toDouble(), 0.0).toInt()) + } + + private fun showInfoItemDialog(item: StreamStatisticsEntry) { + val context: Context? = getContext() + val infoItem: StreamInfoItem = item.toStreamInfoItem() + try { + val dialogBuilder: InfoItemDialog.Builder = InfoItemDialog.Builder((getActivity())!!, (context)!!, this, infoItem) + + // set entries in the middle; the others are added automatically + dialogBuilder + .addEntry(StreamDialogDefaultEntry.DELETE) + .setAction( + StreamDialogDefaultEntry.DELETE, + StreamDialogEntryAction({ f: Fragment?, i: StreamInfoItem? -> deleteEntry(max(itemListAdapter.getItemsList().indexOf(item).toDouble(), 0.0).toInt()) })) + .setAction( + StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND, + StreamDialogEntryAction({ f: Fragment?, i: StreamInfoItem? -> + NavigationHelper.playOnBackgroundPlayer( + context, getPlayQueueStartingAt(item), true) + })) + .create() + .show() + } catch (e: IllegalArgumentException) { + InfoItemDialog.Builder.Companion.reportErrorDuringInitialization(e, infoItem) + } + } + + private fun deleteEntry(index: Int) { + val infoItem: LocalItem? = itemListAdapter.getItemsList().get(index) + if (infoItem is StreamStatisticsEntry) { + val onDelete: Disposable = recordManager + .deleteStreamHistoryAndState(infoItem.streamId) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + Action({ + 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() + } + }), + Consumer({ throwable: Throwable? -> + showSnackBarError(ErrorInfo((throwable)!!, + UserAction.DELETE_FROM_HISTORY, "Deleting item")) + })) + disposables.add(onDelete) + } + } + + override val playQueue: PlayQueue + get() { + return getPlayQueue(0) + } + + private fun getPlayQueue(index: Int): PlayQueue { + if (itemListAdapter == null) { + return SinglePlayQueue(emptyList(), 0) + } + val infoItems: List? = itemListAdapter.getItemsList() + val streamInfoItems: MutableList = ArrayList(infoItems!!.size) + for (item: LocalItem? in infoItems) { + if (item is StreamStatisticsEntry) { + streamInfoItems.add(item.toStreamInfoItem()) + } + } + return SinglePlayQueue(streamInfoItems, index) + } + + private enum class StatisticSortMode { + LAST_PLAYED, + MOST_PLAYED + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.java deleted file mode 100644 index 16130009b..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.java +++ /dev/null @@ -1,54 +0,0 @@ -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; - }; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.kt b/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.kt new file mode 100644 index 000000000..73f6783de --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalBookmarkPlaylistItemHolder.kt @@ -0,0 +1,46 @@ +package org.schabi.newpipe.local.holder + +import android.view.MotionEvent +import android.view.View +import android.view.View.OnTouchListener +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 + +class LocalBookmarkPlaylistItemHolder internal constructor(infoItemBuilder: LocalItemBuilder, layoutId: Int, + parent: ViewGroup?) : LocalPlaylistItemHolder(infoItemBuilder, layoutId, parent) { + private val itemHandleView: View + + constructor(infoItemBuilder: LocalItemBuilder, + parent: ViewGroup?) : this(infoItemBuilder, R.layout.list_playlist_bookmark_item, parent) + + init { + itemHandleView = itemView.findViewById(R.id.itemHandle) + } + + public override fun updateFromItem(localItem: LocalItem?, + historyRecordManager: HistoryRecordManager?, + dateTimeFormatter: DateTimeFormatter) { + if (!(localItem is PlaylistMetadataEntry)) { + return + } + itemHandleView.setOnTouchListener(getOnTouchListener(localItem)) + super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter) + } + + private fun getOnTouchListener(item: PlaylistMetadataEntry): OnTouchListener { + return OnTouchListener({ view: View, motionEvent: MotionEvent -> + view.performClick() + if ((itemBuilder != null) && (itemBuilder.getOnItemSelectedListener() != null + ) && (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN)) { + itemBuilder.getOnItemSelectedListener().drag(item, + this@LocalBookmarkPlaylistItemHolder) + } + false + }) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalItemHolder.java deleted file mode 100644 index a093d93e1..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalItemHolder.java +++ /dev/null @@ -1,48 +0,0 @@ -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) { } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalItemHolder.kt b/app/src/main/java/org/schabi/newpipe/local/holder/LocalItemHolder.kt new file mode 100644 index 000000000..2784cdc69 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalItemHolder.kt @@ -0,0 +1,38 @@ +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 . + */ +abstract class LocalItemHolder(protected val itemBuilder: LocalItemBuilder, layoutId: Int, + parent: ViewGroup?) : RecyclerView.ViewHolder(LayoutInflater.from(itemBuilder.getContext()).inflate(layoutId, parent, false)) { + abstract fun updateFromItem(item: LocalItem?, historyRecordManager: HistoryRecordManager?, + dateTimeFormatter: DateTimeFormatter) + + open fun updateState(localItem: LocalItem?, + historyRecordManager: HistoryRecordManager?) { + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistCardItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistCardItemHolder.java deleted file mode 100644 index 33418ec98..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistCardItemHolder.java +++ /dev/null @@ -1,17 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistCardItemHolder.kt b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistCardItemHolder.kt new file mode 100644 index 000000000..4bdd80cb0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistCardItemHolder.kt @@ -0,0 +1,11 @@ +package org.schabi.newpipe.local.holder + +import android.view.ViewGroup +import org.schabi.newpipe.R +import org.schabi.newpipe.local.LocalItemBuilder + +/** + * Playlist card layout. + */ +class LocalPlaylistCardItemHolder(infoItemBuilder: LocalItemBuilder, + parent: ViewGroup?) : LocalPlaylistItemHolder(infoItemBuilder, R.layout.list_playlist_card_item, parent) diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistGridItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistGridItemHolder.java deleted file mode 100644 index 2b493f4ee..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistGridItemHolder.java +++ /dev/null @@ -1,13 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistGridItemHolder.kt b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistGridItemHolder.kt new file mode 100644 index 000000000..fd2f3b751 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistGridItemHolder.kt @@ -0,0 +1,8 @@ +package org.schabi.newpipe.local.holder + +import android.view.ViewGroup +import org.schabi.newpipe.R +import org.schabi.newpipe.local.LocalItemBuilder + +class LocalPlaylistGridItemHolder(infoItemBuilder: LocalItemBuilder, + parent: ViewGroup?) : LocalPlaylistItemHolder(infoItemBuilder, R.layout.list_playlist_grid_item, parent) diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java deleted file mode 100644 index 336f5cfe3..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java +++ /dev/null @@ -1,54 +0,0 @@ -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.image.PicassoHelper; -import org.schabi.newpipe.util.Localization; - -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)) { - return; - } - final PlaylistMetadataEntry item = (PlaylistMetadataEntry) localItem; - - itemTitleView.setText(item.name); - itemStreamCountView.setText(Localization.localizeStreamCountMini( - itemStreamCountView.getContext(), item.streamCount)); - itemUploaderView.setVisibility(View.INVISIBLE); - - PicassoHelper.loadPlaylistThumbnail(item.thumbnailUrl).into(itemThumbnailView); - - if (item instanceof PlaylistDuplicatesEntry - && ((PlaylistDuplicatesEntry) item).timesStreamIsContained > 0) { - itemView.setAlpha(GRAYED_OUT_ALPHA); - } else { - itemView.setAlpha(1.0f); - } - - super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.kt b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.kt new file mode 100644 index 000000000..b9958a4da --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.kt @@ -0,0 +1,43 @@ +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.PicassoHelper +import java.time.format.DateTimeFormatter + +open class LocalPlaylistItemHolder : PlaylistItemHolder { + constructor(infoItemBuilder: LocalItemBuilder, parent: ViewGroup?) : super(infoItemBuilder, parent) + internal constructor(infoItemBuilder: LocalItemBuilder, layoutId: Int, + parent: ViewGroup?) : super(infoItemBuilder, layoutId, parent) + + public override fun updateFromItem(localItem: LocalItem?, + historyRecordManager: HistoryRecordManager?, + dateTimeFormatter: DateTimeFormatter) { + if (!(localItem is PlaylistMetadataEntry)) { + return + } + val item: PlaylistMetadataEntry = localItem + itemTitleView.setText(item.name) + itemStreamCountView.setText(Localization.localizeStreamCountMini( + itemStreamCountView.getContext(), item.streamCount)) + itemUploaderView.setVisibility(View.INVISIBLE) + PicassoHelper.loadPlaylistThumbnail(item.thumbnailUrl).into(itemThumbnailView) + if ((item is PlaylistDuplicatesEntry + && item.timesStreamIsContained > 0)) { + itemView.setAlpha(GRAYED_OUT_ALPHA) + } else { + itemView.setAlpha(1.0f) + } + super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter) + } + + companion object { + private val GRAYED_OUT_ALPHA: Float = 0.6f + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamCardItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamCardItemHolder.java deleted file mode 100644 index 7f81a527f..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamCardItemHolder.java +++ /dev/null @@ -1,17 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamCardItemHolder.kt b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamCardItemHolder.kt new file mode 100644 index 000000000..c6c90f002 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamCardItemHolder.kt @@ -0,0 +1,11 @@ +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. + */ +class LocalPlaylistStreamCardItemHolder(infoItemBuilder: LocalItemBuilder, + parent: ViewGroup?) : LocalPlaylistStreamItemHolder(infoItemBuilder, R.layout.list_stream_playlist_card_item, parent) diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamGridItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamGridItemHolder.java deleted file mode 100644 index e2f936792..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamGridItemHolder.java +++ /dev/null @@ -1,13 +0,0 @@ -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 - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamGridItemHolder.kt b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamGridItemHolder.kt new file mode 100644 index 000000000..22c44f852 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamGridItemHolder.kt @@ -0,0 +1,8 @@ +package org.schabi.newpipe.local.holder + +import android.view.ViewGroup +import org.schabi.newpipe.R +import org.schabi.newpipe.local.LocalItemBuilder + +class LocalPlaylistStreamGridItemHolder(infoItemBuilder: LocalItemBuilder, + parent: ViewGroup?) : LocalPlaylistStreamItemHolder(infoItemBuilder, R.layout.list_stream_playlist_grid_item, parent) diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java deleted file mode 100644 index 89a714fd7..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java +++ /dev/null @@ -1,141 +0,0 @@ -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.image.PicassoHelper; -import org.schabi.newpipe.util.ServiceHelper; -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 - PicassoHelper.loadThumbnail(item.getStreamEntity().getThumbnailUrl()) - .into(itemThumbnailView); - - 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; - }; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.kt b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.kt new file mode 100644 index 000000000..5ef1807fd --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.kt @@ -0,0 +1,125 @@ +package org.schabi.newpipe.local.holder + +import android.view.MotionEvent +import android.view.View +import android.view.View.OnLongClickListener +import android.view.View.OnTouchListener +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.animate +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.PicassoHelper +import org.schabi.newpipe.views.AnimatedProgressBar +import java.time.format.DateTimeFormatter +import java.util.concurrent.TimeUnit + +open class LocalPlaylistStreamItemHolder internal constructor(infoItemBuilder: LocalItemBuilder, layoutId: Int, + parent: ViewGroup?) : LocalItemHolder(infoItemBuilder, layoutId, parent) { + val itemThumbnailView: ImageView + val itemVideoTitleView: TextView + private val itemAdditionalDetailsView: TextView + val itemDurationView: TextView + private val itemHandleView: View + private val itemProgressView: AnimatedProgressBar + + init { + 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) + } + + constructor(infoItemBuilder: LocalItemBuilder, + parent: ViewGroup?) : this(infoItemBuilder, R.layout.list_stream_playlist_item, parent) + + public override fun updateFromItem(localItem: LocalItem?, + historyRecordManager: HistoryRecordManager?, + dateTimeFormatter: DateTimeFormatter) { + if (!(localItem is PlaylistStreamEntry)) { + return + } + val item: PlaylistStreamEntry = localItem + itemVideoTitleView.setText(item.streamEntity.title) + itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.streamEntity.uploader, + ServiceHelper.getNameOfServiceById(item.streamEntity.serviceId))) + if (item.streamEntity.duration > 0) { + itemDurationView.setText(Localization.getDurationString(item.streamEntity.duration)) + itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), + R.color.duration_background_color)) + itemDurationView.setVisibility(View.VISIBLE) + if ((DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext()) + && item.progressMillis > 0)) { + itemProgressView.setVisibility(View.VISIBLE) + itemProgressView.setMax(item.streamEntity.duration.toInt()) + itemProgressView.setProgress(TimeUnit.MILLISECONDS + .toSeconds(item.progressMillis).toInt()) + } else { + itemProgressView.setVisibility(View.GONE) + } + } else { + itemDurationView.setVisibility(View.GONE) + } + + // Default thumbnail is shown on error, while loading and if the url is empty + PicassoHelper.loadThumbnail(item.streamEntity.thumbnailUrl) + .into(itemThumbnailView) + itemView.setOnClickListener(View.OnClickListener({ view: View? -> + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().selected(item) + } + })) + itemView.setLongClickable(true) + itemView.setOnLongClickListener(OnLongClickListener({ view: View? -> + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().held(item) + } + true + })) + itemHandleView.setOnTouchListener(getOnTouchListener(item)) + } + + public override fun updateState(localItem: LocalItem?, + historyRecordManager: HistoryRecordManager?) { + if (!(localItem is PlaylistStreamEntry)) { + return + } + val item: PlaylistStreamEntry = localItem + if ((DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext()) + && (item.progressMillis > 0) && (item.streamEntity.duration > 0))) { + itemProgressView.setMax(item.streamEntity.duration.toInt()) + if (itemProgressView.getVisibility() == View.VISIBLE) { + itemProgressView.setProgressAnimated(TimeUnit.MILLISECONDS + .toSeconds(item.progressMillis).toInt()) + } else { + itemProgressView.setProgress(TimeUnit.MILLISECONDS + .toSeconds(item.progressMillis).toInt()) + itemProgressView.animate(true, 500) + } + } else if (itemProgressView.getVisibility() == View.VISIBLE) { + itemProgressView.animate(false, 500) + } + } + + private fun getOnTouchListener(item: PlaylistStreamEntry): OnTouchListener { + return OnTouchListener({ view: View, motionEvent: MotionEvent -> + view.performClick() + if ((itemBuilder != null) && (itemBuilder.getOnItemSelectedListener() != null + ) && (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN)) { + itemBuilder.getOnItemSelectedListener().drag(item, + this@LocalPlaylistStreamItemHolder) + } + false + }) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamCardItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamCardItemHolder.java deleted file mode 100644 index 4e03d5fb1..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamCardItemHolder.java +++ /dev/null @@ -1,13 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamCardItemHolder.kt b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamCardItemHolder.kt new file mode 100644 index 000000000..dcbffad84 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamCardItemHolder.kt @@ -0,0 +1,8 @@ +package org.schabi.newpipe.local.holder + +import android.view.ViewGroup +import org.schabi.newpipe.R +import org.schabi.newpipe.local.LocalItemBuilder + +class LocalStatisticStreamCardItemHolder(infoItemBuilder: LocalItemBuilder, + parent: ViewGroup?) : LocalStatisticStreamItemHolder(infoItemBuilder, R.layout.list_stream_card_item, parent) diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamGridItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamGridItemHolder.java deleted file mode 100644 index 39a43b034..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamGridItemHolder.java +++ /dev/null @@ -1,13 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamGridItemHolder.kt b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamGridItemHolder.kt new file mode 100644 index 000000000..df5990068 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamGridItemHolder.kt @@ -0,0 +1,8 @@ +package org.schabi.newpipe.local.holder + +import android.view.ViewGroup +import org.schabi.newpipe.R +import org.schabi.newpipe.local.LocalItemBuilder + +class LocalStatisticStreamGridItemHolder(infoItemBuilder: LocalItemBuilder, + parent: ViewGroup?) : LocalStatisticStreamItemHolder(infoItemBuilder, R.layout.list_stream_grid_item, parent) diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java deleted file mode 100644 index 150a35eb5..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java +++ /dev/null @@ -1,161 +0,0 @@ -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.image.PicassoHelper; -import org.schabi.newpipe.util.ServiceHelper; -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 - PicassoHelper.loadThumbnail(item.getStreamEntity().getThumbnailUrl()) - .into(itemThumbnailView); - - 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); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.kt b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.kt new file mode 100644 index 000000000..2ba06f78c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.kt @@ -0,0 +1,140 @@ +package org.schabi.newpipe.local.holder + +import android.view.View +import android.view.View.OnLongClickListener +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.stream.StreamStatisticsEntry +import org.schabi.newpipe.ktx.animate +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.PicassoHelper +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 . + */ +open class LocalStatisticStreamItemHolder internal constructor(infoItemBuilder: LocalItemBuilder, layoutId: Int, + parent: ViewGroup?) : LocalItemHolder(infoItemBuilder, layoutId, parent) { + val itemThumbnailView: ImageView + val itemVideoTitleView: TextView + val itemUploaderView: TextView + val itemDurationView: TextView + val itemAdditionalDetails: TextView? + private val itemProgressView: AnimatedProgressBar + + constructor(itemBuilder: LocalItemBuilder, + parent: ViewGroup?) : this(itemBuilder, R.layout.list_stream_item, parent) + + init { + 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 fun getStreamInfoDetailLine(entry: StreamStatisticsEntry, + dateTimeFormatter: DateTimeFormatter): String { + return Localization.concatenateStrings( // watchCount + Localization.shortViewCount(itemBuilder.getContext(), entry.watchCount), + dateTimeFormatter.format(entry.latestAccessDate), // serviceName + ServiceHelper.getNameOfServiceById(entry.streamEntity.serviceId)) + } + + public override fun updateFromItem(localItem: LocalItem?, + historyRecordManager: HistoryRecordManager?, + dateTimeFormatter: DateTimeFormatter) { + if (!(localItem is StreamStatisticsEntry)) { + return + } + val item: StreamStatisticsEntry = localItem + itemVideoTitleView.setText(item.streamEntity.title) + itemUploaderView.setText(item.streamEntity.uploader) + if (item.streamEntity.duration > 0) { + itemDurationView.setText(Localization.getDurationString(item.streamEntity.duration)) + itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), + R.color.duration_background_color)) + itemDurationView.setVisibility(View.VISIBLE) + if ((DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext()) + && item.progressMillis > 0)) { + itemProgressView.setVisibility(View.VISIBLE) + itemProgressView.setMax(item.streamEntity.duration.toInt()) + itemProgressView.setProgress(TimeUnit.MILLISECONDS + .toSeconds(item.progressMillis).toInt()) + } 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 + PicassoHelper.loadThumbnail(item.streamEntity.thumbnailUrl) + .into(itemThumbnailView) + itemView.setOnClickListener(View.OnClickListener({ view: View? -> + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().selected(item) + } + })) + itemView.setLongClickable(true) + itemView.setOnLongClickListener(OnLongClickListener({ view: View? -> + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().held(item) + } + true + })) + } + + public override fun updateState(localItem: LocalItem?, + historyRecordManager: HistoryRecordManager?) { + if (!(localItem is StreamStatisticsEntry)) { + return + } + val item: StreamStatisticsEntry = localItem + if ((DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext()) + && (item.progressMillis > 0) && (item.streamEntity.duration > 0))) { + itemProgressView.setMax(item.streamEntity.duration.toInt()) + if (itemProgressView.getVisibility() == View.VISIBLE) { + itemProgressView.setProgressAnimated(TimeUnit.MILLISECONDS + .toSeconds(item.progressMillis).toInt()) + } else { + itemProgressView.setProgress(TimeUnit.MILLISECONDS + .toSeconds(item.progressMillis).toInt()) + itemProgressView.animate(true, 500) + } + } else if (itemProgressView.getVisibility() == View.VISIBLE) { + itemProgressView.animate(false, 500) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/PlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/PlaylistItemHolder.java deleted file mode 100644 index e8c53161e..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/PlaylistItemHolder.java +++ /dev/null @@ -1,52 +0,0 @@ -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; - }); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/PlaylistItemHolder.kt b/app/src/main/java/org/schabi/newpipe/local/holder/PlaylistItemHolder.kt new file mode 100644 index 000000000..83b53ce97 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/PlaylistItemHolder.kt @@ -0,0 +1,46 @@ +package org.schabi.newpipe.local.holder + +import android.view.View +import android.view.View.OnLongClickListener +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 + +abstract class PlaylistItemHolder(infoItemBuilder: LocalItemBuilder, layoutId: Int, + parent: ViewGroup?) : LocalItemHolder(infoItemBuilder, layoutId, parent) { + val itemThumbnailView: ImageView + val itemStreamCountView: TextView + val itemTitleView: TextView + val itemUploaderView: TextView + + init { + itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView) + itemTitleView = itemView.findViewById(R.id.itemTitleView) + itemStreamCountView = itemView.findViewById(R.id.itemStreamCountView) + itemUploaderView = itemView.findViewById(R.id.itemUploaderView) + } + + constructor(infoItemBuilder: LocalItemBuilder, parent: ViewGroup?) : this(infoItemBuilder, R.layout.list_playlist_mini_item, parent) + + public override fun updateFromItem(localItem: LocalItem?, + historyRecordManager: HistoryRecordManager?, + dateTimeFormatter: DateTimeFormatter) { + itemView.setOnClickListener(View.OnClickListener({ view: View? -> + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().selected(localItem) + } + })) + itemView.setLongClickable(true) + itemView.setOnLongClickListener(OnLongClickListener({ view: View? -> + if (itemBuilder.getOnItemSelectedListener() != null) { + itemBuilder.getOnItemSelectedListener().held(localItem) + } + true + })) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.java deleted file mode 100644 index 6d61d1e08..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.java +++ /dev/null @@ -1,54 +0,0 @@ -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; - }; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.kt b/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.kt new file mode 100644 index 000000000..6eb7713f4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/RemoteBookmarkPlaylistItemHolder.kt @@ -0,0 +1,46 @@ +package org.schabi.newpipe.local.holder + +import android.view.MotionEvent +import android.view.View +import android.view.View.OnTouchListener +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 + +class RemoteBookmarkPlaylistItemHolder internal constructor(infoItemBuilder: LocalItemBuilder, layoutId: Int, + parent: ViewGroup?) : RemotePlaylistItemHolder(infoItemBuilder, layoutId, parent) { + private val itemHandleView: View + + constructor(infoItemBuilder: LocalItemBuilder, + parent: ViewGroup?) : this(infoItemBuilder, R.layout.list_playlist_bookmark_item, parent) + + init { + itemHandleView = itemView.findViewById(R.id.itemHandle) + } + + public override fun updateFromItem(localItem: LocalItem?, + historyRecordManager: HistoryRecordManager?, + dateTimeFormatter: DateTimeFormatter) { + if (!(localItem is PlaylistRemoteEntity)) { + return + } + itemHandleView.setOnTouchListener(getOnTouchListener(localItem)) + super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter) + } + + private fun getOnTouchListener(item: PlaylistRemoteEntity): OnTouchListener { + return OnTouchListener({ view: View, motionEvent: MotionEvent -> + view.performClick() + if ((itemBuilder != null) && (itemBuilder.getOnItemSelectedListener() != null + ) && (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN)) { + itemBuilder.getOnItemSelectedListener().drag(item, + this@RemoteBookmarkPlaylistItemHolder) + } + false + }) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistCardItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistCardItemHolder.java deleted file mode 100644 index 74a67c3db..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistCardItemHolder.java +++ /dev/null @@ -1,17 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistCardItemHolder.kt b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistCardItemHolder.kt new file mode 100644 index 000000000..1605127a4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistCardItemHolder.kt @@ -0,0 +1,11 @@ +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. + */ +class RemotePlaylistCardItemHolder(infoItemBuilder: LocalItemBuilder, + parent: ViewGroup?) : RemotePlaylistItemHolder(infoItemBuilder, R.layout.list_playlist_card_item, parent) diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistGridItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistGridItemHolder.java deleted file mode 100644 index 00dcefbda..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistGridItemHolder.java +++ /dev/null @@ -1,13 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistGridItemHolder.kt b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistGridItemHolder.kt new file mode 100644 index 000000000..15ea55acc --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistGridItemHolder.kt @@ -0,0 +1,8 @@ +package org.schabi.newpipe.local.holder + +import android.view.ViewGroup +import org.schabi.newpipe.R +import org.schabi.newpipe.local.LocalItemBuilder + +class RemotePlaylistGridItemHolder(infoItemBuilder: LocalItemBuilder, + parent: ViewGroup?) : RemotePlaylistItemHolder(infoItemBuilder, R.layout.list_playlist_grid_item, parent) diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java deleted file mode 100644 index 765732063..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java +++ /dev/null @@ -1,52 +0,0 @@ -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.image.PicassoHelper; -import org.schabi.newpipe.util.ServiceHelper; - -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)) { - return; - } - final PlaylistRemoteEntity item = (PlaylistRemoteEntity) localItem; - - itemTitleView.setText(item.getName()); - 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())); - } - - PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView); - - super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.kt b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.kt new file mode 100644 index 000000000..ec5d649d5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.kt @@ -0,0 +1,41 @@ +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.PicassoHelper +import java.time.format.DateTimeFormatter + +open class RemotePlaylistItemHolder : PlaylistItemHolder { + constructor(infoItemBuilder: LocalItemBuilder, + parent: ViewGroup?) : super(infoItemBuilder, parent) + + internal constructor(infoItemBuilder: LocalItemBuilder, layoutId: Int, + parent: ViewGroup?) : super(infoItemBuilder, layoutId, parent) + + public override fun updateFromItem(localItem: LocalItem?, + historyRecordManager: HistoryRecordManager?, + dateTimeFormatter: DateTimeFormatter) { + if (!(localItem is PlaylistRemoteEntity)) { + return + } + val item: PlaylistRemoteEntity = localItem + itemTitleView.setText(item.getName()) + 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())) + } + PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView) + super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java deleted file mode 100644 index e2d0f5986..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java +++ /dev/null @@ -1,888 +0,0 @@ -package org.schabi.newpipe.local.playlist; - -import static org.schabi.newpipe.error.ErrorUtil.showUiErrorSnackbar; -import static org.schabi.newpipe.ktx.ViewUtils.animate; -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.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 androidx.viewbinding.ViewBinding; - -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.debounce.DebounceSavable; -import org.schabi.newpipe.util.debounce.DebounceSaver; -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.external_communication.ShareUtils; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.stream.Collectors; - -import icepick.State; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -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 LocalPlaylistFragment 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 ViewBinding getListHeader() { - headerBinding = LocalPlaylistHeaderBinding.inflate(activity.getLayoutInflater(), itemsList, - false); - playlistControlBinding = headerBinding.playlistControl; - - headerBinding.playlistTitleView.setSelected(true); - - return headerBinding; - } - - @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) { - final StreamEntity item = - ((PlaylistStreamEntry) selectedItem).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) { - new AlertDialog.Builder(requireContext()) - .setMessage(R.string.remove_watched_popup_warning) - .setTitle(R.string.remove_watched_popup_title) - .setPositiveButton(R.string.ok, (d, id) -> - removeWatchedStreams(false)) - .setNeutralButton( - R.string.remove_watched_popup_yes_and_partially_watched_videos, - (d, id) -> removeWatchedStreams(true)) - .setNegativeButton(R.string.cancel, - (d, id) -> d.cancel()) - .show(); - } - } else if (item.getItemId() == R.id.menu_item_remove_duplicates) { - if (!isRewritingPlaylist) { - openRemoveDuplicatesDialog(); - } - } else { - return super.onOptionsItemSelected(item); - } - return true; - } - - /** - * Shares the playlist as a list of stream URLs if {@code shouldSharePlaylistDetails} is - * set to {@code false}. Shares the playlist name along with a list of video titles and URLs - * if {@code shouldSharePlaylistDetails} is set to {@code true}. - * - * @param shouldSharePlaylistDetails Whether the playlist details should be included in the - * shared content. - */ - private void sharePlaylist(final boolean shouldSharePlaylistDetails) { - final Context context = requireContext(); - - disposables.add(playlistManager.getPlaylistStreams(playlistId) - .flatMapSingle(playlist -> Single.just(playlist.stream() - .map(PlaylistStreamEntry::getStreamEntity) - .map(streamEntity -> { - if (shouldSharePlaylistDetails) { - return context.getString(R.string.video_details_list_item, - streamEntity.getTitle(), streamEntity.getUrl()); - } else { - return streamEntity.getUrl(); - } - }) - .collect(Collectors.joining("\n")))) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(urlsText -> ShareUtils.shareText( - context, name, shouldSharePlaylistDetails - ? context.getString(R.string.share_playlist_content_details, - name, urlsText) : urlsText), - 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; - - if (removePartiallyWatched) { - for (final var playlistItem : playlist) { - final int indexInHistory = Collections.binarySearch(historyStreamIds, - playlistItem.getStreamId()); - - if (indexInHistory < 0) { - itemsToKeep.add(playlistItem); - } else if (!isThumbnailPermanent && !thumbnailVideoRemoved - && playlistManager.getPlaylistThumbnailStreamId(playlistId) - == playlistItem.getStreamEntity().getUid()) { - thumbnailVideoRemoved = true; - } - } - } else { - 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 || (streamStateEntity != null - && !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(); - - 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 DialogEditTextBinding 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(); - - 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(); - } - - /** - *

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) { - streamIds.add(((PlaylistStreamEntry) item).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 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)) - ); - } - } - - 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) - .setMessage(R.string.share_playlist_with_titles_message) - .setCancelable(true) - .setPositiveButton(R.string.share_playlist_with_titles, (dialog, which) -> - sharePlaylist(/* shouldSharePlaylistDetails= */ true) - ) - .setNegativeButton(R.string.share_playlist_with_list, (dialog, which) -> - sharePlaylist(/* shouldSharePlaylistDetails= */ false) - ) - .show(); - } - - public void setTabsPagerAdapter( - @Nullable final MainFragment.SelectedTabsPagerAdapter tabsPagerAdapter) { - this.tabsPagerAdapter = tabsPagerAdapter; - } -} - diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.kt b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.kt new file mode 100644 index 000000000..98ecf764e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.kt @@ -0,0 +1,798 @@ +package org.schabi.newpipe.local.playlist + +import android.content.Context +import android.content.DialogInterface +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.Toast +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import androidx.viewbinding.ViewBinding +import icepick.State +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Maybe +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.core.SingleSource +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.functions.Action +import io.reactivex.rxjava3.functions.BiFunction +import io.reactivex.rxjava3.functions.Consumer +import io.reactivex.rxjava3.schedulers.Schedulers +import org.reactivestreams.Subscriber +import org.reactivestreams.Subscription +import org.schabi.newpipe.BaseFragment +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.database.stream.model.StreamStateEntity +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.ErrorUtil.Companion.showUiErrorSnackbar +import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.fragments.MainFragment.SelectedTabsPagerAdapter +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.info_list.dialog.StreamDialogEntry.StreamDialogEntryAction +import org.schabi.newpipe.ktx.animate +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.Localization +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.OnClickGesture +import org.schabi.newpipe.util.PlayButtonHelper +import org.schabi.newpipe.util.ThemeHelper +import org.schabi.newpipe.util.debounce.DebounceSavable +import org.schabi.newpipe.util.debounce.DebounceSaver +import java.util.Collections +import java.util.concurrent.atomic.AtomicBoolean +import java.util.function.Predicate +import java.util.stream.Collectors +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.sign + +class LocalPlaylistFragment() : BaseLocalListFragment?, Void?>(), PlaylistControlViewHolder, DebounceSavable { + @State + protected var playlistId: Long? = null + + @State + protected var name: String? = null + + @State + var itemsListState: Parcelable? = null + private var headerBinding: LocalPlaylistHeaderBinding? = null + private var playlistControlBinding: PlaylistControlBinding? = null + private var itemTouchHelper: ItemTouchHelper? = null + private var playlistManager: LocalPlaylistManager? = null + private var databaseSubscription: Subscription? = null + private var disposables: CompositeDisposable? = null + + /** Whether the playlist has been fully loaded from db. */ + private var isLoadingComplete: AtomicBoolean? = null + + /** Used to debounce saving playlist edits to disk. */ + private var debounceSaver: DebounceSaver? = null + + /** Flag to prevent simultaneous rewrites of the playlist. */ + private var isRewritingPlaylist: Boolean = false + + /** + * The pager adapter that the fragment is created from when it is used as frontpage, i.e. + * [.useAsFrontPage] is [true]. + */ + private var tabsPagerAdapter: SelectedTabsPagerAdapter? = null + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle - Creation + /////////////////////////////////////////////////////////////////////////// + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + playlistManager = LocalPlaylistManager(NewPipeDatabase.getInstance(requireContext())) + disposables = CompositeDisposable() + isLoadingComplete = AtomicBoolean() + debounceSaver = DebounceSaver(this) + } + + public override fun onCreateView(inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_playlist, container, false) + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Lifecycle - Views + /////////////////////////////////////////////////////////////////////////// + public override fun setTitle(title: String?) { + super.setTitle(title) + if (headerBinding != null) { + headerBinding!!.playlistTitleView.setText(title) + } + } + + override fun initViews(rootView: View, savedInstanceState: Bundle?) { + super.initViews(rootView, savedInstanceState) + setTitle(name) + } + + protected override val listHeader: ViewBinding? + protected get() { + headerBinding = LocalPlaylistHeaderBinding.inflate(activity!!.getLayoutInflater(), itemsList, + false) + playlistControlBinding = headerBinding!!.playlistControl + headerBinding!!.playlistTitleView.setSelected(true) + return headerBinding + } + + override fun initListeners() { + super.initListeners() + headerBinding!!.playlistTitleView.setOnClickListener(View.OnClickListener({ view: View? -> createRenameDialog() })) + itemTouchHelper = ItemTouchHelper(itemTouchCallback) + itemTouchHelper!!.attachToRecyclerView(itemsList) + itemListAdapter!!.setSelectedListener(object : OnClickGesture { + public override fun selected(selectedItem: LocalItem?) { + if (selectedItem is PlaylistStreamEntry) { + val item: StreamEntity = selectedItem.streamEntity + NavigationHelper.openVideoDetailFragment(requireContext(), getFM(), + item.serviceId, item.url, item.title, null, false) + } + } + + public override fun held(selectedItem: LocalItem?) { + if (selectedItem is PlaylistStreamEntry) { + showInfoItemDialog(selectedItem) + } + } + + public override fun drag(selectedItem: LocalItem?, + viewHolder: RecyclerView.ViewHolder?) { + if (itemTouchHelper != null) { + itemTouchHelper!!.startDrag((viewHolder)!!) + } + } + }) + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Lifecycle - Loading + /////////////////////////////////////////////////////////////////////////// + public override fun showLoading() { + super.showLoading() + if (headerBinding != null) { + headerBinding!!.getRoot().animate(false, 200) + playlistControlBinding!!.getRoot().animate(false, 200) + } + } + + public override fun hideLoading() { + super.hideLoading() + if (headerBinding != null) { + headerBinding!!.getRoot().animate(true, 200) + playlistControlBinding!!.getRoot().animate(true, 200) + } + } + + public override fun startLoading(forceLoad: Boolean) { + 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(playlistObserver) + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment Lifecycle - Destruction + /////////////////////////////////////////////////////////////////////////// + public override fun onPause() { + super.onPause() + itemsListState = itemsList!!.getLayoutManager()!!.onSaveInstanceState() + + // Save on exit + saveImmediate() + } + + public override fun onCreateOptionsMenu(menu: Menu, + inflater: MenuInflater) { + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, ("onCreateOptionsMenu() called with: " + + "menu = [" + menu + "], inflater = [" + inflater + "]")) + } + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.menu_local_playlist, menu) + } + + public override fun 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 + } + + public override fun 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 + } + + private val playlistObserver: Subscriber?> + /////////////////////////////////////////////////////////////////////////// + private get() { + return object : Subscriber?> { + public override fun onSubscribe(s: Subscription) { + showLoading() + isLoadingComplete!!.set(false) + if (databaseSubscription != null) { + databaseSubscription!!.cancel() + } + databaseSubscription = s + databaseSubscription!!.request(1) + } + + public override fun onNext(streams: List?) { + // 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) + } + } + + public override fun onError(exception: Throwable) { + showError(ErrorInfo(exception, UserAction.REQUESTED_BOOKMARK, + "Loading local playlist")) + } + + public override fun onComplete() {} + } + } + + public override fun onOptionsItemSelected(item: MenuItem): Boolean { + 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) { + AlertDialog.Builder(requireContext()) + .setMessage(R.string.remove_watched_popup_warning) + .setTitle(R.string.remove_watched_popup_title) + .setPositiveButton(R.string.ok, DialogInterface.OnClickListener({ d: DialogInterface?, id: Int -> removeWatchedStreams(false) })) + .setNeutralButton( + R.string.remove_watched_popup_yes_and_partially_watched_videos, + DialogInterface.OnClickListener({ d: DialogInterface?, id: Int -> removeWatchedStreams(true) })) + .setNegativeButton(R.string.cancel, + DialogInterface.OnClickListener({ d: DialogInterface, id: Int -> d.cancel() })) + .show() + } + } else if (item.getItemId() == R.id.menu_item_remove_duplicates) { + if (!isRewritingPlaylist) { + openRemoveDuplicatesDialog() + } + } else { + return super.onOptionsItemSelected(item) + } + return true + } + + /** + * Shares the playlist as a list of stream URLs if `shouldSharePlaylistDetails` is + * set to `false`. Shares the playlist name along with a list of video titles and URLs + * if `shouldSharePlaylistDetails` is set to `true`. + * + * @param shouldSharePlaylistDetails Whether the playlist details should be included in the + * shared content. + */ + private fun sharePlaylist(shouldSharePlaylistDetails: Boolean) { + val context: Context = requireContext() + disposables!!.add(playlistManager!!.getPlaylistStreams((playlistId)!!) + .flatMapSingle(io.reactivex.rxjava3.functions.Function, SingleSource>({ playlist: List -> + Single.just(playlist.stream() + .map(PlaylistStreamEntry::streamEntity) + .map(java.util.function.Function({ streamEntity: StreamEntity -> + if (shouldSharePlaylistDetails) { + return@map context.getString(R.string.video_details_list_item, + streamEntity.title, streamEntity.url) + } else { + return@map streamEntity.url + } + })) + .collect(Collectors.joining("\n"))) + })) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer({ urlsText: String? -> + shareText( + context, name, if (shouldSharePlaylistDetails) context.getString(R.string.share_playlist_content_details, + name, urlsText) else urlsText) + }), + Consumer({ throwable: Throwable? -> showUiErrorSnackbar(this, "Sharing playlist", (throwable)!!) }))) + } + + fun removeWatchedStreams(removePartiallyWatched: Boolean) { + if (isRewritingPlaylist) { + return + } + isRewritingPlaylist = true + showLoading() + val recordManager: HistoryRecordManager = HistoryRecordManager(getContext()) + val historyIdsMaybe: Maybe> = recordManager.getStreamHistorySortedById() + .firstElement() // already sorted by ^ getStreamHistorySortedById(), binary search can be used + .map(io.reactivex.rxjava3.functions.Function?, List>({ historyList: List? -> + historyList!!.stream().map(StreamHistoryEntry::streamId) + .collect(Collectors.toList()) + })) + val streamsMaybe: Maybe, Boolean>> = playlistManager!!.getPlaylistStreams((playlistId)!!) + .firstElement() + .zipWith(historyIdsMaybe, BiFunction?, List, Pair, Boolean>>({ playlist: List?, historyStreamIds: List? -> + // Remove Watched, Functionality data + val itemsToKeep: MutableList = ArrayList() + val isThumbnailPermanent: Boolean = playlistManager + .getIsPlaylistThumbnailPermanent((playlistId)!!) + var thumbnailVideoRemoved: Boolean = false + if (removePartiallyWatched) { + for (playlistItem: PlaylistStreamEntry? in playlist!!) { + val indexInHistory: Int = Collections.binarySearch(historyStreamIds, + playlistItem!!.streamId) + if (indexInHistory < 0) { + itemsToKeep.add(playlistItem) + } else if ((!isThumbnailPermanent && !thumbnailVideoRemoved + && ((playlistManager!!.getPlaylistThumbnailStreamId((playlistId)!!) + == playlistItem.streamEntity.uid)))) { + thumbnailVideoRemoved = true + } + } + } else { + val streamStates: List? = recordManager + .loadLocalStreamStateBatch(playlist).blockingGet() + for (i in playlist!!.indices) { + val playlistItem: PlaylistStreamEntry? = playlist.get(i) + val streamStateEntity: StreamStateEntity? = streamStates!!.get(i) + val indexInHistory: Int = Collections.binarySearch(historyStreamIds, + playlistItem!!.streamId) + val duration: Long = playlistItem.toStreamInfoItem().getDuration() + if (indexInHistory < 0 || ((streamStateEntity != null + && !streamStateEntity.isFinished(duration)))) { + itemsToKeep.add(playlistItem) + } else if ((!isThumbnailPermanent && !thumbnailVideoRemoved + && ((playlistManager!!.getPlaylistThumbnailStreamId((playlistId)!!) + == playlistItem.streamEntity.uid)))) { + thumbnailVideoRemoved = true + } + } + } + Pair(itemsToKeep, thumbnailVideoRemoved) + })) + disposables!!.add(streamsMaybe.subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer({ flow: Pair, Boolean> -> + val itemsToKeep: List = flow.first + val thumbnailVideoRemoved: Boolean = flow.second + itemListAdapter!!.clearStreamItemList() + itemListAdapter!!.addItems(itemsToKeep) + debounceSaver!!.setHasChangesToSave() + if (thumbnailVideoRemoved) { + updateThumbnailUrl() + } + val videoCount: Long = itemListAdapter.getItemsList().size.toLong() + setStreamCountAndOverallDuration(itemListAdapter.getItemsList()) + if (videoCount == 0L) { + showEmptyState() + } + hideLoading() + isRewritingPlaylist = false + }), Consumer({ throwable: Throwable? -> + showError(ErrorInfo((throwable)!!, UserAction.REQUESTED_BOOKMARK, + "Removing watched videos, partially watched=" + removePartiallyWatched)) + }))) + } + + public override fun handleResult(result: List) { + 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 fun resetFragment() { + super.resetFragment() + if (databaseSubscription != null) { + databaseSubscription!!.cancel() + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Playlist Metadata/Streams Manipulation + ////////////////////////////////////////////////////////////////////////// */ + private fun createRenameDialog() { + if ((playlistId == null) || (name == null) || (getContext() == null)) { + return + } + val dialogBinding: DialogEditTextBinding = 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) + AlertDialog.Builder((getContext())!!) + .setTitle(R.string.rename_playlist) + .setView(dialogBinding.getRoot()) + .setCancelable(true) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.rename, DialogInterface.OnClickListener({ dialogInterface: DialogInterface?, i: Int -> changePlaylistName(dialogBinding.dialogEditText.getText().toString()) })) + .show() + } + + private fun changePlaylistName(title: String) { + if (playlistManager == null) { + return + } + name = title + setTitle(title) + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, ("Updating playlist id=[" + playlistId + "] " + + "with new title=[" + title + "] items")) + } + val disposable: Disposable = playlistManager!!.renamePlaylist((playlistId)!!, title) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer({ longs: Int? -> }), Consumer({ throwable: Throwable? -> + showError(ErrorInfo((throwable)!!, UserAction.REQUESTED_BOOKMARK, + "Renaming playlist")) + })) + disposables!!.add(disposable) + } + + private fun changeThumbnailStreamId(thumbnailStreamId: Long, isPermanent: Boolean) { + if (playlistManager == null || (!isPermanent && playlistManager!! + .getIsPlaylistThumbnailPermanent((playlistId)!!))) { + return + } + val successToast: Toast = Toast.makeText(getActivity(), + R.string.playlist_thumbnail_change_success, + Toast.LENGTH_SHORT) + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, ("Updating playlist id=[" + playlistId + "] " + + "with new thumbnail stream id=[" + thumbnailStreamId + "]")) + } + val disposable: Disposable = playlistManager!! + .changePlaylistThumbnail((playlistId)!!, thumbnailStreamId, isPermanent) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer({ ignore: Int? -> successToast.show() }), Consumer({ throwable: Throwable? -> + showError(ErrorInfo((throwable)!!, UserAction.REQUESTED_BOOKMARK, + "Changing playlist thumbnail")) + })) + disposables!!.add(disposable) + } + + private fun updateThumbnailUrl() { + if (playlistManager!!.getIsPlaylistThumbnailPermanent((playlistId)!!)) { + return + } + val thumbnailStreamId: Long + if (!itemListAdapter.getItemsList().isEmpty()) { + thumbnailStreamId = (itemListAdapter.getItemsList().get(0) as PlaylistStreamEntry?) + .streamEntity.uid + } else { + thumbnailStreamId = PlaylistEntity.Companion.DEFAULT_THUMBNAIL_ID + } + changeThumbnailStreamId(thumbnailStreamId, false) + } + + private fun openRemoveDuplicatesDialog() { + AlertDialog.Builder((getActivity())!!) + .setTitle(R.string.remove_duplicates_title) + .setMessage(R.string.remove_duplicates_message) + .setPositiveButton(R.string.ok, DialogInterface.OnClickListener({ dialog: DialogInterface?, i: Int -> removeDuplicatesInPlaylist() })) + .setNeutralButton(R.string.cancel, null) + .show() + } + + private fun removeDuplicatesInPlaylist() { + if (isRewritingPlaylist) { + return + } + isRewritingPlaylist = true + showLoading() + val streamsMaybe: Maybe?> = playlistManager + .getDistinctPlaylistStreams((playlistId)!!).firstElement() + disposables!!.add(streamsMaybe.subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer({ itemsToKeep: List? -> + itemListAdapter!!.clearStreamItemList() + itemListAdapter!!.addItems(itemsToKeep) + setStreamCountAndOverallDuration(itemListAdapter.getItemsList()) + debounceSaver!!.setHasChangesToSave() + hideLoading() + isRewritingPlaylist = false + }), Consumer({ throwable: Throwable? -> + showError(ErrorInfo((throwable)!!, UserAction.REQUESTED_BOOKMARK, + "Removing duplicated streams")) + }))) + } + + private fun deleteItem(item: PlaylistStreamEntry) { + if (itemListAdapter == null) { + return + } + itemListAdapter!!.removeItem(item) + if (playlistManager!!.getPlaylistThumbnailStreamId((playlistId)!!) == item.streamId) { + updateThumbnailUrl() + } + setStreamCountAndOverallDuration(itemListAdapter.getItemsList()) + debounceSaver!!.setHasChangesToSave() + } + + /** + * + * 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. + */ + public override fun 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 + } + val items: List? = itemListAdapter.getItemsList() + val streamIds: MutableList = ArrayList(items!!.size) + for (item: LocalItem? in items) { + if (item is PlaylistStreamEntry) { + streamIds.add(item.streamId) + } + } + if (BaseFragment.Companion.DEBUG) { + Log.d(TAG, ("Updating playlist id=[" + playlistId + "] " + + "with [" + streamIds.size + "] items")) + } + val disposable: Disposable = playlistManager!!.updateJoin((playlistId)!!, streamIds) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + Action({ + if (debounceSaver != null) { + debounceSaver!!.setNoChangesToSave() + } + }), + Consumer({ throwable: Throwable? -> + showError(ErrorInfo((throwable)!!, + UserAction.REQUESTED_BOOKMARK, "Saving playlist")) + }) + ) + disposables!!.add(disposable) + } + + private val itemTouchCallback: ItemTouchHelper.SimpleCallback + private get() { + var directions: Int = ItemTouchHelper.UP or ItemTouchHelper.DOWN + if (ThemeHelper.shouldUseGridLayout(requireContext())) { + directions = directions or (ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT) + } + return object : ItemTouchHelper.SimpleCallback(directions, + ItemTouchHelper.ACTION_STATE_IDLE) { + public override fun interpolateOutOfBoundsScroll(recyclerView: RecyclerView, + viewSize: Int, + viewSizeOutOfBounds: Int, + totalSize: Int, + msSinceStartScroll: Long): Int { + val standardSpeed: Int = super.interpolateOutOfBoundsScroll(recyclerView, + viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll) + val minimumAbsVelocity: Int = max(MINIMUM_INITIAL_DRAG_VELOCITY.toDouble(), abs(standardSpeed.toDouble())).toInt() + return minimumAbsVelocity * sign(viewSizeOutOfBounds.toDouble()).toInt() + } + + public override fun onMove(recyclerView: RecyclerView, + source: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder): Boolean { + if ((source.getItemViewType() != target.getItemViewType() + || itemListAdapter == null)) { + return false + } + val sourceIndex: Int = source.getBindingAdapterPosition() + val targetIndex: Int = target.getBindingAdapterPosition() + val isSwapped: Boolean = itemListAdapter!!.swapItems(sourceIndex, targetIndex) + if (isSwapped) { + debounceSaver!!.setHasChangesToSave() + } + return isSwapped + } + + public override fun isLongPressDragEnabled(): Boolean { + return false + } + + public override fun isItemViewSwipeEnabled(): Boolean { + return false + } + + public override fun onSwiped(viewHolder: RecyclerView.ViewHolder, + swipeDir: Int) { + } + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + ////////////////////////////////////////////////////////////////////////// */ + private fun getPlayQueueStartingAt(infoItem: PlaylistStreamEntry): PlayQueue { + return getPlayQueue(max(itemListAdapter.getItemsList().indexOf(infoItem).toDouble(), 0.0).toInt()) + } + + protected fun showInfoItemDialog(item: PlaylistStreamEntry) { + val infoItem: StreamInfoItem = item.toStreamInfoItem() + try { + val context: Context? = getContext() + val dialogBuilder: InfoItemDialog.Builder = 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, + StreamDialogEntryAction({ f: Fragment?, i: StreamInfoItem? -> + NavigationHelper.playOnBackgroundPlayer( + context, getPlayQueueStartingAt(item), true) + })) + .setAction( + StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL, + StreamDialogEntryAction({ f: Fragment?, i: StreamInfoItem? -> + changeThumbnailStreamId(item.streamEntity.uid, + true) + })) + .setAction( + StreamDialogDefaultEntry.DELETE, + StreamDialogEntryAction({ f: Fragment?, i: StreamInfoItem? -> deleteItem(item) })) + .create() + .show() + } catch (e: IllegalArgumentException) { + InfoItemDialog.Builder.Companion.reportErrorDuringInitialization(e, infoItem) + } + } + + private fun setInitialData(pid: Long, title: String?) { + playlistId = pid + name = if (!TextUtils.isEmpty(title)) title else "" + } + + private fun setStreamCountAndOverallDuration(itemsList: ArrayList?) { + if (activity != null && headerBinding != null) { + val streamCount: Long = itemsList!!.size.toLong() + val playlistOverallDurationSeconds: Long = itemsList.stream() + .filter(Predicate({ obj: LocalItem? -> PlaylistStreamEntry::class.java.isInstance(obj) })) + .map(java.util.function.Function({ obj: LocalItem? -> PlaylistStreamEntry::class.java.cast(obj) })) + .map(PlaylistStreamEntry::streamEntity) + .mapToLong(StreamEntity::duration) + .sum() + headerBinding!!.playlistStreamCount.setText( + Localization.concatenateStrings( + Localization.localizeStreamCount(activity!!, streamCount), + Localization.getDurationString(playlistOverallDurationSeconds)) + ) + } + } + + override val playQueue: PlayQueue + get() { + return getPlayQueue(0) + } + + private fun getPlayQueue(index: Int): PlayQueue { + if (itemListAdapter == null) { + return SinglePlayQueue(emptyList(), 0) + } + val infoItems: List? = itemListAdapter.getItemsList() + val streamInfoItems: MutableList = ArrayList(infoItems!!.size) + for (item: LocalItem? in infoItems) { + if (item is PlaylistStreamEntry) { + streamInfoItems.add(item.toStreamInfoItem()) + } + } + return 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 fun createShareConfirmationDialog() { + AlertDialog.Builder(requireContext()) + .setTitle(R.string.share_playlist) + .setMessage(R.string.share_playlist_with_titles_message) + .setCancelable(true) + .setPositiveButton(R.string.share_playlist_with_titles, DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> sharePlaylist( /* shouldSharePlaylistDetails= */true) }) + ) + .setNegativeButton(R.string.share_playlist_with_list, DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> sharePlaylist( /* shouldSharePlaylistDetails= */false) }) + ) + .show() + } + + fun setTabsPagerAdapter( + tabsPagerAdapter: SelectedTabsPagerAdapter?) { + this.tabsPagerAdapter = tabsPagerAdapter + } + + companion object { + private val MINIMUM_INITIAL_DRAG_VELOCITY: Int = 12 + fun getInstance(playlistId: Long, name: String?): LocalPlaylistFragment { + val instance: LocalPlaylistFragment = LocalPlaylistFragment() + instance.setInitialData(playlistId, name) + return instance + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java deleted file mode 100644 index dd9307675..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java +++ /dev/null @@ -1,189 +0,0 @@ -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) - .getIsThumbnailPermanent(); - } - - 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.setIsThumbnailPermanent(isPermanent); - } - return playlistTable.update(playlist); - }).subscribeOn(Schedulers.io()); - } - - public Maybe hasPlaylists() { - return playlistTable.getCount() - .firstElement() - .map(count -> count > 0) - .subscribeOn(Schedulers.io()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.kt b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.kt new file mode 100644 index 000000000..e5e83f565 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.kt @@ -0,0 +1,191 @@ +package org.schabi.newpipe.local.playlist + +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Maybe +import io.reactivex.rxjava3.functions.Function +import io.reactivex.rxjava3.functions.Predicate +import io.reactivex.rxjava3.schedulers.Schedulers +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.concurrent.Callable + +class LocalPlaylistManager(private val database: AppDatabase) { + private val streamTable: StreamDAO? + private val playlistTable: PlaylistDAO? + private val playlistStreamTable: PlaylistStreamDAO? + + init { + streamTable = database.streamDAO() + playlistTable = database.playlistDAO() + playlistStreamTable = database.playlistStreamDAO() + } + + fun createPlaylist(name: String?, streams: List?): Maybe> { + // 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(Callable>({ + database.runInTransaction>(Callable({ + val streamIds: List = streamTable!!.upsertAll(streams) + val newPlaylist: PlaylistEntity = PlaylistEntity(name, false, + streamIds.get(0), -1) + insertJoinEntities(playlistTable!!.insert(newPlaylist), + streamIds, 0) + }) + ) + })).subscribeOn(Schedulers.io()) + } + + fun appendToPlaylist(playlistId: Long, + streams: List?): Maybe> { + return playlistStreamTable!!.getMaximumIndexOf(playlistId) + .firstElement() + .map(Function>({ maxJoinIndex: Int? -> + database.runInTransaction>(Callable({ + val streamIds: List = streamTable!!.upsertAll(streams) + insertJoinEntities(playlistId, streamIds, maxJoinIndex!! + 1) + }) + ) + })).subscribeOn(Schedulers.io()) + } + + private fun insertJoinEntities(playlistId: Long, streamIds: List, + indexOffset: Int): List? { + val joinEntities: MutableList = ArrayList(streamIds.size) + for (index in streamIds.indices) { + joinEntities.add(PlaylistStreamEntity(playlistId, streamIds.get(index), + index + indexOffset)) + } + return playlistStreamTable!!.insertAll(joinEntities) + } + + fun updateJoin(playlistId: Long, streamIds: List): Completable { + val joinEntities: MutableList = ArrayList(streamIds.size) + for (i in streamIds.indices) { + joinEntities.add(PlaylistStreamEntity(playlistId, streamIds.get(i), i)) + } + return Completable.fromRunnable(Runnable({ + database.runInTransaction(Runnable({ + playlistStreamTable!!.deleteBatch(playlistId) + playlistStreamTable.insertAll(joinEntities) + })) + })).subscribeOn(Schedulers.io()) + } + + fun updatePlaylists(updateItems: List, + deletedItems: List): Completable { + val items: MutableList = ArrayList(updateItems.size) + for (item: PlaylistMetadataEntry in updateItems) { + items.add(PlaylistEntity(item)) + } + return Completable.fromRunnable(Runnable({ + database.runInTransaction(Runnable({ + for (uid: Long in deletedItems) { + playlistTable!!.deletePlaylist(uid) + } + for (item: PlaylistEntity in items) { + playlistTable!!.upsertPlaylist(item) + } + })) + })).subscribeOn(Schedulers.io()) + } + + fun getDistinctPlaylistStreams(playlistId: Long): Flowable?> { + 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 [PlaylistDuplicatesEntry] + */ + fun getPlaylistDuplicates(streamUrl: String?): Flowable?> { + return playlistStreamTable!!.getPlaylistDuplicatesMetadata(streamUrl) + .subscribeOn(Schedulers.io()) + } + + val playlists: Flowable?> + get() { + return playlistStreamTable!!.getPlaylistMetadata().subscribeOn(Schedulers.io()) + } + + fun getPlaylistStreams(playlistId: Long): Flowable?> { + return playlistStreamTable!!.getOrderedStreamsOf(playlistId).subscribeOn(Schedulers.io()) + } + + fun renamePlaylist(playlistId: Long, name: String?): Maybe { + return modifyPlaylist(playlistId, name, THUMBNAIL_ID_LEAVE_UNCHANGED, false) + } + + fun changePlaylistThumbnail(playlistId: Long, + thumbnailStreamId: Long, + isPermanent: Boolean): Maybe { + return modifyPlaylist(playlistId, null, thumbnailStreamId, isPermanent) + } + + fun getPlaylistThumbnailStreamId(playlistId: Long): Long { + return playlistTable!!.getPlaylist(playlistId).blockingFirst().get(0)!!.getThumbnailStreamId() + } + + fun getIsPlaylistThumbnailPermanent(playlistId: Long): Boolean { + return playlistTable!!.getPlaylist(playlistId).blockingFirst().get(0) + .getIsThumbnailPermanent() + } + + fun getAutomaticPlaylistThumbnailStreamId(playlistId: Long): Long { + val streamId: Long = playlistStreamTable!!.getAutomaticThumbnailStreamId(playlistId) + .blockingFirst() + if (streamId < 0) { + return PlaylistEntity.Companion.DEFAULT_THUMBNAIL_ID + } + return streamId + } + + private fun modifyPlaylist(playlistId: Long, + name: String?, + thumbnailStreamId: Long, + isPermanent: Boolean): Maybe { + return playlistTable!!.getPlaylist(playlistId) + .firstElement() + .filter(Predicate?>({ playlistEntities: List? -> !playlistEntities!!.isEmpty() })) + .map(Function?, Int?>({ playlistEntities: List? -> + val playlist: PlaylistEntity = playlistEntities!!.get(0) + if (name != null) { + playlist.setName(name) + } + if (thumbnailStreamId != THUMBNAIL_ID_LEAVE_UNCHANGED) { + playlist.setThumbnailStreamId(thumbnailStreamId) + playlist.setIsThumbnailPermanent(isPermanent) + } + playlistTable.update(playlist) + })).subscribeOn(Schedulers.io()) + } + + fun hasPlaylists(): Maybe { + return playlistTable!!.getCount() + .firstElement() + .map(Function({ count: Long? -> count!! > 0 })) + .subscribeOn(Schedulers.io()) + } + + companion object { + private val THUMBNAIL_ID_LEAVE_UNCHANGED: Long = -2 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java deleted file mode 100644 index 4cc51f752..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.java +++ /dev/null @@ -1,65 +0,0 @@ -package org.schabi.newpipe.local.playlist; - -import org.schabi.newpipe.database.AppDatabase; -import org.schabi.newpipe.database.playlist.dao.PlaylistRemoteDAO; -import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; -import org.schabi.newpipe.extractor.playlist.PlaylistInfo; - -import java.util.List; - -import io.reactivex.rxjava3.core.Completable; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class RemotePlaylistManager { - - private final AppDatabase database; - private final PlaylistRemoteDAO playlistRemoteTable; - - public RemotePlaylistManager(final AppDatabase db) { - database = db; - playlistRemoteTable = db.playlistRemoteDAO(); - } - - public Flowable> getPlaylists() { - return playlistRemoteTable.getPlaylists().subscribeOn(Schedulers.io()); - } - - public Flowable> getPlaylist(final PlaylistInfo info) { - return playlistRemoteTable.getPlaylist(info.getServiceId(), info.getUrl()) - .subscribeOn(Schedulers.io()); - } - - public Single deletePlaylist(final long playlistId) { - return Single.fromCallable(() -> playlistRemoteTable.deletePlaylist(playlistId)) - .subscribeOn(Schedulers.io()); - } - - public Completable updatePlaylists(final List updateItems, - final List deletedItems) { - return Completable.fromRunnable(() -> database.runInTransaction(() -> { - for (final Long uid: deletedItems) { - playlistRemoteTable.deletePlaylist(uid); - } - for (final PlaylistRemoteEntity item: updateItems) { - playlistRemoteTable.upsert(item); - } - })).subscribeOn(Schedulers.io()); - } - - public Single onBookmark(final PlaylistInfo playlistInfo) { - return Single.fromCallable(() -> { - final PlaylistRemoteEntity playlist = new PlaylistRemoteEntity(playlistInfo); - return playlistRemoteTable.upsert(playlist); - }).subscribeOn(Schedulers.io()); - } - - public Single onUpdate(final long playlistId, final PlaylistInfo playlistInfo) { - return Single.fromCallable(() -> { - final PlaylistRemoteEntity playlist = new PlaylistRemoteEntity(playlistInfo); - playlist.setUid(playlistId); - return playlistRemoteTable.update(playlist); - }).subscribeOn(Schedulers.io()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.kt b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.kt new file mode 100644 index 000000000..5a8aab030 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/RemotePlaylistManager.kt @@ -0,0 +1,63 @@ +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.dao.PlaylistRemoteDAO +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity +import org.schabi.newpipe.extractor.playlist.PlaylistInfo +import java.util.concurrent.Callable + +class RemotePlaylistManager(private val database: AppDatabase) { + private val playlistRemoteTable: PlaylistRemoteDAO? + + init { + playlistRemoteTable = database.playlistRemoteDAO() + } + + val playlists: Flowable?> + get() { + return playlistRemoteTable!!.getPlaylists().subscribeOn(Schedulers.io()) + } + + fun getPlaylist(info: PlaylistInfo): Flowable?> { + return playlistRemoteTable!!.getPlaylist(info.getServiceId().toLong(), info.getUrl()) + .subscribeOn(Schedulers.io()) + } + + fun deletePlaylist(playlistId: Long): Single { + return Single.fromCallable(Callable({ playlistRemoteTable!!.deletePlaylist(playlistId) })) + .subscribeOn(Schedulers.io()) + } + + fun updatePlaylists(updateItems: List, + deletedItems: List): Completable { + return Completable.fromRunnable(Runnable({ + database.runInTransaction(Runnable({ + for (uid: Long in deletedItems) { + playlistRemoteTable!!.deletePlaylist(uid) + } + for (item: PlaylistRemoteEntity in updateItems) { + playlistRemoteTable!!.upsert(item) + } + })) + })).subscribeOn(Schedulers.io()) + } + + fun onBookmark(playlistInfo: PlaylistInfo): Single { + return Single.fromCallable(Callable({ + val playlist: PlaylistRemoteEntity = PlaylistRemoteEntity(playlistInfo) + playlistRemoteTable!!.upsert(playlist) + })).subscribeOn(Schedulers.io()) + } + + fun onUpdate(playlistId: Long, playlistInfo: PlaylistInfo): Single { + return Single.fromCallable(Callable({ + val playlist: PlaylistRemoteEntity = PlaylistRemoteEntity(playlistInfo) + playlist.setUid(playlistId) + playlistRemoteTable!!.update(playlist) + })).subscribeOn(Schedulers.io()) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java b/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java deleted file mode 100644 index da8e1070a..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java +++ /dev/null @@ -1,68 +0,0 @@ -package org.schabi.newpipe.local.subscription; - -import android.app.Dialog; -import android.content.Intent; -import android.os.Bundle; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.fragment.app.DialogFragment; -import androidx.fragment.app.Fragment; - -import org.schabi.newpipe.R; - -import icepick.Icepick; -import icepick.State; - -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - -public class ImportConfirmationDialog extends DialogFragment { - @State - protected Intent resultServiceIntent; - - public static void show(@NonNull final Fragment fragment, - @NonNull final Intent resultServiceIntent) { - final ImportConfirmationDialog confirmationDialog = new ImportConfirmationDialog(); - confirmationDialog.setResultServiceIntent(resultServiceIntent); - confirmationDialog.show(fragment.getParentFragmentManager(), null); - } - - public void setResultServiceIntent(final Intent resultServiceIntent) { - this.resultServiceIntent = resultServiceIntent; - } - - @NonNull - @Override - public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { - assureCorrectAppLanguage(getContext()); - return new AlertDialog.Builder(requireContext()) - .setMessage(R.string.import_network_expensive_warning) - .setCancelable(true) - .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.ok, (dialogInterface, i) -> { - if (resultServiceIntent != null && getContext() != null) { - getContext().startService(resultServiceIntent); - } - dismiss(); - }) - .create(); - } - - @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - if (resultServiceIntent == null) { - throw new IllegalStateException("Result intent is null"); - } - - Icepick.restoreInstanceState(this, savedInstanceState); - } - - @Override - public void onSaveInstanceState(@NonNull final Bundle outState) { - super.onSaveInstanceState(outState); - Icepick.saveInstanceState(this, outState); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.kt new file mode 100644 index 000000000..dab9b73e5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.kt @@ -0,0 +1,58 @@ +package org.schabi.newpipe.local.subscription + +import android.app.Dialog +import android.content.DialogInterface +import android.content.Intent +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import icepick.Icepick +import icepick.State +import org.schabi.newpipe.R +import org.schabi.newpipe.util.Localization + +class ImportConfirmationDialog() : DialogFragment() { + @State + protected var resultServiceIntent: Intent? = null + fun setResultServiceIntent(resultServiceIntent: Intent?) { + this.resultServiceIntent = resultServiceIntent + } + + public override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + Localization.assureCorrectAppLanguage(getContext()) + return AlertDialog.Builder(requireContext()) + .setMessage(R.string.import_network_expensive_warning) + .setCancelable(true) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.ok, DialogInterface.OnClickListener({ dialogInterface: DialogInterface?, i: Int -> + if (resultServiceIntent != null && getContext() != null) { + getContext()!!.startService(resultServiceIntent) + } + dismiss() + })) + .create() + } + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (resultServiceIntent == null) { + throw IllegalStateException("Result intent is null") + } + Icepick.restoreInstanceState(this, savedInstanceState) + } + + public override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + Icepick.saveInstanceState(this, outState) + } + + companion object { + fun show(fragment: Fragment, + resultServiceIntent: Intent) { + val confirmationDialog: ImportConfirmationDialog = ImportConfirmationDialog() + confirmationDialog.setResultServiceIntent(resultServiceIntent) + confirmationDialog.show(fragment.getParentFragmentManager(), null) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java deleted file mode 100644 index 56972b60d..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java +++ /dev/null @@ -1,228 +0,0 @@ -package org.schabi.newpipe.local.subscription; - -import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.CHANNEL_URL_MODE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.INPUT_STREAM_MODE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE; - -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 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.services.SubscriptionsImportService; -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; - -import icepick.State; - -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, - ServiceHelper.getNameOfServiceById(currentServiceId), - "Service does not support importing subscriptions", - 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 Intent(activity, SubscriptionsImportService.class) - .putExtra(KEY_MODE, CHANNEL_URL_MODE) - .putExtra(KEY_VALUE, value) - .putExtra(Constants.KEY_SERVICE_ID, currentServiceId)); - } - - 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) { - if (result.getData() == null) { - return; - } - - if (result.getResultCode() == Activity.RESULT_OK && result.getData().getData() != null) { - ImportConfirmationDialog.show(this, - new Intent(activity, SubscriptionsImportService.class) - .putExtra(KEY_MODE, INPUT_STREAM_MODE) - .putExtra(KEY_VALUE, result.getData().getData()) - .putExtra(Constants.KEY_SERVICE_ID, currentServiceId)); - } - } - - /////////////////////////////////////////////////////////////////////////// - // 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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.kt new file mode 100644 index 000000000..6a2ea5ac9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.kt @@ -0,0 +1,194 @@ +package org.schabi.newpipe.local.subscription + +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.ActivityResultCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.StringRes +import androidx.appcompat.app.ActionBar +import androidx.core.text.util.LinkifyCompat +import icepick.State +import org.schabi.newpipe.BaseFragment +import org.schabi.newpipe.R +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.ErrorUtil.Companion.showSnackbar +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.extractor.subscription.SubscriptionExtractor.ContentSource +import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService +import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard +import org.schabi.newpipe.streams.io.StoredFileHelper +import org.schabi.newpipe.util.ServiceHelper + +class SubscriptionsImportFragment() : BaseFragment() { + @State + var currentServiceId: Int = NO_SERVICE_ID + private var supportedSources: List? = null + private var relatedUrl: String? = null + + @StringRes + private var instructionsString: Int = 0 + + /*////////////////////////////////////////////////////////////////////////// + // Views + ////////////////////////////////////////////////////////////////////////// */ + private var infoTextView: TextView? = null + private var inputText: EditText? = null + private var inputButton: Button? = null + private val requestImportFileLauncher: ActivityResultLauncher = registerForActivityResult(StartActivityForResult(), ActivityResultCallback({ result: ActivityResult -> requestImportFileResult(result) })) + private fun setInitialData(serviceId: Int) { + currentServiceId = serviceId + } + + /////////////////////////////////////////////////////////////////////////// + // Fragment LifeCycle + /////////////////////////////////////////////////////////////////////////// + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setupServiceVariables() + if (supportedSources!!.isEmpty() && currentServiceId != NO_SERVICE_ID) { + showSnackbar((activity)!!, + ErrorInfo(arrayOf(), UserAction.SUBSCRIPTION_IMPORT_EXPORT, + ServiceHelper.getNameOfServiceById(currentServiceId), + "Service does not support importing subscriptions", + R.string.general_error)) + activity!!.finish() + } + } + + public override fun onResume() { + super.onResume() + setTitle(getString(R.string.import_title)) + } + + public override fun onCreateView(inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_import, container, false) + } + + /*///////////////////////////////////////////////////////////////////////// + // Fragment Views + ///////////////////////////////////////////////////////////////////////// */ + override fun initViews(rootView: View, savedInstanceState: Bundle?) { + 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(ContentSource.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("") + } + val supportActionBar: ActionBar? = activity!!.getSupportActionBar() + if (supportActionBar != null) { + supportActionBar.setDisplayShowTitleEnabled(true) + setTitle(getString(R.string.import_title)) + } + } + + override fun initListeners() { + super.initListeners() + inputButton!!.setOnClickListener(View.OnClickListener({ v: View? -> onImportClicked() })) + } + + private fun onImportClicked() { + if (inputText!!.getVisibility() == View.VISIBLE) { + val value: String = inputText!!.getText().toString() + if (!value.isEmpty()) { + onImportUrl(value) + } + } else { + onImportFile() + } + } + + fun onImportUrl(value: String?) { + ImportConfirmationDialog.Companion.show(this, Intent(activity, SubscriptionsImportService::class.java) + .putExtra(SubscriptionsImportService.Companion.KEY_MODE, SubscriptionsImportService.Companion.CHANNEL_URL_MODE) + .putExtra(SubscriptionsImportService.Companion.KEY_VALUE, value) + .putExtra(KEY_SERVICE_ID, currentServiceId)) + } + + fun onImportFile() { + NoFileManagerSafeGuard.launchSafe( + requestImportFileLauncher, // leave */* mime type to support all services + // with different mime types and file extensions + StoredFileHelper.Companion.getPicker((activity)!!, "*/*"), + TAG, + getContext() + ) + } + + private fun requestImportFileResult(result: ActivityResult) { + if (result.getData() == null) { + return + } + if (result.getResultCode() == Activity.RESULT_OK && result.getData()!!.getData() != null) { + ImportConfirmationDialog.Companion.show(this, + Intent(activity, SubscriptionsImportService::class.java) + .putExtra(SubscriptionsImportService.Companion.KEY_MODE, SubscriptionsImportService.Companion.INPUT_STREAM_MODE) + .putExtra(SubscriptionsImportService.Companion.KEY_VALUE, result.getData()!!.getData()) + .putExtra(KEY_SERVICE_ID, currentServiceId)) + } + } + + /////////////////////////////////////////////////////////////////////////// + // Subscriptions + /////////////////////////////////////////////////////////////////////////// + private fun setupServiceVariables() { + if (currentServiceId != NO_SERVICE_ID) { + try { + val extractor: SubscriptionExtractor = NewPipe.getService(currentServiceId) + .getSubscriptionExtractor() + supportedSources = extractor.getSupportedSources() + relatedUrl = extractor.getRelatedUrl() + instructionsString = ServiceHelper.getImportInstructions(currentServiceId) + return + } catch (ignored: ExtractionException) { + } + } + supportedSources = emptyList() + relatedUrl = null + instructionsString = 0 + } + + private fun setInfoText(infoString: String) { + infoTextView!!.setText(infoString) + LinkifyCompat.addLinks((infoTextView)!!, Linkify.WEB_URLS) + } + + companion object { + fun getInstance(serviceId: Int): SubscriptionsImportFragment { + val instance: SubscriptionsImportFragment = SubscriptionsImportFragment() + instance.setInitialData(serviceId) + return instance + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java deleted file mode 100644 index b7c11b160..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.java +++ /dev/null @@ -1,233 +0,0 @@ -/* - * Copyright 2018 Mauricio Colli - * BaseImportExportService.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.services; - -import android.app.Service; -import android.content.Intent; -import android.os.Build; -import android.os.IBinder; -import android.text.TextUtils; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; -import androidx.core.app.ServiceCompat; - -import org.reactivestreams.Publisher; -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.subscription.SubscriptionExtractor; -import org.schabi.newpipe.ktx.ExceptionUtils; -import org.schabi.newpipe.local.subscription.SubscriptionManager; - -import java.io.FileNotFoundException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.functions.Function; -import io.reactivex.rxjava3.processors.PublishProcessor; - -public abstract class BaseImportExportService extends Service { - protected final String TAG = this.getClass().getSimpleName(); - - protected final CompositeDisposable disposables = new CompositeDisposable(); - protected final PublishProcessor notificationUpdater = PublishProcessor.create(); - - protected NotificationManagerCompat notificationManager; - protected NotificationCompat.Builder notificationBuilder; - protected SubscriptionManager subscriptionManager; - - private static final int NOTIFICATION_SAMPLING_PERIOD = 2500; - - protected final AtomicInteger currentProgress = new AtomicInteger(-1); - protected final AtomicInteger maxProgress = new AtomicInteger(-1); - protected final ImportExportEventListener eventListener = new ImportExportEventListener() { - @Override - public void onSizeReceived(final int size) { - maxProgress.set(size); - currentProgress.set(0); - } - - @Override - public void onItemCompleted(final String itemName) { - currentProgress.incrementAndGet(); - notificationUpdater.onNext(itemName); - } - }; - - protected Toast toast; - - @Nullable - @Override - public IBinder onBind(final Intent intent) { - return null; - } - - @Override - public void onCreate() { - super.onCreate(); - subscriptionManager = new SubscriptionManager(this); - setupNotification(); - } - - @Override - public void onDestroy() { - super.onDestroy(); - disposeAll(); - } - - protected void disposeAll() { - disposables.clear(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Notification Impl - //////////////////////////////////////////////////////////////////////////*/ - - protected abstract int getNotificationId(); - - @StringRes - public abstract int getTitle(); - - protected void setupNotification() { - notificationManager = NotificationManagerCompat.from(this); - notificationBuilder = createNotification(); - startForeground(getNotificationId(), notificationBuilder.build()); - - final Function, Publisher> throttleAfterFirstEmission = flow -> - flow.take(1).concatWith(flow.skip(1) - .throttleLast(NOTIFICATION_SAMPLING_PERIOD, TimeUnit.MILLISECONDS)); - - disposables.add(notificationUpdater - .filter(s -> !s.isEmpty()) - .publish(throttleAfterFirstEmission) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(this::updateNotification)); - } - - protected void updateNotification(final String text) { - notificationBuilder - .setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1); - - final String progressText = currentProgress + "/" + maxProgress; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - if (!TextUtils.isEmpty(text)) { - notificationBuilder.setContentText(text + " (" + progressText + ")"); - } - } else { - notificationBuilder.setContentInfo(progressText); - notificationBuilder.setContentText(text); - } - - notificationManager.notify(getNotificationId(), notificationBuilder.build()); - } - - protected void stopService() { - postErrorResult(null, null); - } - - protected void stopAndReportError(final Throwable throwable, final String request) { - stopService(); - ErrorUtil.createNotification(this, new ErrorInfo( - throwable, UserAction.SUBSCRIPTION_IMPORT_EXPORT, request)); - } - - protected void postErrorResult(final String title, final String text) { - disposeAll(); - ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE); - stopSelf(); - - if (title == null) { - return; - } - - final String textOrEmpty = text == null ? "" : text; - notificationBuilder = new NotificationCompat - .Builder(this, getString(R.string.notification_channel_id)) - .setSmallIcon(R.drawable.ic_newpipe_triangle_white) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setContentTitle(title) - .setStyle(new NotificationCompat.BigTextStyle().bigText(textOrEmpty)) - .setContentText(textOrEmpty); - notificationManager.notify(getNotificationId(), notificationBuilder.build()); - } - - protected NotificationCompat.Builder createNotification() { - return new 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) - .setContentTitle(getString(getTitle())); - } - - /*////////////////////////////////////////////////////////////////////////// - // Toast - //////////////////////////////////////////////////////////////////////////*/ - - protected void showToast(@StringRes final int message) { - showToast(getString(message)); - } - - protected void showToast(final String message) { - if (toast != null) { - toast.cancel(); - } - - toast = Toast.makeText(this, message, Toast.LENGTH_SHORT); - toast.show(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Error handling - //////////////////////////////////////////////////////////////////////////*/ - - protected void handleError(@StringRes final int errorTitle, @NonNull final Throwable error) { - String message = getErrorMessage(error); - - if (TextUtils.isEmpty(message)) { - final String errorClassName = error.getClass().getName(); - message = getString(R.string.error_occurred_detail, errorClassName); - } - - showToast(errorTitle); - postErrorResult(getString(errorTitle), message); - } - - protected String getErrorMessage(final Throwable error) { - String message = null; - if (error instanceof SubscriptionExtractor.InvalidSourceException) { - message = getString(R.string.invalid_source); - } else if (error instanceof FileNotFoundException) { - message = getString(R.string.invalid_file); - } else if (ExceptionUtils.isNetworkRelated(error)) { - message = getString(R.string.network_error); - } - return message; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.kt new file mode 100644 index 000000000..51c4c8256 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/BaseImportExportService.kt @@ -0,0 +1,203 @@ +/* + * Copyright 2018 Mauricio Colli + * BaseImportExportService.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.services + +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.text.TextUtils +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.ServiceCompat +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.functions.Consumer +import io.reactivex.rxjava3.functions.Function +import io.reactivex.rxjava3.functions.Predicate +import io.reactivex.rxjava3.processors.PublishProcessor +import org.reactivestreams.Publisher +import org.schabi.newpipe.R +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.ErrorUtil.Companion.createNotification +import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException +import org.schabi.newpipe.ktx.isNetworkRelated +import org.schabi.newpipe.local.subscription.SubscriptionManager +import java.io.FileNotFoundException +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger + +abstract class BaseImportExportService() : Service() { + protected val TAG: String = this.javaClass.getSimpleName() + protected val disposables: CompositeDisposable = CompositeDisposable() + protected val notificationUpdater: PublishProcessor = PublishProcessor.create() + protected var notificationManager: NotificationManagerCompat? = null + protected var notificationBuilder: NotificationCompat.Builder? = null + protected var subscriptionManager: SubscriptionManager? = null + protected val currentProgress: AtomicInteger = AtomicInteger(-1) + protected val maxProgress: AtomicInteger = AtomicInteger(-1) + protected val eventListener: ImportExportEventListener = object : ImportExportEventListener { + public override fun onSizeReceived(size: Int) { + maxProgress.set(size) + currentProgress.set(0) + } + + public override fun onItemCompleted(itemName: String) { + currentProgress.incrementAndGet() + notificationUpdater.onNext(itemName) + } + } + protected var toast: Toast? = null + public override fun onBind(intent: Intent): IBinder? { + return null + } + + public override fun onCreate() { + super.onCreate() + subscriptionManager = SubscriptionManager(this) + setupNotification() + } + + public override fun onDestroy() { + super.onDestroy() + disposeAll() + } + + protected open fun disposeAll() { + disposables.clear() + } + + protected abstract val notificationId: Int + + @get:StringRes + abstract val title: Int + protected fun setupNotification() { + notificationManager = NotificationManagerCompat.from(this) + notificationBuilder = createNotification() + startForeground(notificationId, notificationBuilder!!.build()) + val throttleAfterFirstEmission: Function, Publisher> = Function({ flow: Flowable -> + flow.take(1).concatWith(flow.skip(1) + .throttleLast(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS)) + }) + disposables.add(notificationUpdater + .filter(Predicate({ s: String -> !s.isEmpty() })) + .publish(throttleAfterFirstEmission) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer({ text: String -> updateNotification(text) }))) + } + + protected fun updateNotification(text: String) { + notificationBuilder + .setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1) + val progressText: String = currentProgress.toString() + "/" + maxProgress + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + if (!TextUtils.isEmpty(text)) { + notificationBuilder!!.setContentText(text + " (" + progressText + ")") + } + } else { + notificationBuilder!!.setContentInfo(progressText) + notificationBuilder!!.setContentText(text) + } + notificationManager!!.notify(notificationId, notificationBuilder!!.build()) + } + + protected fun stopService() { + postErrorResult(null, null) + } + + protected fun stopAndReportError(throwable: Throwable?, request: String?) { + stopService() + createNotification(this, ErrorInfo( + (throwable)!!, UserAction.SUBSCRIPTION_IMPORT_EXPORT, (request)!!)) + } + + protected fun postErrorResult(title: String?, text: String?) { + disposeAll() + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + stopSelf() + if (title == null) { + return + } + val textOrEmpty: String = if (text == null) "" else text + notificationBuilder = NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentTitle(title) + .setStyle(NotificationCompat.BigTextStyle().bigText(textOrEmpty)) + .setContentText(textOrEmpty) + notificationManager!!.notify(notificationId, notificationBuilder!!.build()) + } + + protected fun createNotification(): NotificationCompat.Builder { + 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) + .setContentTitle(getString(title)) + } + + /*////////////////////////////////////////////////////////////////////////// + // Toast + ////////////////////////////////////////////////////////////////////////// */ + protected fun showToast(@StringRes message: Int) { + showToast(getString(message)) + } + + protected fun showToast(message: String?) { + if (toast != null) { + toast!!.cancel() + } + toast = Toast.makeText(this, message, Toast.LENGTH_SHORT) + toast.show() + } + + /*////////////////////////////////////////////////////////////////////////// + // Error handling + ////////////////////////////////////////////////////////////////////////// */ + protected fun handleError(@StringRes errorTitle: Int, error: Throwable) { + var message: String? = getErrorMessage(error) + if (TextUtils.isEmpty(message)) { + val errorClassName: String = error.javaClass.getName() + message = getString(R.string.error_occurred_detail, errorClassName) + } + showToast(errorTitle) + postErrorResult(getString(errorTitle), message) + } + + protected fun getErrorMessage(error: Throwable): String? { + var message: String? = null + if (error is InvalidSourceException) { + message = getString(R.string.invalid_source) + } else if (error is FileNotFoundException) { + message = getString(R.string.invalid_file) + } else if (error.isNetworkRelated) { + message = getString(R.string.network_error) + } + return message + } + + companion object { + private val NOTIFICATION_SAMPLING_PERIOD: Int = 2500 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.kt similarity index 60% rename from app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java rename to app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.kt index 7352d1f12..001d09bfe 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportEventListener.kt @@ -1,17 +1,17 @@ -package org.schabi.newpipe.local.subscription.services; +package org.schabi.newpipe.local.subscription.services -public interface ImportExportEventListener { +open interface ImportExportEventListener { /** * Called when the size has been resolved. * * @param size how many items there are to import/export */ - void onSizeReceived(int size); + fun onSizeReceived(size: Int) /** * Called every time an item has been parsed/resolved. * * @param itemName the name of the subscription item */ - void onItemCompleted(String itemName); + fun onItemCompleted(itemName: String) } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java deleted file mode 100644 index 611a1cd30..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.java +++ /dev/null @@ -1,158 +0,0 @@ -/* - * 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.services; - -import androidx.annotation.Nullable; - -import com.grack.nanojson.JsonAppendableWriter; -import com.grack.nanojson.JsonArray; -import com.grack.nanojson.JsonObject; -import com.grack.nanojson.JsonParser; -import com.grack.nanojson.JsonWriter; - -import org.schabi.newpipe.BuildConfig; -import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException; -import org.schabi.newpipe.extractor.subscription.SubscriptionItem; - -import java.io.InputStream; -import java.io.OutputStream; -import java.util.ArrayList; -import java.util.List; - -/** - * A JSON implementation capable of importing and exporting subscriptions, it has the advantage - * of being able to transfer subscriptions to any device. - */ -public final class ImportExportJsonHelper { - /*////////////////////////////////////////////////////////////////////////// - // Json implementation - //////////////////////////////////////////////////////////////////////////*/ - - private static final String JSON_APP_VERSION_KEY = "app_version"; - private static final String JSON_APP_VERSION_INT_KEY = "app_version_int"; - - private static final String JSON_SUBSCRIPTIONS_ARRAY_KEY = "subscriptions"; - - private static final String JSON_SERVICE_ID_KEY = "service_id"; - private static final String JSON_URL_KEY = "url"; - private static final String JSON_NAME_KEY = "name"; - - private ImportExportJsonHelper() { } - - /** - * Read a JSON source through the input stream. - * - * @param in the input stream (e.g. a file) - * @param eventListener listener for the events generated - * @return the parsed subscription items - */ - public static List readFrom( - final InputStream in, @Nullable final ImportExportEventListener eventListener) - throws InvalidSourceException { - if (in == null) { - throw new InvalidSourceException("input is null"); - } - - final List channels = new ArrayList<>(); - - try { - final JsonObject parentObject = JsonParser.object().from(in); - - if (!parentObject.has(JSON_SUBSCRIPTIONS_ARRAY_KEY)) { - throw new InvalidSourceException("Channels array is null"); - } - - final JsonArray channelsArray = parentObject.getArray(JSON_SUBSCRIPTIONS_ARRAY_KEY); - - if (eventListener != null) { - eventListener.onSizeReceived(channelsArray.size()); - } - - for (final Object o : channelsArray) { - if (o instanceof JsonObject) { - final JsonObject itemObject = (JsonObject) o; - final int serviceId = itemObject.getInt(JSON_SERVICE_ID_KEY, 0); - final String url = itemObject.getString(JSON_URL_KEY); - final String name = itemObject.getString(JSON_NAME_KEY); - - if (url != null && name != null && !url.isEmpty() && !name.isEmpty()) { - channels.add(new SubscriptionItem(serviceId, url, name)); - if (eventListener != null) { - eventListener.onItemCompleted(name); - } - } - } - } - } catch (final Throwable e) { - throw new InvalidSourceException("Couldn't parse json", e); - } - - return channels; - } - - /** - * 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) - * @param eventListener listener for the events generated - */ - public static void writeTo(final List items, final OutputStream out, - @Nullable final ImportExportEventListener eventListener) { - final JsonAppendableWriter writer = JsonWriter.on(out); - writeTo(items, writer, eventListener); - writer.done(); - } - - /** - * @see #writeTo(List, OutputStream, ImportExportEventListener) - * @param items the list of subscriptions items - * @param writer the output {@link JsonAppendableWriter} - * @param eventListener listener for the events generated - */ - public static void writeTo(final List items, - final JsonAppendableWriter writer, - @Nullable final ImportExportEventListener eventListener) { - if (eventListener != null) { - eventListener.onSizeReceived(items.size()); - } - - writer.object(); - - writer.value(JSON_APP_VERSION_KEY, BuildConfig.VERSION_NAME); - writer.value(JSON_APP_VERSION_INT_KEY, BuildConfig.VERSION_CODE); - - writer.array(JSON_SUBSCRIPTIONS_ARRAY_KEY); - for (final SubscriptionItem item : items) { - writer.object(); - writer.value(JSON_SERVICE_ID_KEY, item.getServiceId()); - writer.value(JSON_URL_KEY, item.getUrl()); - writer.value(JSON_NAME_KEY, item.getName()); - writer.end(); - - if (eventListener != null) { - eventListener.onItemCompleted(item.getName()); - } - } - writer.end(); - - writer.end(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.kt new file mode 100644 index 000000000..2c5c75b39 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/ImportExportJsonHelper.kt @@ -0,0 +1,129 @@ +/* + * 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.services + +import com.grack.nanojson.JsonWriter +import org.schabi.newpipe.BuildConfig +import org.schabi.newpipe.extractor.subscription.SubscriptionItem +import java.io.InputStream +import java.io.OutputStream + +/** + * A JSON implementation capable of importing and exporting subscriptions, it has the advantage + * of being able to transfer subscriptions to any device. + */ +object ImportExportJsonHelper { + /*////////////////////////////////////////////////////////////////////////// + // Json implementation + ////////////////////////////////////////////////////////////////////////// */ + private val JSON_APP_VERSION_KEY: String = "app_version" + private val JSON_APP_VERSION_INT_KEY: String = "app_version_int" + private val JSON_SUBSCRIPTIONS_ARRAY_KEY: String = "subscriptions" + private val JSON_SERVICE_ID_KEY: String = "service_id" + private val JSON_URL_KEY: String = "url" + private val JSON_NAME_KEY: String = "name" + + /** + * Read a JSON source through the input stream. + * + * @param in the input stream (e.g. a file) + * @param eventListener listener for the events generated + * @return the parsed subscription items + */ + @JvmStatic + @Throws(InvalidSourceException::class) + fun readFrom( + `in`: InputStream?, eventListener: ImportExportEventListener?): List { + if (`in` == null) { + throw InvalidSourceException("input is null") + } + val channels: MutableList = ArrayList() + try { + val parentObject: JsonObject = JsonParser.`object`().from(`in`) + if (!parentObject.has(JSON_SUBSCRIPTIONS_ARRAY_KEY)) { + throw InvalidSourceException("Channels array is null") + } + val channelsArray: JsonArray = parentObject.getArray(JSON_SUBSCRIPTIONS_ARRAY_KEY) + if (eventListener != null) { + eventListener.onSizeReceived(channelsArray.size) + } + for (o: Any in channelsArray) { + if (o is JsonObject) { + val itemObject: JsonObject = o as JsonObject + val serviceId: Int = itemObject.getInt(JSON_SERVICE_ID_KEY, 0) + val url: String? = itemObject.getString(JSON_URL_KEY) + val name: String? = itemObject.getString(JSON_NAME_KEY) + if ((url != null) && (name != null) && !url.isEmpty() && !name.isEmpty()) { + channels.add(SubscriptionItem(serviceId, url, name)) + if (eventListener != null) { + eventListener.onItemCompleted(name) + } + } + } + } + } catch (e: Throwable) { + throw InvalidSourceException("Couldn't parse json", e) + } + return channels + } + + /** + * 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) + * @param eventListener listener for the events generated + */ + fun writeTo(items: List?, out: OutputStream?, + eventListener: ImportExportEventListener?) { + val writer: JsonAppendableWriter = JsonWriter.on(out) + writeTo(items, writer, eventListener) + writer.done() + } + + /** + * @see .writeTo + * @param items the list of subscriptions items + * @param writer the output [JsonAppendableWriter] + * @param eventListener listener for the events generated + */ + fun writeTo(items: List, + writer: JsonAppendableWriter, + eventListener: ImportExportEventListener?) { + if (eventListener != null) { + eventListener.onSizeReceived(items.size) + } + writer.`object`() + writer.value(JSON_APP_VERSION_KEY, BuildConfig.VERSION_NAME) + writer.value(JSON_APP_VERSION_INT_KEY, BuildConfig.VERSION_CODE) + writer.array(JSON_SUBSCRIPTIONS_ARRAY_KEY) + for (item: SubscriptionItem in items) { + writer.`object`() + writer.value(JSON_SERVICE_ID_KEY, item.getServiceId()) + writer.value(JSON_URL_KEY, item.getUrl()) + writer.value(JSON_NAME_KEY, item.getName()) + writer.end() + if (eventListener != null) { + eventListener.onItemCompleted(item.getName()) + } + } + writer.end() + writer.end() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java deleted file mode 100644 index 54809068a..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright 2018 Mauricio Colli - * SubscriptionsExportService.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.services; - -import static org.schabi.newpipe.MainActivity.DEBUG; - -import android.content.Intent; -import android.net.Uri; -import android.util.Log; - -import androidx.core.content.IntentCompat; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; - -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; -import org.schabi.newpipe.App; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.subscription.SubscriptionEntity; -import org.schabi.newpipe.extractor.subscription.SubscriptionItem; -import org.schabi.newpipe.streams.io.SharpOutputStream; -import org.schabi.newpipe.streams.io.StoredFileHelper; - -import java.io.IOException; -import java.io.OutputStream; -import java.util.ArrayList; -import java.util.List; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.functions.Function; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class SubscriptionsExportService extends BaseImportExportService { - public static final String KEY_FILE_PATH = "key_file_path"; - - /** - * A {@link LocalBroadcastManager local broadcast} will be made with this action - * when the export is successfully completed. - */ - public static final String EXPORT_COMPLETE_ACTION = App.PACKAGE_NAME + ".local.subscription" - + ".services.SubscriptionsExportService.EXPORT_COMPLETE"; - - private Subscription subscription; - private StoredFileHelper outFile; - private OutputStream outputStream; - - @Override - public int onStartCommand(final Intent intent, final int flags, final int startId) { - if (intent == null || subscription != null) { - return START_NOT_STICKY; - } - - final Uri path = IntentCompat.getParcelableExtra(intent, KEY_FILE_PATH, Uri.class); - if (path == null) { - stopAndReportError(new IllegalStateException( - "Exporting to a file, but the path is null"), - "Exporting subscriptions"); - return START_NOT_STICKY; - } - - try { - outFile = new StoredFileHelper(this, path, "application/json"); - outputStream = new SharpOutputStream(outFile.getStream()); - } catch (final IOException e) { - handleError(e); - return START_NOT_STICKY; - } - - startExport(); - - return START_NOT_STICKY; - } - - @Override - protected int getNotificationId() { - return 4567; - } - - @Override - public int getTitle() { - return R.string.export_ongoing; - } - - @Override - protected void disposeAll() { - super.disposeAll(); - if (subscription != null) { - subscription.cancel(); - } - } - - private void startExport() { - showToast(R.string.export_ongoing); - - subscriptionManager.subscriptionTable().getAll().take(1) - .map(subscriptionEntities -> { - final List result = - new ArrayList<>(subscriptionEntities.size()); - for (final SubscriptionEntity entity : subscriptionEntities) { - result.add(new SubscriptionItem(entity.getServiceId(), entity.getUrl(), - entity.getName())); - } - return result; - }) - .map(exportToFile()) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getSubscriber()); - } - - private Subscriber getSubscriber() { - return new Subscriber() { - @Override - public void onSubscribe(final Subscription s) { - subscription = s; - s.request(1); - } - - @Override - public void onNext(final StoredFileHelper file) { - if (DEBUG) { - Log.d(TAG, "startExport() success: file = " + file); - } - } - - @Override - public void onError(final Throwable error) { - Log.e(TAG, "onError() called with: error = [" + error + "]", error); - handleError(error); - } - - @Override - public void onComplete() { - LocalBroadcastManager.getInstance(SubscriptionsExportService.this) - .sendBroadcast(new Intent(EXPORT_COMPLETE_ACTION)); - showToast(R.string.export_complete_toast); - stopService(); - } - }; - } - - private Function, StoredFileHelper> exportToFile() { - return subscriptionItems -> { - ImportExportJsonHelper.writeTo(subscriptionItems, outputStream, eventListener); - return outFile; - }; - } - - protected void handleError(final Throwable error) { - super.handleError(R.string.subscriptions_export_unsuccessful, error); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.kt new file mode 100644 index 000000000..bbec298d2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.kt @@ -0,0 +1,149 @@ +/* + * Copyright 2018 Mauricio Colli + * SubscriptionsExportService.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.services + +import android.content.Intent +import android.net.Uri +import android.util.Log +import androidx.core.content.IntentCompat +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.functions.Function +import io.reactivex.rxjava3.schedulers.Schedulers +import org.reactivestreams.Subscriber +import org.reactivestreams.Subscription +import org.schabi.newpipe.App +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.R +import org.schabi.newpipe.database.subscription.SubscriptionEntity +import org.schabi.newpipe.extractor.subscription.SubscriptionItem +import org.schabi.newpipe.streams.io.SharpOutputStream +import org.schabi.newpipe.streams.io.StoredFileHelper +import java.io.IOException +import java.io.OutputStream + +class SubscriptionsExportService() : BaseImportExportService() { + private var subscription: Subscription? = null + private var outFile: StoredFileHelper? = null + private var outputStream: OutputStream? = null + public override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + if (intent == null || subscription != null) { + return START_NOT_STICKY + } + val path: Uri? = IntentCompat.getParcelableExtra(intent, KEY_FILE_PATH, Uri::class.java) + if (path == null) { + stopAndReportError(IllegalStateException( + "Exporting to a file, but the path is null"), + "Exporting subscriptions") + return START_NOT_STICKY + } + try { + outFile = StoredFileHelper(this, path, "application/json") + outputStream = SharpOutputStream(outFile!!.getStream()) + } catch (e: IOException) { + handleError(e) + return START_NOT_STICKY + } + startExport() + return START_NOT_STICKY + } + + protected override val notificationId: Int + protected get() { + return 4567 + } + override val title: Int + get() { + return R.string.export_ongoing + } + + override fun disposeAll() { + super.disposeAll() + if (subscription != null) { + subscription!!.cancel() + } + } + + private fun startExport() { + showToast(R.string.export_ongoing) + subscriptionManager!!.subscriptionTable().getAll().take(1) + .map(Function, List>({ subscriptionEntities: List -> + val result: MutableList = ArrayList(subscriptionEntities.size) + for (entity: SubscriptionEntity in subscriptionEntities) { + result.add(SubscriptionItem(entity.getServiceId(), entity.getUrl(), + entity.getName())) + } + result + })) + .map(exportToFile()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(subscriber) + } + + private val subscriber: Subscriber + private get() { + return object : Subscriber { + public override fun onSubscribe(s: Subscription) { + subscription = s + s.request(1) + } + + public override fun onNext(file: StoredFileHelper) { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "startExport() success: file = " + file) + } + } + + public override fun onError(error: Throwable) { + Log.e(TAG, "onError() called with: error = [" + error + "]", error) + handleError(error) + } + + public override fun onComplete() { + LocalBroadcastManager.getInstance(this@SubscriptionsExportService) + .sendBroadcast(Intent(EXPORT_COMPLETE_ACTION)) + showToast(R.string.export_complete_toast) + stopService() + } + } + } + + private fun exportToFile(): Function, StoredFileHelper?> { + return Function({ subscriptionItems: List? -> + ImportExportJsonHelper.writeTo(subscriptionItems, outputStream, eventListener) + outFile + }) + } + + protected fun handleError(error: Throwable) { + super.handleError(R.string.subscriptions_export_unsuccessful, error) + } + + companion object { + val KEY_FILE_PATH: String = "key_file_path" + + /** + * A [local broadcast][LocalBroadcastManager] will be made with this action + * when the export is successfully completed. + */ + val EXPORT_COMPLETE_ACTION: String = (App.Companion.PACKAGE_NAME + ".local.subscription" + + ".services.SubscriptionsExportService.EXPORT_COMPLETE") + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java deleted file mode 100644 index 442c7fddb..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java +++ /dev/null @@ -1,327 +0,0 @@ -/* - * Copyright 2018 Mauricio Colli - * SubscriptionsImportService.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.services; - -import static org.schabi.newpipe.MainActivity.DEBUG; -import static org.schabi.newpipe.streams.io.StoredFileHelper.DEFAULT_MIME; - -import android.content.Intent; -import android.net.Uri; -import android.text.TextUtils; -import android.util.Log; -import android.util.Pair; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.IntentCompat; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; - -import org.reactivestreams.Subscriber; -import org.reactivestreams.Subscription; -import org.schabi.newpipe.App; -import org.schabi.newpipe.R; -import org.schabi.newpipe.database.subscription.SubscriptionEntity; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.channel.ChannelInfo; -import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; -import org.schabi.newpipe.extractor.subscription.SubscriptionItem; -import org.schabi.newpipe.ktx.ExceptionUtils; -import org.schabi.newpipe.streams.io.SharpInputStream; -import org.schabi.newpipe.streams.io.StoredFileHelper; -import org.schabi.newpipe.util.Constants; -import org.schabi.newpipe.util.ExtractorHelper; - -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Objects; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Flowable; -import io.reactivex.rxjava3.core.Notification; -import io.reactivex.rxjava3.functions.Consumer; -import io.reactivex.rxjava3.functions.Function; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public class SubscriptionsImportService extends BaseImportExportService { - public static final int CHANNEL_URL_MODE = 0; - public static final int INPUT_STREAM_MODE = 1; - public static final int PREVIOUS_EXPORT_MODE = 2; - public static final String KEY_MODE = "key_mode"; - public static final String KEY_VALUE = "key_value"; - - /** - * A {@link LocalBroadcastManager local broadcast} will be made with this action - * when the import is successfully completed. - */ - public static final String IMPORT_COMPLETE_ACTION = App.PACKAGE_NAME + ".local.subscription" - + ".services.SubscriptionsImportService.IMPORT_COMPLETE"; - - /** - * How many extractions running in parallel. - */ - public static final int PARALLEL_EXTRACTIONS = 8; - - /** - * Number of items to buffer to mass-insert in the subscriptions table, - * this leads to a better performance as we can then use db transactions. - */ - public static final int BUFFER_COUNT_BEFORE_INSERT = 50; - - private Subscription subscription; - private int currentMode; - private int currentServiceId; - @Nullable - private String channelUrl; - @Nullable - private InputStream inputStream; - @Nullable - private String inputStreamType; - - @Override - public int onStartCommand(final Intent intent, final int flags, final int startId) { - if (intent == null || subscription != null) { - return START_NOT_STICKY; - } - - currentMode = intent.getIntExtra(KEY_MODE, -1); - currentServiceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, Constants.NO_SERVICE_ID); - - if (currentMode == CHANNEL_URL_MODE) { - channelUrl = intent.getStringExtra(KEY_VALUE); - } else { - final Uri uri = IntentCompat.getParcelableExtra(intent, KEY_VALUE, Uri.class); - if (uri == null) { - stopAndReportError(new IllegalStateException( - "Importing from input stream, but file path is null"), - "Importing subscriptions"); - return START_NOT_STICKY; - } - - try { - final StoredFileHelper fileHelper = new StoredFileHelper(this, uri, DEFAULT_MIME); - inputStream = new SharpInputStream(fileHelper.getStream()); - inputStreamType = fileHelper.getType(); - - if (inputStreamType == null || inputStreamType.equals(DEFAULT_MIME)) { - // mime type could not be determined, just take file extension - final String name = fileHelper.getName(); - final int pointIndex = name.lastIndexOf('.'); - if (pointIndex == -1 || pointIndex >= name.length() - 1) { - inputStreamType = DEFAULT_MIME; // no extension, will fail in the extractor - } else { - inputStreamType = name.substring(pointIndex + 1); - } - } - } catch (final IOException e) { - handleError(e); - return START_NOT_STICKY; - } - } - - if (currentMode == -1 || currentMode == CHANNEL_URL_MODE && channelUrl == null) { - final String errorDescription = "Some important field is null or in illegal state: " - + "currentMode=[" + currentMode + "], " - + "channelUrl=[" + channelUrl + "], " - + "inputStream=[" + inputStream + "]"; - stopAndReportError(new IllegalStateException(errorDescription), - "Importing subscriptions"); - return START_NOT_STICKY; - } - - startImport(); - return START_NOT_STICKY; - } - - @Override - protected int getNotificationId() { - return 4568; - } - - @Override - public int getTitle() { - return R.string.import_ongoing; - } - - @Override - protected void disposeAll() { - super.disposeAll(); - if (subscription != null) { - subscription.cancel(); - } - } - - /*////////////////////////////////////////////////////////////////////////// - // Imports - //////////////////////////////////////////////////////////////////////////*/ - - private void startImport() { - showToast(R.string.import_ongoing); - - Flowable> flowable = null; - switch (currentMode) { - case CHANNEL_URL_MODE: - flowable = importFromChannelUrl(); - break; - case INPUT_STREAM_MODE: - flowable = importFromInputStream(); - break; - case PREVIOUS_EXPORT_MODE: - flowable = importFromPreviousExport(); - break; - } - - if (flowable == null) { - final String message = "Flowable given by \"importFrom\" is null " - + "(current mode: " + currentMode + ")"; - stopAndReportError(new IllegalStateException(message), "Importing subscriptions"); - return; - } - - flowable.doOnNext(subscriptionItems -> - eventListener.onSizeReceived(subscriptionItems.size())) - .flatMap(Flowable::fromIterable) - - .parallel(PARALLEL_EXTRACTIONS) - .runOn(Schedulers.io()) - .map((Function>>>) subscriptionItem -> { - try { - final ChannelInfo channelInfo = ExtractorHelper - .getChannelInfo(subscriptionItem.getServiceId(), - subscriptionItem.getUrl(), true) - .blockingGet(); - return Notification.createOnNext(new Pair<>(channelInfo, - Collections.singletonList( - ExtractorHelper.getChannelTab( - subscriptionItem.getServiceId(), - channelInfo.getTabs().get(0), true).blockingGet() - ))); - } catch (final Throwable e) { - return Notification.createOnError(e); - } - }) - .sequential() - - .observeOn(Schedulers.io()) - .doOnNext(getNotificationsConsumer()) - - .buffer(BUFFER_COUNT_BEFORE_INSERT) - .map(upsertBatch()) - - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getSubscriber()); - } - - private Subscriber> getSubscriber() { - return new Subscriber<>() { - @Override - public void onSubscribe(final Subscription s) { - subscription = s; - s.request(Long.MAX_VALUE); - } - - @Override - public void onNext(final List successfulInserted) { - if (DEBUG) { - Log.d(TAG, "startImport() " + successfulInserted.size() - + " items successfully inserted into the database"); - } - } - - @Override - public void onError(final Throwable error) { - Log.e(TAG, "Got an error!", error); - handleError(error); - } - - @Override - public void onComplete() { - LocalBroadcastManager.getInstance(SubscriptionsImportService.this) - .sendBroadcast(new Intent(IMPORT_COMPLETE_ACTION)); - showToast(R.string.import_complete_toast); - stopService(); - } - }; - } - - private Consumer>>> getNotificationsConsumer() { - return notification -> { - if (notification.isOnNext()) { - final String name = notification.getValue().first.getName(); - eventListener.onItemCompleted(!TextUtils.isEmpty(name) ? name : ""); - } else if (notification.isOnError()) { - final Throwable error = notification.getError(); - final Throwable cause = error.getCause(); - if (error instanceof IOException) { - throw error; - } else if (cause instanceof IOException) { - throw cause; - } else if (ExceptionUtils.isNetworkRelated(error)) { - throw new IOException(error); - } - - eventListener.onItemCompleted(""); - } - }; - } - - private Function>>>, - List> upsertBatch() { - return notificationList -> { - final List>> infoList = - new ArrayList<>(notificationList.size()); - for (final Notification>> n : notificationList) { - if (n.isOnNext()) { - infoList.add(n.getValue()); - } - } - - return subscriptionManager.upsertAll(infoList); - }; - } - - private Flowable> importFromChannelUrl() { - return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId) - .getSubscriptionExtractor() - .fromChannelUrl(channelUrl)); - } - - private Flowable> importFromInputStream() { - Objects.requireNonNull(inputStream); - Objects.requireNonNull(inputStreamType); - - return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId) - .getSubscriptionExtractor() - .fromInputStream(inputStream, inputStreamType)); - } - - private Flowable> importFromPreviousExport() { - return Flowable.fromCallable(() -> ImportExportJsonHelper.readFrom(inputStream, null)); - } - - protected void handleError(@NonNull final Throwable error) { - super.handleError(R.string.subscriptions_import_unsuccessful, error); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.kt new file mode 100644 index 000000000..a9ff8925d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.kt @@ -0,0 +1,282 @@ +/* + * Copyright 2018 Mauricio Colli + * SubscriptionsImportService.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.services + +import android.content.Intent +import android.net.Uri +import android.text.TextUtils +import android.util.Log +import android.util.Pair +import androidx.core.content.IntentCompat +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.core.Notification +import io.reactivex.rxjava3.functions.Consumer +import io.reactivex.rxjava3.functions.Function +import io.reactivex.rxjava3.schedulers.Schedulers +import org.reactivestreams.Publisher +import org.reactivestreams.Subscriber +import org.reactivestreams.Subscription +import org.schabi.newpipe.App +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.R +import org.schabi.newpipe.database.subscription.SubscriptionEntity +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.channel.ChannelInfo +import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo +import org.schabi.newpipe.extractor.subscription.SubscriptionItem +import org.schabi.newpipe.ktx.isNetworkRelated +import org.schabi.newpipe.streams.io.SharpInputStream +import org.schabi.newpipe.streams.io.StoredFileHelper +import org.schabi.newpipe.util.ExtractorHelper +import java.io.IOException +import java.io.InputStream +import java.util.Objects +import java.util.concurrent.Callable + +class SubscriptionsImportService() : BaseImportExportService() { + private var subscription: Subscription? = null + private var currentMode: Int = 0 + private var currentServiceId: Int = 0 + private var channelUrl: String? = null + private var inputStream: InputStream? = null + private var inputStreamType: String? = null + public override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + if (intent == null || subscription != null) { + return START_NOT_STICKY + } + currentMode = intent.getIntExtra(KEY_MODE, -1) + currentServiceId = intent.getIntExtra(KEY_SERVICE_ID, NO_SERVICE_ID) + if (currentMode == CHANNEL_URL_MODE) { + channelUrl = intent.getStringExtra(KEY_VALUE) + } else { + val uri: Uri? = IntentCompat.getParcelableExtra(intent, KEY_VALUE, Uri::class.java) + if (uri == null) { + stopAndReportError(IllegalStateException( + "Importing from input stream, but file path is null"), + "Importing subscriptions") + return START_NOT_STICKY + } + try { + val fileHelper: StoredFileHelper = StoredFileHelper(this, uri, StoredFileHelper.Companion.DEFAULT_MIME) + inputStream = SharpInputStream(fileHelper.getStream()) + inputStreamType = fileHelper.getType() + if (inputStreamType == null || (inputStreamType == StoredFileHelper.Companion.DEFAULT_MIME)) { + // mime type could not be determined, just take file extension + val name: String? = fileHelper.getName() + val pointIndex: Int = name!!.lastIndexOf('.') + if (pointIndex == -1 || pointIndex >= name.length - 1) { + inputStreamType = StoredFileHelper.Companion.DEFAULT_MIME // no extension, will fail in the extractor + } else { + inputStreamType = name.substring(pointIndex + 1) + } + } + } catch (e: IOException) { + handleError(e) + return START_NOT_STICKY + } + } + if (currentMode == -1 || currentMode == CHANNEL_URL_MODE && channelUrl == null) { + val errorDescription: String = ("Some important field is null or in illegal state: " + + "currentMode=[" + currentMode + "], " + + "channelUrl=[" + channelUrl + "], " + + "inputStream=[" + inputStream + "]") + stopAndReportError(IllegalStateException(errorDescription), + "Importing subscriptions") + return START_NOT_STICKY + } + startImport() + return START_NOT_STICKY + } + + protected override val notificationId: Int + protected get() { + return 4568 + } + override val title: Int + get() { + return R.string.import_ongoing + } + + override fun disposeAll() { + super.disposeAll() + if (subscription != null) { + subscription!!.cancel() + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Imports + ////////////////////////////////////////////////////////////////////////// */ + private fun startImport() { + showToast(R.string.import_ongoing) + var flowable: Flowable?>? = null + when (currentMode) { + CHANNEL_URL_MODE -> flowable = importFromChannelUrl() + INPUT_STREAM_MODE -> flowable = importFromInputStream() + PREVIOUS_EXPORT_MODE -> flowable = importFromPreviousExport() + } + if (flowable == null) { + val message: String = ("Flowable given by \"importFrom\" is null " + + "(current mode: " + currentMode + ")") + stopAndReportError(IllegalStateException(message), "Importing subscriptions") + return + } + flowable.doOnNext(Consumer({ subscriptionItems: List? -> eventListener.onSizeReceived(subscriptionItems!!.size) })) + .flatMap(Function?, Publisher>({ source: List? -> Flowable.fromIterable(source) })) + .parallel(PARALLEL_EXTRACTIONS) + .runOn(Schedulers.io()) + .map(Function({ subscriptionItem: SubscriptionItem -> + try { + val channelInfo: ChannelInfo? = ExtractorHelper.getChannelInfo(subscriptionItem.getServiceId(), + subscriptionItem.getUrl(), true) + .blockingGet() + return@Function Notification.createOnNext>>(Pair>(channelInfo, listOf( + ExtractorHelper.getChannelTab( + subscriptionItem.getServiceId(), + channelInfo!!.getTabs().get(0), true).blockingGet() + ))) + } catch (e: Throwable) { + return@Function Notification.createOnError>>(e) + } + }) as Function>>>?) + .sequential() + .observeOn(Schedulers.io()) + .doOnNext(notificationsConsumer) + .buffer(BUFFER_COUNT_BEFORE_INSERT) + .map(upsertBatch()) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(subscriber) + } + + private val subscriber: Subscriber> + private get() { + return object : Subscriber> { + public override fun onSubscribe(s: Subscription) { + subscription = s + s.request(Long.MAX_VALUE) + } + + public override fun onNext(successfulInserted: List) { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, ("startImport() " + successfulInserted.size + + " items successfully inserted into the database")) + } + } + + public override fun onError(error: Throwable) { + Log.e(TAG, "Got an error!", error) + handleError(error) + } + + public override fun onComplete() { + LocalBroadcastManager.getInstance(this@SubscriptionsImportService) + .sendBroadcast(Intent(IMPORT_COMPLETE_ACTION)) + showToast(R.string.import_complete_toast) + stopService() + } + } + } + private val notificationsConsumer: Consumer>>> + private get() { + return Consumer({ notification: Notification>> -> + if (notification.isOnNext()) { + val name: String = notification.getValue()!!.first!!.getName() + eventListener.onItemCompleted(if (!TextUtils.isEmpty(name)) name else "") + } else if (notification.isOnError()) { + val error: Throwable? = notification.getError() + val cause: Throwable? = error!!.cause + if (error is IOException) { + throw error + } else if (cause is IOException) { + throw cause + } else if (error.isNetworkRelated) { + throw IOException(error) + } + eventListener.onItemCompleted("") + } + }) + } + + private fun upsertBatch(): Function>>>, List> { + return Function({ notificationList: List>>> -> + val infoList: MutableList>> = ArrayList(notificationList.size) + for (n: Notification>> in notificationList) { + if (n.isOnNext()) { + infoList.add((n.getValue())!!) + } + } + subscriptionManager!!.upsertAll(infoList) + }) + } + + private fun importFromChannelUrl(): Flowable?> { + return Flowable.fromCallable(Callable({ + NewPipe.getService(currentServiceId) + .getSubscriptionExtractor() + .fromChannelUrl(channelUrl) + })) + } + + private fun importFromInputStream(): Flowable?> { + Objects.requireNonNull(inputStream) + Objects.requireNonNull(inputStreamType) + return Flowable.fromCallable(Callable({ + NewPipe.getService(currentServiceId) + .getSubscriptionExtractor() + .fromInputStream((inputStream)!!, (inputStreamType)!!) + })) + } + + private fun importFromPreviousExport(): Flowable?> { + return Flowable.fromCallable(Callable({ ImportExportJsonHelper.readFrom(inputStream, null) })) + } + + protected fun handleError(error: Throwable) { + super.handleError(R.string.subscriptions_import_unsuccessful, error) + } + + companion object { + val CHANNEL_URL_MODE: Int = 0 + val INPUT_STREAM_MODE: Int = 1 + val PREVIOUS_EXPORT_MODE: Int = 2 + val KEY_MODE: String = "key_mode" + val KEY_VALUE: String = "key_value" + + /** + * A [local broadcast][LocalBroadcastManager] will be made with this action + * when the import is successfully completed. + */ + val IMPORT_COMPLETE_ACTION: String = (App.Companion.PACKAGE_NAME + ".local.subscription" + + ".services.SubscriptionsImportService.IMPORT_COMPLETE") + + /** + * How many extractions running in parallel. + */ + val PARALLEL_EXTRACTIONS: Int = 8 + + /** + * Number of items to buffer to mass-insert in the subscriptions table, + * this leads to a better performance as we can then use db transactions. + */ + val BUFFER_COUNT_BEFORE_INSERT: Int = 50 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/AudioServiceLeakFix.java b/app/src/main/java/org/schabi/newpipe/player/AudioServiceLeakFix.java deleted file mode 100644 index c36a77421..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/AudioServiceLeakFix.java +++ /dev/null @@ -1,29 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/AudioServiceLeakFix.kt b/app/src/main/java/org/schabi/newpipe/player/AudioServiceLeakFix.kt new file mode 100644 index 000000000..76b840ae2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/AudioServiceLeakFix.kt @@ -0,0 +1,26 @@ +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 + */ +class AudioServiceLeakFix internal constructor(base: Context?) : ContextWrapper(base) { + public override fun getSystemService(name: String): Any { + if ((AUDIO_SERVICE == name)) { + return getApplicationContext().getSystemService(name) + } + return super.getSystemService(name) + } + + companion object { + fun preventLeakOf(base: Context?): ContextWrapper { + return AudioServiceLeakFix(base) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java deleted file mode 100644 index c012f6008..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java +++ /dev/null @@ -1,676 +0,0 @@ -package org.schabi.newpipe.player; - -import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu; -import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - -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) { - assureCorrectAppLanguage(this); - 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) { - switch (item.getItemId()) { - case android.R.id.home: - finish(); - return true; - case R.id.action_settings: - NavigationHelper.openSettings(this); - return true; - case R.id.action_append_playlist: - PlaylistDialog.showForPlayQueue(player, getSupportFragmentManager()); - return true; - case R.id.action_playback_speed: - openPlaybackParameterDialog(); - return true; - case R.id.action_mute: - player.toggleMute(); - return true; - case R.id.action_system_audio: - startActivity(new Intent(Settings.ACTION_SOUND_SETTINGS)); - return true; - case R.id.action_switch_main: - this.player.setRecovery(); - NavigationHelper.playOnMainPlayer(this, player.getPlayQueue(), true); - return true; - case R.id.action_switch_popup: - if (PermissionHelper.isPopupEnabledElseAsk(this)) { - this.player.setRecovery(); - NavigationHelper.playOnPopupPlayer(this, player.getPlayQueue(), true); - } - return true; - case 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() { - final Intent bindIntent = new Intent(this, PlayerService.class); - 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).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(R.drawable.exo_controls_repeat_off); - break; - case com.google.android.exoplayer2.Player.REPEAT_MODE_ONE: - queueControlBinding.controlRepeat - .setImageResource(R.drawable.exo_controls_repeat_one); - break; - case com.google.android.exoplayer2.Player.REPEAT_MODE_ALL: - queueControlBinding.controlRepeat - .setImageResource(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); - }); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.kt b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.kt new file mode 100644 index 000000000..8d06db1f3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.kt @@ -0,0 +1,615 @@ +package org.schabi.newpipe.player + +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 android.widget.SeekBar.OnSeekBarChangeListener +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.QueueItemMenuUtil +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.extractor.stream.StreamType +import org.schabi.newpipe.fragments.OnScrollBelowItemsListener +import org.schabi.newpipe.local.dialog.PlaylistDialog +import org.schabi.newpipe.local.feed.FeedFragment.Companion.newInstance +import org.schabi.newpipe.player.PlayerService.LocalBinder +import org.schabi.newpipe.player.event.PlayerEventListener +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.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.Optional +import java.util.function.Consumer +import java.util.function.Function +import kotlin.math.abs +import kotlin.math.min + +class PlayQueueActivity() : AppCompatActivity(), PlayerEventListener, OnSeekBarChangeListener, View.OnClickListener, PlaybackParameterDialog.Callback { + private var player: Player? = null + private var serviceBound: Boolean = false + private var serviceConnection: ServiceConnection? = null + private var seeking: Boolean = false + + //////////////////////////////////////////////////////////////////////////// + // Views + //////////////////////////////////////////////////////////////////////////// + private var queueControlBinding: ActivityPlayerQueueControlBinding? = null + private var itemTouchHelper: ItemTouchHelper? = null + private var menu: Menu? = null + + //////////////////////////////////////////////////////////////////////////// + // Activity Lifecycle + //////////////////////////////////////////////////////////////////////////// + override fun onCreate(savedInstanceState: Bundle?) { + Localization.assureCorrectAppLanguage(this) + 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() + } + + public override fun onCreateOptionsMenu(m: Menu): Boolean { + 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 + public override fun onPrepareOptionsMenu(m: Menu): Boolean { + 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) + } + + public override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.getItemId()) { + android.R.id.home -> { + finish() + return true + } + + R.id.action_settings -> { + NavigationHelper.openSettings(this) + return true + } + + R.id.action_append_playlist -> { + PlaylistDialog.Companion.showForPlayQueue(player, getSupportFragmentManager()) + return true + } + + R.id.action_playback_speed -> { + openPlaybackParameterDialog() + return true + } + + R.id.action_mute -> { + player!!.toggleMute() + return true + } + + R.id.action_system_audio -> { + startActivity(Intent(Settings.ACTION_SOUND_SETTINGS)) + return true + } + + R.id.action_switch_main -> { + player!!.setRecovery() + NavigationHelper.playOnMainPlayer(this, (player!!.getPlayQueue())!!, true) + return true + } + + R.id.action_switch_popup -> { + if (PermissionHelper.isPopupEnabledElseAsk(this)) { + player!!.setRecovery() + NavigationHelper.playOnPopupPlayer(this, player!!.getPlayQueue(), true) + } + return true + } + + R.id.action_switch_background -> { + 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 fun onDestroy() { + super.onDestroy() + unbind() + } + + //////////////////////////////////////////////////////////////////////////// + // Service Connection + //////////////////////////////////////////////////////////////////////////// + private fun bind() { + val bindIntent: Intent = Intent(this, PlayerService::class.java) + val success: Boolean = bindService(bindIntent, (serviceConnection)!!, BIND_AUTO_CREATE) + if (!success) { + unbindService((serviceConnection)!!) + } + serviceBound = success + } + + private fun 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 fun getServiceConnection(): ServiceConnection { + return object : ServiceConnection { + public override fun onServiceDisconnected(name: ComponentName) { + Log.d(TAG, "Player service is disconnected") + } + + public override fun onServiceConnected(name: ComponentName, service: IBinder) { + Log.d(TAG, "Player service is connected") + if (service is LocalBinder) { + player = service.getPlayer() + } + if ((player == null) || (player!!.getPlayQueue() == null) || player!!.exoPlayerIsNull()) { + unbind() + } else { + onQueueUpdate(player!!.getPlayQueue()) + buildComponents() + if (player != null) { + player!!.setActivityListener(this@PlayQueueActivity) + } + } + } + } + } + + //////////////////////////////////////////////////////////////////////////// + // Component Building + //////////////////////////////////////////////////////////////////////////// + private fun buildComponents() { + buildQueue() + buildMetadata() + buildSeekBar() + buildControls() + } + + private fun buildQueue() { + queueControlBinding!!.playQueue.setLayoutManager(LinearLayoutManager(this)) + queueControlBinding!!.playQueue.setClickable(true) + queueControlBinding!!.playQueue.setLongClickable(true) + queueControlBinding!!.playQueue.clearOnScrollListeners() + queueControlBinding!!.playQueue.addOnScrollListener(getQueueScrollListener()) + itemTouchHelper = ItemTouchHelper(getItemTouchCallback()) + itemTouchHelper!!.attachToRecyclerView(queueControlBinding!!.playQueue) + } + + private fun buildMetadata() { + queueControlBinding!!.metadata.setOnClickListener(this) + queueControlBinding!!.songName.setSelected(true) + queueControlBinding!!.artistName.setSelected(true) + } + + private fun buildSeekBar() { + queueControlBinding!!.seekBar.setOnSeekBarChangeListener(this) + queueControlBinding!!.liveSync.setOnClickListener(this) + } + + private fun 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 fun getQueueScrollListener(): OnScrollBelowItemsListener { + return object : OnScrollBelowItemsListener() { + public override fun onScrolledDown(recyclerView: RecyclerView?) { + if ((player != null) && (player!!.getPlayQueue() != null + ) && !player!!.getPlayQueue()!!.isComplete()) { + player!!.getPlayQueue()!!.fetch() + } else { + queueControlBinding!!.playQueue.clearOnScrollListeners() + } + } + } + } + + private fun getItemTouchCallback(): ItemTouchHelper.SimpleCallback { + return object : PlayQueueItemTouchCallback() { + public override fun onMove(sourceIndex: Int, targetIndex: Int) { + if (player != null) { + player!!.getPlayQueue()!!.move(sourceIndex, targetIndex) + } + } + + public override fun onSwiped(index: Int) { + if (index != -1) { + player!!.getPlayQueue()!!.remove(index) + } + } + } + } + + private fun getOnSelectedListener(): PlayQueueItemBuilder.OnSelectedListener { + return object : PlayQueueItemBuilder.OnSelectedListener { + public override fun selected(item: PlayQueueItem?, view: View?) { + if (player != null) { + player!!.selectQueueItem(item) + } + } + + public override fun held(item: PlayQueueItem, view: View?) { + if (player != null && player!!.getPlayQueue()!!.indexOf(item) != -1) { + QueueItemMenuUtil.openPopupMenu(player!!.getPlayQueue(), item, view, false, + getSupportFragmentManager(), this@PlayQueueActivity) + } + } + + public override fun onStartDrag(viewHolder: PlayQueueItemHolder?) { + if (itemTouchHelper != null) { + itemTouchHelper!!.startDrag((viewHolder)!!) + } + } + } + } + + private fun scrollToSelected() { + if (player == null) { + return + } + val currentPlayingIndex: Int = player!!.getPlayQueue().getIndex() + val currentVisibleIndex: Int + if (queueControlBinding!!.playQueue.getLayoutManager() is LinearLayoutManager) { + val layout: LinearLayoutManager? = queueControlBinding!!.playQueue.getLayoutManager() as LinearLayoutManager? + currentVisibleIndex = layout!!.findFirstVisibleItemPosition() + } else { + currentVisibleIndex = 0 + } + val distance: Int = abs((currentPlayingIndex - currentVisibleIndex).toDouble()).toInt() + if (distance < SMOOTH_SCROLL_MAXIMUM_DISTANCE) { + queueControlBinding!!.playQueue.smoothScrollToPosition(currentPlayingIndex) + } else { + queueControlBinding!!.playQueue.scrollToPosition(currentPlayingIndex) + } + } + + //////////////////////////////////////////////////////////////////////////// + // Component On-Click Listener + //////////////////////////////////////////////////////////////////////////// + public override fun onClick(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 fun openPlaybackParameterDialog() { + if (player == null) { + return + } + PlaybackParameterDialog.Companion.newInstance(player!!.getPlaybackSpeed().toDouble(), player!!.getPlaybackPitch().toDouble(), + player!!.getPlaybackSkipSilence(), this).show(getSupportFragmentManager(), TAG) + } + + public override fun onPlaybackParameterChanged(playbackTempo: Float, playbackPitch: Float, + playbackSkipSilence: Boolean) { + if (player != null) { + player!!.setPlaybackParameters(playbackTempo, playbackPitch, playbackSkipSilence) + onPlaybackParameterChanged(player!!.getPlaybackParameters()) + } + } + + //////////////////////////////////////////////////////////////////////////// + // Seekbar Listener + //////////////////////////////////////////////////////////////////////////// + public override fun onProgressChanged(seekBar: SeekBar, progress: Int, + fromUser: Boolean) { + if (fromUser) { + val seekTime: String? = Localization.getDurationString((progress / 1000).toLong()) + queueControlBinding!!.currentTime.setText(seekTime) + queueControlBinding!!.seekDisplay.setText(seekTime) + } + } + + public override fun onStartTrackingTouch(seekBar: SeekBar) { + seeking = true + queueControlBinding!!.seekDisplay.setVisibility(View.VISIBLE) + } + + public override fun onStopTrackingTouch(seekBar: SeekBar) { + if (player != null) { + player!!.seekTo(seekBar.getProgress().toLong()) + } + queueControlBinding!!.seekDisplay.setVisibility(View.GONE) + seeking = false + } + + //////////////////////////////////////////////////////////////////////////// + // Binding Service Listener + //////////////////////////////////////////////////////////////////////////// + public override fun onQueueUpdate(queue: PlayQueue?) { + if (queue == null) { + queueControlBinding!!.playQueue.setAdapter(null) + } else { + val adapter: PlayQueueAdapter = PlayQueueAdapter(this, queue) + adapter.setSelectedListener(getOnSelectedListener()) + queueControlBinding!!.playQueue.setAdapter(adapter) + } + } + + public override fun onPlaybackUpdate(state: Int, repeatMode: Int, shuffled: Boolean, + parameters: PlaybackParameters?) { + onStateChanged(state) + onPlayModeChanged(repeatMode, shuffled) + onPlaybackParameterChanged(parameters) + onMaybeMuteChanged() + } + + public override fun onProgressUpdate(currentProgress: Int, duration: Int, + bufferPercent: Int) { + // Set buffer progress + queueControlBinding!!.seekBar.setSecondaryProgress(((queueControlBinding!!.seekBar.getMax() + * (bufferPercent.toFloat() / 100))).toInt()) + + // Set Duration + queueControlBinding!!.seekBar.setMax(duration) + queueControlBinding!!.endTime.setText(Localization.getDurationString((duration / 1000).toLong())) + + // Set current time if not seeking + if (!seeking) { + queueControlBinding!!.seekBar.setProgress(currentProgress) + queueControlBinding!!.currentTime.setText(Localization.getDurationString((currentProgress / 1000).toLong())) + } + if (player != null) { + queueControlBinding!!.liveSync.setClickable(!player!!.isLiveEdge()) + } + + // this will make sure progressCurrentTime has the same width as progressEndTime + val currentTimeParams: ViewGroup.LayoutParams = queueControlBinding!!.currentTime.getLayoutParams() + currentTimeParams.width = queueControlBinding!!.endTime.getWidth() + queueControlBinding!!.currentTime.setLayoutParams(currentTimeParams) + } + + public override fun onMetadataUpdate(info: StreamInfo?, queue: PlayQueue?) { + if (info != null) { + queueControlBinding!!.songName.setText(info.getName()) + queueControlBinding!!.artistName.setText(info.getUploaderName()) + queueControlBinding!!.endTime.setVisibility(View.GONE) + queueControlBinding!!.liveSync.setVisibility(View.GONE) + when (info.getStreamType()) { + StreamType.LIVE_STREAM, StreamType.AUDIO_LIVE_STREAM -> queueControlBinding!!.liveSync.setVisibility(View.VISIBLE) + else -> queueControlBinding!!.endTime.setVisibility(View.VISIBLE) + } + scrollToSelected() + } + } + + public override fun onServiceStopped() { + unbind() + finish() + } + + //////////////////////////////////////////////////////////////////////////// + // Binding Service Helper + //////////////////////////////////////////////////////////////////////////// + private fun onStateChanged(state: Int) { + val playPauseButton: ImageButton = queueControlBinding!!.controlPlayPause + when (state) { + Player.Companion.STATE_PAUSED -> { + playPauseButton.setImageResource(R.drawable.ic_play_arrow) + playPauseButton.setContentDescription(getString(R.string.play)) + } + + Player.Companion.STATE_PLAYING -> { + playPauseButton.setImageResource(R.drawable.ic_pause) + playPauseButton.setContentDescription(getString(R.string.pause)) + } + + Player.Companion.STATE_COMPLETED -> { + playPauseButton.setImageResource(R.drawable.ic_replay) + playPauseButton.setContentDescription(getString(R.string.replay)) + } + + else -> {} + } + when (state) { + Player.Companion.STATE_PAUSED, Player.Companion.STATE_PLAYING, Player.Companion.STATE_COMPLETED -> { + queueControlBinding!!.controlPlayPause.setClickable(true) + queueControlBinding!!.controlPlayPause.setVisibility(View.VISIBLE) + queueControlBinding!!.controlProgressBar.setVisibility(View.GONE) + } + + else -> { + queueControlBinding!!.controlPlayPause.setClickable(false) + queueControlBinding!!.controlPlayPause.setVisibility(View.INVISIBLE) + queueControlBinding!!.controlProgressBar.setVisibility(View.VISIBLE) + } + } + } + + private fun onPlayModeChanged(repeatMode: Int, shuffled: Boolean) { + when (repeatMode) { + com.google.android.exoplayer2.Player.REPEAT_MODE_OFF -> queueControlBinding!!.controlRepeat + .setImageResource(R.drawable.exo_controls_repeat_off) + + com.google.android.exoplayer2.Player.REPEAT_MODE_ONE -> queueControlBinding!!.controlRepeat + .setImageResource(R.drawable.exo_controls_repeat_one) + + com.google.android.exoplayer2.Player.REPEAT_MODE_ALL -> queueControlBinding!!.controlRepeat + .setImageResource(R.drawable.exo_controls_repeat_all) + } + val shuffleAlpha: Int = if (shuffled) 255 else 77 + queueControlBinding!!.controlShuffle.setImageAlpha(shuffleAlpha) + } + + private fun onPlaybackParameterChanged(parameters: PlaybackParameters?) { + if ((parameters != null) && (menu != null) && (player != null)) { + val item: MenuItem = menu!!.findItem(R.id.action_playback_speed) + item.setTitle(PlayerHelper.formatSpeed(parameters.speed.toDouble())) + } + } + + private fun onMaybeMuteChanged() { + if (menu != null && player != null) { + val item: MenuItem = menu!!.findItem(R.id.action_mute) + + //Change the mute-button item in ActionBar + //1) Text change: + item.setTitle(if (player!!.isMuted()) R.string.unmute else R.string.mute) + + //2) Icon change accordingly to current App Theme + // using rootView.getContext() because getApplicationContext() didn't work + item.setIcon(if (player!!.isMuted()) R.drawable.ic_volume_off else R.drawable.ic_volume_up) + } + } + + public override fun onAudioTrackUpdate() { + buildAudioTrackMenu() + } + + private fun buildAudioTrackMenu() { + if (menu == null) { + return + } + val audioTrackSelector: MenuItem = menu!!.findItem(R.id.action_audio_track) + val availableStreams: List? = Optional.ofNullable(player) + .map(Function({ obj: Player -> obj.getCurrentMetadata() })) + .flatMap(Function>({ obj: MediaItemTag? -> obj.getMaybeAudioTrack() })) + .map?>(Function?>({ getAudioStreams() })) + .orElse(null) + val selectedAudioStream: Optional = Optional.ofNullable(player) + .flatMap(Function?>({ obj: Player -> obj.getSelectedAudioStream() })) + if ((availableStreams == null) || (availableStreams.size < 2 + ) || selectedAudioStream.isEmpty()) { + audioTrackSelector.setVisible(false) + } else { + val audioTrackMenu: SubMenu? = audioTrackSelector.getSubMenu() + audioTrackMenu!!.clear() + for (i in availableStreams.indices) { + val audioStream: AudioStream = availableStreams.get(i) + audioTrackMenu.add(MENU_ID_AUDIO_TRACK, i, Menu.NONE, + Localization.audioTrackName(this, audioStream)) + } + val s: AudioStream = selectedAudioStream.get() + val trackName: String? = Localization.audioTrackName(this, s) + audioTrackSelector.setTitle( + getString(R.string.play_queue_audio_track, trackName)) + val shortName: String = if (s.getAudioLocale() != null) s.getAudioLocale()!!.getLanguage() else (trackName)!! + audioTrackSelector.setTitleCondensed( + shortName.substring(0, min(shortName.length.toDouble(), 2.0).toInt())) + audioTrackSelector.setVisible(true) + } + } + + /** + * Called when an item from the audio track selector is selected. + * + * @param itemId index of the selected item + */ + private fun onAudioTrackClick(itemId: Int) { + if (player!!.getCurrentMetadata() == null) { + return + } + player!!.getCurrentMetadata().getMaybeAudioTrack().ifPresent(Consumer({ audioTrack: MediaItemTag.AudioTrack? -> + val availableStreams: List = audioTrack.getAudioStreams() + val selectedStreamIndex: Int = audioTrack.getSelectedAudioStreamIndex() + if (selectedStreamIndex == itemId || availableStreams.size <= itemId) { + return@ifPresent + } + val newAudioTrack: String? = availableStreams.get(itemId)!!.getAudioTrackId() + player!!.setAudioTrack(newAudioTrack) + })) + } + + companion object { + private val TAG: String = PlayQueueActivity::class.java.getSimpleName() + private val SMOOTH_SCROLL_MAXIMUM_DISTANCE: Int = 80 + private val MENU_ID_AUDIO_TRACK: Int = 71 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java deleted file mode 100644 index 49e72328e..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ /dev/null @@ -1,2341 +0,0 @@ -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_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.nextRepeatMode; -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 org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; -import static java.util.concurrent.TimeUnit.MILLISECONDS; - -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.graphics.drawable.Drawable; -import android.media.AudioManager; -import android.util.Log; -import android.view.LayoutInflater; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -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.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 com.squareup.picasso.Picasso; -import com.squareup.picasso.Target; - -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.stream.AudioStream; -import org.schabi.newpipe.extractor.Image; -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.resolver.AudioPlaybackResolver; -import org.schabi.newpipe.player.resolver.VideoPlaybackResolver; -import org.schabi.newpipe.player.resolver.VideoPlaybackResolver.SourceType; -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.ListHelper; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.image.PicassoHelper; -import org.schabi.newpipe.util.SerializedCache; -import org.schabi.newpipe.util.StreamTypeUtil; - -import java.util.List; -import java.util.Optional; -import java.util.stream.IntStream; - -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.disposables.SerialDisposable; - -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 REPEAT_MODE = "repeat_mode"; - public static final String PLAYBACK_QUALITY = "playback_quality"; - public static final String PLAY_QUEUE_KEY = "play_queue_key"; - public static final String ENQUEUE = "enqueue"; - public static final String ENQUEUE_NEXT = "enqueue_next"; - 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 IS_MUTED = "is_muted"; - - /*////////////////////////////////////////////////////////////////////////// - // 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; - private static final String PICASSO_PLAYER_THUMBNAIL_TAG = "PICASSO_PLAYER_THUMBNAIL_TAG"; - - /*////////////////////////////////////////////////////////////////////////// - // 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; - - /*////////////////////////////////////////////////////////////////////////// - // 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(); - - // This is the only listener we need for thumbnail loading, since there is always at most only - // one thumbnail being loaded at a time. This field is also here to maintain a strong reference, - // which would otherwise be garbage collected since Picasso holds weak references to targets. - @NonNull - private final Target currentThumbnailTarget; - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - @NonNull - private final Context context; - @NonNull - private final SharedPreferences prefs; - @NonNull - private final HistoryRecordManager recordManager; - - - /*////////////////////////////////////////////////////////////////////////// - // Constructor - //////////////////////////////////////////////////////////////////////////*/ - //region Constructor - - public Player(@NonNull final PlayerService service) { - 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); - - currentThumbnailTarget = getCurrentThumbnailTarget(); - - // 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), - 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) { - // fail fast if no play queue was provided - final String queueCache = intent.getStringExtra(PLAY_QUEUE_KEY); - if (queueCache == null) { - return; - } - final PlayQueue newQueue = SerializedCache.getInstance().take(queueCache, PlayQueue.class); - if (newQueue == null) { - return; - } - - final PlayerType oldPlayerType = playerType; - playerType = PlayerType.retrieveFromIntent(intent); - initUIsForCurrentPlayerType(); - // We need to setup audioOnly before super(), see "sourceOf" - isAudioOnly = audioPlayerSelected(); - - if (intent.hasExtra(PLAYBACK_QUALITY)) { - videoResolver.setPlaybackQuality(intent.getStringExtra(PLAYBACK_QUALITY)); - } - - // Resolve enqueue intents - if (intent.getBooleanExtra(ENQUEUE, false) && playQueue != null) { - playQueue.append(newQueue.getStreams()); - return; - - // Resolve enqueue next intents - } else if (intent.getBooleanExtra(ENQUEUE_NEXT, false) && playQueue != null) { - final int currentIndex = playQueue.getIndex(); - playQueue.append(newQueue.getStreams()); - playQueue.move(playQueue.size() - 1, currentIndex + 1); - return; - } - - final PlaybackParameters savedParameters = retrievePlaybackParametersFromPrefs(this); - final float playbackSpeed = savedParameters.speed; - final float playbackPitch = savedParameters.pitch; - final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString( - R.string.playback_skip_silence_key), getPlaybackSkipSilence()); - - final boolean samePlayQueue = playQueue != null && playQueue.equalStreamsAndIndex(newQueue); - final int repeatMode = intent.getIntExtra(REPEAT_MODE, getRepeatMode()); - final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true); - final boolean isMuted = intent.getBooleanExtra(IS_MUTED, isMuted()); - - /* - * 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().getUrl().equals(playQueue.getItem().getUrl()) - && 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 - && !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, repeatMode, playbackSpeed, playbackPitch, - playbackSkipSilence, playWhenReady, isMuted); - }, - error -> { - if (DEBUG) { - Log.w(TAG, "Failed to start playback", error); - } - // In case any error we can start playback without history - initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch, - playbackSkipSilence, playWhenReady, isMuted); - }, - () -> { - // Completed but not found in history - initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch, - playbackSkipSilence, playWhenReady, isMuted); - } - )); - } 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, repeatMode, playbackSpeed, - playbackPitch, playbackSkipSilence, playWhenReady, isMuted); - } - - 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) - setRecovery(); - reloadPlayQueueManager(); - } - - UIs.call(PlayerUi::setupAfterIntent); - NavigationHelper.sendPlayerStartedEvent(context); - } - - private void initUIsForCurrentPlayerType() { - if ((UIs.get(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN) - || (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.addAndPrepare(new MainPlayerUi(this, binding)); - break; - case POPUP: - UIs.destroyAll(MainPlayerUi.class); - UIs.addAndPrepare(new PopupPlayerUi(this, binding)); - break; - case AUDIO: - UIs.destroyAll(VideoPlayerUi.class); - break; - } - } - - private void initPlayback(@NonNull final PlayQueue queue, - @RepeatMode final int repeatMode, - final float playbackSpeed, - final float playbackPitch, - final boolean playbackSkipSilence, - final boolean playOnReady, - final boolean isMuted) { - destroyPlayer(); - initPlayer(playOnReady); - setRepeatMode(repeatMode); - setPlaybackParameters(playbackSpeed, playbackPitch, 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); - cancelLoadingCurrentThumbnail(); - - 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.stopService(); - } - - 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.stopService(); - 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_CONFIGURATION_CHANGED: - assureCorrectAppLanguage(service); - 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(); - context.registerReceiver(broadcastReceiver, intentFilter); - } - - 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 Target getCurrentThumbnailTarget() { - // a Picasso target is just a listener for thumbnail loading events - return new Target() { - @Override - public void onBitmapLoaded(final Bitmap bitmap, final Picasso.LoadedFrom from) { - if (DEBUG) { - Log.d(TAG, "Thumbnail - onBitmapLoaded() called with: bitmap = [" + bitmap - + " -> " + bitmap.getWidth() + "x" + bitmap.getHeight() + "], from = [" - + from + "]"); - } - // there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too. - onThumbnailLoaded(bitmap); - } - - @Override - public void onBitmapFailed(final Exception e, final Drawable errorDrawable) { - Log.e(TAG, "Thumbnail - onBitmapFailed() called", e); - // there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too. - onThumbnailLoaded(null); - } - - @Override - public void onPrepareLoad(final Drawable placeHolderDrawable) { - if (DEBUG) { - Log.d(TAG, "Thumbnail - onPrepareLoad() called"); - } - } - }; - } - - private void loadCurrentThumbnail(final List thumbnails) { - if (DEBUG) { - Log.d(TAG, "Thumbnail - loadCurrentThumbnail() called with thumbnails = [" - + thumbnails.size() + "]"); - } - - // first cancel any previous loading - cancelLoadingCurrentThumbnail(); - - // 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 Picasso. - onThumbnailLoaded(null); - if (thumbnails.isEmpty()) { - return; - } - - // scale down the notification thumbnail for performance - PicassoHelper.loadScaledDownThumbnail(context, thumbnails) - .tag(PICASSO_PLAYER_THUMBNAIL_TAG) - .into(currentThumbnailTarget); - } - - private void cancelLoadingCurrentThumbnail() { - // cancel the Picasso job associated with the player thumbnail, if any - PicassoHelper.cancelTag(PICASSO_PLAYER_THUMBNAIL_TAG); - } - - 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 Picasso'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 setRepeatMode(@RepeatMode final int repeatMode) { - if (!exoPlayerIsNull()) { - simpleExoPlayer.setRepeatMode(repeatMode); - } - } - - public void cycleNextRepeatMode() { - setRepeatMode(nextRepeatMode(getRepeatMode())); - } - - @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()); - 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)); - } - //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()); - } - 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 useVideoSource 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 useVideoSource(final boolean videoEnabled) { - if (playQueue == null || audioPlayerSelected()) { - return; - } - - isAudioOnly = !videoEnabled; - - 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 (playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) { - reloadPlayQueueManager(); - } - - setRecovery(); - - // Disable or enable video and subtitles renderers depending of the videoEnabled value - trackSelector.setParameters(trackSelector.buildUponParameters() - .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !videoEnabled) - .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, !videoEnabled)); - }, () -> { - /* - 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 below - - 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 - */ - reloadPlayQueueManager(); - setRecovery(); - }); - } - - /** - * 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 -} diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.kt b/app/src/main/java/org/schabi/newpipe/player/Player.kt new file mode 100644 index 000000000..1a0821fa9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/Player.kt @@ -0,0 +1,2033 @@ +package org.schabi.newpipe.player + +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.graphics.drawable.Drawable +import android.media.AudioManager +import android.util.Log +import android.view.LayoutInflater +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.DiscontinuityReason +import com.google.android.exoplayer2.Player.PositionInfo +import com.google.android.exoplayer2.Timeline +import com.google.android.exoplayer2.Tracks +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.MappedTrackInfo +import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter +import com.google.android.exoplayer2.video.VideoSize +import com.squareup.picasso.Picasso.LoadedFrom +import com.squareup.picasso.Target +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.disposables.SerialDisposable +import io.reactivex.rxjava3.functions.Action +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.R +import org.schabi.newpipe.database.stream.model.StreamStateEntity +import org.schabi.newpipe.databinding.PlayerBinding +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.ErrorUtil.Companion.createNotification +import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.extractor.Image +import org.schabi.newpipe.extractor.ServiceList +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.extractor.utils.Utils +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.NotificationConstants +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.resolver.AudioPlaybackResolver +import org.schabi.newpipe.player.resolver.VideoPlaybackResolver +import org.schabi.newpipe.player.resolver.VideoPlaybackResolver.QualityResolver +import org.schabi.newpipe.player.resolver.VideoPlaybackResolver.SourceType +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.ListHelper +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.SerializedCache +import org.schabi.newpipe.util.StreamTypeUtil +import org.schabi.newpipe.util.image.PicassoHelper +import java.util.Optional +import java.util.concurrent.TimeUnit +import java.util.function.Function +import java.util.function.IntPredicate +import java.util.function.Predicate +import java.util.function.Supplier +import java.util.stream.IntStream +import kotlin.math.max + +class Player(//TODO try to remove and replace everything with context + private val service: PlayerService) : PlaybackListener, com.google.android.exoplayer2.Player.Listener { + /*////////////////////////////////////////////////////////////////////////// + // Playback + ////////////////////////////////////////////////////////////////////////// */ + // play queue might be null e.g. while player is starting + private var playQueue: PlayQueue? = null + private var playQueueManager: MediaSourceManager? = null + private var currentItem: PlayQueueItem? = null + private var currentMetadata: MediaItemTag? = null + private var currentThumbnail: Bitmap? = null + + /*////////////////////////////////////////////////////////////////////////// + // Player + ////////////////////////////////////////////////////////////////////////// */ + private var simpleExoPlayer: ExoPlayer? = null + private var audioReactor: AudioReactor? = null + private val trackSelector: DefaultTrackSelector + private val loadController: LoadController + private val renderFactory: DefaultRenderersFactory + private val videoResolver: VideoPlaybackResolver + private val audioResolver: AudioPlaybackResolver + + /*////////////////////////////////////////////////////////////////////////// + // Player states + ////////////////////////////////////////////////////////////////////////// */ + private var playerType: PlayerType = PlayerType.MAIN + private var currentState: Int = 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 var isAudioOnly: Boolean = false + private var isPrepared: Boolean = false + + /*////////////////////////////////////////////////////////////////////////// + // UIs, listeners and disposables + ////////////////////////////////////////////////////////////////////////// */ + // keep the unusual member name + private val UIs: PlayerUiList + private var broadcastReceiver: BroadcastReceiver? = null + private var intentFilter: IntentFilter? = null + private var fragmentListener: PlayerServiceEventListener? = null + private var activityListener: PlayerEventListener? = null + private val progressUpdateDisposable: SerialDisposable = SerialDisposable() + private val databaseUpdateDisposable: CompositeDisposable = CompositeDisposable() + + // This is the only listener we need for thumbnail loading, since there is always at most only + // one thumbnail being loaded at a time. This field is also here to maintain a strong reference, + // which would otherwise be garbage collected since Picasso holds weak references to targets. + private val currentThumbnailTarget: Target + + /*////////////////////////////////////////////////////////////////////////// + // Utils + ////////////////////////////////////////////////////////////////////////// */ + private val context: Context + private val prefs: SharedPreferences + private val recordManager: HistoryRecordManager + + /*////////////////////////////////////////////////////////////////////////// + // Constructor + ////////////////////////////////////////////////////////////////////////// */ + //region Constructor + init { + context = service + prefs = PreferenceManager.getDefaultSharedPreferences(context) + recordManager = HistoryRecordManager(context) + setupBroadcastReceiver() + trackSelector = DefaultTrackSelector(context, PlayerHelper.getQualitySelector()) + val dataSource: PlayerDataSource = PlayerDataSource(context, + DefaultBandwidthMeter.Builder(context).build()) + loadController = LoadController() + renderFactory = if (prefs.getBoolean( + context.getString( + R.string.always_use_exoplayer_set_output_surface_workaround_key), false)) CustomRenderersFactory(context) else DefaultRenderersFactory(context) + renderFactory.setEnableDecoderFallback( + prefs.getBoolean( + context.getString( + R.string.use_exoplayer_decoder_fallback_key), false)) + videoResolver = VideoPlaybackResolver(context, dataSource, getQualityResolver()) + audioResolver = AudioPlaybackResolver(context, dataSource) + currentThumbnailTarget = getCurrentThumbnailTarget() + + // 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 = PlayerUiList( + MediaSessionPlayerUi(this), + NotificationPlayerUi(this) + ) + } + + private fun getQualityResolver(): QualityResolver { + return object : QualityResolver { + public override fun getDefaultResolutionIndex(sortedVideos: List): Int { + return if (videoPlayerSelected()) ListHelper.getDefaultResolutionIndex(context, sortedVideos) else ListHelper.getPopupDefaultResolutionIndex(context, sortedVideos) + } + + public override fun getOverrideResolutionIndex(sortedVideos: List, + playbackQuality: String?): Int { + return if (videoPlayerSelected()) ListHelper.getResolutionIndex(context, sortedVideos, playbackQuality) else ListHelper.getPopupResolutionIndex(context, sortedVideos, playbackQuality) + } + } + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Playback initialization via intent + ////////////////////////////////////////////////////////////////////////// */ + //region Playback initialization via intent + fun handleIntent(intent: Intent) { + // fail fast if no play queue was provided + val queueCache: String? = intent.getStringExtra(PLAY_QUEUE_KEY) + if (queueCache == null) { + return + } + val newQueue: PlayQueue? = SerializedCache.Companion.getInstance().take(queueCache, PlayQueue::class.java) + if (newQueue == null) { + return + } + val oldPlayerType: PlayerType = playerType + playerType = PlayerType.Companion.retrieveFromIntent(intent) + initUIsForCurrentPlayerType() + // We need to setup audioOnly before super(), see "sourceOf" + isAudioOnly = audioPlayerSelected() + if (intent.hasExtra(PLAYBACK_QUALITY)) { + videoResolver.setPlaybackQuality(intent.getStringExtra(PLAYBACK_QUALITY)) + } + + // Resolve enqueue intents + if (intent.getBooleanExtra(ENQUEUE, false) && playQueue != null) { + playQueue!!.append(newQueue.getStreams()) + return + + // Resolve enqueue next intents + } else if (intent.getBooleanExtra(ENQUEUE_NEXT, false) && playQueue != null) { + val currentIndex: Int = playQueue.getIndex() + playQueue!!.append(newQueue.getStreams()) + playQueue!!.move(playQueue!!.size() - 1, currentIndex + 1) + return + } + val savedParameters: PlaybackParameters? = PlayerHelper.retrievePlaybackParametersFromPrefs(this) + val playbackSpeed: Float = savedParameters!!.speed + val playbackPitch: Float = savedParameters.pitch + val playbackSkipSilence: Boolean = getPrefs().getBoolean(getContext().getString( + R.string.playback_skip_silence_key), getPlaybackSkipSilence()) + val samePlayQueue: Boolean = playQueue != null && playQueue!!.equalStreamsAndIndex(newQueue) + val repeatMode: Int = intent.getIntExtra(REPEAT_MODE, getRepeatMode()) + val playWhenReady: Boolean = intent.getBooleanExtra(PLAY_WHEN_READY, true) + val isMuted: Boolean = intent.getBooleanExtra(IS_MUTED, isMuted()) + + /* + * 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().getUrl() == playQueue!!.getItem().getUrl()) && (newQueue.getItem().getRecoveryPosition() != PlayQueueItem.Companion.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 + && !newQueue.isEmpty() + && (newQueue.getItem() != null + ) && (newQueue.getItem().getRecoveryPosition() == PlayQueueItem.Companion.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( + io.reactivex.rxjava3.functions.Consumer({ state: StreamStateEntity? -> + 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, repeatMode, playbackSpeed, playbackPitch, + playbackSkipSilence, playWhenReady, isMuted) + }), + io.reactivex.rxjava3.functions.Consumer({ error: Throwable? -> + if (DEBUG) { + Log.w(TAG, "Failed to start playback", error) + } + // In case any error we can start playback without history + initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch, + playbackSkipSilence, playWhenReady, isMuted) + }), + Action({ + // Completed but not found in history + initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch, + playbackSkipSilence, playWhenReady, isMuted) + }) + )) + } else { + // Good to go... + // In a case of equal PlayQueues we can re-init old one but only when it is disposed + initPlayback((if (samePlayQueue) playQueue else newQueue)!!, repeatMode, playbackSpeed, + playbackPitch, playbackSkipSilence, playWhenReady, isMuted) + } + 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) + setRecovery() + reloadPlayQueueManager() + } + UIs.call(java.util.function.Consumer({ obj: PlayerUi -> obj.setupAfterIntent() })) + NavigationHelper.sendPlayerStartedEvent(context) + } + + private fun initUIsForCurrentPlayerType() { + if (((UIs.get((MainPlayerUi::class.java)).isPresent() && playerType == PlayerType.MAIN) + || (UIs.get((PopupPlayerUi::class.java)).isPresent() && playerType == PlayerType.POPUP))) { + // correct UI already in place + return + } + + // try to reuse binding if possible + val binding: PlayerBinding = UIs.get((VideoPlayerUi::class.java)).map(Function({ obj: VideoPlayerUi? -> obj.getBinding() })) + .orElseGet(Supplier({ + if (playerType == PlayerType.AUDIO) { + return@orElseGet null + } else { + return@orElseGet PlayerBinding.inflate(LayoutInflater.from(context)) + } + })) + when (playerType) { + PlayerType.MAIN -> { + UIs.destroyAll(PopupPlayerUi::class.java) + UIs.addAndPrepare(MainPlayerUi(this, binding)) + } + + PlayerType.POPUP -> { + UIs.destroyAll(MainPlayerUi::class.java) + UIs.addAndPrepare(PopupPlayerUi(this, binding)) + } + + PlayerType.AUDIO -> UIs.destroyAll(VideoPlayerUi::class.java) + } + } + + private fun initPlayback(queue: PlayQueue, + repeatMode: @com.google.android.exoplayer2.Player.RepeatMode Int, + playbackSpeed: Float, + playbackPitch: Float, + playbackSkipSilence: Boolean, + playOnReady: Boolean, + isMuted: Boolean) { + destroyPlayer() + initPlayer(playOnReady) + setRepeatMode(repeatMode) + setPlaybackParameters(playbackSpeed, playbackPitch, playbackSkipSilence) + playQueue = queue + playQueue!!.init() + reloadPlayQueueManager() + UIs.call(java.util.function.Consumer({ obj: PlayerUi -> obj.initPlayback() })) + simpleExoPlayer!!.setVolume((if (isMuted) 0 else 1).toFloat()) + notifyQueueUpdateToListeners() + } + + private fun initPlayer(playOnReady: Boolean) { + if (DEBUG) { + Log.d(TAG, "initPlayer() called with: playOnReady = [" + playOnReady + "]") + } + simpleExoPlayer = 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 = AudioReactor(context, simpleExoPlayer!!) + registerBroadcastReceiver() + + // Setup UIs + UIs.call(java.util.function.Consumer({ obj: PlayerUi -> obj.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 fun destroyPlayer() { + if (DEBUG) { + Log.d(TAG, "destroyPlayer() called") + } + UIs.call(java.util.function.Consumer({ obj: PlayerUi -> obj.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() + } + } + + fun destroy() { + if (DEBUG) { + Log.d(TAG, "destroy() called") + } + saveStreamProgressState() + setRecovery() + stopActivityBinding() + destroyPlayer() + unregisterBroadcastReceiver() + databaseUpdateDisposable.clear() + progressUpdateDisposable.set(null) + cancelLoadingCurrentThumbnail() + UIs.destroyAll(Any::class.java) // destroy every UI: obviously every UI extends Object + } + + fun setRecovery() { + if (playQueue == null || exoPlayerIsNull()) { + return + } + val queuePos: Int = playQueue.getIndex() + val windowPos: Long = simpleExoPlayer!!.getCurrentPosition() + val duration: Long = simpleExoPlayer!!.getDuration() + + // No checks due to https://github.com/TeamNewPipe/NewPipe/pull/7195#issuecomment-962624380 + setRecovery(queuePos, MathUtils.clamp(windowPos, 0, duration)) + } + + private fun setRecovery(queuePos: Int, windowPos: Long) { + if (playQueue == null || playQueue!!.size() <= queuePos) { + return + } + if (DEBUG) { + Log.d(TAG, "Setting recovery, queue: " + queuePos + ", pos: " + windowPos) + } + playQueue!!.setRecovery(queuePos, windowPos) + } + + fun reloadPlayQueueManager() { + if (playQueueManager != null) { + playQueueManager!!.dispose() + } + if (playQueue != null) { + playQueueManager = MediaSourceManager(this, playQueue!!) + } + } + + // own playback listener + public override fun onPlaybackShutdown() { + if (DEBUG) { + Log.d(TAG, "onPlaybackShutdown() called") + } + // destroys the service, which in turn will destroy the player + service.stopService() + } + + fun smoothStopForImmediateReusing() { + // Pausing would make transition from one stream to a new stream not smooth, so only stop + simpleExoPlayer!!.stop() + setRecovery() + UIs.call(java.util.function.Consumer({ obj: PlayerUi -> obj.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 fun setupBroadcastReceiver() { + if (DEBUG) { + Log.d(TAG, "setupBroadcastReceiver() called") + } + broadcastReceiver = object : BroadcastReceiver() { + public override fun onReceive(ctx: Context, intent: Intent) { + onBroadcastReceived(intent) + } + } + intentFilter = IntentFilter() + intentFilter!!.addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + intentFilter!!.addAction(NotificationConstants.ACTION_CLOSE) + intentFilter!!.addAction(NotificationConstants.ACTION_PLAY_PAUSE) + intentFilter!!.addAction(NotificationConstants.ACTION_PLAY_PREVIOUS) + intentFilter!!.addAction(NotificationConstants.ACTION_PLAY_NEXT) + intentFilter!!.addAction(NotificationConstants.ACTION_FAST_REWIND) + intentFilter!!.addAction(NotificationConstants.ACTION_FAST_FORWARD) + intentFilter!!.addAction(NotificationConstants.ACTION_REPEAT) + intentFilter!!.addAction(NotificationConstants.ACTION_SHUFFLE) + intentFilter!!.addAction(NotificationConstants.ACTION_RECREATE_NOTIFICATION) + intentFilter!!.addAction(VideoDetailFragment.Companion.ACTION_VIDEO_FRAGMENT_RESUMED) + intentFilter!!.addAction(VideoDetailFragment.Companion.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 fun onBroadcastReceived(intent: Intent?) { + if (intent == null || intent.getAction() == null) { + return + } + if (DEBUG) { + Log.d(TAG, "onBroadcastReceived() called with: intent = [" + intent + "]") + } + when (intent.getAction()) { + AudioManager.ACTION_AUDIO_BECOMING_NOISY -> pause() + NotificationConstants.ACTION_CLOSE -> service.stopService() + NotificationConstants.ACTION_PLAY_PAUSE -> playPause() + NotificationConstants.ACTION_PLAY_PREVIOUS -> playPrevious() + NotificationConstants.ACTION_PLAY_NEXT -> playNext() + NotificationConstants.ACTION_FAST_REWIND -> fastRewind() + NotificationConstants.ACTION_FAST_FORWARD -> fastForward() + NotificationConstants.ACTION_REPEAT -> cycleNextRepeatMode() + NotificationConstants.ACTION_SHUFFLE -> toggleShuffleModeEnabled() + Intent.ACTION_CONFIGURATION_CHANGED -> { + Localization.assureCorrectAppLanguage(service) + if (DEBUG) { + Log.d(TAG, "ACTION_CONFIGURATION_CHANGED received") + } + } + } + UIs.call(java.util.function.Consumer({ playerUi: PlayerUi -> playerUi.onBroadcastReceived(intent) })) + } + + private fun registerBroadcastReceiver() { + // Try to unregister current first + unregisterBroadcastReceiver() + context.registerReceiver(broadcastReceiver, intentFilter) + } + + private fun unregisterBroadcastReceiver() { + try { + context.unregisterReceiver(broadcastReceiver) + } catch (unregisteredException: IllegalArgumentException) { + Log.w(TAG, ("Broadcast receiver already unregistered: " + + unregisteredException.message)) + } + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Thumbnail loading + ////////////////////////////////////////////////////////////////////////// */ + //region Thumbnail loading + private fun getCurrentThumbnailTarget(): Target { + // a Picasso target is just a listener for thumbnail loading events + return object : Target { + public override fun onBitmapLoaded(bitmap: Bitmap, from: LoadedFrom) { + if (DEBUG) { + Log.d(TAG, ("Thumbnail - onBitmapLoaded() called with: bitmap = [" + bitmap + + " -> " + bitmap.getWidth() + "x" + bitmap.getHeight() + "], from = [" + + from + "]")) + } + // there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too. + onThumbnailLoaded(bitmap) + } + + public override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) { + Log.e(TAG, "Thumbnail - onBitmapFailed() called", e) + // there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too. + onThumbnailLoaded(null) + } + + public override fun onPrepareLoad(placeHolderDrawable: Drawable) { + if (DEBUG) { + Log.d(TAG, "Thumbnail - onPrepareLoad() called") + } + } + } + } + + private fun loadCurrentThumbnail(thumbnails: List) { + if (DEBUG) { + Log.d(TAG, ("Thumbnail - loadCurrentThumbnail() called with thumbnails = [" + + thumbnails.size + "]")) + } + + // first cancel any previous loading + cancelLoadingCurrentThumbnail() + + // 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 Picasso. + onThumbnailLoaded(null) + if (thumbnails.isEmpty()) { + return + } + + // scale down the notification thumbnail for performance + PicassoHelper.loadScaledDownThumbnail(context, thumbnails) + .tag(PICASSO_PLAYER_THUMBNAIL_TAG) + .into(currentThumbnailTarget) + } + + private fun cancelLoadingCurrentThumbnail() { + // cancel the Picasso job associated with the player thumbnail, if any + PicassoHelper.cancelTag(PICASSO_PLAYER_THUMBNAIL_TAG) + } + + private fun onThumbnailLoaded(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 Picasso's target. + if (currentThumbnail != bitmap) { + currentThumbnail = bitmap + UIs.call(java.util.function.Consumer({ playerUi: PlayerUi -> playerUi.onThumbnailLoaded(bitmap) })) + } + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Playback parameters + ////////////////////////////////////////////////////////////////////////// */ + //region Playback parameters + fun getPlaybackSpeed(): Float { + return getPlaybackParameters().speed + } + + fun setPlaybackSpeed(speed: Float) { + setPlaybackParameters(speed, getPlaybackPitch(), getPlaybackSkipSilence()) + } + + fun getPlaybackPitch(): Float { + return getPlaybackParameters().pitch + } + + fun getPlaybackSkipSilence(): Boolean { + return !exoPlayerIsNull() && simpleExoPlayer!!.getSkipSilenceEnabled() + } + + fun getPlaybackParameters(): PlaybackParameters { + 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 + */ + fun setPlaybackParameters(speed: Float, pitch: Float, + skipSilence: Boolean) { + val roundedSpeed: Float = Math.round(speed * 100.0f) / 100.0f + val roundedPitch: Float = Math.round(pitch * 100.0f) / 100.0f + PlayerHelper.savePlaybackParametersToPrefs(this, roundedSpeed, roundedPitch, skipSilence) + simpleExoPlayer!!.setPlaybackParameters( + PlaybackParameters(roundedSpeed, roundedPitch)) + simpleExoPlayer!!.setSkipSilenceEnabled(skipSilence) + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Progress loop and updates + ////////////////////////////////////////////////////////////////////////// */ + //region Progress loop and updates + private fun onUpdateProgress(currentProgress: Int, + duration: Int, + bufferPercent: Int) { + if (isPrepared) { + UIs.call(java.util.function.Consumer({ ui: PlayerUi -> ui.onUpdateProgress(currentProgress, duration, bufferPercent) })) + notifyProgressUpdateToListeners(currentProgress, duration, bufferPercent) + } + } + + fun startProgressLoop() { + progressUpdateDisposable.set(getProgressUpdateDisposable()) + } + + private fun stopProgressLoop() { + progressUpdateDisposable.set(null) + } + + fun isProgressLoopRunning(): Boolean { + return progressUpdateDisposable.get() != null + } + + fun triggerProgressUpdate() { + if (exoPlayerIsNull()) { + return + } + onUpdateProgress(max((simpleExoPlayer!!.getCurrentPosition().toInt()).toDouble(), 0.0).toInt(), simpleExoPlayer!!.getDuration().toInt(), simpleExoPlayer!!.getBufferedPercentage()) + } + + private fun getProgressUpdateDisposable(): Disposable { + return Observable.interval(PROGRESS_LOOP_INTERVAL_MILLIS.toLong(), TimeUnit.MILLISECONDS, + AndroidSchedulers.mainThread()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(io.reactivex.rxjava3.functions.Consumer({ ignored: Long? -> triggerProgressUpdate() }), + io.reactivex.rxjava3.functions.Consumer({ error: Throwable? -> Log.e(TAG, "Progress update failure: ", error) })) + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Playback states + ////////////////////////////////////////////////////////////////////////// */ + //region Playback states + public override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + if (DEBUG) { + Log.d(TAG, ("ExoPlayer - onPlayWhenReadyChanged() called with: " + + "playWhenReady = [" + playWhenReady + "], " + + "reason = [" + reason + "]")) + } + val playbackState: Int = if (exoPlayerIsNull()) com.google.android.exoplayer2.Player.STATE_IDLE else simpleExoPlayer!!.getPlaybackState() + updatePlaybackState(playWhenReady, playbackState) + } + + public override fun onPlaybackStateChanged(playbackState: Int) { + if (DEBUG) { + Log.d(TAG, ("ExoPlayer - onPlaybackStateChanged() called with: " + + "playbackState = [" + playbackState + "]")) + } + updatePlaybackState(getPlayWhenReady(), playbackState) + } + + private fun updatePlaybackState(playWhenReady: Boolean, playbackState: Int) { + 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 + } + when (playbackState) { + com.google.android.exoplayer2.Player.STATE_IDLE -> isPrepared = false + com.google.android.exoplayer2.Player.STATE_BUFFERING -> if (isPrepared) { + changeState(STATE_BUFFERING) + } + + com.google.android.exoplayer2.Player.STATE_READY -> { + if (!isPrepared) { + isPrepared = true + onPrepared(playWhenReady) + } + changeState(if (playWhenReady) STATE_PLAYING else STATE_PAUSED) + } + + com.google.android.exoplayer2.Player.STATE_ENDED -> { + changeState(STATE_COMPLETED) + saveStreamProgressStateCompleted() + isPrepared = false + } + } + } + + // exoplayer listener + public override fun onIsLoadingChanged(isLoading: Boolean) { + if (!isLoading && (currentState == STATE_PAUSED) && isProgressLoopRunning()) { + stopProgressLoop() + } else if (isLoading && !isProgressLoopRunning()) { + startProgressLoop() + } + } + + // own playback listener + public override fun onPlaybackBlock() { + if (exoPlayerIsNull()) { + return + } + if (DEBUG) { + Log.d(TAG, "Playback - onPlaybackBlock() called") + } + currentItem = null + currentMetadata = null + simpleExoPlayer!!.stop() + isPrepared = false + changeState(STATE_BLOCKED) + } + + // own playback listener + public override fun onPlaybackUnblock(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() + } + + fun changeState(state: Int) { + if (DEBUG) { + Log.d(TAG, "changeState() called with: state = [" + state + "]") + } + currentState = state + when (state) { + STATE_BLOCKED -> onBlocked() + STATE_PLAYING -> onPlaying() + STATE_BUFFERING -> onBuffering() + STATE_PAUSED -> onPaused() + STATE_PAUSED_SEEK -> onPausedSeek() + STATE_COMPLETED -> onCompleted() + } + notifyPlaybackUpdateToListeners() + } + + private fun onPrepared(playWhenReady: Boolean) { + if (DEBUG) { + Log.d(TAG, "onPrepared() called with: playWhenReady = [" + playWhenReady + "]") + } + UIs.call(java.util.function.Consumer({ obj: PlayerUi -> obj.onPrepared() })) + if (playWhenReady && !isMuted()) { + audioReactor!!.requestAudioFocus() + } + } + + private fun onBlocked() { + if (DEBUG) { + Log.d(TAG, "onBlocked() called") + } + if (!isProgressLoopRunning()) { + startProgressLoop() + } + UIs.call(java.util.function.Consumer({ obj: PlayerUi -> obj.onBlocked() })) + } + + private fun onPlaying() { + if (DEBUG) { + Log.d(TAG, "onPlaying() called") + } + if (!isProgressLoopRunning()) { + startProgressLoop() + } + UIs.call(java.util.function.Consumer({ obj: PlayerUi -> obj.onPlaying() })) + } + + private fun onBuffering() { + if (DEBUG) { + Log.d(TAG, "onBuffering() called") + } + UIs.call(java.util.function.Consumer({ obj: PlayerUi -> obj.onBuffering() })) + } + + private fun onPaused() { + if (DEBUG) { + Log.d(TAG, "onPaused() called") + } + if (isProgressLoopRunning()) { + stopProgressLoop() + } + UIs.call(java.util.function.Consumer({ obj: PlayerUi -> obj.onPaused() })) + } + + private fun onPausedSeek() { + if (DEBUG) { + Log.d(TAG, "onPausedSeek() called") + } + UIs.call(java.util.function.Consumer({ obj: PlayerUi -> obj.onPausedSeek() })) + } + + private fun onCompleted() { + if (DEBUG) { + Log.d(TAG, "onCompleted() called" + (if (playQueue == null) ". playQueue is null" else "")) + } + if (playQueue == null) { + return + } + UIs.call(java.util.function.Consumer({ obj: PlayerUi -> obj.onCompleted() })) + if (playQueue.getIndex() < playQueue!!.size() - 1) { + playQueue!!.offsetIndex(+1) + } + if (isProgressLoopRunning()) { + stopProgressLoop() + } + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Repeat and shuffle + ////////////////////////////////////////////////////////////////////////// */ + //region Repeat and shuffle + fun getRepeatMode(): @com.google.android.exoplayer2.Player.RepeatMode Int { + return if (exoPlayerIsNull()) com.google.android.exoplayer2.Player.REPEAT_MODE_OFF else simpleExoPlayer!!.getRepeatMode() + } + + fun setRepeatMode(repeatMode: @com.google.android.exoplayer2.Player.RepeatMode Int) { + if (!exoPlayerIsNull()) { + simpleExoPlayer!!.setRepeatMode(repeatMode) + } + } + + fun cycleNextRepeatMode() { + setRepeatMode(PlayerHelper.nextRepeatMode(getRepeatMode())) + } + + public override fun onRepeatModeChanged(repeatMode: @com.google.android.exoplayer2.Player.RepeatMode Int) { + if (DEBUG) { + Log.d(TAG, ("ExoPlayer - onRepeatModeChanged() called with: " + + "repeatMode = [" + repeatMode + "]")) + } + UIs.call(java.util.function.Consumer({ playerUi: PlayerUi -> playerUi.onRepeatModeChanged(repeatMode) })) + notifyPlaybackUpdateToListeners() + } + + public override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + if (DEBUG) { + Log.d(TAG, ("ExoPlayer - onShuffleModeEnabledChanged() called with: " + + "mode = [" + shuffleModeEnabled + "]")) + } + if (playQueue != null) { + if (shuffleModeEnabled) { + playQueue!!.shuffle() + } else { + playQueue!!.unshuffle() + } + } + UIs.call(java.util.function.Consumer({ playerUi: PlayerUi -> playerUi.onShuffleModeEnabledChanged(shuffleModeEnabled) })) + notifyPlaybackUpdateToListeners() + } + + fun toggleShuffleModeEnabled() { + if (!exoPlayerIsNull()) { + simpleExoPlayer!!.setShuffleModeEnabled(!simpleExoPlayer!!.getShuffleModeEnabled()) + } + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Mute / Unmute + ////////////////////////////////////////////////////////////////////////// */ + //region Mute / Unmute + fun toggleMute() { + val wasMuted: Boolean = isMuted() + simpleExoPlayer!!.setVolume((if (wasMuted) 1 else 0).toFloat()) + if (wasMuted) { + audioReactor!!.requestAudioFocus() + } else { + audioReactor!!.abandonAudioFocus() + } + UIs.call(java.util.function.Consumer({ playerUi: PlayerUi -> playerUi.onMuteUnmuteChanged(!wasMuted) })) + notifyPlaybackUpdateToListeners() + } + + fun isMuted(): Boolean { + return !exoPlayerIsNull() && simpleExoPlayer!!.getVolume() == 0f + } + //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 + * [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 [PlaybackException], which + * are also captured by [ExoPlayer] and stops the playback. + * + * @param player The [com.google.android.exoplayer2.Player] whose state changed. + * @param events The [com.google.android.exoplayer2.Player.Events] that has triggered + * the player state changes. + */ + public override fun onEvents(player: com.google.android.exoplayer2.Player, + events: com.google.android.exoplayer2.Player.Events) { + super.onEvents(player, events) + MediaItemTag.Companion.from(player.getCurrentMediaItem()).ifPresent(java.util.function.Consumer({ tag: MediaItemTag -> + if (tag === currentMetadata) { + return@ifPresent // we still have the same metadata, no need to do anything + } + val previousInfo: StreamInfo? = Optional.ofNullable(currentMetadata) + .flatMap(Function>({ obj: MediaItemTag -> obj.getMaybeStreamInfo() })).orElse(null) + val previousAudioTrack: MediaItemTag.AudioTrack? = Optional.ofNullable(currentMetadata) + .flatMap(Function>({ obj: MediaItemTag -> obj.getMaybeAudioTrack() })).orElse(null) + currentMetadata = tag + if (!currentMetadata.getErrors().isEmpty()) { + // new errors might have been added even if previousInfo == tag.getMaybeStreamInfo() + val errorInfo: ErrorInfo = ErrorInfo( + currentMetadata.getErrors(), + UserAction.PLAY_STREAM, + ("Loading failed for [" + currentMetadata.getTitle() + + "]: " + currentMetadata.getStreamUrl()), + currentMetadata.getServiceId()) + createNotification(context, errorInfo) + } + currentMetadata.getMaybeStreamInfo().ifPresent(java.util.function.Consumer({ info: StreamInfo -> + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onEvents() update stream info: " + info.getName()) + } + if (previousInfo == null || !(previousInfo.getUrl() == info.getUrl())) { + // only update with the new stream info if it has actually changed + updateMetadataWith(info) + } else if ((previousAudioTrack == null + || tag.getMaybeAudioTrack() + .map(Function({ t: MediaItemTag.AudioTrack? -> + t.getSelectedAudioStreamIndex() + != previousAudioTrack.getSelectedAudioStreamIndex() + })) + .orElse(false))) { + notifyAudioTrackUpdateToListeners() + } + })) + })) + } + + public override fun onTracksChanged(tracks: Tracks) { + if (DEBUG) { + Log.d(TAG, ("ExoPlayer - onTracksChanged(), " + + "track group size = " + tracks.getGroups().size)) + } + UIs.call(java.util.function.Consumer({ playerUi: PlayerUi -> playerUi.onTextTracksChanged(tracks) })) + } + + public override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) { + if (DEBUG) { + Log.d(TAG, ("ExoPlayer - playbackParameters(), speed = [" + playbackParameters.speed + + "], pitch = [" + playbackParameters.pitch + "]")) + } + UIs.call(java.util.function.Consumer({ playerUi: PlayerUi -> playerUi.onPlaybackParametersChanged(playbackParameters) })) + } + + public override fun onPositionDiscontinuity(oldPosition: PositionInfo, + newPosition: PositionInfo, + discontinuityReason: @DiscontinuityReason Int) { + 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 + val newIndex: Int = newPosition.mediaItemIndex + when (discontinuityReason) { + com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_AUTO_TRANSITION, com.google.android.exoplayer2.Player.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() == com.google.android.exoplayer2.Player.REPEAT_MODE_ONE && newIndex == playQueue.getIndex()) { + registerStreamViewed() + break + } + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onSeekProcessed() called") + } + if (isPrepared) { + saveStreamProgressState() + } + // Player index may be invalid when playback is blocked + if (getCurrentState() != STATE_BLOCKED && newIndex != playQueue.getIndex()) { + saveStreamProgressStateCompleted() // current stream has ended + playQueue.setIndex(newIndex) + } + } + + com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK -> { + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onSeekProcessed() called") + } + if (isPrepared) { + saveStreamProgressState() + } + if (getCurrentState() != STATE_BLOCKED && newIndex != playQueue.getIndex()) { + saveStreamProgressStateCompleted() + playQueue.setIndex(newIndex) + } + } + + com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT, com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_INTERNAL -> if (getCurrentState() != STATE_BLOCKED && newIndex != playQueue.getIndex()) { + saveStreamProgressStateCompleted() + playQueue.setIndex(newIndex) + } + + com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SKIP -> {} + } + } + + public override fun onRenderedFirstFrame() { + UIs.call(java.util.function.Consumer({ obj: PlayerUi -> obj.onRenderedFirstFrame() })) + } + + public override fun onCues(cueGroup: CueGroup) { + UIs.call(java.util.function.Consumer({ playerUi: PlayerUi -> playerUi.onCues(cueGroup.cues) })) + } + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Errors + ////////////////////////////////////////////////////////////////////////// */ + //region Errors + /** + * Process exceptions produced by [ExoPlayer][com.google.android.exoplayer2.ExoPlayer]. + * + * There are multiple types of errors: + * + * * [BEHIND_LIVE_WINDOW][PlaybackException.ERROR_CODE_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 [BAD_IO][PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE] to + * [UNSUPPORTED_FORMATS][PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED]: + * 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 [PlayQueue]. + * * For [PLAYER_TIMEOUT][PlaybackException.ERROR_CODE_TIMEOUT], + * [MEDIA_SOURCE_RESOLVER_TIMEOUT][PlaybackException.ERROR_CODE_IO_UNSPECIFIED] and + * [NO_NETWORK][PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED]: + * We can keep set the recovery record and keep to player at the current state until + * it is ready to play by restarting the [MediaSourceManager]. + * * On any ExoPlayer specific issue internal to its device interaction, such as + * [DECODER_ERROR][PlaybackException.ERROR_CODE_DECODER_INIT_FAILED]: + * 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 + */ + // 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. + public override fun onPlayerError(error: PlaybackException) { + Log.e(TAG, "ExoPlayer - onPlayerError() called with:", error) + saveStreamProgressState() + var isCatchableException: Boolean = false + when (error.errorCode) { + PlaybackException.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() + } + + PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, PlaybackException.ERROR_CODE_IO_NO_PERMISSION, PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED, PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED, PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED, PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED -> // Source errors, signal on playQueue and move on: + if (!exoPlayerIsNull() && playQueue != null) { + playQueue!!.error() + } + + PlaybackException.ERROR_CODE_TIMEOUT, PlaybackException.ERROR_CODE_IO_UNSPECIFIED, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT, PlaybackException.ERROR_CODE_UNSPECIFIED -> { + // Reload playback on unexpected errors: + setRecovery() + reloadPlayQueueManager() + } + + else -> // API, remote and renderer errors belong here: + onPlaybackShutdown() + } + if (!isCatchableException) { + createErrorNotification(error) + } + if (fragmentListener != null) { + fragmentListener!!.onPlayerError(error, isCatchableException) + } + } + + private fun createErrorNotification(error: PlaybackException) { + val errorInfo: ErrorInfo + if (currentMetadata == null) { + errorInfo = ErrorInfo(error, UserAction.PLAY_STREAM, + ("Player error[type=" + error.getErrorCodeName() + + "] occurred, currentMetadata is null")) + } else { + errorInfo = ErrorInfo(error, UserAction.PLAY_STREAM, + ("Player error[type=" + error.getErrorCodeName() + + "] occurred while playing " + currentMetadata.getStreamUrl()), + currentMetadata.getServiceId()) + } + createNotification(context, errorInfo) + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Playback position and seek + ////////////////////////////////////////////////////////////////////////// */ + //region Playback position and seek + // own playback listener (this is a getter) + public override fun isApproachingPlaybackEdge(timeToEndMillis: Long): Boolean { + // If live, then not near playback edge + // If not playing, then not approaching playback edge + if (exoPlayerIsNull() || isLive() || !isPlaying()) { + return false + } + val currentPositionMillis: Long = simpleExoPlayer!!.getCurrentPosition() + val currentDurationMillis: Long = 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 + */ + fun isLiveEdge(): Boolean { + if (exoPlayerIsNull() || !isLive()) { + return false + } + val currentTimeline: Timeline = simpleExoPlayer!!.getCurrentTimeline() + val currentWindowIndex: Int = simpleExoPlayer!!.getCurrentMediaItemIndex() + if (currentTimeline.isEmpty() || (currentWindowIndex < 0 + ) || (currentWindowIndex >= currentTimeline.getWindowCount())) { + return false + } + val timelineWindow: Timeline.Window = Timeline.Window() + currentTimeline.getWindow(currentWindowIndex, timelineWindow) + return timelineWindow.getDefaultPositionMs() <= simpleExoPlayer!!.getCurrentPosition() + } + + // own playback listener + public override fun onPlaybackSynchronize(item: PlayQueueItem, wasBlocked: Boolean) { + 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 + } + val playQueueIndex: Int = playQueue!!.indexOf(item) + val playlistIndex: Int = simpleExoPlayer!!.getCurrentMediaItemIndex() + val playlistSize: Int = simpleExoPlayer!!.getCurrentTimeline().getWindowCount() + val removeThumbnailBeforeSync: Boolean = (currentItem == null + ) || (currentItem.getServiceId() != item.getServiceId() + ) || !(currentItem.getUrl() == 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.Companion.RECOVERY_UNSET) { + simpleExoPlayer!!.seekTo(playQueueIndex, item.getRecoveryPosition()) + playQueue!!.unsetRecovery(playQueueIndex) + } else { + simpleExoPlayer!!.seekToDefaultPosition(playQueueIndex) + } + } + } + + fun seekTo(positionMillis: Long) { + 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 fun seekBy(offsetMillis: Long) { + if (DEBUG) { + Log.d(TAG, "seekBy() called with: offsetMillis = [" + offsetMillis + "]") + } + seekTo(simpleExoPlayer!!.getCurrentPosition() + offsetMillis) + } + + fun seekToDefault() { + if (!exoPlayerIsNull()) { + simpleExoPlayer!!.seekToDefaultPosition() + } + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Player actions (play, pause, previous, fast-forward, ...) + ////////////////////////////////////////////////////////////////////////// */ + //region Player actions (play, pause, previous, fast-forward, ...) + fun 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() + } + + fun pause() { + if (DEBUG) { + Log.d(TAG, "pause() called") + } + if (audioReactor == null || exoPlayerIsNull()) { + return + } + audioReactor!!.abandonAudioFocus() + simpleExoPlayer!!.pause() + saveStreamProgressState() + } + + fun 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() + } + } + + fun 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() + } + + fun playNext() { + if (DEBUG) { + Log.d(TAG, "onPlayNext() called") + } + if (playQueue == null) { + return + } + saveStreamProgressState() + playQueue!!.offsetIndex(+1) + triggerProgressUpdate() + } + + fun fastForward() { + if (DEBUG) { + Log.d(TAG, "fastRewind() called") + } + seekBy(PlayerHelper.retrieveSeekDurationFromPreferences(this).toLong()) + triggerProgressUpdate() + } + + fun fastRewind() { + if (DEBUG) { + Log.d(TAG, "fastRewind() called") + } + seekBy(-PlayerHelper.retrieveSeekDurationFromPreferences(this).toLong()) + triggerProgressUpdate() + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // StreamInfo history: views and progress + ////////////////////////////////////////////////////////////////////////// */ + //region StreamInfo history: views and progress + private fun registerStreamViewed() { + getCurrentStreamInfo().ifPresent(java.util.function.Consumer({ info: StreamInfo? -> + databaseUpdateDisposable + .add(recordManager.onViewed(info).onErrorComplete().subscribe()) + })) + } + + private fun saveStreamProgressState(progressMillis: Long) { + getCurrentStreamInfo().ifPresent(java.util.function.Consumer({ info: StreamInfo -> + if (!prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) { + return@ifPresent + } + if (DEBUG) { + Log.d(TAG, ("saveStreamProgressState() called with: progressMillis=" + progressMillis + + ", currentMetadata=[" + info.getName() + "]")) + } + databaseUpdateDisposable.add(recordManager.saveStreamState(info, progressMillis) + .observeOn(AndroidSchedulers.mainThread()) + .doOnError(io.reactivex.rxjava3.functions.Consumer({ e: Throwable -> + if (DEBUG) { + e.printStackTrace() + } + })) + .onErrorComplete() + .subscribe()) + })) + } + + fun 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()) + } + + fun saveStreamProgressStateCompleted() { + // current stream has ended, so the progress is its duration (+1 to overcome rounding) + getCurrentStreamInfo().ifPresent(java.util.function.Consumer({ info: StreamInfo? -> saveStreamProgressState((info!!.getDuration() + 1) * 1000) })) + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Metadata + ////////////////////////////////////////////////////////////////////////// */ + //region Metadata + private fun updateMetadataWith(info: StreamInfo) { + if (DEBUG) { + Log.d(TAG, "Playback - onMetadataChanged() called, playing: " + info.getName()) + } + if (exoPlayerIsNull()) { + return + } + maybeAutoQueueNextStream(info) + loadCurrentThumbnail(info.getThumbnails()) + registerStreamViewed() + notifyMetadataUpdateToListeners() + notifyAudioTrackUpdateToListeners() + UIs.call(java.util.function.Consumer({ playerUi: PlayerUi -> playerUi.onMetadataChanged(info) })) + } + + fun getVideoUrl(): String { + return if (currentMetadata == null) context.getString(R.string.unknown_content) else currentMetadata.getStreamUrl() + } + + fun getVideoUrlAtCurrentTime(): String { + val timeSeconds: Long = simpleExoPlayer!!.getCurrentPosition() / 1000 + var videoUrl: String = getVideoUrl() + if (!isLive() && (timeSeconds >= 0) && (currentMetadata != null + ) && (currentMetadata.getServiceId() == ServiceList.YouTube.getServiceId())) { + // Timestamp doesn't make sense in a live stream so drop it + videoUrl += ("&t=" + timeSeconds) + } + return videoUrl + } + + fun getVideoTitle(): String { + return if (currentMetadata == null) context.getString(R.string.unknown_content) else currentMetadata.getTitle() + } + + fun getUploaderName(): String { + return if (currentMetadata == null) context.getString(R.string.unknown_content) else currentMetadata.getUploaderName() + } + + fun getThumbnail(): Bitmap? { + return currentThumbnail + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Play queue, segments and streams + ////////////////////////////////////////////////////////////////////////// */ + //region Play queue, segments and streams + private fun maybeAutoQueueNextStream(info: StreamInfo) { + if ((playQueue == null) || (playQueue.getIndex() != playQueue!!.size() - 1 + ) || (getRepeatMode() != com.google.android.exoplayer2.Player.REPEAT_MODE_OFF + ) || !PlayerHelper.isAutoQueueEnabled(context)) { + return + } + // auto queue when starting playback on the last item when not repeating + val autoQueue: PlayQueue? = PlayerHelper.autoQueueOf(info, + playQueue!!.getStreams()) + if (autoQueue != null) { + playQueue!!.append(autoQueue.getStreams()) + } + } + + fun selectQueueItem(item: PlayQueueItem?) { + if (playQueue == null || exoPlayerIsNull()) { + return + } + val index: Int = playQueue!!.indexOf((item)!!) + if (index == -1) { + return + } + if (playQueue.getIndex() == index && simpleExoPlayer!!.getCurrentMediaItemIndex() == index) { + seekToDefault() + } else { + saveStreamProgressState() + } + playQueue.setIndex(index) + } + + public override fun onPlayQueueEdited() { + notifyPlaybackUpdateToListeners() + UIs.call(java.util.function.Consumer({ obj: PlayerUi -> obj.onPlayQueueEdited() })) + } + + public override fun sourceOf(item: PlayQueueItem?, info: StreamInfo): MediaSource? { + 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 useVideoSource method), except for HLS streams + // (see https://github.com/google/ExoPlayer/issues/9282). + return videoResolver.resolve(info) + } + + fun disablePreloadingOfCurrentTrack() { + loadController.disablePreloadingOfCurrentTrack() + } + + fun getSelectedVideoStream(): Optional { + return Optional.ofNullable(currentMetadata) + .flatMap(Function>({ obj: MediaItemTag -> obj.getMaybeQuality() })) + .filter(Predicate({ quality: MediaItemTag.Quality? -> + val selectedStreamIndex: Int = quality.getSelectedVideoStreamIndex() + (selectedStreamIndex >= 0 + && selectedStreamIndex < quality.getSortedVideoStreams().size) + })) + .map(Function({ quality: MediaItemTag.Quality? -> + quality.getSortedVideoStreams() + .get(quality.getSelectedVideoStreamIndex()) + })) + } + + fun getSelectedAudioStream(): Optional { + return Optional.ofNullable(currentMetadata) + .flatMap(Function>({ obj: MediaItemTag -> obj.getMaybeAudioTrack() })) + .map(Function({ getSelectedAudioStream() })) + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Captions (text tracks) + ////////////////////////////////////////////////////////////////////////// */ + //region Captions (text tracks) + fun getCaptionRendererIndex(): Int { + if (exoPlayerIsNull()) { + return RENDERER_UNAVAILABLE + } + for (t in 0 until simpleExoPlayer!!.getRendererCount()) { + if (simpleExoPlayer!!.getRendererType(t) == C.TRACK_TYPE_TEXT) { + return t + } + } + return RENDERER_UNAVAILABLE + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Video size + ////////////////////////////////////////////////////////////////////////// */ + //region Video size + // exoplayer listener + public override fun onVideoSizeChanged(videoSize: VideoSize) { + if (DEBUG) { + Log.d(TAG, ("onVideoSizeChanged() called with: " + + "width / height = [" + videoSize.width + " / " + videoSize.height + + " = " + ((videoSize.width.toFloat()) / videoSize.height) + "], " + + "unappliedRotationDegrees = [" + videoSize.unappliedRotationDegrees + "], " + + "pixelWidthHeightRatio = [" + videoSize.pixelWidthHeightRatio + "]")) + } + UIs.call(java.util.function.Consumer({ playerUi: PlayerUi -> playerUi.onVideoSizeChanged(videoSize) })) + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Activity / fragment binding + ////////////////////////////////////////////////////////////////////////// */ + //region Activity / fragment binding + fun setFragmentListener(listener: PlayerServiceEventListener?) { + fragmentListener = listener + UIs.call(java.util.function.Consumer({ obj: PlayerUi -> obj.onFragmentListenerSet() })) + notifyQueueUpdateToListeners() + notifyMetadataUpdateToListeners() + notifyPlaybackUpdateToListeners() + triggerProgressUpdate() + } + + fun removeFragmentListener(listener: PlayerServiceEventListener) { + if (fragmentListener === listener) { + fragmentListener = null + } + } + + fun setActivityListener(listener: PlayerEventListener?) { + activityListener = listener + // TODO why not queue update? + notifyMetadataUpdateToListeners() + notifyPlaybackUpdateToListeners() + triggerProgressUpdate() + } + + fun removeActivityListener(listener: PlayerEventListener) { + if (activityListener === listener) { + activityListener = null + } + } + + fun stopActivityBinding() { + if (fragmentListener != null) { + fragmentListener!!.onServiceStopped() + fragmentListener = null + } + if (activityListener != null) { + activityListener!!.onServiceStopped() + activityListener = null + } + } + + private fun notifyQueueUpdateToListeners() { + if (fragmentListener != null && playQueue != null) { + fragmentListener!!.onQueueUpdate(playQueue) + } + if (activityListener != null && playQueue != null) { + activityListener!!.onQueueUpdate(playQueue) + } + } + + private fun notifyMetadataUpdateToListeners() { + getCurrentStreamInfo().ifPresent(java.util.function.Consumer({ info: StreamInfo? -> + if (fragmentListener != null) { + fragmentListener!!.onMetadataUpdate(info, playQueue) + } + if (activityListener != null) { + activityListener!!.onMetadataUpdate(info, playQueue) + } + })) + } + + private fun 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 fun notifyProgressUpdateToListeners(currentProgress: Int, + duration: Int, + bufferPercent: Int) { + if (fragmentListener != null) { + fragmentListener!!.onProgressUpdate(currentProgress, duration, bufferPercent) + } + if (activityListener != null) { + activityListener!!.onProgressUpdate(currentProgress, duration, bufferPercent) + } + } + + private fun notifyAudioTrackUpdateToListeners() { + if (fragmentListener != null) { + fragmentListener!!.onAudioTrackUpdate() + } + if (activityListener != null) { + activityListener!!.onAudioTrackUpdate() + } + } + + fun useVideoSource(videoEnabled: Boolean) { + if (playQueue == null || audioPlayerSelected()) { + return + } + isAudioOnly = !videoEnabled + getCurrentStreamInfo().ifPresentOrElse(java.util.function.Consumer({ info: StreamInfo -> + // In case we don't know the source type, fall back to either video-with-audio, or + // audio-only source type + val sourceType: SourceType? = videoResolver.getStreamSourceType() + .orElse(SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY) + if (playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) { + reloadPlayQueueManager() + } + setRecovery() + + // Disable or enable video and subtitles renderers depending of the videoEnabled value + trackSelector.setParameters(trackSelector.buildUponParameters() + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !videoEnabled) + .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, !videoEnabled)) + }), Runnable({ + + /* + 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 below + + 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 + */reloadPlayQueueManager() + setRecovery() + })) + } + + /** + * 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 [audio stream][StreamType.AUDIO_STREAM], an + * [audio live stream][StreamType.AUDIO_LIVE_STREAM], or a + * [ended audio live stream][StreamType.POST_LIVE_AUDIO_STREAM]; + * * the content is a [live stream][StreamType.LIVE_STREAM] and the source type is a + * [live source][SourceType.LIVE_STREAM]; + * * the content's source is [a video stream][SourceType.VIDEO_WITH_SEPARATED_AUDIO] or has no audio-only streams available **and** is a + * [video stream][StreamType.VIDEO_STREAM], an + * [ended live stream][StreamType.POST_LIVE_STREAM], or a + * [live stream][StreamType.LIVE_STREAM]. + * + * + * + * + * @param sourceType the [SourceType] of the stream + * @param streamInfo the [StreamInfo] of the stream + * @param videoRendererIndex the video renderer index of the video source, if that's a video + * source (or [.RENDERER_UNAVAILABLE]) + * @return whether the play queue manager needs to be reloaded + */ + private fun playQueueManagerReloadingNeeded(sourceType: SourceType?, + streamInfo: StreamInfo, + videoRendererIndex: Int): Boolean { + val streamType: StreamType = streamInfo.getStreamType() + val isStreamTypeAudio: Boolean = 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 + && Utils.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 + fun getCurrentStreamInfo(): Optional { + return Optional.ofNullable(currentMetadata).flatMap(Function>({ obj: MediaItemTag -> obj.getMaybeStreamInfo() })) + } + + fun getCurrentState(): Int { + return currentState + } + + fun exoPlayerIsNull(): Boolean { + return simpleExoPlayer == null + } + + fun getExoPlayer(): ExoPlayer? { + return simpleExoPlayer + } + + fun isStopped(): Boolean { + return exoPlayerIsNull() || simpleExoPlayer!!.getPlaybackState() == ExoPlayer.STATE_IDLE + } + + fun isPlaying(): Boolean { + return !exoPlayerIsNull() && simpleExoPlayer!!.isPlaying() + } + + fun getPlayWhenReady(): Boolean { + return !exoPlayerIsNull() && simpleExoPlayer!!.getPlayWhenReady() + } + + fun isLoading(): Boolean { + return !exoPlayerIsNull() && simpleExoPlayer!!.isLoading() + } + + private fun isLive(): Boolean { + try { + return !exoPlayerIsNull() && simpleExoPlayer!!.isCurrentMediaItemDynamic() + } catch (e: IndexOutOfBoundsException) { + // 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 + } + } + + fun setPlaybackQuality(quality: String?) { + saveStreamProgressState() + setRecovery() + videoResolver.setPlaybackQuality(quality) + reloadPlayQueueManager() + } + + fun setAudioTrack(audioTrackId: String?) { + saveStreamProgressState() + setRecovery() + videoResolver.setAudioTrack(audioTrackId) + audioResolver.setAudioTrack(audioTrackId) + reloadPlayQueueManager() + } + + fun getContext(): Context { + return context + } + + fun getPrefs(): SharedPreferences { + return prefs + } + + fun getPlayerType(): PlayerType { + return playerType + } + + fun audioPlayerSelected(): Boolean { + return playerType == PlayerType.AUDIO + } + + fun videoPlayerSelected(): Boolean { + return playerType == PlayerType.MAIN + } + + fun popupPlayerSelected(): Boolean { + return playerType == PlayerType.POPUP + } + + fun getPlayQueue(): PlayQueue? { + return playQueue + } + + fun getAudioReactor(): AudioReactor? { + return audioReactor + } + + fun getService(): PlayerService { + return service + } + + fun isAudioOnly(): Boolean { + return isAudioOnly + } + + fun getTrackSelector(): DefaultTrackSelector { + return trackSelector + } + + fun getCurrentMetadata(): MediaItemTag? { + return currentMetadata + } + + fun getCurrentItem(): PlayQueueItem? { + return currentItem + } + + fun getFragmentListener(): Optional { + return Optional.ofNullable(fragmentListener) + } + + /** + * @return the user interfaces connected with the player + */ + // keep the unusual method name + fun UIs(): PlayerUiList { + return UIs + } + + /** + * Get the video renderer index of the current playing stream. + * + * + * This method returns the video renderer index of the current + * [MappingTrackSelector.MappedTrackInfo] or [.RENDERER_UNAVAILABLE] if the current + * [MappingTrackSelector.MappedTrackInfo] is null or if there is no video renderer index. + * + * @return the video renderer index or [.RENDERER_UNAVAILABLE] if it cannot be get + */ + private fun getVideoRendererIndex(): Int { + val 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(IntPredicate({ i: Int -> + (!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 + + companion object { + val DEBUG: Boolean = MainActivity.Companion.DEBUG + val TAG: String = Player::class.java.getSimpleName() + + /*////////////////////////////////////////////////////////////////////////// + // States + ////////////////////////////////////////////////////////////////////////// */ + val STATE_PREFLIGHT: Int = -1 + val STATE_BLOCKED: Int = 123 + val STATE_PLAYING: Int = 124 + val STATE_BUFFERING: Int = 125 + val STATE_PAUSED: Int = 126 + val STATE_PAUSED_SEEK: Int = 127 + val STATE_COMPLETED: Int = 128 + + /*////////////////////////////////////////////////////////////////////////// + // Intent + ////////////////////////////////////////////////////////////////////////// */ + val REPEAT_MODE: String = "repeat_mode" + val PLAYBACK_QUALITY: String = "playback_quality" + val PLAY_QUEUE_KEY: String = "play_queue_key" + val ENQUEUE: String = "enqueue" + val ENQUEUE_NEXT: String = "enqueue_next" + val RESUME_PLAYBACK: String = "resume_playback" + val PLAY_WHEN_READY: String = "play_when_ready" + val PLAYER_TYPE: String = "player_type" + val IS_MUTED: String = "is_muted" + + /*////////////////////////////////////////////////////////////////////////// + // Time constants + ////////////////////////////////////////////////////////////////////////// */ + val PLAY_PREV_ACTIVATION_LIMIT_MILLIS: Int = 5000 // 5 seconds + val PROGRESS_LOOP_INTERVAL_MILLIS: Int = 1000 // 1 second + + /*////////////////////////////////////////////////////////////////////////// + // Other constants + ////////////////////////////////////////////////////////////////////////// */ + val RENDERER_UNAVAILABLE: Int = -1 + private val PICASSO_PLAYER_THUMBNAIL_TAG: String = "PICASSO_PLAYER_THUMBNAIL_TAG" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java deleted file mode 100644 index e7abf4320..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java +++ /dev/null @@ -1,185 +0,0 @@ -/* - * 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 static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.os.Binder; -import android.os.IBinder; -import android.util.Log; - -import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi; -import org.schabi.newpipe.player.notification.NotificationPlayerUi; -import org.schabi.newpipe.util.ThemeHelper; - -import java.lang.ref.WeakReference; - - -/** - * One service for all players. - */ -public final class PlayerService extends Service { - private static final String TAG = PlayerService.class.getSimpleName(); - private static final boolean DEBUG = Player.DEBUG; - - private Player player; - - private final IBinder mBinder = new PlayerService.LocalBinder(this); - - - /*////////////////////////////////////////////////////////////////////////// - // Service's LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate() { - if (DEBUG) { - Log.d(TAG, "onCreate() called"); - } - assureCorrectAppLanguage(this); - ThemeHelper.setTheme(this); - - player = new Player(this); - /* - Create the player notification and start immediately the service in foreground, - otherwise if nothing is played or initializing the player and its components (especially - loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the - service would never be put in the foreground while we said to the system we would do so - */ - player.UIs().get(NotificationPlayerUi.class) - .ifPresent(NotificationPlayerUi::createNotificationAndStartForeground); - } - - @Override - public int onStartCommand(final Intent intent, final int flags, final int startId) { - if (DEBUG) { - Log.d(TAG, "onStartCommand() called with: intent = [" + intent - + "], flags = [" + flags + "], startId = [" + startId + "]"); - } - - /* - 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 - */ - if (player != null) { - player.UIs().get(NotificationPlayerUi.class) - .ifPresent(NotificationPlayerUi::createNotificationAndStartForeground); - } - - if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction()) - && (player == null || player.getPlayQueue() == null)) { - /* - No need to process media button's actions if the player is not working, otherwise - the player service would strangely start with nothing to play - Stop the service in this case, which will be removed from the foreground and its - notification cancelled in its destruction - */ - stopSelf(); - return START_NOT_STICKY; - } - - if (player != null) { - player.handleIntent(intent); - 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"); - } - cleanup(); - } - - private void cleanup() { - if (player != null) { - player.destroy(); - player = null; - } - } - - public void stopService() { - cleanup(); - stopSelf(); - } - - @Override - protected void attachBaseContext(final Context base) { - super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base)); - } - - @Override - public IBinder onBind(final Intent intent) { - return mBinder; - } - - 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(); - } - - public Player getPlayer() { - return playerService.get().player; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt new file mode 100644 index 000000000..a1d8e2d4c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.kt @@ -0,0 +1,166 @@ +/* + * 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.app.Service +import android.content.Context +import android.content.Intent +import android.os.Binder +import android.os.IBinder +import android.util.Log +import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi +import org.schabi.newpipe.player.notification.NotificationPlayerUi +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.ThemeHelper +import java.lang.ref.WeakReference +import java.util.function.Consumer + +/** + * One service for all players. + */ +class PlayerService() : Service() { + private var player: Player? = null + private val mBinder: IBinder = LocalBinder(this) + + /*////////////////////////////////////////////////////////////////////////// + // Service's LifeCycle + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreate() { + if (DEBUG) { + Log.d(TAG, "onCreate() called") + } + Localization.assureCorrectAppLanguage(this) + ThemeHelper.setTheme(this) + player = Player(this) + /* + Create the player notification and start immediately the service in foreground, + otherwise if nothing is played or initializing the player and its components (especially + loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the + service would never be put in the foreground while we said to the system we would do so + */player!!.UIs().get((NotificationPlayerUi::class.java)) + .ifPresent(Consumer({ obj: NotificationPlayerUi? -> obj!!.createNotificationAndStartForeground() })) + } + + public override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + if (DEBUG) { + Log.d(TAG, ("onStartCommand() called with: intent = [" + intent + + "], flags = [" + flags + "], startId = [" + startId + "]")) + } + + /* + 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 + */if (player != null) { + player!!.UIs().get((NotificationPlayerUi::class.java)) + .ifPresent(Consumer({ obj: NotificationPlayerUi? -> obj!!.createNotificationAndStartForeground() })) + } + if (((Intent.ACTION_MEDIA_BUTTON == intent.getAction()) && (player == null || player!!.getPlayQueue() == null))) { + /* + No need to process media button's actions if the player is not working, otherwise + the player service would strangely start with nothing to play + Stop the service in this case, which will be removed from the foreground and its + notification cancelled in its destruction + */ + stopSelf() + return START_NOT_STICKY + } + if (player != null) { + player!!.handleIntent(intent) + player!!.UIs().get((MediaSessionPlayerUi::class.java)) + .ifPresent(Consumer({ ui: MediaSessionPlayerUi? -> ui!!.handleMediaButtonIntent(intent) })) + } + return START_NOT_STICKY + } + + fun 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() + } + } + + public override fun onTaskRemoved(rootIntent: Intent) { + super.onTaskRemoved(rootIntent) + if (player != null && !player!!.videoPlayerSelected()) { + return + } + onDestroy() + // Unload from memory completely + Runtime.getRuntime().halt(0) + } + + public override fun onDestroy() { + if (DEBUG) { + Log.d(TAG, "destroy() called") + } + cleanup() + } + + private fun cleanup() { + if (player != null) { + player!!.destroy() + player = null + } + } + + fun stopService() { + cleanup() + stopSelf() + } + + override fun attachBaseContext(base: Context) { + super.attachBaseContext(AudioServiceLeakFix.Companion.preventLeakOf(base)) + } + + public override fun onBind(intent: Intent): IBinder? { + return mBinder + } + + class LocalBinder internal constructor(playerService: PlayerService) : Binder() { + private val playerService: WeakReference + + init { + this.playerService = WeakReference(playerService) + } + + fun getService(): PlayerService? { + return playerService.get() + } + + fun getPlayer(): Player? { + return playerService.get()!!.player + } + } + + companion object { + private val TAG: String = PlayerService::class.java.getSimpleName() + private val DEBUG: Boolean = Player.Companion.DEBUG + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerType.java b/app/src/main/java/org/schabi/newpipe/player/PlayerType.java deleted file mode 100644 index 171a70395..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerType.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.schabi.newpipe.player; - -import static org.schabi.newpipe.player.Player.PLAYER_TYPE; - -import android.content.Intent; - -public enum PlayerType { - MAIN, - AUDIO, - POPUP; - - /** - * @return an integer representing this {@link PlayerType}, to be used to save it in intents - * @see #retrieveFromIntent(Intent) Use retrieveFromIntent() to retrieve and convert player type - * integers from an intent - */ - public int valueForIntent() { - return ordinal(); - } - - /** - * @param intent the intent to retrieve a player type from - * @return the player type integer retrieved from the intent, converted back into a {@link - * PlayerType}, or {@link PlayerType#MAIN} if there is no player type extra in the - * intent - * @throws ArrayIndexOutOfBoundsException if the intent contains an invalid player type integer - * @see #valueForIntent() Use valueForIntent() to obtain valid player type integers - */ - public static PlayerType retrieveFromIntent(final Intent intent) { - return values()[intent.getIntExtra(PLAYER_TYPE, MAIN.valueForIntent())]; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerType.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerType.kt new file mode 100644 index 000000000..1fa31ae58 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerType.kt @@ -0,0 +1,30 @@ +package org.schabi.newpipe.player + +import android.content.Intent + +enum class PlayerType { + MAIN, + AUDIO, + POPUP; + + /** + * @return an integer representing this [PlayerType], to be used to save it in intents + * @see .retrieveFromIntent + */ + fun valueForIntent(): Int { + return ordinal + } + + companion object { + /** + * @param intent the intent to retrieve a player type from + * @return the player type integer retrieved from the intent, converted back into a [ ], or [PlayerType.MAIN] if there is no player type extra in the + * intent + * @throws ArrayIndexOutOfBoundsException if the intent contains an invalid player type integer + * @see .valueForIntent + */ + fun retrieveFromIntent(intent: Intent): PlayerType { + return entries.get(intent.getIntExtra(Player.Companion.PLAYER_TYPE, MAIN.valueForIntent())) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java b/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java deleted file mode 100644 index 676443a9c..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java +++ /dev/null @@ -1,136 +0,0 @@ -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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.kt b/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.kt new file mode 100644 index 000000000..917dbb27d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.kt @@ -0,0 +1,119 @@ +package org.schabi.newpipe.player.datasource + +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 org.schabi.newpipe.extractor.utils.Utils +import java.nio.charset.StandardCharsets + +/** + * A [HlsDataSourceFactory] which allows playback of non-URI media HLS playlists for + * [HlsMediaSource][com.google.android.exoplayer2.source.hls.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 + * [DefaultDataSource][com.google.android.exoplayer2.upstream.DefaultDataSource]s. + * + * + * + * + * See [.createDataSource] for changes and implementation details. + * + */ +class NonUriHlsDataSourceFactory +/** + * Create a [NonUriHlsDataSourceFactory] instance. + * + * @param dataSourceFactory the [DataSource.Factory] which will be used to build + * non manifests [DataSource]s, which must not be null + * @param playlistStringByteArray a byte array of the HLS playlist, which must not be null + */ private constructor(private val dataSourceFactory: DataSource.Factory, + private val playlistStringByteArray: ByteArray) : HlsDataSourceFactory { + /** + * Builder class of [NonUriHlsDataSourceFactory] instances. + */ + class Builder() { + private var dataSourceFactory: DataSource.Factory? = null + private var playlistString: String? = null + + /** + * Set the [DataSource.Factory] which will be used to create non manifest contents + * [DataSource]s. + * + * @param dataSourceFactoryForNonManifestContents the [DataSource.Factory] which will + * be used to create non manifest contents + * [DataSource]s, which cannot be null + */ + fun setDataSourceFactory( + dataSourceFactoryForNonManifestContents: DataSource.Factory) { + 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 + */ + fun setPlaylistString(hlsPlaylistString: String) { + playlistString = hlsPlaylistString + } + + /** + * Create a new [NonUriHlsDataSourceFactory] with the given data source factory and + * the given HLS playlist. + * + * @return a [NonUriHlsDataSourceFactory] + * @throws IllegalArgumentException if the data source factory is null or if the HLS + * playlist string set is null or empty + */ + fun build(): NonUriHlsDataSourceFactory { + if (dataSourceFactory == null) { + throw IllegalArgumentException( + "No DataSource.Factory valid instance has been specified.") + } + if (Utils.isNullOrEmpty(playlistString)) { + throw IllegalArgumentException("No HLS valid playlist has been specified.") + } + return NonUriHlsDataSourceFactory(dataSourceFactory!!, + playlistString!!.toByteArray(StandardCharsets.UTF_8)) + } + } + + /** + * Create a [DataSource] for the given data type. + * + * + * + * Contrary to [ ExoPlayer's default implementation][com.google.android.exoplayer2.source.hls.DefaultHlsDataSourceFactory], this implementation is not always using the + * [DataSource.Factory] passed to the + * [ HlsMediaSource.Factory][com.google.android.exoplayer2.source.hls.HlsMediaSource.Factory] constructor, only when it's not + * [the manifest type][C.DATA_TYPE_MANIFEST]. + * + * + * + * + * 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 + * [DataSource]s created for media playlists should use the master playlist response + * instead). + * + * + * @param dataType the data type for which the [DataSource] will be used, which is one of + * [C] `.DATA_TYPE_*` constants + * @return a [DataSource] for the given data type + */ + public override fun createDataSource(dataType: Int): DataSource { + // 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 ByteArrayDataSource(playlistStringByteArray) + } + return dataSourceFactory.createDataSource() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java deleted file mode 100644 index cf1f03b45..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java +++ /dev/null @@ -1,1014 +0,0 @@ -/* - * 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.isTvHtml5SimplyEmbeddedPlayerStreamingUrl; -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 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) - || isTvHtml5SimplyEmbeddedPlayerStreamingUrl(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); - httpURLConnection.setDoOutput(httpBody != null); - - // Mobile clients uses POST requests to fetch contents - httpURLConnection.setRequestMethod(isAndroidStreamingUrl || isIosStreamingUrl - ? "POST" - : DataSpec.getStringForHttpMethod(httpMethod)); - - if (httpBody != null) { - httpURLConnection.setFixedLengthStreamingMode(httpBody.length); - httpURLConnection.connect(); - final OutputStream os = httpURLConnection.getOutputStream(); - os.write(httpBody); - os.close(); - } else { - httpURLConnection.connect(); - } - 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(); - } - } -} - diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.kt b/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.kt new file mode 100644 index 000000000..2e3306614 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.kt @@ -0,0 +1,894 @@ +/* + * 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 android.net.Uri +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.DataSourceException +import com.google.android.exoplayer2.upstream.DataSpec +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource +import com.google.android.exoplayer2.upstream.HttpDataSource +import com.google.android.exoplayer2.upstream.HttpDataSource.HttpDataSourceException +import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidContentTypeException +import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException +import com.google.android.exoplayer2.upstream.HttpDataSource.RequestProperties +import com.google.android.exoplayer2.upstream.HttpUtil +import com.google.android.exoplayer2.upstream.TransferListener +import com.google.android.exoplayer2.util.Assertions +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 org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper +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.Objects +import java.util.zip.GZIPInputStream +import kotlin.math.min + +/** + * An [HttpDataSource] that uses Android's [HttpURLConnection], based on + * [com.google.android.exoplayer2.upstream.DefaultHttpDataSource], for YouTube streams. + * + * + * + * It adds more headers to `videoplayback` URLs, such as `Origin`, `Referer` + * (only where it's relevant) and also more parameters, such as `rn` and replaces the use of + * the `Range` header by the corresponding parameter (`range`), if enabled. + * + * + * There are many unused methods in this class because everything was copied from [ ] with as little changes as possible. + * SonarQube warnings were also suppressed for the same reason. + */ +class YoutubeHttpDataSource private constructor(private val connectTimeoutMillis: Int, + private val readTimeoutMillis: Int, + private val allowCrossProtocolRedirects: Boolean, + private val rangeParameterEnabled: Boolean, + private val rnParameterEnabled: Boolean, + private val defaultRequestProperties: RequestProperties?, + private val contentTypePredicate: Predicate?, + keepPostFor302Redirects: Boolean) : BaseDataSource(true), HttpDataSource { + /** + * [DataSource.Factory] for [YoutubeHttpDataSource] instances. + */ + class Factory() : HttpDataSource.Factory { + private val defaultRequestProperties: RequestProperties + private var transferListener: TransferListener? = null + private var contentTypePredicate: Predicate? = null + private var connectTimeoutMs: Int + private var readTimeoutMs: Int + private var allowCrossProtocolRedirects: Boolean = false + private var keepPostFor302Redirects: Boolean = false + private var rangeParameterEnabled: Boolean = false + private var rnParameterEnabled: Boolean = false + + /** + * Creates an instance. + */ + init { + defaultRequestProperties = RequestProperties() + connectTimeoutMs = DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS + readTimeoutMs = DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS + } + + public override fun setDefaultRequestProperties( + defaultRequestPropertiesMap: Map): Factory { + defaultRequestProperties.clearAndSet(defaultRequestPropertiesMap) + return this + } + + /** + * Sets the connect timeout, in milliseconds. + * + * + * + * The default is [DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS]. + * + * + * @param connectTimeoutMsValue The connect timeout, in milliseconds, that will be used. + * @return This factory. + */ + fun setConnectTimeoutMs(connectTimeoutMsValue: Int): Factory { + connectTimeoutMs = connectTimeoutMsValue + return this + } + + /** + * Sets the read timeout, in milliseconds. + * + * + * The default is [DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS]. + * + * @param readTimeoutMsValue The connect timeout, in milliseconds, that will be used. + * @return This factory. + */ + fun setReadTimeoutMs(readTimeoutMsValue: Int): Factory { + readTimeoutMs = readTimeoutMsValue + return this + } + + /** + * Sets whether to allow cross protocol redirects. + * + * + * The default is `false`. + * + * @param allowCrossProtocolRedirectsValue Whether to allow cross protocol redirects. + * @return This factory. + */ + fun setAllowCrossProtocolRedirects( + allowCrossProtocolRedirectsValue: Boolean): Factory { + allowCrossProtocolRedirects = allowCrossProtocolRedirectsValue + return this + } + + /** + * Sets whether the use of the `range` parameter instead of the `Range` header + * to request ranges of streams is enabled. + * + * + * + * Note that it must be not enabled on streams which are using a [ ], as it will break playback + * for them (some exceptions may be thrown). + * + * + * @param rangeParameterEnabledValue whether the use of the `range` parameter instead + * of the `Range` header (must be only enabled when + * non-`ProgressiveMediaSource`s) + * @return This factory. + */ + fun setRangeParameterEnabled(rangeParameterEnabledValue: Boolean): Factory { + rangeParameterEnabled = rangeParameterEnabledValue + return this + } + + /** + * Sets whether the use of the `rn`, which stands for request number, parameter is + * enabled. + * + * + * + * Note that it should be not enabled on streams which are using `/` to delimit URLs + * parameters, such as the streams of HLS manifests. + * + * + * @param rnParameterEnabledValue whether the appending the `rn` parameter to + * `videoplayback` URLs + * @return This factory. + */ + fun setRnParameterEnabled(rnParameterEnabledValue: Boolean): Factory { + rnParameterEnabled = rnParameterEnabledValue + return this + } + + /** + * Sets a content type [Predicate]. If a content type is rejected by the predicate + * then a [HttpDataSource.InvalidContentTypeException] is thrown from + * [YoutubeHttpDataSource.open]. + * + * + * + * The default is `null`. + * + * + * @param contentTypePredicateToSet The content type [Predicate], or `null` to + * clear a predicate that was previously set. + * @return This factory. + */ + fun setContentTypePredicate( + contentTypePredicateToSet: Predicate?): Factory { + contentTypePredicate = contentTypePredicateToSet + return this + } + + /** + * Sets the [TransferListener] that will be used. + * + * + * The default is `null`. + * + * + * See [DataSource.addTransferListener]. + * + * @param transferListenerToUse The listener that will be used. + * @return This factory. + */ + fun setTransferListener( + transferListenerToUse: TransferListener?): Factory { + 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. + */ + fun setKeepPostFor302Redirects(keepPostFor302RedirectsValue: Boolean): Factory { + keepPostFor302Redirects = keepPostFor302RedirectsValue + return this + } + + public override fun createDataSource(): YoutubeHttpDataSource { + val dataSource: YoutubeHttpDataSource = YoutubeHttpDataSource( + connectTimeoutMs, + readTimeoutMs, + allowCrossProtocolRedirects, + rangeParameterEnabled, + rnParameterEnabled, + defaultRequestProperties, + contentTypePredicate, + keepPostFor302Redirects) + if (transferListener != null) { + dataSource.addTransferListener(transferListener!!) + } + return dataSource + } + } + + private val requestProperties: RequestProperties + private val keepPostFor302Redirects: Boolean + private var dataSpec: DataSpec? = null + private var connection: HttpURLConnection? = null + private var inputStream: InputStream? = null + private var opened: Boolean = false + private var responseCode: Int = 0 + private var bytesToRead: Long = 0 + private var bytesRead: Long = 0 + private var requestNumber: Long + + init { + requestProperties = RequestProperties() + this.keepPostFor302Redirects = keepPostFor302Redirects + requestNumber = 0 + } + + public override fun getUri(): Uri? { + return if (connection == null) null else Uri.parse(connection!!.getURL().toString()) + } + + public override fun getResponseCode(): Int { + return if (connection == null || responseCode <= 0) -1 else responseCode + } + + public override fun getResponseHeaders(): Map> { + 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 NullFilteringHeadersMap(connection!!.getHeaderFields()) + } + + public override fun setRequestProperty(name: String, value: String) { + Assertions.checkNotNull(name) + Assertions.checkNotNull(value) + requestProperties.set(name, value) + } + + public override fun clearRequestProperty(name: String) { + Assertions.checkNotNull(name) + requestProperties.remove(name) + } + + public override fun clearAllRequestProperties() { + requestProperties.clear() + } + + /** + * Opens the source to read the specified data. + */ + @Throws(HttpDataSourceException::class) + public override fun open(dataSpecParameter: DataSpec): Long { + this.dataSpec = dataSpecParameter + bytesRead = 0 + bytesToRead = 0 + transferInitializing(dataSpecParameter) + val httpURLConnection: HttpURLConnection? + val responseMessage: String + try { + connection = makeConnection(dataSpec!!) + httpURLConnection = connection + responseCode = httpURLConnection!!.getResponseCode() + responseMessage = httpURLConnection.getResponseMessage() + } catch (e: IOException) { + closeConnectionQuietly() + throw HttpDataSourceException.createForIOException(e, dataSpec!!, + HttpDataSourceException.TYPE_OPEN) + } + + // Check for a valid response code. + if (responseCode < 200 || responseCode > 299) { + val headers: Map> = httpURLConnection.getHeaderFields() + if (responseCode == 416) { + val documentSize: Long = HttpUtil.getDocumentSize( + httpURLConnection.getHeaderField(HttpHeaders.CONTENT_RANGE)) + if (dataSpecParameter.position == documentSize) { + opened = true + transferStarted(dataSpecParameter) + return if (dataSpecParameter.length != C.LENGTH_UNSET.toLong()) dataSpecParameter.length else 0 + } + } + val errorStream: InputStream? = httpURLConnection.getErrorStream() + var errorResponseBody: ByteArray? + try { + errorResponseBody = if (errorStream != null) Util.toByteArray(errorStream) else Util.EMPTY_BYTE_ARRAY + } catch (e: IOException) { + errorResponseBody = Util.EMPTY_BYTE_ARRAY + } + closeConnectionQuietly() + val cause: IOException? = if (responseCode == 416) DataSourceException( + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE) else null + throw InvalidResponseCodeException(responseCode, responseMessage, cause, headers, + dataSpec!!, (errorResponseBody)!!) + } + + // Check for a valid content type. + val contentType: String = httpURLConnection.getContentType() + if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) { + closeConnectionQuietly() + throw InvalidContentTypeException(contentType, dataSpecParameter) + } + val bytesToSkip: Long + 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 = if (responseCode == 200 && dataSpecParameter.position != 0L) dataSpecParameter.position else 0 + } else { + bytesToSkip = 0 + } + + + // Determine the length of the data to be read, after skipping. + val isCompressed: Boolean = isCompressed((httpURLConnection)) + if (!isCompressed) { + if (dataSpecParameter.length != C.LENGTH_UNSET.toLong()) { + bytesToRead = dataSpecParameter.length + } else { + val contentLength: Long = HttpUtil.getContentLength( + httpURLConnection.getHeaderField(HttpHeaders.CONTENT_LENGTH), + httpURLConnection.getHeaderField(HttpHeaders.CONTENT_RANGE)) + bytesToRead = if (contentLength != C.LENGTH_UNSET.toLong()) (contentLength - bytesToSkip) else C.LENGTH_UNSET.toLong() + } + } 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 = GZIPInputStream(inputStream) + } + } catch (e: IOException) { + closeConnectionQuietly() + throw HttpDataSourceException(e, dataSpec!!, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN) + } + opened = true + transferStarted(dataSpecParameter) + try { + skipFully(bytesToSkip, dataSpec!!) + } catch (e: IOException) { + closeConnectionQuietly() + if (e is HttpDataSourceException) { + throw (e as HttpDataSourceException?)!! + } + throw HttpDataSourceException(e, dataSpec!!, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN) + } + return bytesToRead + } + + @Throws(HttpDataSourceException::class) + public override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + try { + return readInternal(buffer, offset, length) + } catch (e: IOException) { + throw HttpDataSourceException.createForIOException(e, Util.castNonNull(dataSpec), + HttpDataSourceException.TYPE_READ) + } + } + + @Throws(HttpDataSourceException::class) + public override fun close() { + try { + val connectionInputStream: InputStream? = inputStream + if (connectionInputStream != null) { + val bytesRemaining: Long = if (bytesToRead == C.LENGTH_UNSET.toLong()) C.LENGTH_UNSET.toLong() else bytesToRead - bytesRead + maybeTerminateInputStream(connection, bytesRemaining) + try { + connectionInputStream.close() + } catch (e: IOException) { + throw HttpDataSourceException(e, Util.castNonNull(dataSpec), + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_CLOSE) + } + } + } finally { + inputStream = null + closeConnectionQuietly() + if (opened) { + opened = false + transferEnded() + } + } + } + + @Throws(IOException::class) + private fun makeConnection(dataSpecToUse: DataSpec): HttpURLConnection { + var url: URL = URL(dataSpecToUse.uri.toString()) + var httpMethod: @DataSpec.HttpMethod Int = dataSpecToUse.httpMethod + var httpBody: ByteArray? = dataSpecToUse.httpBody + val position: Long = dataSpecToUse.position + val length: Long = dataSpecToUse.length + val allowGzip: Boolean = 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. + var redirectCount: Int = 0 + while (redirectCount++ <= MAX_REDIRECTS) { + val httpURLConnection: HttpURLConnection = makeConnection(url, httpMethod, httpBody, + position, length, allowGzip, false, dataSpecToUse.httpRequestHeaders) + val httpURLConnectionResponseCode: Int = httpURLConnection.getResponseCode() + val location: String = 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() + val shouldKeepPost: Boolean = (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 HttpDataSourceException( + 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 `null` if not required. + * @param position The byte offset of the requested data. + * @param length The length of the requested data, or [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 + */ + @Throws(IOException::class) + private fun makeConnection( + url: URL, + httpMethod: @DataSpec.HttpMethod Int, + httpBody: ByteArray?, + position: Long, + length: Long, + allowGzip: Boolean, + followRedirects: Boolean, + requestParameters: Map): HttpURLConnection { + // This is the method that contains breaking changes with respect to DefaultHttpDataSource! + var requestUrl: String = 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 + val isVideoPlaybackUrl: Boolean = url.getPath().startsWith("/videoplayback") + if (isVideoPlaybackUrl && rnParameterEnabled && !requestUrl.contains(RN_PARAMETER)) { + requestUrl += RN_PARAMETER + requestNumber + ++requestNumber + } + if (rangeParameterEnabled && isVideoPlaybackUrl) { + val rangeParameterBuilt: String? = buildRangeParameter(position, length) + if (rangeParameterBuilt != null) { + requestUrl += rangeParameterBuilt + } + } + val httpURLConnection: HttpURLConnection = openConnection(URL(requestUrl)) + httpURLConnection.setConnectTimeout(connectTimeoutMillis) + httpURLConnection.setReadTimeout(readTimeoutMillis) + val requestHeaders: MutableMap = HashMap() + if (defaultRequestProperties != null) { + requestHeaders.putAll(defaultRequestProperties.getSnapshot()) + } + requestHeaders.putAll(requestProperties.getSnapshot()) + requestHeaders.putAll(requestParameters) + for (property: Map.Entry in requestHeaders.entries) { + httpURLConnection.setRequestProperty(property.key, property.value) + } + if (!rangeParameterEnabled) { + val rangeHeader: String? = HttpUtil.buildRangeRequestHeader(position, length) + if (rangeHeader != null) { + httpURLConnection.setRequestProperty(HttpHeaders.RANGE, rangeHeader) + } + } + if ((YoutubeParsingHelper.isWebStreamingUrl(requestUrl) + || YoutubeParsingHelper.isTvHtml5SimplyEmbeddedPlayerStreamingUrl(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") + val isAndroidStreamingUrl: Boolean = YoutubeParsingHelper.isAndroidStreamingUrl(requestUrl) + val isIosStreamingUrl: Boolean = YoutubeParsingHelper.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, + YoutubeParsingHelper.getAndroidUserAgent(null)) + } else if (isIosStreamingUrl) { + httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, + YoutubeParsingHelper.getIosUserAgent(null)) + } else { + // non-mobile user agent + httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, DownloaderImpl.Companion.USER_AGENT) + } + httpURLConnection.setRequestProperty(HttpHeaders.ACCEPT_ENCODING, + if (allowGzip) "gzip" else "identity") + httpURLConnection.setInstanceFollowRedirects(followRedirects) + httpURLConnection.setDoOutput(httpBody != null) + + // Mobile clients uses POST requests to fetch contents + httpURLConnection.setRequestMethod(if (isAndroidStreamingUrl || isIosStreamingUrl) "POST" else DataSpec.getStringForHttpMethod(httpMethod)) + if (httpBody != null) { + httpURLConnection.setFixedLengthStreamingMode(httpBody.size) + httpURLConnection.connect() + val os: OutputStream = httpURLConnection.getOutputStream() + os.write(httpBody) + os.close() + } else { + httpURLConnection.connect() + } + return httpURLConnection + } + + /** + * Creates an [HttpURLConnection] that is connected with the `url`. + * + * @param url the [URL] to create an [HttpURLConnection] + * @return an [HttpURLConnection] created with the `url` + */ + @Throws(IOException::class) + private fun openConnection(url: URL): HttpURLConnection { + return url.openConnection() as HttpURLConnection + } + + /** + * Handles a redirect. + * + * @param originalUrl The original URL. + * @param location The Location header in the response. May be `null`. + * @param dataSpecToHandleRedirect The [DataSpec]. + * @return The next URL. + * @throws HttpDataSourceException If redirection isn't possible. + */ + @Throws(HttpDataSourceException::class) + private fun handleRedirect(originalUrl: URL, + location: String?, + dataSpecToHandleRedirect: DataSpec): URL { + if (location == null) { + throw HttpDataSourceException("Null location redirect", dataSpecToHandleRedirect, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN) + } + + // Form the new url. + val url: URL + try { + url = URL(originalUrl, location) + } catch (e: MalformedURLException) { + throw HttpDataSourceException(e, dataSpecToHandleRedirect, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN) + } + + // Check that the protocol of the new url is supported. + val protocol: String = url.getProtocol() + if (!("https" == protocol) && !("http" == protocol)) { + throw HttpDataSourceException("Unsupported protocol redirect: " + protocol, + dataSpecToHandleRedirect, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN) + } + if (!allowCrossProtocolRedirects && !(protocol == originalUrl.getProtocol())) { + throw 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 [DataSpec]. + * @throws IOException If the thread is interrupted during the operation, or if the data ended + * before skipping the specified number of bytes. + */ + @Throws(IOException::class) + private fun skipFully(bytesToSkip: Long, dataSpecToUse: DataSpec) { + var bytesToSkip: Long = bytesToSkip + if (bytesToSkip == 0L) { + return + } + val skipBuffer: ByteArray = ByteArray(4096) + while (bytesToSkip > 0) { + val readLength: Int = min(bytesToSkip.toDouble(), skipBuffer.size.toDouble()).toInt() + val read: Int = Util.castNonNull(inputStream).read(skipBuffer, 0, readLength) + if (Thread.currentThread().isInterrupted()) { + throw HttpDataSourceException( + InterruptedIOException(), + dataSpecToUse, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN) + } + if (read == -1) { + throw HttpDataSourceException( + dataSpecToUse, + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, + HttpDataSourceException.TYPE_OPEN) + } + bytesToSkip -= read.toLong() + bytesTransferred(read) + } + } + + /** + * Reads up to `length` bytes of data and stores them into `buffer`, starting at + * index `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 `buffer` at which data should be written. + * @param readLength The maximum number of bytes to read. + * @return The number of bytes read, or [C.RESULT_END_OF_INPUT] if the end of the opened + * range is reached. + * @throws IOException If an error occurs reading from the source. + */ + @Throws(IOException::class) + private fun readInternal(buffer: ByteArray, offset: Int, readLength: Int): Int { + var readLength: Int = readLength + if (readLength == 0) { + return 0 + } + if (bytesToRead != C.LENGTH_UNSET.toLong()) { + val bytesRemaining: Long = bytesToRead - bytesRead + if (bytesRemaining == 0L) { + return C.RESULT_END_OF_INPUT + } + readLength = min(readLength.toDouble(), bytesRemaining.toDouble()).toInt() + } + val read: Int = Util.castNonNull(inputStream).read(buffer, offset, readLength) + if (read == -1) { + return C.RESULT_END_OF_INPUT + } + bytesRead += read.toLong() + bytesTransferred(read) + return read + } + + /** + * Closes the current connection quietly, if there is one. + */ + private fun closeConnectionQuietly() { + if (connection != null) { + try { + connection!!.disconnect() + } catch (e: Exception) { + Log.e(TAG, "Unexpected error while disconnecting", e) + } + connection = null + } + } + + private class NullFilteringHeadersMap internal constructor(private val headers: Map?>) : ForwardingMap?>() { + override fun delegate(): Map?> { + return headers + } + + public override fun containsKey(key: Any?): Boolean { + return key != null && super.containsKey(key) + } + + public override operator fun get(key: Any?): List? { + return if (key == null) null else super.get(key) + } + + public override fun keySet(): Set { + return Sets.filter(super.keys, Predicate({ obj: String? -> Objects.nonNull(obj) })) + } + + public override fun entrySet(): Set?>> { + return Sets.filter?>>(super.entries, Predicate({ entry: Map.Entry?> -> entry.key != null })) + } + + public override fun size(): Int { + return super.size - (if (super.containsKey(null)) 1 else 0) + } + + public override fun isEmpty(): Boolean { + return super.isEmpty() || (super.size == 1 && super.containsKey(null)) + } + + public override fun containsValue(value: Any?): Boolean { + return super.standardContainsValue(value) + } + + public override fun equals(`object`: Any?): Boolean { + return `object` != null && super.standardEquals(`object`) + } + + public override fun hashCode(): Int { + return super.standardHashCode() + } + } + + companion object { + private val TAG: String = YoutubeHttpDataSource::class.java.getSimpleName() + private val MAX_REDIRECTS: Int = 20 // Same limit as okhttp. + private val HTTP_STATUS_TEMPORARY_REDIRECT: Int = 307 + private val HTTP_STATUS_PERMANENT_REDIRECT: Int = 308 + private val MAX_BYTES_TO_DRAIN: Long = 2048 + private val RN_PARAMETER: String = "&rn=" + private val YOUTUBE_BASE_URL: String = "https://www.youtube.com" + + /** + * On platform API levels 19 and 20, okhttp's implementation of [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 [InputStream] should be terminated. + * @param bytesRemaining The number of bytes remaining to be read from the input stream if its + * length is known. [C.LENGTH_UNSET] otherwise. + */ + private fun maybeTerminateInputStream(connection: HttpURLConnection?, + bytesRemaining: Long) { + if ((connection == null) || (Util.SDK_INT < 19) || (Util.SDK_INT > 20)) { + return + } + try { + val inputStream: InputStream = connection.getInputStream() + if (bytesRemaining == C.LENGTH_UNSET.toLong()) { + // 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 + } + val className: String = inputStream.javaClass.getName() + if ((("com.android.okhttp.internal.http.HttpTransport\$ChunkedInputStream" + == className) || ("com.android.okhttp.internal.http.HttpTransport\$FixedLengthInputStream" + == className))) { + val superclass: Class<*> = inputStream.javaClass.getSuperclass() + val unexpectedEndOfInput: Method = Assertions.checkNotNull(superclass).getDeclaredMethod( + "unexpectedEndOfInput") + unexpectedEndOfInput.setAccessible(true) + unexpectedEndOfInput.invoke(inputStream) + } + } catch (e: Exception) { + // 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. + } + } + + private fun isCompressed(connection: HttpURLConnection): Boolean { + val contentEncoding: String = connection.getHeaderField("Content-Encoding") + return "gzip".equals(contentEncoding, ignoreCase = true) + } + + /** + * Builds a `range` parameter for the given position and length. + * + * + * + * To fetch its contents, YouTube use range requests which append a `range` parameter + * to videoplayback URLs instead of the `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 [C.LENGTH_UNSET] if the request is unbounded. + * @return The corresponding `range` parameter, or `null` if this parameter is + * unnecessary because the whole resource is being requested. + */ + private fun buildRangeParameter(position: Long, length: Long): String? { + if (position == 0L && length == C.LENGTH_UNSET.toLong()) { + return null + } + val rangeParameter: StringBuilder = StringBuilder() + rangeParameter.append("&range=") + rangeParameter.append(position) + rangeParameter.append("-") + if (length != C.LENGTH_UNSET.toLong()) { + rangeParameter.append(position + length - 1) + } + return rangeParameter.toString() + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/event/OnKeyDownListener.java b/app/src/main/java/org/schabi/newpipe/player/event/OnKeyDownListener.java deleted file mode 100644 index fc1f9d80d..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/event/OnKeyDownListener.java +++ /dev/null @@ -1,5 +0,0 @@ -package org.schabi.newpipe.player.event; - -public interface OnKeyDownListener { - boolean onKeyDown(int keyCode); -} diff --git a/app/src/main/java/org/schabi/newpipe/player/event/OnKeyDownListener.kt b/app/src/main/java/org/schabi/newpipe/player/event/OnKeyDownListener.kt new file mode 100644 index 000000000..7ff7d6dc3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/event/OnKeyDownListener.kt @@ -0,0 +1,5 @@ +package org.schabi.newpipe.player.event + +open interface OnKeyDownListener { + fun onKeyDown(keyCode: Int): Boolean +} diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java deleted file mode 100644 index 2cca259c2..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java +++ /dev/null @@ -1,16 +0,0 @@ -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(); -} diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.kt b/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.kt new file mode 100644 index 000000000..ea594a1e1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.kt @@ -0,0 +1,16 @@ +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 + +open interface PlayerEventListener { + fun onQueueUpdate(queue: PlayQueue?) + fun onPlaybackUpdate(state: Int, repeatMode: Int, shuffled: Boolean, + parameters: PlaybackParameters?) + + fun onProgressUpdate(currentProgress: Int, duration: Int, bufferPercent: Int) + fun onMetadataUpdate(info: StreamInfo?, queue: PlayQueue?) + fun onAudioTrackUpdate() {} + fun onServiceStopped() +} diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java deleted file mode 100644 index 8c18fd2ad..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java +++ /dev/null @@ -1,17 +0,0 @@ -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(); -} diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.kt b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.kt new file mode 100644 index 000000000..fa63d4c9b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.kt @@ -0,0 +1,12 @@ +package org.schabi.newpipe.player.event + +import com.google.android.exoplayer2.PlaybackException + +open interface PlayerServiceEventListener : PlayerEventListener { + fun onViewCreated() + fun onFullscreenStateChanged(fullscreen: Boolean) + fun onScreenRotationButtonClicked() + fun onMoreOptionsLongClicked() + fun onPlayerError(error: PlaybackException?, isCatchableException: Boolean) + fun hideSystemUiIfNeeded() +} diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java deleted file mode 100644 index 8effe2f0e..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.schabi.newpipe.player.event; - -import org.schabi.newpipe.player.PlayerService; -import org.schabi.newpipe.player.Player; - -public interface PlayerServiceExtendedEventListener extends PlayerServiceEventListener { - void onServiceConnected(Player player, - PlayerService playerService, - boolean playAfterConnect); - void onServiceDisconnected(); -} diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.kt b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.kt new file mode 100644 index 000000000..74e81e23e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.kt @@ -0,0 +1,12 @@ +package org.schabi.newpipe.player.event + +import org.schabi.newpipe.player.Player +import org.schabi.newpipe.player.PlayerService + +open interface PlayerServiceExtendedEventListener : PlayerServiceEventListener { + fun onServiceConnected(player: Player?, + playerService: PlayerService?, + playAfterConnect: Boolean) + + fun onServiceDisconnected() +} diff --git a/app/src/main/java/org/schabi/newpipe/player/gesture/CustomBottomSheetBehavior.java b/app/src/main/java/org/schabi/newpipe/player/gesture/CustomBottomSheetBehavior.java deleted file mode 100644 index 0970dbeb6..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/gesture/CustomBottomSheetBehavior.java +++ /dev/null @@ -1,84 +0,0 @@ -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); - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/player/gesture/CustomBottomSheetBehavior.kt b/app/src/main/java/org/schabi/newpipe/player/gesture/CustomBottomSheetBehavior.kt new file mode 100644 index 000000000..a9e21b828 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/gesture/CustomBottomSheetBehavior.kt @@ -0,0 +1,69 @@ +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.coordinatorlayout.widget.CoordinatorLayout +import com.google.android.material.bottomsheet.BottomSheetBehavior +import org.schabi.newpipe.R + +class CustomBottomSheetBehavior(context: Context, + attrs: AttributeSet?) : BottomSheetBehavior(context, attrs) { + var globalRect: Rect = Rect() + private var skippingInterception: Boolean = false + private val skipInterceptionOfElements: List = java.util.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) + + public override fun onInterceptTouchEvent(parent: CoordinatorLayout, + child: FrameLayout, + event: MotionEvent): Boolean { + // 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() == 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() == STATE_EXPANDED + && event.getAction() == MotionEvent.ACTION_DOWN)) { + // Without overriding scrolling will not work when user touches these elements + for (element: Int in skipInterceptionOfElements) { + val view: View? = child.findViewById(element) + if (view != null) { + val visible: Boolean = view.getGlobalVisibleRect(globalRect) + if ((visible + && globalRect.contains(event.getRawX().toInt(), event.getRawY().toInt()))) { + // 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) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java deleted file mode 100644 index a05990816..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java +++ /dev/null @@ -1,167 +0,0 @@ -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) { - if (!PlayerHelper.isUsingDSP()) { - return; - } - 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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.kt b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.kt new file mode 100644 index 000000000..6df3f5bdb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.kt @@ -0,0 +1,141 @@ +package org.schabi.newpipe.player.helper + +import android.animation.Animator +import android.animation.AnimatorListenerAdapter +import android.animation.ValueAnimator +import android.animation.ValueAnimator.AnimatorUpdateListener +import android.content.Context +import android.content.Intent +import android.media.AudioManager +import android.media.AudioManager.OnAudioFocusChangeListener +import android.media.audiofx.AudioEffect +import android.util.Log +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 +import com.google.android.exoplayer2.analytics.AnalyticsListener.EventTime + +class AudioReactor(private val context: Context, + private val player: ExoPlayer) : OnAudioFocusChangeListener, AnalyticsListener { + private val audioManager: AudioManager? + private val request: AudioFocusRequestCompat + + init { + audioManager = ContextCompat.getSystemService(context, AudioManager::class.java) + player.addAnalyticsListener(this) + request = AudioFocusRequestCompat.Builder(FOCUS_GAIN_TYPE) //.setAcceptsDelayedFocusGain(true) + .setWillPauseWhenDucked(true) + .setOnAudioFocusChangeListener(this) + .build() + } + + fun dispose() { + abandonAudioFocus() + player.removeAnalyticsListener(this) + notifyAudioSessionUpdate(false, player.getAudioSessionId()) + } + + /*////////////////////////////////////////////////////////////////////////// + // Audio Manager + ////////////////////////////////////////////////////////////////////////// */ + fun requestAudioFocus() { + AudioManagerCompat.requestAudioFocus((audioManager)!!, request) + } + + fun abandonAudioFocus() { + AudioManagerCompat.abandonAudioFocusRequest((audioManager)!!, request) + } + + var volume: Int + get() { + return audioManager!!.getStreamVolume(STREAM_TYPE) + } + set(volume) { + audioManager!!.setStreamVolume(STREAM_TYPE, volume, 0) + } + val maxVolume: Int + get() { + return AudioManagerCompat.getStreamMaxVolume((audioManager)!!, STREAM_TYPE) + } + + /*////////////////////////////////////////////////////////////////////////// + // AudioFocus + ////////////////////////////////////////////////////////////////////////// */ + public override fun onAudioFocusChange(focusChange: Int) { + Log.d(TAG, "onAudioFocusChange() called with: focusChange = [" + focusChange + "]") + when (focusChange) { + AudioManager.AUDIOFOCUS_GAIN -> onAudioFocusGain() + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> onAudioFocusLossCanDuck() + AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> onAudioFocusLoss() + } + } + + private fun onAudioFocusGain() { + Log.d(TAG, "onAudioFocusGain() called") + player.setVolume(DUCK_AUDIO_TO) + animateAudio(DUCK_AUDIO_TO, 1.0f) + if (PlayerHelper.isResumeAfterAudioFocusGain(context)) { + player.play() + } + } + + private fun onAudioFocusLoss() { + Log.d(TAG, "onAudioFocusLoss() called") + player.pause() + } + + private fun onAudioFocusLossCanDuck() { + Log.d(TAG, "onAudioFocusLossCanDuck() called") + // Set the volume to 1/10 on ducking + player.setVolume(DUCK_AUDIO_TO) + } + + private fun animateAudio(from: Float, to: Float) { + val valueAnimator: ValueAnimator = ValueAnimator() + valueAnimator.setFloatValues(from, to) + valueAnimator.setDuration(DUCK_DURATION.toLong()) + valueAnimator.addListener(object : AnimatorListenerAdapter() { + public override fun onAnimationStart(animation: Animator) { + player.setVolume(from) + } + + public override fun onAnimationCancel(animation: Animator) { + player.setVolume(to) + } + + public override fun onAnimationEnd(animation: Animator) { + player.setVolume(to) + } + }) + valueAnimator.addUpdateListener(AnimatorUpdateListener({ animation: ValueAnimator -> player.setVolume((animation.getAnimatedValue() as Float)) })) + valueAnimator.start() + } + + /*////////////////////////////////////////////////////////////////////////// + // Audio Processing + ////////////////////////////////////////////////////////////////////////// */ + public override fun onAudioSessionIdChanged(eventTime: EventTime, + audioSessionId: Int) { + notifyAudioSessionUpdate(true, audioSessionId) + } + + private fun notifyAudioSessionUpdate(active: Boolean, audioSessionId: Int) { + if (!PlayerHelper.isUsingDSP()) { + return + } + val intent: Intent = Intent(if (active) AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION else 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) + } + + companion object { + private val TAG: String = "AudioFocusReactor" + private val DUCK_DURATION: Int = 1500 + private val DUCK_AUDIO_TO: Float = .2f + private val FOCUS_GAIN_TYPE: Int = AudioManagerCompat.AUDIOFOCUS_GAIN + private val STREAM_TYPE: Int = AudioManager.STREAM_MUSIC + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java deleted file mode 100644 index 41fcc823a..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java +++ /dev/null @@ -1,46 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.kt b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.kt new file mode 100644 index 000000000..62d4103c3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.kt @@ -0,0 +1,29 @@ +package org.schabi.newpipe.player.helper + +import android.content.Context +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 + +internal class CacheFactory(private val context: Context, + private val transferListener: TransferListener, + private val cache: SimpleCache?, + private val upstreamDataSourceFactory: DataSource.Factory?) : DataSource.Factory { + public override fun createDataSource(): DataSource { + val dataSource: DefaultDataSource = DefaultDataSource.Factory(context, + (upstreamDataSourceFactory)!!) + .setTransferListener(transferListener) + .createDataSource() + val fileSource: FileDataSource = FileDataSource() + val dataSink: CacheDataSink = CacheDataSink((cache)!!, PlayerHelper.getPreferredFileSize()) + return CacheDataSource((cache), dataSource, fileSource, dataSink, CACHE_FLAGS, null) + } + + companion object { + private val CACHE_FLAGS: Int = CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CustomMediaCodecVideoRenderer.java b/app/src/main/java/org/schabi/newpipe/player/helper/CustomMediaCodecVideoRenderer.java deleted file mode 100644 index 66ac6d50b..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/CustomMediaCodecVideoRenderer.java +++ /dev/null @@ -1,54 +0,0 @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CustomMediaCodecVideoRenderer.kt b/app/src/main/java/org/schabi/newpipe/player/helper/CustomMediaCodecVideoRenderer.kt new file mode 100644 index 000000000..75a9ba7e5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/CustomMediaCodecVideoRenderer.kt @@ -0,0 +1,46 @@ +package org.schabi.newpipe.player.helper + +import android.content.Context +import android.os.Handler +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 [MediaCodecVideoRenderer] which always enable the output surface workaround that + * ExoPlayer enables on several devices which are known to implement + * [ MediaCodec.setOutputSurface(Surface)][android.media.MediaCodec.setOutputSurface] incorrectly. + * + * + * + * See [MediaCodecVideoRenderer.codecNeedsSetOutputSurfaceWorkaround] for more + * details. + * + * + * + * + * This custom [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 `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). + * + */ +class CustomMediaCodecVideoRenderer(context: Context?, + codecAdapterFactory: MediaCodecAdapter.Factory?, + mediaCodecSelector: MediaCodecSelector?, + allowedJoiningTimeMs: Long, + enableDecoderFallback: Boolean, + eventHandler: Handler?, + eventListener: VideoRendererEventListener?, + maxDroppedFramesToNotify: Int) : MediaCodecVideoRenderer((context)!!, (codecAdapterFactory)!!, (mediaCodecSelector)!!, allowedJoiningTimeMs, + enableDecoderFallback, eventHandler, eventListener, maxDroppedFramesToNotify) { + override fun codecNeedsSetOutputSurfaceWorkaround(name: String): Boolean { + return true + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CustomRenderersFactory.java b/app/src/main/java/org/schabi/newpipe/player/helper/CustomRenderersFactory.java deleted file mode 100644 index 668b48c30..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/CustomRenderersFactory.java +++ /dev/null @@ -1,43 +0,0 @@ -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)); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CustomRenderersFactory.kt b/app/src/main/java/org/schabi/newpipe/player/helper/CustomRenderersFactory.kt new file mode 100644 index 000000000..8b75313b8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/CustomRenderersFactory.kt @@ -0,0 +1,34 @@ +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 + +/** + * A [DefaultRenderersFactory] which only uses [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. + * + */ +class CustomRenderersFactory(context: Context?) : DefaultRenderersFactory((context)!!) { + override fun buildVideoRenderers(context: Context, + extensionRendererMode: @ExtensionRendererMode Int, + mediaCodecSelector: MediaCodecSelector, + enableDecoderFallback: Boolean, + eventHandler: Handler, + eventListener: VideoRendererEventListener, + allowedVideoJoiningTimeMs: Long, + out: ArrayList) { + out.add(CustomMediaCodecVideoRenderer(context, getCodecAdapterFactory(), + mediaCodecSelector, allowedVideoJoiningTimeMs, enableDecoderFallback, eventHandler, + eventListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY)) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java deleted file mode 100644 index ec0e4e4a7..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java +++ /dev/null @@ -1,42 +0,0 @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.kt b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.kt new file mode 100644 index 000000000..3ff08e5ec --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.kt @@ -0,0 +1,39 @@ +package org.schabi.newpipe.player.helper + +import com.google.android.exoplayer2.DefaultLoadControl + +class LoadController() : DefaultLoadControl() { + private var preloadingEnabled: Boolean = true + public override fun onPrepared() { + preloadingEnabled = true + super.onPrepared() + } + + public override fun onStopped() { + preloadingEnabled = true + super.onStopped() + } + + public override fun onReleased() { + preloadingEnabled = true + super.onReleased() + } + + public override fun shouldContinueLoading(playbackPositionUs: Long, + bufferedDurationUs: Long, + playbackSpeed: Float): Boolean { + if (!preloadingEnabled) { + return false + } + return super.shouldContinueLoading( + playbackPositionUs, bufferedDurationUs, playbackSpeed) + } + + fun disablePreloadingOfCurrentTrack() { + preloadingEnabled = false + } + + companion object { + val TAG: String = "LoadController" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/LockManager.java b/app/src/main/java/org/schabi/newpipe/player/helper/LockManager.java deleted file mode 100644 index 270156fe9..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/LockManager.java +++ /dev/null @@ -1,54 +0,0 @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/LockManager.kt b/app/src/main/java/org/schabi/newpipe/player/helper/LockManager.kt new file mode 100644 index 000000000..c7c9840ec --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/LockManager.kt @@ -0,0 +1,50 @@ +package org.schabi.newpipe.player.helper + +import android.content.Context +import android.net.wifi.WifiManager +import android.net.wifi.WifiManager.WifiLock +import android.os.PowerManager +import android.os.PowerManager.WakeLock +import android.util.Log +import androidx.core.content.ContextCompat + +class LockManager(context: Context) { + private val TAG: String = "LockManager@" + hashCode() + private val powerManager: PowerManager? + private val wifiManager: WifiManager? + private var wakeLock: WakeLock? = null + private var wifiLock: WifiLock? = null + + init { + powerManager = ContextCompat.getSystemService(context.getApplicationContext(), + PowerManager::class.java) + wifiManager = ContextCompat.getSystemService(context, WifiManager::class.java) + } + + fun 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() + } + } + + fun releaseWifiAndCpu() { + Log.d(TAG, "releaseWifiAndCpu() called") + if (wakeLock != null && wakeLock!!.isHeld()) { + wakeLock!!.release() + } + if (wifiLock != null && wifiLock!!.isHeld()) { + wifiLock!!.release() + } + wakeLock = null + wifiLock = null + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java deleted file mode 100644 index 796208a04..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java +++ /dev/null @@ -1,597 +0,0 @@ -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.Localization.assureCorrectAppLanguage; -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 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; - -import icepick.Icepick; -import icepick.State; - -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); - Icepick.saveInstanceState(this, outState); - } - - /*////////////////////////////////////////////////////////////////////////// - // Dialog - //////////////////////////////////////////////////////////////////////////*/ - - @NonNull - @Override - public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { - assureCorrectAppLanguage(getContext()); - Icepick.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(), 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(), 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(), 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(), 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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.kt b/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.kt new file mode 100644 index 000000000..1925b955f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.kt @@ -0,0 +1,539 @@ +package org.schabi.newpipe.player.helper + +import android.app.Dialog +import android.content.Context +import android.content.DialogInterface +import android.graphics.drawable.LayerDrawable +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.CheckBox +import android.widget.CompoundButton +import android.widget.SeekBar +import android.widget.SeekBar.OnSeekBarChangeListener +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.core.math.MathUtils +import androidx.fragment.app.DialogFragment +import androidx.preference.PreferenceManager +import icepick.Icepick +import icepick.State +import org.schabi.newpipe.R +import org.schabi.newpipe.databinding.DialogPlaybackParameterBinding +import org.schabi.newpipe.ktx.animateRotation +import org.schabi.newpipe.player.Player +import org.schabi.newpipe.player.ui.VideoPlayerUi +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener +import org.schabi.newpipe.util.SliderStrategy +import org.schabi.newpipe.util.SliderStrategy.Quadratic +import org.schabi.newpipe.util.ThemeHelper +import java.util.Objects +import java.util.function.BiConsumer +import java.util.function.Consumer +import java.util.function.DoubleConsumer +import java.util.function.DoubleFunction +import java.util.function.DoubleSupplier +import kotlin.math.min + +class PlaybackParameterDialog() : DialogFragment() { + private var callback: Callback? = null + + @State + var initialTempo: Double = DEFAULT_TEMPO + + @State + var initialPitchPercent: Double = DEFAULT_PITCH_PERCENT + + @State + var initialSkipSilence: Boolean = DEFAULT_SKIP_SILENCE + + @State + var tempo: Double = DEFAULT_TEMPO + + @State + var pitchPercent: Double = DEFAULT_PITCH_PERCENT + + @State + var skipSilence: Boolean = DEFAULT_SKIP_SILENCE + private var binding: DialogPlaybackParameterBinding? = null + + /*////////////////////////////////////////////////////////////////////////// + // Lifecycle + ////////////////////////////////////////////////////////////////////////// */ + public override fun onAttach(context: Context) { + super.onAttach(context) + if (context is Callback) { + callback = context + } else if (callback == null) { + dismiss() + } + } + + public override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + Icepick.saveInstanceState(this, outState) + } + + /*////////////////////////////////////////////////////////////////////////// + // Dialog + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + Localization.assureCorrectAppLanguage(getContext()) + Icepick.restoreInstanceState(this, savedInstanceState) + binding = DialogPlaybackParameterBinding.inflate(getLayoutInflater()) + initUI() + val dialogBuilder: AlertDialog.Builder = AlertDialog.Builder(requireActivity()) + .setView(binding!!.getRoot()) + .setCancelable(true) + .setNegativeButton(R.string.cancel, DialogInterface.OnClickListener({ dialogInterface: DialogInterface?, i: Int -> + setAndUpdateTempo(initialTempo) + setAndUpdatePitch(initialPitchPercent) + setAndUpdateSkipSilence(initialSkipSilence) + updateCallback() + })) + .setNeutralButton(R.string.playback_reset, DialogInterface.OnClickListener({ dialogInterface: DialogInterface?, i: Int -> + setAndUpdateTempo(DEFAULT_TEMPO) + setAndUpdatePitch(DEFAULT_PITCH_PERCENT) + setAndUpdateSkipSilence(DEFAULT_SKIP_SILENCE) + updateCallback() + })) + .setPositiveButton(R.string.ok, DialogInterface.OnClickListener({ dialogInterface: DialogInterface?, i: Int -> updateCallback() })) + return dialogBuilder.create() + } + + /*////////////////////////////////////////////////////////////////////////// + // UI Initialization and Control + ////////////////////////////////////////////////////////////////////////// */ + private fun initUI() { + // Tempo + setText(binding!!.tempoMinimumText, DoubleFunction({ obj: Double -> PlayerHelper.formatSpeed() }), MIN_PITCH_OR_SPEED) + setText(binding!!.tempoMaximumText, DoubleFunction({ obj: Double -> PlayerHelper.formatSpeed() }), MAX_PITCH_OR_SPEED) + binding!!.tempoSeekbar.setMax(QUADRATIC_STRATEGY.progressOf(MAX_PITCH_OR_SPEED)) + setAndUpdateTempo(tempo) + binding!!.tempoSeekbar.setOnSeekBarChangeListener( + getTempoOrPitchSeekbarChangeListener( + QUADRATIC_STRATEGY, DoubleConsumer({ newTempo: Double -> onTempoSliderUpdated(newTempo) }))) + registerOnStepClickListener( + binding!!.tempoStepDown, + DoubleSupplier({ tempo }), + -1.0, DoubleConsumer({ newTempo: Double -> onTempoSliderUpdated(newTempo) })) + registerOnStepClickListener( + binding!!.tempoStepUp, + DoubleSupplier({ tempo }), + 1.0, DoubleConsumer({ newTempo: Double -> onTempoSliderUpdated(newTempo) })) + + // Pitch + binding!!.pitchToogleControlModes.setOnClickListener(View.OnClickListener({ v: View? -> + val isCurrentlyVisible: Boolean = binding!!.pitchControlModeTabs.getVisibility() == View.GONE + binding!!.pitchControlModeTabs.setVisibility(if (isCurrentlyVisible) View.VISIBLE else View.GONE) + binding!!.pitchToogleControlModes.animateRotation(VideoPlayerUi.Companion.DEFAULT_CONTROLS_DURATION, if (isCurrentlyVisible) 180 else 0) + })) + pitchControlModeComponentMappings + .forEach(BiConsumer({ semitones: Boolean, textView: TextView -> setupPitchControlModeTextView(semitones, textView) })) + // Initialization is done at the end + + // Pitch - Percent + setText(binding!!.pitchPercentMinimumText, DoubleFunction({ obj: Double -> PlayerHelper.formatPitch() }), MIN_PITCH_OR_SPEED) + setText(binding!!.pitchPercentMaximumText, DoubleFunction({ obj: Double -> PlayerHelper.formatPitch() }), MAX_PITCH_OR_SPEED) + binding!!.pitchPercentSeekbar.setMax(QUADRATIC_STRATEGY.progressOf(MAX_PITCH_OR_SPEED)) + setAndUpdatePitch(pitchPercent) + binding!!.pitchPercentSeekbar.setOnSeekBarChangeListener( + getTempoOrPitchSeekbarChangeListener( + QUADRATIC_STRATEGY, DoubleConsumer({ newPitch: Double -> onPitchPercentSliderUpdated(newPitch) }))) + registerOnStepClickListener( + binding!!.pitchPercentStepDown, + DoubleSupplier({ pitchPercent }), + -1.0, DoubleConsumer({ newPitch: Double -> onPitchPercentSliderUpdated(newPitch) })) + registerOnStepClickListener( + binding!!.pitchPercentStepUp, + DoubleSupplier({ pitchPercent }), + 1.0, DoubleConsumer({ newPitch: Double -> onPitchPercentSliderUpdated(newPitch) })) + + // Pitch - Semitone + binding!!.pitchSemitoneSeekbar.setOnSeekBarChangeListener( + getTempoOrPitchSeekbarChangeListener( + SEMITONE_STRATEGY, DoubleConsumer({ newPitch: Double -> onPitchPercentSliderUpdated(newPitch) }))) + registerOnSemitoneStepClickListener( + binding!!.pitchSemitoneStepDown, + -1, DoubleConsumer({ newPitch: Double -> onPitchPercentSliderUpdated(newPitch) })) + registerOnSemitoneStepClickListener( + binding!!.pitchSemitoneStepUp, + 1, DoubleConsumer({ newPitch: Double -> onPitchPercentSliderUpdated(newPitch) })) + + // Steps + stepSizeComponentMappings + .forEach(BiConsumer({ stepSizeValue: Double, textView: TextView -> setupStepTextView(stepSizeValue, textView) })) + // Initialize UI + setStepSizeToUI(currentStepSize) + + // Bottom controls + bindCheckboxWithBoolPref( + binding!!.unhookCheckbox, + R.string.playback_unhook_key, + true, + Consumer({ isChecked: Boolean? -> + if (!isChecked!!) { + // when unchecked, slide back to the minimum of current tempo or pitch + ensureHookIsValidAndUpdateCallBack() + } + })) + setAndUpdateSkipSilence(skipSilence) + binding!!.skipSilenceCheckbox.setOnCheckedChangeListener(CompoundButton.OnCheckedChangeListener({ compoundButton: CompoundButton?, isChecked: Boolean -> + skipSilence = isChecked + updateCallback() + })) + + // PitchControlMode has to be initialized at the end because it requires the unhookCheckbox + changePitchControlMode(isCurrentPitchControlModeSemitone) + } + + // -- General formatting -- + private fun setText( + textView: TextView, + formatter: DoubleFunction, + value: Double + ) { + Objects.requireNonNull(textView).setText(formatter.apply(value)) + } + + // -- Steps -- + private fun registerOnStepClickListener( + stepTextView: TextView, + currentValueSupplier: DoubleSupplier, + direction: Double, // -1 for step down, +1 for step up + newValueConsumer: DoubleConsumer + ) { + stepTextView.setOnClickListener(View.OnClickListener({ view: View? -> + newValueConsumer.accept( + currentValueSupplier.getAsDouble() + 1 * currentStepSize * direction) + updateCallback() + })) + } + + private fun registerOnSemitoneStepClickListener( + stepTextView: TextView, + direction: Int, // -1 for step down, +1 for step up + newValueConsumer: DoubleConsumer + ) { + stepTextView.setOnClickListener(View.OnClickListener({ view: View? -> + newValueConsumer.accept(PlayerSemitoneHelper.semitonesToPercent( + PlayerSemitoneHelper.percentToSemitones(pitchPercent) + direction)) + updateCallback() + })) + } + + // -- Pitch -- + private fun setupPitchControlModeTextView( + semitones: Boolean, + textView: TextView + ) { + textView.setOnClickListener(View.OnClickListener({ view: View? -> + PreferenceManager.getDefaultSharedPreferences(requireContext()) + .edit() + .putBoolean(getString(R.string.playback_adjust_by_semitones_key), semitones) + .apply() + changePitchControlMode(semitones) + })) + } + + private val pitchControlModeComponentMappings: Map + private get() { + return java.util.Map.of(PITCH_CTRL_MODE_PERCENT, binding!!.pitchControlModePercent, + PITCH_CTRL_MODE_SEMITONE, binding!!.pitchControlModeSemitone) + } + + private fun changePitchControlMode(semitones: Boolean) { + // Bring all textviews into a normal state + val pitchCtrlModeComponentMapping: Map = pitchControlModeComponentMappings + pitchCtrlModeComponentMapping.forEach(BiConsumer({ v: Boolean?, textView: TextView -> + textView.setBackground( + ThemeHelper.resolveDrawable(requireContext(), R.attr.selectableItemBackground)) + })) + + // Mark the selected textview + val textView: TextView? = pitchCtrlModeComponentMapping.get(semitones) + if (textView != null) { + textView.setBackground(LayerDrawable(arrayOf( + ThemeHelper.resolveDrawable(requireContext(), R.attr.dashed_border), + ThemeHelper.resolveDrawable(requireContext(), R.attr.selectableItemBackground) + ))) + } + + // Show or hide component + binding!!.pitchPercentControl.setVisibility(if (semitones) View.GONE else View.VISIBLE) + binding!!.pitchSemitoneControl.setVisibility(if (semitones) View.VISIBLE else View.GONE) + if (semitones) { + // Recalculate pitch percent when changing to semitone + // (as it could be an invalid semitone value) + val newPitchPercent: Double = calcValidPitch(pitchPercent) + + // If the values differ set the new pitch + if (pitchPercent != newPitchPercent) { + if (Player.Companion.DEBUG) { + Log.d(TAG, ("Bringing pitchPercent to correct corresponding semitone: " + + "currentPitchPercent = " + pitchPercent + ", " + + "newPitchPercent = " + newPitchPercent) + ) + } + onPitchPercentSliderUpdated(newPitchPercent) + updateCallback() + } + } else if (!binding!!.unhookCheckbox.isChecked()) { + // When changing to percent it's possible that tempo is != pitch + ensureHookIsValidAndUpdateCallBack() + } + } + + private val isCurrentPitchControlModeSemitone: Boolean + private get() { + return PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getBoolean( + getString(R.string.playback_adjust_by_semitones_key), + PITCH_CTRL_MODE_PERCENT) + } + + // -- Steps (Set) -- + private fun setupStepTextView( + stepSizeValue: Double, + textView: TextView + ) { + setText(textView, DoubleFunction({ percent: Double -> getPercentString(percent) }), stepSizeValue) + textView.setOnClickListener(View.OnClickListener({ view: View? -> + PreferenceManager.getDefaultSharedPreferences(requireContext()) + .edit() + .putFloat(getString(R.string.adjustment_step_key), stepSizeValue.toFloat()) + .apply() + setStepSizeToUI(stepSizeValue) + })) + } + + private val stepSizeComponentMappings: Map + private get() { + return java.util.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 fun setStepSizeToUI(newStepSize: Double) { + // Bring all textviews into a normal state + val stepSiteComponentMapping: Map = stepSizeComponentMappings + stepSiteComponentMapping.forEach(BiConsumer({ v: Double?, textView: TextView -> + textView.setBackground( + ThemeHelper.resolveDrawable(requireContext(), R.attr.selectableItemBackground)) + })) + + // Mark the selected textview + val textView: TextView? = stepSiteComponentMapping.get(newStepSize) + if (textView != null) { + textView.setBackground(LayerDrawable(arrayOf( + ThemeHelper.resolveDrawable(requireContext(), R.attr.dashed_border), + ThemeHelper.resolveDrawable(requireContext(), 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 val currentStepSize: Double + private get() { + return PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getFloat(getString(R.string.adjustment_step_key), DEFAULT_STEP.toFloat()).toDouble() + } + + // -- Additional options -- + private fun setAndUpdateSkipSilence(newSkipSilence: Boolean) { + skipSilence = newSkipSilence + binding!!.skipSilenceCheckbox.setChecked(newSkipSilence) + } + + // this method was written to be reusable + private fun bindCheckboxWithBoolPref( + checkBox: CheckBox, + @StringRes resId: Int, + defaultValue: Boolean, + onInitialValueOrValueChange: Consumer + ) { + val prefValue: Boolean = PreferenceManager + .getDefaultSharedPreferences(requireContext()) + .getBoolean(getString(resId), defaultValue) + checkBox.setChecked(prefValue) + onInitialValueOrValueChange.accept(prefValue) + checkBox.setOnCheckedChangeListener(CompoundButton.OnCheckedChangeListener({ compoundButton: CompoundButton?, isChecked: Boolean -> + // 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 fun ensureHookIsValidAndUpdateCallBack() { + if (tempo != pitchPercent) { + setSliders(min(tempo, pitchPercent)) + updateCallback() + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Sliders + ////////////////////////////////////////////////////////////////////////// */ + private fun getTempoOrPitchSeekbarChangeListener( + sliderStrategy: SliderStrategy, + newValueConsumer: DoubleConsumer + ): OnSeekBarChangeListener { + return object : SimpleOnSeekBarChangeListener() { + public override fun onProgressChanged(seekBar: SeekBar, + progress: Int, + fromUser: Boolean) { + if (fromUser) { // ensure that the user triggered the change + newValueConsumer.accept(sliderStrategy.valueOf(progress)) + updateCallback() + } + } + } + } + + private fun onTempoSliderUpdated(newTempo: Double) { + if (!binding!!.unhookCheckbox.isChecked()) { + setSliders(newTempo) + } else { + setAndUpdateTempo(newTempo) + } + } + + private fun onPitchPercentSliderUpdated(newPitch: Double) { + if (!binding!!.unhookCheckbox.isChecked()) { + setSliders(newPitch) + } else { + setAndUpdatePitch(newPitch) + } + } + + private fun setSliders(newValue: Double) { + setAndUpdateTempo(newValue) + setAndUpdatePitch(newValue) + } + + private fun setAndUpdateTempo(newTempo: Double) { + tempo = MathUtils.clamp(newTempo, MIN_PITCH_OR_SPEED, MAX_PITCH_OR_SPEED) + binding!!.tempoSeekbar.setProgress(QUADRATIC_STRATEGY.progressOf(tempo)) + setText(binding!!.tempoCurrentText, DoubleFunction({ obj: Double -> PlayerHelper.formatSpeed() }), tempo) + } + + private fun setAndUpdatePitch(newPitch: Double) { + pitchPercent = calcValidPitch(newPitch) + binding!!.pitchPercentSeekbar.setProgress(QUADRATIC_STRATEGY.progressOf(pitchPercent)) + binding!!.pitchSemitoneSeekbar.setProgress(SEMITONE_STRATEGY.progressOf(pitchPercent)) + setText(binding!!.pitchPercentCurrentText, DoubleFunction({ obj: Double -> PlayerHelper.formatPitch() }), + pitchPercent) + setText(binding!!.pitchSemitoneCurrentText, DoubleFunction({ obj: Double -> PlayerSemitoneHelper.formatPitchSemitones() }), + pitchPercent) + } + + private fun calcValidPitch(newPitch: Double): Double { + val calcPitch: Double = MathUtils.clamp(newPitch, MIN_PITCH_OR_SPEED, MAX_PITCH_OR_SPEED) + if (!isCurrentPitchControlModeSemitone) { + return calcPitch + } + return PlayerSemitoneHelper.semitonesToPercent( + PlayerSemitoneHelper.percentToSemitones(calcPitch)) + } + + /*////////////////////////////////////////////////////////////////////////// + // Helper + ////////////////////////////////////////////////////////////////////////// */ + private fun updateCallback() { + if (callback == null) { + return + } + if (Player.Companion.DEBUG) { + Log.d(TAG, ("Updating callback: " + + "tempo = " + tempo + ", " + + "pitchPercent = " + pitchPercent + ", " + + "skipSilence = " + skipSilence) + ) + } + callback!!.onPlaybackParameterChanged(tempo.toFloat(), pitchPercent.toFloat(), skipSilence) + } + + open interface Callback { + fun onPlaybackParameterChanged(playbackTempo: Float, playbackPitch: Float, + playbackSkipSilence: Boolean) + } + + companion object { + private val TAG: String = "PlaybackParameterDialog" + + // Minimum allowable range in ExoPlayer + private val MIN_PITCH_OR_SPEED: Double = 0.10 + private val MAX_PITCH_OR_SPEED: Double = 3.00 + private val PITCH_CTRL_MODE_PERCENT: Boolean = false + private val PITCH_CTRL_MODE_SEMITONE: Boolean = true + private val STEP_1_PERCENT_VALUE: Double = 0.01 + private val STEP_5_PERCENT_VALUE: Double = 0.05 + private val STEP_10_PERCENT_VALUE: Double = 0.10 + private val STEP_25_PERCENT_VALUE: Double = 0.25 + private val STEP_100_PERCENT_VALUE: Double = 1.00 + private val DEFAULT_TEMPO: Double = 1.00 + private val DEFAULT_PITCH_PERCENT: Double = 1.00 + private val DEFAULT_STEP: Double = STEP_25_PERCENT_VALUE + private val DEFAULT_SKIP_SILENCE: Boolean = false + private val QUADRATIC_STRATEGY: SliderStrategy = Quadratic( + MIN_PITCH_OR_SPEED, + MAX_PITCH_OR_SPEED, + 1.00, + 10000) + private val SEMITONE_STRATEGY: SliderStrategy = object : SliderStrategy { + public override fun progressOf(value: Double): Int { + return PlayerSemitoneHelper.percentToSemitones(value) + 12 + } + + public override fun valueOf(progress: Int): Double { + return PlayerSemitoneHelper.semitonesToPercent(progress - 12) + } + } + + fun newInstance( + playbackTempo: Double, + playbackPitch: Double, + playbackSkipSilence: Boolean, + callback: Callback? + ): PlaybackParameterDialog { + val dialog: PlaybackParameterDialog = 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 + } + + private fun getStepUpPercentString(percent: Double): String { + return '+'.toString() + getPercentString(percent) + } + + private fun getStepDownPercentString(percent: Double): String { + return '-'.toString() + getPercentString(percent) + } + + private fun getPercentString(percent: Double): String { + return PlayerHelper.formatPitch(percent) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java deleted file mode 100644 index 0530d56e9..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java +++ /dev/null @@ -1,217 +0,0 @@ -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); - } - //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 -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.kt b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.kt new file mode 100644 index 000000000..77ad7130c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.kt @@ -0,0 +1,206 @@ +package org.schabi.newpipe.player.helper + +import android.content.Context +import android.util.Log +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.HlsDataSourceFactory +import com.google.android.exoplayer2.source.hls.HlsMediaSource +import com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistTracker +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParserFactory +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker +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.LoadErrorHandlingPolicy +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.MainActivity +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 + +class PlayerDataSource(context: Context, + transferListener: TransferListener) { + private val progressiveLoadIntervalBytes: Int + + // Generic Data Source Factories (without or with cache) + private val cachelessDataSourceFactory: DataSource.Factory + private val cacheDataSourceFactory: CacheFactory + + // YouTube-specific Data Source Factories (with cache) + // They use YoutubeHttpDataSource.Factory, with different parameters each + private val ytHlsCacheDataSourceFactory: CacheFactory + private val ytDashCacheDataSourceFactory: CacheFactory + private val ytProgressiveDashCacheDataSourceFactory: CacheFactory + + init { + 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 = DefaultDataSource.Factory(context, + DefaultHttpDataSource.Factory().setUserAgent(DownloaderImpl.Companion.USER_AGENT)) + .setTransferListener(transferListener) + cacheDataSourceFactory = CacheFactory(context, transferListener, cache, + DefaultHttpDataSource.Factory().setUserAgent(DownloaderImpl.Companion.USER_AGENT)) + + // YouTube-specific data source factories use getYoutubeHttpDataSourceFactory() + ytHlsCacheDataSourceFactory = CacheFactory(context, transferListener, cache, + getYoutubeHttpDataSourceFactory(false, false)) + ytDashCacheDataSourceFactory = CacheFactory(context, transferListener, cache, + getYoutubeHttpDataSourceFactory(true, true)) + ytProgressiveDashCacheDataSourceFactory = 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) + } + + val liveSsMediaSourceFactory: SsMediaSource.Factory + //region Live media source factories + get() { + return sSMediaSourceFactory.setLivePresentationDelayMs(LIVE_STREAM_EDGE_GAP_MILLIS.toLong()) + } + val liveHlsMediaSourceFactory: HlsMediaSource.Factory + get() { + return HlsMediaSource.Factory(cachelessDataSourceFactory) + .setAllowChunklessPreparation(true) + .setPlaylistTrackerFactory(HlsPlaylistTracker.Factory({ dataSourceFactory: HlsDataSourceFactory?, loadErrorHandlingPolicy: LoadErrorHandlingPolicy?, playlistParserFactory: HlsPlaylistParserFactory? -> + DefaultHlsPlaylistTracker((dataSourceFactory)!!, (loadErrorHandlingPolicy)!!, + (playlistParserFactory)!!, + PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT) + })) + } + val liveDashMediaSourceFactory: DashMediaSource.Factory + get() { + return DashMediaSource.Factory( + getDefaultDashChunkSourceFactory(cachelessDataSourceFactory), + cachelessDataSourceFactory) + } + + //endregion + //region Generic media source factories + fun getHlsMediaSourceFactory( + hlsDataSourceFactoryBuilder: NonUriHlsDataSourceFactory.Builder?): HlsMediaSource.Factory { + if (hlsDataSourceFactoryBuilder != null) { + hlsDataSourceFactoryBuilder.setDataSourceFactory(cacheDataSourceFactory) + return HlsMediaSource.Factory(hlsDataSourceFactoryBuilder.build()) + } + return HlsMediaSource.Factory(cacheDataSourceFactory) + } + + val dashMediaSourceFactory: DashMediaSource.Factory + get() { + return DashMediaSource.Factory( + getDefaultDashChunkSourceFactory(cacheDataSourceFactory), + cacheDataSourceFactory) + } + val progressiveMediaSourceFactory: ProgressiveMediaSource.Factory + get() { + return ProgressiveMediaSource.Factory(cacheDataSourceFactory) + .setContinueLoadingCheckIntervalBytes(progressiveLoadIntervalBytes) + } + val sSMediaSourceFactory: SsMediaSource.Factory + get() { + return SsMediaSource.Factory( + DefaultSsChunkSource.Factory(cachelessDataSourceFactory), + cachelessDataSourceFactory) + } + val singleSampleMediaSourceFactory: SingleSampleMediaSource.Factory + get() { + return SingleSampleMediaSource.Factory(cacheDataSourceFactory) + } + val youtubeHlsMediaSourceFactory: HlsMediaSource.Factory + //endregion + get() { + return HlsMediaSource.Factory(ytHlsCacheDataSourceFactory) + } + val youtubeDashMediaSourceFactory: DashMediaSource.Factory + get() { + return DashMediaSource.Factory( + getDefaultDashChunkSourceFactory(ytDashCacheDataSourceFactory), + ytDashCacheDataSourceFactory) + } + val youtubeProgressiveMediaSourceFactory: ProgressiveMediaSource.Factory + get() { + return ProgressiveMediaSource.Factory(ytProgressiveDashCacheDataSourceFactory) + .setContinueLoadingCheckIntervalBytes(progressiveLoadIntervalBytes) + } + + companion object { + val TAG: String = PlayerDataSource::class.java.getSimpleName() + val LIVE_STREAM_EDGE_GAP_MILLIS: Int = 10000 + + /** + * An approximately 4.3 times greater value than the + * [default][DefaultHlsPlaylistTracker.DEFAULT_PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT] + * to ensure that (very) low latency livestreams which got stuck for a moment don't crash too + * early. + */ + private val PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT: Double = 15.0 + + /** + * The maximum number of generated manifests per cache, in + * [YoutubeProgressiveDashManifestCreator], [YoutubeOtfDashManifestCreator] and + * [YoutubePostLiveStreamDvrDashManifestCreator]. + */ + private val MAX_MANIFEST_CACHE_SIZE: Int = 500 + + /** + * The folder name in which the ExoPlayer cache will be written. + */ + private val CACHE_FOLDER_NAME: String = "exoplayer" + + /** + * The [SimpleCache] instance which will be used to build + * [com.google.android.exoplayer2.upstream.cache.CacheDataSource]s instances (with + * [CacheFactory]). + */ + private var cache: SimpleCache? = null + + //endregion + //region Static methods + private fun getDefaultDashChunkSourceFactory( + dataSourceFactory: DataSource.Factory): DefaultDashChunkSource.Factory { + return DefaultDashChunkSource.Factory(dataSourceFactory) + } + + private fun getYoutubeHttpDataSourceFactory( + rangeParameterEnabled: Boolean, + rnParameterEnabled: Boolean): YoutubeHttpDataSource.Factory? { + return YoutubeHttpDataSource.Factory() + .setRangeParameterEnabled(rangeParameterEnabled) + .setRnParameterEnabled(rnParameterEnabled) + } + + private fun instantiateCacheIfNeeded(context: Context) { + if (cache == null) { + val cacheDir: File = File(context.getExternalCacheDir(), CACHE_FOLDER_NAME) + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "instantiateCacheIfNeeded: cacheDir = " + cacheDir.getAbsolutePath()) + } + if (!cacheDir.exists() && !cacheDir.mkdir()) { + Log.w(TAG, "instantiateCacheIfNeeded: could not create cache dir") + } + val evictor: LeastRecentlyUsedCacheEvictor = LeastRecentlyUsedCacheEvictor(PlayerHelper.getPreferredCacheSize()) + cache = SimpleCache(cacheDir, evictor, StandaloneDatabaseProvider(context)) + } + } //endregion + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java deleted file mode 100644 index a110a80d6..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java +++ /dev/null @@ -1,506 +0,0 @@ -package org.schabi.newpipe.player.helper; - -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 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.Player.RepeatMode; -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 com.google.android.exoplayer2.util.MimeTypes; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.MediaFormat; -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 java.lang.annotation.Retention; -import java.text.DecimalFormat; -import java.text.NumberFormat; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Formatter; -import java.util.HashSet; -import java.util.List; -import java.util.Locale; -import java.util.Objects; -import java.util.Set; -import java.util.concurrent.TimeUnit; - -public final class PlayerHelper { - private static final StringBuilder STRING_BUILDER = new StringBuilder(); - private static final Formatter STRING_FORMATTER = - new Formatter(STRING_BUILDER, Locale.getDefault()); - private static final NumberFormat SPEED_FORMATTER = new DecimalFormat("0.##x"); - private static final NumberFormat PITCH_FORMATTER = new DecimalFormat("##%"); - - @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() { - } - - //////////////////////////////////////////////////////////////////////////// - // Exposed helpers - //////////////////////////////////////////////////////////////////////////// - - @NonNull - public static String getTimeString(final int milliSeconds) { - final int seconds = (milliSeconds % 60000) / 1000; - final int minutes = (milliSeconds % 3600000) / 60000; - final int hours = (milliSeconds % 86400000) / 3600000; - final int days = (milliSeconds % (86400000 * 7)) / 86400000; - - STRING_BUILDER.setLength(0); - return (days > 0 - ? STRING_FORMATTER.format("%d:%02d:%02d:%02d", days, hours, minutes, seconds) - : hours > 0 - ? STRING_FORMATTER.format("%d:%02d:%02d", hours, minutes, seconds) - : STRING_FORMATTER.format("%02d:%02d", minutes, seconds) - ).toString(); - } - - @NonNull - public static String formatSpeed(final double speed) { - return SPEED_FORMATTER.format(speed); - } - - @NonNull - public static String formatPitch(final double pitch) { - return PITCH_FORMATTER.format(pitch); - } - - @NonNull - public static String subtitleMimeTypesOf(@NonNull final MediaFormat format) { - switch (format) { - case VTT: - return MimeTypes.TEXT_VTT; - case TTML: - return MimeTypes.APPLICATION_TTML; - default: - throw new IllegalArgumentException("Unrecognized mime type: " + format.name()); - } - } - - @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.getResources().getString(R.string.resize_fit); - case AspectRatioFrameLayout.RESIZE_MODE_FILL: - return context.getResources().getString(R.string.resize_fill); - case AspectRatioFrameLayout.RESIZE_MODE_ZOOM: - return context.getResources().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 = new HashSet<>(existingItems.size()); - for (final PlayQueueItem item : existingItems) { - urls.add(item.getUrl()); - } - - 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)); - } - - //////////////////////////////////////////////////////////////////////////// - // Settings 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); - } - - public static boolean isUsingDSP() { - return true; - } - - @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; - } - - //////////////////////////////////////////////////////////////////////////// - // 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; - } - - - //////////////////////////////////////////////////////////////////////////// - // Utils used by player - //////////////////////////////////////////////////////////////////////////// - - @RepeatMode - public static int nextRepeatMode(@RepeatMode final int repeatMode) { - switch (repeatMode) { - case REPEAT_MODE_OFF: - return REPEAT_MODE_ONE; - case REPEAT_MODE_ONE: - return REPEAT_MODE_ALL; - case REPEAT_MODE_ALL: - default: - return REPEAT_MODE_OFF; - } - } - - @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)))); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.kt b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.kt new file mode 100644 index 000000000..7f734adc3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.kt @@ -0,0 +1,427 @@ +package org.schabi.newpipe.player.helper + +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.core.content.ContextCompat +import androidx.preference.PreferenceManager +import com.google.android.exoplayer2.PlaybackParameters +import com.google.android.exoplayer2.Player +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 com.google.android.exoplayer2.util.MimeTypes +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.InfoItem +import org.schabi.newpipe.extractor.MediaFormat +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.playqueue.PlayQueue +import org.schabi.newpipe.player.playqueue.PlayQueueItem +import org.schabi.newpipe.player.playqueue.SinglePlayQueue +import org.schabi.newpipe.util.ListHelper +import java.text.DecimalFormat +import java.text.NumberFormat +import java.util.Collections +import java.util.Formatter +import java.util.Locale +import java.util.Objects +import java.util.concurrent.TimeUnit + +object PlayerHelper { + private val STRING_BUILDER: StringBuilder = StringBuilder() + private val STRING_FORMATTER: Formatter = Formatter(STRING_BUILDER, Locale.getDefault()) + private val SPEED_FORMATTER: NumberFormat = DecimalFormat("0.##x") + private val PITCH_FORMATTER: NumberFormat = DecimalFormat("##%") + + //////////////////////////////////////////////////////////////////////////// + // Exposed helpers + //////////////////////////////////////////////////////////////////////////// + fun getTimeString(milliSeconds: Int): String { + val seconds: Int = (milliSeconds % 60000) / 1000 + val minutes: Int = (milliSeconds % 3600000) / 60000 + val hours: Int = (milliSeconds % 86400000) / 3600000 + val days: Int = (milliSeconds % (86400000 * 7)) / 86400000 + STRING_BUILDER.setLength(0) + return (if (days > 0) STRING_FORMATTER.format("%d:%02d:%02d:%02d", days, hours, minutes, seconds) else if (hours > 0) STRING_FORMATTER.format("%d:%02d:%02d", hours, minutes, seconds) else STRING_FORMATTER.format("%02d:%02d", minutes, seconds)).toString() + } + + fun formatSpeed(speed: Double): String { + return SPEED_FORMATTER.format(speed) + } + + fun formatPitch(pitch: Double): String { + return PITCH_FORMATTER.format(pitch) + } + + fun subtitleMimeTypesOf(format: MediaFormat): String { + when (format) { + MediaFormat.VTT -> return MimeTypes.TEXT_VTT + MediaFormat.TTML -> return MimeTypes.APPLICATION_TTML + else -> throw IllegalArgumentException("Unrecognized mime type: " + format.name) + } + } + + fun captionLanguageOf(context: Context, + subtitles: SubtitlesStream): String { + val displayName: String = subtitles.getDisplayLanguageName() + return displayName + (if (subtitles.isAutoGenerated()) " (" + context.getString(R.string.caption_auto_generated) + ")" else "") + } + + fun captionLanguageStemOf(language: String): String { + if (!language.contains("(") || !language.contains(")")) { + return language + } + if (language.startsWith("(")) { + // language text is right-to-left + val parts: Array = language.split("\\)".toRegex()).dropLastWhile({ it.isEmpty() }).toTypedArray() + return parts.get(parts.size - 1).trim({ it <= ' ' }) + } + return language.split("\\(".toRegex()).dropLastWhile({ it.isEmpty() }).toTypedArray().get(0).trim({ it <= ' ' }) + } + + fun resizeTypeOf(context: Context, + resizeMode: @ResizeMode Int): String { + when (resizeMode) { + AspectRatioFrameLayout.RESIZE_MODE_FIT -> return context.getResources().getString(R.string.resize_fit) + AspectRatioFrameLayout.RESIZE_MODE_FILL -> return context.getResources().getString(R.string.resize_fill) + AspectRatioFrameLayout.RESIZE_MODE_ZOOM -> return context.getResources().getString(R.string.resize_zoom) + AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT, AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH -> throw IllegalArgumentException("Unrecognized resize mode: " + resizeMode) + else -> throw IllegalArgumentException("Unrecognized resize mode: " + resizeMode) + } + } + + /** + * Given a [StreamInfo] and the existing queue items, + * provide the [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 [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 [StreamInfo.getRelatedItems]. Non-stream items are ignored. + * + * + * @param info currently playing stream + * @param existingItems existing items in the queue + * @return [SinglePlayQueue] with the next stream to queue + */ + fun autoQueueOf(info: StreamInfo, + existingItems: List): PlayQueue? { + val urls: MutableSet = HashSet(existingItems.size) + for (item: PlayQueueItem? in existingItems) { + urls.add(item.getUrl()) + } + val relatedItems: List = info.getRelatedItems() + if (Utils.isNullOrEmpty(relatedItems)) { + return null + } + if ((relatedItems.get(0) is StreamInfoItem + && !urls.contains(relatedItems.get(0).getUrl()))) { + return getAutoQueuedSinglePlayQueue(relatedItems.get(0) as StreamInfoItem?) + } + val autoQueueItems: MutableList = ArrayList() + for (item: InfoItem? in relatedItems) { + if (item is StreamInfoItem && !urls.contains(item.getUrl())) { + autoQueueItems.add(item as StreamInfoItem?) + } + } + Collections.shuffle(autoQueueItems) + return if (autoQueueItems.isEmpty()) null else getAutoQueuedSinglePlayQueue(autoQueueItems.get(0)) + } + + //////////////////////////////////////////////////////////////////////////// + // Settings Resolution + //////////////////////////////////////////////////////////////////////////// + fun isResumeAfterAudioFocusGain(context: Context): Boolean { + return getPreferences(context) + .getBoolean(context.getString(R.string.resume_on_audio_focus_gain_key), false) + } + + fun getActionForRightGestureSide(context: Context): String? { + return getPreferences(context) + .getString(context.getString(R.string.right_gesture_control_key), + context.getString(R.string.default_right_gesture_control_value)) + } + + fun getActionForLeftGestureSide(context: Context): String? { + return getPreferences(context) + .getString(context.getString(R.string.left_gesture_control_key), + context.getString(R.string.default_left_gesture_control_value)) + } + + fun isStartMainPlayerFullscreenEnabled(context: Context): Boolean { + return getPreferences(context) + .getBoolean(context.getString(R.string.start_main_player_fullscreen_key), false) + } + + fun isAutoQueueEnabled(context: Context): Boolean { + return getPreferences(context) + .getBoolean(context.getString(R.string.auto_queue_key), false) + } + + fun isClearingQueueConfirmationRequired(context: Context): Boolean { + return getPreferences(context) + .getBoolean(context.getString(R.string.clear_queue_confirmation_key), false) + } + + @MinimizeMode + fun getMinimizeOnExitAction(context: Context): Int { + val action: String? = getPreferences(context) + .getString(context.getString(R.string.minimize_on_exit_key), "") + if ((action == context.getString(R.string.minimize_on_exit_popup_key))) { + return MinimizeMode.MINIMIZE_ON_EXIT_MODE_POPUP + } else if ((action == context.getString(R.string.minimize_on_exit_none_key))) { + return MinimizeMode.MINIMIZE_ON_EXIT_MODE_NONE + } else { + return MinimizeMode.MINIMIZE_ON_EXIT_MODE_BACKGROUND // default + } + } + + @AutoplayType + fun getAutoplayType(context: Context): Int { + val type: String? = getPreferences(context).getString( + context.getString(R.string.autoplay_key), "") + if ((type == context.getString(R.string.autoplay_always_key))) { + return AutoplayType.AUTOPLAY_TYPE_ALWAYS + } else if ((type == context.getString(R.string.autoplay_never_key))) { + return AutoplayType.AUTOPLAY_TYPE_NEVER + } else { + return AutoplayType.AUTOPLAY_TYPE_WIFI // default + } + } + + fun isAutoplayAllowedByUser(context: Context): Boolean { + when (getAutoplayType(context)) { + AutoplayType.AUTOPLAY_TYPE_NEVER -> return false + AutoplayType.AUTOPLAY_TYPE_WIFI -> return !ListHelper.isMeteredNetwork(context) + AutoplayType.AUTOPLAY_TYPE_ALWAYS -> return true + else -> return true + } + } + + fun getSeekParameters(context: Context): SeekParameters { + return if (isUsingInexactSeek(context)) SeekParameters.CLOSEST_SYNC else SeekParameters.EXACT + } + + val preferredCacheSize: Long + get() { + return 64 * 1024 * 1024L + } + val preferredFileSize: Long + get() { + return 2 * 1024 * 1024L // ExoPlayer CacheDataSink.MIN_RECOMMENDED_FRAGMENT_SIZE + } + val qualitySelector: ExoTrackSelection.Factory + get() { + return AdaptiveTrackSelection.Factory( + 1000, + AdaptiveTrackSelection.DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, + AdaptiveTrackSelection.DEFAULT_BANDWIDTH_FRACTION) + } + val isUsingDSP: Boolean + get() { + return true + } + + fun getCaptionStyle(context: Context): CaptionStyleCompat { + val captioningManager: CaptioningManager? = ContextCompat.getSystemService(context, + CaptioningManager::class.java) + 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 + */ + fun getCaptionScale(context: Context): Float { + val captioningManager: CaptioningManager? = ContextCompat.getSystemService(context, + CaptioningManager::class.java) + 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 + */ + fun getScreenBrightness(context: Context): Float { + val sp: SharedPreferences = getPreferences(context) + val timestamp: Long = 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).toFloat() + } else { + return sp.getFloat(context.getString(R.string.screen_brightness_key), -1f) + } + } + + fun setScreenBrightness(context: Context, + screenBrightness: Float) { + getPreferences(context).edit() + .putFloat(context.getString(R.string.screen_brightness_key), screenBrightness) + .putLong(context.getString(R.string.screen_brightness_timestamp_key), + System.currentTimeMillis()) + .apply() + } + + fun globalScreenOrientationLocked(context: Context?): Boolean { + // 1: Screen orientation changes using accelerometer + // 0: Screen orientation is locked + // if the accelerometer sensor is missing completely, assume locked orientation + return (Settings.System.getInt( + context!!.getContentResolver(), Settings.System.ACCELEROMETER_ROTATION, 0) == 0 + || !context.getPackageManager() + .hasSystemFeature(PackageManager.FEATURE_SENSOR_ACCELEROMETER)) + } + + fun getProgressiveLoadIntervalBytes(context: Context): Int { + val preferredIntervalBytes: String? = 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) + == preferredIntervalBytes)) { + return ProgressiveMediaSource.DEFAULT_LOADING_CHECK_INTERVAL_BYTES + } + // Keeping the same KiB unit used by ProgressiveMediaSource + return preferredIntervalBytes!!.toInt() * 1024 + } + + //////////////////////////////////////////////////////////////////////////// + // Private helpers + //////////////////////////////////////////////////////////////////////////// + private fun getPreferences(context: Context): SharedPreferences { + return PreferenceManager.getDefaultSharedPreferences(context) + } + + private fun isUsingInexactSeek(context: Context): Boolean { + return getPreferences(context) + .getBoolean(context.getString(R.string.use_inexact_seek_key), false) + } + + private fun getAutoQueuedSinglePlayQueue( + streamInfoItem: StreamInfoItem?): SinglePlayQueue { + val singlePlayQueue: SinglePlayQueue = SinglePlayQueue((streamInfoItem)!!) + Objects.requireNonNull(singlePlayQueue.getItem()).setAutoQueued(true) + return singlePlayQueue + } + + //////////////////////////////////////////////////////////////////////////// + // Utils used by player + //////////////////////////////////////////////////////////////////////////// + fun nextRepeatMode(repeatMode: @Player.RepeatMode Int): @Player.RepeatMode Int { + when (repeatMode) { + Player.REPEAT_MODE_OFF -> return Player.REPEAT_MODE_ONE + Player.REPEAT_MODE_ONE -> return Player.REPEAT_MODE_ALL + Player.REPEAT_MODE_ALL -> return Player.REPEAT_MODE_OFF + else -> return Player.REPEAT_MODE_OFF + } + } + + fun retrieveResizeModeFromPrefs(player: org.schabi.newpipe.player.Player): @ResizeMode Int { + 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 + fun nextResizeModeAndSaveToPrefs(player: org.schabi.newpipe.player.Player, + resizeMode: @ResizeMode Int): @ResizeMode Int { + val newResizeMode: Int + when (resizeMode) { + AspectRatioFrameLayout.RESIZE_MODE_FIT -> newResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL + AspectRatioFrameLayout.RESIZE_MODE_FILL -> newResizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM + AspectRatioFrameLayout.RESIZE_MODE_ZOOM -> newResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + else -> newResizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + } + + // 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 + } + + fun retrievePlaybackParametersFromPrefs(player: org.schabi.newpipe.player.Player): PlaybackParameters { + val speed: Float = player.getPrefs().getFloat(player.getContext().getString( + R.string.playback_speed_key), player.getPlaybackSpeed()) + val pitch: Float = player.getPrefs().getFloat(player.getContext().getString( + R.string.playback_pitch_key), player.getPlaybackPitch()) + return PlaybackParameters(speed, pitch) + } + + fun savePlaybackParametersToPrefs(player: org.schabi.newpipe.player.Player, + speed: Float, + pitch: Float, + skipSilence: Boolean) { + 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() + } + + fun getMinimumVideoHeight(width: Float): Float { + return width / (16.0f / 9.0f) // Respect the 16:9 ratio that most videos have + } + + fun retrieveSeekDurationFromPreferences(player: org.schabi.newpipe.player.Player): Int { + return Objects.requireNonNull(player.getPrefs().getString( + player.getContext().getString(R.string.seek_duration_key), + player.getContext().getString(R.string.seek_duration_default_value))).toInt() + } + + @Retention(AnnotationRetention.SOURCE) + @IntDef([AutoplayType.AUTOPLAY_TYPE_ALWAYS, AutoplayType.AUTOPLAY_TYPE_WIFI, AutoplayType.AUTOPLAY_TYPE_NEVER]) + annotation class AutoplayType() { + companion object { + val AUTOPLAY_TYPE_ALWAYS: Int = 0 + val AUTOPLAY_TYPE_WIFI: Int = 1 + val AUTOPLAY_TYPE_NEVER: Int = 2 + } + } + + @Retention(AnnotationRetention.SOURCE) + @IntDef([MinimizeMode.MINIMIZE_ON_EXIT_MODE_NONE, MinimizeMode.MINIMIZE_ON_EXIT_MODE_BACKGROUND, MinimizeMode.MINIMIZE_ON_EXIT_MODE_POPUP]) + annotation class MinimizeMode() { + companion object { + val MINIMIZE_ON_EXIT_MODE_NONE: Int = 0 + val MINIMIZE_ON_EXIT_MODE_BACKGROUND: Int = 1 + val MINIMIZE_ON_EXIT_MODE_POPUP: Int = 2 + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java deleted file mode 100644 index b55a6547a..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java +++ /dev/null @@ -1,306 +0,0 @@ -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; - -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; - @Nullable private Player player; - - /** - * Returns the current {@link PlayerType} of the {@link PlayerService} service, - * otherwise `null` if no service is running. - * - * @return Current PlayerType - */ - @Nullable - public PlayerType getType() { - if (player == null) { - return null; - } - return player.getPlayerType(); - } - - public boolean isPlaying() { - if (player == null) { - return false; - } - return player.isPlaying(); - } - - public boolean isPlayerOpen() { - return player != null; - } - - /** - * 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 player != null && player.getPlayQueue() != null; - } - - public boolean isBound() { - return bound; - } - - public int getQueueSize() { - if (player == null || player.getPlayQueue() == null) { - // player play queue might be null e.g. while player is starting - return 0; - } - return player.getPlayQueue().size(); - } - - public int getQueuePosition() { - if (player == null || player.getPlayQueue() == null) { - return 0; - } - return player.getPlayQueue().getIndex(); - } - - public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) { - listener = newListener; - - if (listener == null) { - return; - } - - // Force reload data from service - if (player != null) { - listener.onServiceConnected(player, playerService, false); - startPlayerListener(); - } - } - - // helper to handle context in common place as using the same - // context to bind/unbind a service is crucial - private Context getCommonContext() { - return App.getApp(); - } - - public void startService(final boolean playAfterConnect, - final PlayerServiceExtendedEventListener newListener) { - 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); - ContextCompat.startForegroundService(context, new Intent(context, PlayerService.class)); - serviceConnection.doPlayAfterConnect(playAfterConnect); - bind(context); - } - - public void stopService() { - final Context context = getCommonContext(); - unbind(context); - context.stopService(new Intent(context, PlayerService.class)); - } - - class PlayerServiceConnection implements ServiceConnection { - - private boolean playAfterConnect = false; - - 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(); - player = localBinder.getPlayer(); - if (listener != null) { - listener.onServiceConnected(player, playerService, playAfterConnect); - } - startPlayerListener(); - } - } - - private void bind(final Context context) { - if (DEBUG) { - Log.d(TAG, "bind() called"); - } - - final Intent serviceIntent = new Intent(context, PlayerService.class); - bound = context.bindService(serviceIntent, serviceConnection, - Context.BIND_AUTO_CREATE); - if (!bound) { - context.unbindService(serviceConnection); - } - } - - private void unbind(final Context context) { - if (DEBUG) { - Log.d(TAG, "unbind() called"); - } - - if (bound) { - context.unbindService(serviceConnection); - bound = false; - stopPlayerListener(); - playerService = null; - player = null; - if (listener != null) { - listener.onServiceDisconnected(); - } - } - } - - private void startPlayerListener() { - if (player != null) { - player.setFragmentListener(internalListener); - } - } - - private void stopPlayerListener() { - if (player != null) { - player.removeFragmentListener(internalListener); - } - } - - 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()); - } - }; -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt new file mode 100644 index 000000000..8f5cd38c5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.kt @@ -0,0 +1,276 @@ +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.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.Player +import org.schabi.newpipe.player.PlayerService +import org.schabi.newpipe.player.PlayerService.LocalBinder +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 + +class PlayerHolder private constructor() { + private var listener: PlayerServiceExtendedEventListener? = null + private val serviceConnection: PlayerServiceConnection = PlayerServiceConnection() + var isBound: Boolean = false + private set + private var playerService: PlayerService? = null + private var player: Player? = null + val type: PlayerType? + /** + * Returns the current [PlayerType] of the [PlayerService] service, + * otherwise `null` if no service is running. + * + * @return Current PlayerType + */ + get() { + if (player == null) { + return null + } + return player!!.getPlayerType() + } + val isPlaying: Boolean + get() { + if (player == null) { + return false + } + return player!!.isPlaying() + } + val isPlayerOpen: Boolean + get() { + return player != null + } + val isPlayQueueReady: Boolean + /** + * 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) + */ + get() { + return player != null && player!!.getPlayQueue() != null + } + val queueSize: Int + get() { + if (player == null || player!!.getPlayQueue() == null) { + // player play queue might be null e.g. while player is starting + return 0 + } + return player!!.getPlayQueue()!!.size() + } + val queuePosition: Int + get() { + if (player == null || player!!.getPlayQueue() == null) { + return 0 + } + return player!!.getPlayQueue().getIndex() + } + + fun setListener(newListener: PlayerServiceExtendedEventListener?) { + listener = newListener + if (listener == null) { + return + } + + // Force reload data from service + if (player != null) { + listener!!.onServiceConnected(player, playerService, false) + startPlayerListener() + } + } + + private val commonContext: Context + // helper to handle context in common place as using the same + private get() { + return App.Companion.getApp() + } + + fun startService(playAfterConnect: Boolean, + newListener: PlayerServiceExtendedEventListener?) { + val context: Context = commonContext + setListener(newListener) + if (isBound) { + 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) + ContextCompat.startForegroundService(context, Intent(context, PlayerService::class.java)) + serviceConnection.doPlayAfterConnect(playAfterConnect) + bind(context) + } + + fun stopService() { + val context: Context = commonContext + unbind(context) + context.stopService(Intent(context, PlayerService::class.java)) + } + + internal inner class PlayerServiceConnection() : ServiceConnection { + private var playAfterConnect: Boolean = false + fun doPlayAfterConnect(playAfterConnection: Boolean) { + playAfterConnect = playAfterConnection + } + + public override fun onServiceDisconnected(compName: ComponentName) { + if (DEBUG) { + Log.d(TAG, "Player service is disconnected") + } + val context: Context = commonContext + unbind(context) + } + + public override fun onServiceConnected(compName: ComponentName, service: IBinder) { + if (DEBUG) { + Log.d(TAG, "Player service is connected") + } + val localBinder: LocalBinder = service as LocalBinder + playerService = localBinder.getService() + player = localBinder.getPlayer() + if (listener != null) { + listener!!.onServiceConnected(player, playerService, playAfterConnect) + } + startPlayerListener() + } + } + + private fun bind(context: Context) { + if (DEBUG) { + Log.d(TAG, "bind() called") + } + val serviceIntent: Intent = Intent(context, PlayerService::class.java) + isBound = context.bindService(serviceIntent, serviceConnection, + Context.BIND_AUTO_CREATE) + if (!isBound) { + context.unbindService(serviceConnection) + } + } + + private fun unbind(context: Context) { + if (DEBUG) { + Log.d(TAG, "unbind() called") + } + if (isBound) { + context.unbindService(serviceConnection) + isBound = false + stopPlayerListener() + playerService = null + player = null + if (listener != null) { + listener!!.onServiceDisconnected() + } + } + } + + private fun startPlayerListener() { + if (player != null) { + player!!.setFragmentListener(internalListener) + } + } + + private fun stopPlayerListener() { + if (player != null) { + player!!.removeFragmentListener(internalListener) + } + } + + private val internalListener: PlayerServiceEventListener = object : PlayerServiceEventListener { + public override fun onViewCreated() { + if (listener != null) { + listener!!.onViewCreated() + } + } + + public override fun onFullscreenStateChanged(fullscreen: Boolean) { + if (listener != null) { + listener!!.onFullscreenStateChanged(fullscreen) + } + } + + public override fun onScreenRotationButtonClicked() { + if (listener != null) { + listener!!.onScreenRotationButtonClicked() + } + } + + public override fun onMoreOptionsLongClicked() { + if (listener != null) { + listener!!.onMoreOptionsLongClicked() + } + } + + public override fun onPlayerError(error: PlaybackException?, + isCatchableException: Boolean) { + if (listener != null) { + listener!!.onPlayerError(error, isCatchableException) + } + } + + public override fun hideSystemUiIfNeeded() { + if (listener != null) { + listener!!.hideSystemUiIfNeeded() + } + } + + public override fun onQueueUpdate(queue: PlayQueue?) { + if (listener != null) { + listener!!.onQueueUpdate(queue) + } + } + + public override fun onPlaybackUpdate(state: Int, + repeatMode: Int, + shuffled: Boolean, + parameters: PlaybackParameters?) { + if (listener != null) { + listener!!.onPlaybackUpdate(state, repeatMode, shuffled, parameters) + } + } + + public override fun onProgressUpdate(currentProgress: Int, + duration: Int, + bufferPercent: Int) { + if (listener != null) { + listener!!.onProgressUpdate(currentProgress, duration, bufferPercent) + } + } + + public override fun onMetadataUpdate(info: StreamInfo?, queue: PlayQueue?) { + if (listener != null) { + listener!!.onMetadataUpdate(info, queue) + } + } + + public override fun onServiceStopped() { + if (listener != null) { + listener!!.onServiceStopped() + } + unbind(commonContext) + } + } + + companion object { + @get:Synchronized + var instance: PlayerHolder? = null + get() { + if (field == null) { + field = PlayerHolder() + } + return field + } + private set + private val DEBUG: Boolean = MainActivity.Companion.DEBUG + private val TAG: String = PlayerHolder::class.java.getSimpleName() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerSemitoneHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerSemitoneHelper.java deleted file mode 100644 index f1ba90f8e..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerSemitoneHelper.java +++ /dev/null @@ -1,40 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerSemitoneHelper.kt b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerSemitoneHelper.kt new file mode 100644 index 000000000..e563b014a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerSemitoneHelper.kt @@ -0,0 +1,34 @@ +package org.schabi.newpipe.player.helper + +import androidx.core.math.MathUtils +import kotlin.math.ln + +/** + * Converts between percent and 12-tone equal temperament semitones. + *

+ * @see [ + * Wikipedia: Equal temperament.Twelve-tone equal temperament +](https://en.wikipedia.org/wiki/Equal_temperament.Twelve-tone_equal_temperament) * + */ +object PlayerSemitoneHelper { + val SEMITONE_COUNT: Int = 12 + fun formatPitchSemitones(percent: Double): String { + return formatPitchSemitones(percentToSemitones(percent)) + } + + fun formatPitchSemitones(semitones: Int): String { + return if (semitones > 0) "+" + semitones else "" + semitones + } + + fun semitonesToPercent(semitones: Int): Double { + return 2.pow(ensureSemitonesInRange(semitones) / SEMITONE_COUNT.toDouble()) + } + + fun percentToSemitones(percent: Double): Int { + return ensureSemitonesInRange(Math.round(SEMITONE_COUNT * ln(percent) / ln(2.0)).toInt()) + } + + private fun ensureSemitonesInRange(semitones: Int): Int { + return MathUtils.clamp(semitones, -SEMITONE_COUNT, SEMITONE_COUNT) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.java deleted file mode 100644 index 95a4f74af..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.java +++ /dev/null @@ -1,100 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.kt b/app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.kt new file mode 100644 index 000000000..6d9aecf15 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.kt @@ -0,0 +1,70 @@ +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.Optional +import java.util.function.Function + +/** + * This [MediaItemTag] object is designed to contain metadata for a stream + * that has failed to load. It supplies metadata from an underlying + * [PlayQueueItem], which is used by the internal players to resolve actual + * playback info. + * + * This [MediaItemTag] does not contain any [StreamInfo] that can be + * used to start playback and can be detected by checking [ExceptionTag.getErrors] + * when in generic form. + */ +class ExceptionTag private constructor(private val item: PlayQueueItem, + override val errors: List, + private val extras: Any?) : MediaItemTag { + override val serviceId: Int + get() { + return item.getServiceId() + } + override val title: String + get() { + return item.getTitle() + } + override val uploaderName: String + get() { + return item.getUploader() + } + override val durationSeconds: Long + get() { + return item.getDuration() + } + override val streamUrl: String + get() { + return item.getUrl() + } + override val thumbnailUrl: String? + get() { + return ImageStrategy.choosePreferredImage(item.getThumbnails()) + } + override val uploaderUrl: String? + get() { + return item.getUploaderUrl() + } + override val streamType: StreamType + get() { + return item.getStreamType() + } + + public override fun getMaybeExtras(type: Class): Optional? { + return Optional.ofNullable(extras).map(Function({ obj: Any? -> type.cast(obj) })) + } + + public override fun withExtras(extra: T): MediaItemTag { + return ExceptionTag(item, errors, extra) + } + + companion object { + fun of(playQueueItem: PlayQueueItem, + errors: List): ExceptionTag { + return ExceptionTag(playQueueItem, errors, null) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java deleted file mode 100644 index 346bb92fa..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java +++ /dev/null @@ -1,171 +0,0 @@ -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); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.kt b/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.kt new file mode 100644 index 000000000..f6967ec73 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.kt @@ -0,0 +1,117 @@ +package org.schabi.newpipe.player.mediaitem + +import android.net.Uri +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.MediaItem.LocalConfiguration +import com.google.android.exoplayer2.MediaItem.RequestMetadata +import com.google.android.exoplayer2.MediaMetadata +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.Optional +import java.util.UUID +import java.util.function.Function +import java.util.function.Predicate + +/** + * Metadata container and accessor used by player internals. + * + * This interface ensures consistency of fetching metadata on each stream, + * which is encapsulated in a [MediaItem] and delivered via ExoPlayer's + * [Player.Listener] on event triggers to the downstream users. + */ +open interface MediaItemTag { + val errors: List + val serviceId: Int + val title: String + val uploaderName: String + val durationSeconds: Long + val streamUrl: String + val thumbnailUrl: String? + val uploaderUrl: String? + val streamType: StreamType + val maybeStreamInfo: Optional + get() { + return Optional.empty() + } + val maybeQuality: Optional + get() { + return Optional.empty() + } + val maybeAudioTrack: Optional + get() { + return Optional.empty() + } + + fun getMaybeExtras(type: Class): Optional? + fun withExtras(extra: T): MediaItemTag + fun makeMediaId(): String { + return UUID.randomUUID().toString() + "[" + title + "]" + } + + fun asMediaItem(): MediaItem { + val thumbnailUrl: String? = thumbnailUrl + val mediaMetadata: MediaMetadata = MediaMetadata.Builder() + .setArtworkUri(if (thumbnailUrl == null) null else Uri.parse(thumbnailUrl)) + .setArtist(uploaderName) + .setDescription(title) + .setDisplayTitle(title) + .setTitle(title) + .build() + val requestMetaData: RequestMetadata = RequestMetadata.Builder() + .setMediaUri(Uri.parse(streamUrl)) + .build() + return MediaItem.fromUri(streamUrl) + .buildUpon() + .setMediaId(makeMediaId()) + .setMediaMetadata(mediaMetadata) + .setRequestMetadata(requestMetaData) + .setTag(this) + .build() + } + + class Quality private constructor(val sortedVideoStreams: List, + val selectedVideoStreamIndex: Int) { + + val selectedVideoStream: VideoStream? + get() { + return if ((selectedVideoStreamIndex < 0 + || selectedVideoStreamIndex >= sortedVideoStreams.size)) null else sortedVideoStreams.get(selectedVideoStreamIndex) + } + + companion object { + fun of(sortedVideoStreams: List, + selectedVideoStreamIndex: Int): Quality { + return Quality(sortedVideoStreams, selectedVideoStreamIndex) + } + } + } + + class AudioTrack private constructor(val audioStreams: List, + val selectedAudioStreamIndex: Int) { + + val selectedAudioStream: AudioStream? + get() { + return if ((selectedAudioStreamIndex < 0 + || selectedAudioStreamIndex >= audioStreams.size)) null else audioStreams.get(selectedAudioStreamIndex) + } + + companion object { + fun of(audioStreams: List, + selectedAudioStreamIndex: Int): AudioTrack { + return AudioTrack(audioStreams, selectedAudioStreamIndex) + } + } + } + + companion object { + fun from(mediaItem: MediaItem?): Optional { + return Optional.ofNullable(mediaItem) + .map(Function({ item: MediaItem -> item.localConfiguration })) + .map(Function({ localConfiguration: LocalConfiguration? -> localConfiguration!!.tag })) + .filter(Predicate({ obj: Any? -> MediaItemTag::class.java.isInstance(obj) })) + .map(Function({ obj: Any? -> MediaItemTag::class.java.cast(obj) })) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/PlaceholderTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/PlaceholderTag.java deleted file mode 100644 index cce4e9f17..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediaitem/PlaceholderTag.java +++ /dev/null @@ -1,85 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/PlaceholderTag.kt b/app/src/main/java/org/schabi/newpipe/player/mediaitem/PlaceholderTag.kt new file mode 100644 index 000000000..1abb5bd2a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediaitem/PlaceholderTag.kt @@ -0,0 +1,62 @@ +package org.schabi.newpipe.player.mediaitem + +import org.schabi.newpipe.extractor.stream.StreamType +import java.util.Optional +import java.util.function.Function + +/** + * This is a Placeholding [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. + */ +class PlaceholderTag private constructor(private val extras: Any?) : MediaItemTag { + override val errors: List + get() { + return emptyList() + } + override val serviceId: Int + get() { + return NO_SERVICE_ID + } + override val durationSeconds: Long + get() { + return 0 + } + override val thumbnailUrl: String? + get() { + return Companion.streamUrl + } + override val uploaderUrl: String? + get() { + return Companion.streamUrl + } + override val streamType: StreamType + get() { + return StreamType.NONE + } + + public override fun getMaybeExtras(type: Class): Optional? { + return Optional.ofNullable(extras).map(Function({ obj: Any? -> type.cast(obj) })) + } + + public override fun withExtras(extra: T): MediaItemTag { + return PlaceholderTag(extra) + } + + companion object { + val EMPTY: PlaceholderTag = PlaceholderTag(null) + val streamUrl: String = "Placeholder" + get() { + return Companion.field + } + get() + { + return PlaceholderTag.Companion.field + } + get() + { + return PlaceholderTag.Companion.field + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java deleted file mode 100644 index e24a93615..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java +++ /dev/null @@ -1,139 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.kt b/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.kt new file mode 100644 index 000000000..fed6afb04 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.kt @@ -0,0 +1,100 @@ +package org.schabi.newpipe.player.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.Optional +import java.util.function.Function + +/** + * This [MediaItemTag] object contains metadata for a resolved stream + * that is ready for playback. This object guarantees the [StreamInfo] + * is available and may provide the [Quality] of video stream used in + * the [MediaItem]. + */ +class StreamInfoTag private constructor(private val streamInfo: StreamInfo, + private val quality: MediaItemTag.Quality?, + private val audioTrack: MediaItemTag.AudioTrack?, + private val extras: Any?) : MediaItemTag { + override val errors: List + get() { + return emptyList() + } + override val serviceId: Int + get() { + return streamInfo.getServiceId() + } + override val title: String + get() { + return streamInfo.getName() + } + override val uploaderName: String + get() { + return streamInfo.getUploaderName() + } + override val durationSeconds: Long + get() { + return streamInfo.getDuration() + } + override val streamUrl: String + get() { + return streamInfo.getUrl() + } + override val thumbnailUrl: String? + get() { + return ImageStrategy.choosePreferredImage(streamInfo.getThumbnails()) + } + override val uploaderUrl: String? + get() { + return streamInfo.getUploaderUrl() + } + override val streamType: StreamType + get() { + return streamInfo.getStreamType() + } + override val maybeStreamInfo: Optional + get() { + return Optional.of(streamInfo) + } + override val maybeQuality: Optional + get() { + return Optional.ofNullable(quality) + } + override val maybeAudioTrack: Optional + get() { + return Optional.ofNullable(audioTrack) + } + + public override fun getMaybeExtras(type: Class): Optional? { + return Optional.ofNullable(extras).map(Function({ obj: Any? -> type.cast(obj) })) + } + + public override fun withExtras(extra: Any): StreamInfoTag { + return StreamInfoTag(streamInfo, quality, audioTrack, extra) + } + + companion object { + fun of(streamInfo: StreamInfo, + sortedVideoStreams: List, + selectedVideoStreamIndex: Int, + audioStreams: List, + selectedAudioStreamIndex: Int): StreamInfoTag { + val quality: MediaItemTag.Quality = MediaItemTag.Quality.Companion.of(sortedVideoStreams, selectedVideoStreamIndex) + val audioTrack: MediaItemTag.AudioTrack = MediaItemTag.AudioTrack.Companion.of(audioStreams, selectedAudioStreamIndex) + return StreamInfoTag(streamInfo, quality, audioTrack, null) + } + + fun of(streamInfo: StreamInfo, + audioStreams: List, + selectedAudioStreamIndex: Int): StreamInfoTag { + val audioTrack: MediaItemTag.AudioTrack = MediaItemTag.AudioTrack.Companion.of(audioStreams, selectedAudioStreamIndex) + return StreamInfoTag(streamInfo, null, audioTrack, null) + } + + fun of(streamInfo: StreamInfo): StreamInfoTag { + return StreamInfoTag(streamInfo, null, null, null) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java deleted file mode 100644 index 737ebc5dd..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java +++ /dev/null @@ -1,290 +0,0 @@ -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"; - - private MediaSessionCompat mediaSession; - private 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) { - super(player); - ignoreHardwareMediaButtonsKey = - context.getString(R.string.ignore_hardware_media_buttons_key); - } - - @Override - public void initPlayer() { - super.initPlayer(); - destroyPlayer(); // release previously used resources - - mediaSession = new MediaSessionCompat(context, TAG); - mediaSession.setActive(true); - - sessionConnector = new MediaSessionConnector(mediaSession); - 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); - if (sessionConnector != null) { - sessionConnector.setMediaButtonEventHandler(null); - sessionConnector.setPlayer(null); - sessionConnector.setQueueNavigator(null); - sessionConnector = null; - } - if (mediaSession != null) { - mediaSession.setActive(false); - mediaSession.release(); - mediaSession = null; - } - prevNotificationActions = List.of(); - } - - @Override - public void onThumbnailLoaded(@Nullable final Bitmap bitmap) { - super.onThumbnailLoaded(bitmap); - if (sessionConnector != null) { - // 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; - } - - // 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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.kt b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.kt new file mode 100644 index 000000000..35f897d36 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.kt @@ -0,0 +1,259 @@ +package org.schabi.newpipe.player.mediasession + +import android.content.Intent +import android.content.SharedPreferences +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +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.media.session.MediaButtonReceiver +import com.google.android.exoplayer2.ForwardingPlayer +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.MediaButtonEventHandler +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.MediaMetadataProvider +import org.schabi.newpipe.MainActivity +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.Objects +import java.util.Optional +import java.util.function.Consumer +import java.util.function.Function +import java.util.function.IntFunction +import java.util.function.IntUnaryOperator +import java.util.function.Predicate +import java.util.stream.Collectors +import java.util.stream.IntStream + +class MediaSessionPlayerUi(player: Player) : PlayerUi(player), OnSharedPreferenceChangeListener { + private var mediaSession: MediaSessionCompat? = null + private var sessionConnector: MediaSessionConnector? = null + private val ignoreHardwareMediaButtonsKey: String + private var shouldIgnoreHardwareMediaButtons: Boolean = false + + // used to check whether any notification action changed, before sending costly updates + private var prevNotificationActions: List = listOf() + + init { + ignoreHardwareMediaButtonsKey = context.getString(R.string.ignore_hardware_media_buttons_key) + } + + public override fun initPlayer() { + super.initPlayer() + destroyPlayer() // release previously used resources + mediaSession = MediaSessionCompat(context, TAG) + mediaSession!!.setActive(true) + sessionConnector = MediaSessionConnector(mediaSession!!) + sessionConnector!!.setQueueNavigator(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(MediaButtonEventHandler({ p: com.google.android.exoplayer2.Player?, i: Intent? -> shouldIgnoreHardwareMediaButtons })) + + // listen to changes to ignore_hardware_media_buttons_key + updateShouldIgnoreHardwareMediaButtons(player.getPrefs()) + player.getPrefs().registerOnSharedPreferenceChangeListener(this) + sessionConnector!!.setMetadataDeduplicationEnabled(true) + sessionConnector!!.setMediaMetadataProvider(MediaMetadataProvider({ exoPlayer: com.google.android.exoplayer2.Player? -> buildMediaMetadata() })) + + // force updating media session actions by resetting the previous ones + prevNotificationActions = listOf() + updateMediaSessionActions() + } + + public override fun destroyPlayer() { + super.destroyPlayer() + player.getPrefs().unregisterOnSharedPreferenceChangeListener(this) + if (sessionConnector != null) { + sessionConnector!!.setMediaButtonEventHandler(null) + sessionConnector!!.setPlayer(null) + sessionConnector!!.setQueueNavigator(null) + sessionConnector = null + } + if (mediaSession != null) { + mediaSession!!.setActive(false) + mediaSession!!.release() + mediaSession = null + } + prevNotificationActions = listOf() + } + + public override fun onThumbnailLoaded(bitmap: Bitmap?) { + super.onThumbnailLoaded(bitmap) + if (sessionConnector != null) { + // the thumbnail is now loaded: invalidate the metadata to trigger a metadata update + sessionConnector!!.invalidateMediaSessionMetadata() + } + } + + public override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, + key: String?) { + if (key == null || (key == ignoreHardwareMediaButtonsKey)) { + updateShouldIgnoreHardwareMediaButtons(sharedPreferences) + } + } + + fun updateShouldIgnoreHardwareMediaButtons(sharedPreferences: SharedPreferences) { + shouldIgnoreHardwareMediaButtons = sharedPreferences.getBoolean(ignoreHardwareMediaButtonsKey, false) + } + + fun handleMediaButtonIntent(intent: Intent?) { + MediaButtonReceiver.handleIntent(mediaSession, intent) + } + + fun getSessionToken(): Optional { + return Optional.ofNullable(mediaSession).map(Function({ obj: MediaSessionCompat -> obj.getSessionToken() })) + } + + private fun getForwardingPlayer(): ForwardingPlayer { + // 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 object : ForwardingPlayer((player.getExoPlayer())!!) { + public override fun play() { + player.play() + // hide the player controls even if the play command came from the media session + player.UIs().get((VideoPlayerUi::class.java)).ifPresent(Consumer({ ui: VideoPlayerUi? -> ui!!.hideControls(0, 0) })) + } + + public override fun pause() { + player.pause() + } + } + } + + private fun buildMediaMetadata(): MediaMetadataCompat { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "buildMediaMetadata called") + } + + // set title and artist + val builder: MediaMetadataCompat.Builder = 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) + val duration: Long = player.getCurrentStreamInfo() + .filter(Predicate({ info: StreamInfo? -> !StreamTypeUtil.isLiveStream(info!!.getStreamType()) })) + .map(Function({ info: StreamInfo? -> 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 + val showThumbnail: Boolean = player.getPrefs().getBoolean( + context.getString(R.string.show_thumbnail_key), true) + Optional.ofNullable(player.getThumbnail()) + .filter(Predicate({ bitmap: Bitmap? -> showThumbnail })) + .ifPresent(Consumer({ bitmap: Bitmap? -> + builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap) + builder.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, bitmap) + })) + return builder.build() + } + + private fun 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 + } + + // only use the fourth and fifth actions (the settings page also shows only the last 2 on + // Android 13+) + val newNotificationActions: List = IntStream.of(3, 4) + .map(IntUnaryOperator({ i: Int -> + player.getPrefs().getInt( + player.getContext().getString(NotificationConstants.SLOT_PREF_KEYS.get(i)), + NotificationConstants.SLOT_DEFAULTS.get(i)) + })) + .mapToObj(IntFunction({ action: Int -> NotificationActionData.Companion.fromNotificationActionEnum(player, action) })) + .filter(Predicate({ obj: NotificationActionData? -> Objects.nonNull(obj) })) + .collect(Collectors.toList()) + + // avoid costly notification actions update, if nothing changed from last time + if (!(newNotificationActions == prevNotificationActions)) { + prevNotificationActions = newNotificationActions + sessionConnector!!.setCustomActionProviders( + *newNotificationActions.stream() + .map(Function({ data: NotificationActionData? -> SessionConnectorActionProvider(data, context) })) + .toArray(IntFunction>({ _Dummy_.__Array__() }))) + } + } + + public override fun onBlocked() { + super.onBlocked() + updateMediaSessionActions() + } + + public override fun onPlaying() { + super.onPlaying() + updateMediaSessionActions() + } + + public override fun onBuffering() { + super.onBuffering() + updateMediaSessionActions() + } + + public override fun onPaused() { + super.onPaused() + updateMediaSessionActions() + } + + public override fun onPausedSeek() { + super.onPausedSeek() + updateMediaSessionActions() + } + + public override fun onCompleted() { + super.onCompleted() + updateMediaSessionActions() + } + + public override fun onRepeatModeChanged(repeatMode: @com.google.android.exoplayer2.Player.RepeatMode Int) { + super.onRepeatModeChanged(repeatMode) + updateMediaSessionActions() + } + + public override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + super.onShuffleModeEnabledChanged(shuffleModeEnabled) + updateMediaSessionActions() + } + + public override fun onBroadcastReceived(intent: Intent) { + super.onBroadcastReceived(intent) + if ((NotificationConstants.ACTION_RECREATE_NOTIFICATION == intent.getAction())) { + // the notification actions changed + updateMediaSessionActions() + } + } + + public override fun onMetadataChanged(info: StreamInfo) { + super.onMetadataChanged(info) + updateMediaSessionActions() + } + + public override fun onPlayQueueEdited() { + super.onPlayQueueEdited() + updateMediaSessionActions() + } + + companion object { + private val TAG: String = "MediaSessUi" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java deleted file mode 100644 index 3339869c1..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java +++ /dev/null @@ -1,159 +0,0 @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.kt b/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.kt new file mode 100644 index 000000000..362a7e608 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.kt @@ -0,0 +1,133 @@ +package org.schabi.newpipe.player.mediasession + +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 android.support.v4.media.session.PlaybackStateCompat +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.QueueNavigator +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.Optional +import java.util.function.Function +import kotlin.math.min + +class PlayQueueNavigator(private val mediaSession: MediaSessionCompat, + private val player: Player) : QueueNavigator { + private var activeQueueItemId: Long + + init { + activeQueueItemId = MediaSessionCompat.QueueItem.UNKNOWN_ID.toLong() + } + + public override fun getSupportedQueueNavigatorActions( + exoPlayer: com.google.android.exoplayer2.Player?): Long { + return PlaybackStateCompat.ACTION_SKIP_TO_NEXT or PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS or PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM + } + + public override fun onTimelineChanged(exoPlayer: com.google.android.exoplayer2.Player) { + publishFloatingQueueWindow() + } + + public override fun onCurrentMediaItemIndexChanged( + exoPlayer: com.google.android.exoplayer2.Player) { + if ((activeQueueItemId == MediaSessionCompat.QueueItem.UNKNOWN_ID.toLong() + || exoPlayer.getCurrentTimeline().getWindowCount() > MAX_QUEUE_SIZE)) { + publishFloatingQueueWindow() + } else if (!exoPlayer.getCurrentTimeline().isEmpty()) { + activeQueueItemId = exoPlayer.getCurrentMediaItemIndex().toLong() + } + } + + public override fun getActiveQueueItemId( + exoPlayer: com.google.android.exoplayer2.Player?): Long { + return Optional.ofNullable(player.getPlayQueue()).map(Function({ obj: PlayQueue -> obj.getIndex() })).orElse(-1)?.toLong() + } + + public override fun onSkipToPrevious(exoPlayer: com.google.android.exoplayer2.Player) { + player.playPrevious() + } + + public override fun onSkipToQueueItem(exoPlayer: com.google.android.exoplayer2.Player, + id: Long) { + if (player.getPlayQueue() != null) { + player.selectQueueItem(player.getPlayQueue()!!.getItem(id.toInt())) + } + } + + public override fun onSkipToNext(exoPlayer: com.google.android.exoplayer2.Player) { + player.playNext() + } + + private fun publishFloatingQueueWindow() { + val windowCount: Int = Optional.ofNullable(player.getPlayQueue()) + .map(Function({ obj: PlayQueue -> obj.size() })) + .orElse(0) + if (windowCount == 0) { + mediaSession.setQueue(emptyList()) + activeQueueItemId = MediaSessionCompat.QueueItem.UNKNOWN_ID.toLong() + return + } + + // Yes this is almost a copypasta, got a problem with that? =\ + val currentWindowIndex: Int = player.getPlayQueue().getIndex() + val queueSize: Int = min(MAX_QUEUE_SIZE.toDouble(), windowCount.toDouble()).toInt() + val startIndex: Int = Util.constrainValue(currentWindowIndex - ((queueSize - 1) / 2), 0, + windowCount - queueSize) + val queue: MutableList = ArrayList() + for (i in startIndex until (startIndex + queueSize)) { + queue.add(MediaSessionCompat.QueueItem(getQueueMetadata(i), i.toLong())) + } + mediaSession.setQueue(queue) + activeQueueItemId = currentWindowIndex.toLong() + } + + fun getQueueMetadata(index: Int): MediaDescriptionCompat? { + if (player.getPlayQueue() == null) { + return null + } + val item: PlayQueueItem? = player.getPlayQueue()!!.getItem(index) + if (item == null) { + return null + } + val descBuilder: MediaDescriptionCompat.Builder = MediaDescriptionCompat.Builder() + .setMediaId(index.toString()) + .setTitle(item.getTitle()) + .setSubtitle(item.getUploader()) + + // set additional metadata for A2DP/AVRCP (Audio/Video Bluetooth profiles) + val additionalMetadata: Bundle = 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().toLong()) + descBuilder.setExtras(additionalMetadata) + try { + descBuilder.setIconUri(Uri.parse( + ImageStrategy.choosePreferredImage(item.getThumbnails()))) + } catch (e: Throwable) { + // no thumbnail available at all, or the user disabled image loading, + // or the obtained url is not a valid `Uri` + } + return descBuilder.build() + } + + public override fun onCommand(exoPlayer: com.google.android.exoplayer2.Player, + command: String, + extras: Bundle?, + cb: ResultReceiver?): Boolean { + return false + } + + companion object { + private val MAX_QUEUE_SIZE: Int = 10 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.java deleted file mode 100644 index a5c9fccc9..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.java +++ /dev/null @@ -1,47 +0,0 @@ -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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.kt b/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.kt new file mode 100644 index 000000000..9a4e63969 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.kt @@ -0,0 +1,34 @@ +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 com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider +import org.schabi.newpipe.player.notification.NotificationActionData +import java.lang.ref.WeakReference + +class SessionConnectorActionProvider(private val data: NotificationActionData?, + context: Context) : CustomActionProvider { + private val context: WeakReference + + init { + this.context = WeakReference(context) + } + + public override fun onCustomAction(player: Player, + action: String, + extras: Bundle?) { + val actualContext: Context? = context.get() + if (actualContext != null) { + actualContext.sendBroadcast(Intent(action)) + } + } + + public override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? { + return PlaybackStateCompat.CustomAction.Builder( + data!!.action(), data.name(), data.icon() + ).build() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java deleted file mode 100644 index b9ca90d89..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java +++ /dev/null @@ -1,210 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.kt b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.kt new file mode 100644 index 000000000..e93d2f871 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.kt @@ -0,0 +1,178 @@ +package org.schabi.newpipe.player.mediasource + +import android.util.Log +import com.google.android.exoplayer2.MediaItem +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.MediaSource +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 + +class FailedMediaSource(private val playQueueItem: PlayQueueItem, + private val error: Exception, + private val retryTimestamp: Long) : BaseMediaSource(), ManagedMediaSource { + private val TAG: String = "FailedMediaSource@" + Integer.toHexString(hashCode()) + private val 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 + */ + init { + mediaItem = ExceptionTag.Companion.of(playQueueItem, List.of(error)).withExtras(this) + .asMediaItem() + } + + fun getStream(): PlayQueueItem { + return playQueueItem + } + + fun getError(): Exception { + return error + } + + private fun canRetry(): Boolean { + return System.currentTimeMillis() >= retryTimestamp + } + + public override fun getMediaItem(): MediaItem { + return mediaItem + } + + /** + * Prepares the source with [Timeline] info on the silence playback when the error + * is classed as [FailedMediaSourceException], for example, when the error is + * [ExtractionException][org.schabi.newpipe.extractor.exceptions.ExtractionException]. + * These types of error are swallowed by [FailedMediaSource], and the underlying + * exception is carried to the [MediaItem] metadata during playback. + *



+ * If the exception is not known, e.g. [java.net.UnknownHostException] or some + * other network issue, then no source info is refreshed and + * [.maybeThrowSourceInfoRefreshError] be will triggered. + *



+ * Note that this method is called only once until [.releaseSourceInternal] is called, + * so if no action is done in here, playback will stall unless + * [.maybeThrowSourceInfoRefreshError] is called. + * + * @param mediaTransferListener No data transfer listener needed, ignored here. + */ + override fun prepareSourceInternal(mediaTransferListener: TransferListener?) { + Log.e(TAG, "Loading failed source: ", error) + if (error is 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 + * [FailedMediaSource]. The exception is then propagated to the player, which + * [Player][org.schabi.newpipe.player.Player] can react to inside + * [com.google.android.exoplayer2.Player.Listener.onPlayerError]. + * + * @throws IOException An error which will always result in + * [com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_UNSPECIFIED]. + */ + @Throws(IOException::class) + public override fun maybeThrowSourceInfoRefreshError() { + if (!(error is FailedMediaSourceException)) { + throw IOException(error) + } + } + + /** + * This method is only called if [.prepareSourceInternal] + * 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 [Allocator] from which to obtain media buffer allocations. + * @param startPositionUs The expected start position, in microseconds. + * @return The common [MediaPeriod] holding the silence. + */ + public override fun createPeriod(id: MediaSource.MediaPeriodId, + allocator: Allocator, + startPositionUs: Long): MediaPeriod { + return SILENT_MEDIA + } + + public override fun releasePeriod(mediaPeriod: MediaPeriod) { + /* Do Nothing (we want to keep re-using the Silent MediaPeriod) */ + } + + override fun releaseSourceInternal() { + /* Do Nothing, no clean-up for processing/extra thread is needed by this MediaSource */ + } + + public override fun shouldBeReplacedWith(newIdentity: PlayQueueItem, + isInterruptable: Boolean): Boolean { + return newIdentity !== playQueueItem || canRetry() + } + + public override fun isStreamEqual(stream: PlayQueueItem): Boolean { + return playQueueItem === stream + } + + open class FailedMediaSourceException : Exception { + internal constructor(message: String?) : super(message) + internal constructor(cause: Throwable?) : super(cause) + } + + class MediaSourceResolutionException(message: String?) : FailedMediaSourceException(message) + class StreamInfoLoadException(cause: Throwable?) : FailedMediaSourceException(cause) + companion object { + /** + * Play 2 seconds of silenced audio when a stream fails to resolve due to a known issue, + * such as [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. + */ + val SILENCE_DURATION_US: Long = TimeUnit.SECONDS.toMicros(2) + val SILENT_MEDIA: MediaPeriod = makeSilentMediaPeriod(SILENCE_DURATION_US) + fun of(playQueueItem: PlayQueueItem, + error: FailedMediaSourceException): FailedMediaSource { + return FailedMediaSource(playQueueItem, error, Long.MAX_VALUE) + } + + fun of(playQueueItem: PlayQueueItem, + error: Exception, + retryWaitMillis: Long): FailedMediaSource { + return FailedMediaSource(playQueueItem, error, + System.currentTimeMillis() + retryWaitMillis) + } + + private fun makeSilentMediaTimeline(durationUs: Long, + mediaItem: MediaItem): Timeline { + return SinglePeriodTimeline( + durationUs, /* isSeekable= */ + true, /* isDynamic= */ + false, /* useLiveConfiguration= */ + false, /* manifest= */ + null, + mediaItem) + } + + private fun makeSilentMediaPeriod(durationUs: Long): MediaPeriod { + return SilenceMediaSource.Factory() + .setDurationUs(durationUs) + .createMediaSource() + .createPeriod(null, null, 0) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java deleted file mode 100644 index 3bf7c09d9..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java +++ /dev/null @@ -1,64 +0,0 @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.kt b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.kt new file mode 100644 index 000000000..e4eef0a00 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.kt @@ -0,0 +1,54 @@ +package org.schabi.newpipe.player.mediasource + +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.mediasource.LoadedMediaSource +import org.schabi.newpipe.player.mediasource.ManagedMediaSource +import org.schabi.newpipe.player.mediasource.ManagedMediaSourcePlaylist +import org.schabi.newpipe.player.playqueue.PlayQueueItem + +class LoadedMediaSource(source: MediaSource, + tag: MediaItemTag, + private val stream: PlayQueueItem, + private val expireTimestamp: Long) : WrappingMediaSource(source), ManagedMediaSource { + private val mediaItem: MediaItem + + /** + * Uses a [WrappingMediaSource] to wrap one child [MediaSource]s + * containing actual media. This wrapper [LoadedMediaSource] holds the expiration + * timestamp as a [ManagedMediaSource] to allow explicit playlist management under + * [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. + */ + init { + mediaItem = tag.withExtras(this).asMediaItem() + } + + fun getStream(): PlayQueueItem { + return stream + } + + private fun isExpired(): Boolean { + return System.currentTimeMillis() >= expireTimestamp + } + + public override fun getMediaItem(): MediaItem { + return mediaItem + } + + public override fun shouldBeReplacedWith(newIdentity: PlayQueueItem, + isInterruptable: Boolean): Boolean { + return newIdentity !== stream || (isInterruptable && isExpired()) + } + + public override fun isStreamEqual(otherStream: PlayQueueItem): Boolean { + return stream === otherStream + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java deleted file mode 100644 index 9d6b94893..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.java +++ /dev/null @@ -1,30 +0,0 @@ -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); -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.kt b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.kt new file mode 100644 index 000000000..58e7d6f09 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSource.kt @@ -0,0 +1,27 @@ +package org.schabi.newpipe.player.mediasource + +import com.google.android.exoplayer2.source.MediaSource +import org.schabi.newpipe.player.playqueue.PlayQueueItem + +open interface ManagedMediaSource : MediaSource { + /** + * Determines whether or not this [ManagedMediaSource] can be replaced. + * + * @param newIdentity a stream the [ManagedMediaSource] should encapsulate over, if + * it is different from the existing stream in the + * [ManagedMediaSource], then it should be replaced. + * @param isInterruptable specifies if this [ManagedMediaSource] potentially + * being played. + * @return whether this could be replaces + */ + fun shouldBeReplacedWith(newIdentity: PlayQueueItem, isInterruptable: Boolean): Boolean + + /** + * Determines if the [PlayQueueItem] is the one the + * [ManagedMediaSource] encapsulates over. + * + * @param stream play queue item to check + * @return whether this source is for the specified stream + */ + fun isStreamEqual(stream: PlayQueueItem): Boolean +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java deleted file mode 100644 index 4c0380767..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.java +++ /dev/null @@ -1,178 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.kt b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.kt new file mode 100644 index 000000000..c39f16356 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/ManagedMediaSourcePlaylist.kt @@ -0,0 +1,174 @@ +package org.schabi.newpipe.player.mediasource + +import android.os.Handler +import com.google.android.exoplayer2.source.ConcatenatingMediaSource +import com.google.android.exoplayer2.source.ShuffleOrder.UnshuffledShuffleOrder +import org.schabi.newpipe.player.mediaitem.MediaItemTag +import java.util.Optional +import java.util.function.Function + +class ManagedMediaSourcePlaylist() { + private val internalSource: ConcatenatingMediaSource + + init { + internalSource = ConcatenatingMediaSource( /*isPlaylistAtomic=*/false, + UnshuffledShuffleOrder(0)) + } + + /*////////////////////////////////////////////////////////////////////////// + // MediaSource Delegations + ////////////////////////////////////////////////////////////////////////// */ + fun size(): Int { + return internalSource.getSize() + } + + /** + * Returns the [ManagedMediaSource] at the given index of the playlist. + * If the index is invalid, then null is returned. + * + * @param index index of [ManagedMediaSource] to get from the playlist + * @return the [ManagedMediaSource] at the given index of the playlist + */ + operator fun get(index: Int): ManagedMediaSource? { + if (index < 0 || index >= size()) { + return null + } + return MediaItemTag.Companion.from(internalSource.getMediaSource(index).getMediaItem()) + .flatMap(Function?>({ tag: MediaItemTag -> tag.getMaybeExtras(ManagedMediaSource::class.java) })) + .orElse(null) + } + + fun getParentMediaSource(): ConcatenatingMediaSource { + return internalSource + } + /*////////////////////////////////////////////////////////////////////////// + // Playlist Manipulation + ////////////////////////////////////////////////////////////////////////// */ + /** + * Expands the [ConcatenatingMediaSource] by appending it with a + * [PlaceholderMediaSource]. + * + * @see .append + */ + @Synchronized + fun expand() { + append(PlaceholderMediaSource.Companion.COPY) + } + + /** + * Appends a [ManagedMediaSource] to the end of [ConcatenatingMediaSource]. + * + * @see ConcatenatingMediaSource.addMediaSource + * + * @param source [ManagedMediaSource] to append + */ + @Synchronized + fun append(source: ManagedMediaSource) { + internalSource.addMediaSource(source) + } + + /** + * Removes a [ManagedMediaSource] from [ConcatenatingMediaSource] + * at the given index. If this index is out of bound, then the removal is ignored. + * + * @see ConcatenatingMediaSource.removeMediaSource + * @param index of [ManagedMediaSource] to be removed + */ + @Synchronized + fun remove(index: Int) { + if (index < 0 || index > internalSource.getSize()) { + return + } + internalSource.removeMediaSource(index) + } + + /** + * Moves a [ManagedMediaSource] in [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 + * @param source original index of [ManagedMediaSource] + * @param target new index of [ManagedMediaSource] + */ + @Synchronized + fun move(source: Int, target: Int) { + if (source < 0 || target < 0) { + return + } + if (source >= internalSource.getSize() || target >= internalSource.getSize()) { + return + } + internalSource.moveMediaSource(source, target) + } + + /** + * Invalidates the [ManagedMediaSource] at the given index by replacing it + * with a [PlaceholderMediaSource]. + * + * @see .update + * @param index index of [ManagedMediaSource] to invalidate + * @param handler the [Handler] to run `finalizingAction` + * @param finalizingAction a [Runnable] which is executed immediately + * after the media source has been removed from the playlist + */ + @Synchronized + fun invalidate(index: Int, + handler: Handler?, + finalizingAction: Runnable?) { + if (get(index) === PlaceholderMediaSource.Companion.COPY) { + return + } + update(index, PlaceholderMediaSource.Companion.COPY, handler, finalizingAction) + } + + /** + * Updates the [ManagedMediaSource] in [ConcatenatingMediaSource] + * at the given index with a given [ManagedMediaSource]. + * + * @see .update + * @param index index of [ManagedMediaSource] to update + * @param source new [ManagedMediaSource] to use + */ + @Synchronized + fun update(index: Int, source: ManagedMediaSource) { + update(index, source, null, /*doNothing=*/null) + } + + /** + * Updates the [ManagedMediaSource] in [ConcatenatingMediaSource] + * at the given index with a given [ManagedMediaSource]. If the index is out of bound, + * then the replacement is ignored. + * + * @see ConcatenatingMediaSource.addMediaSource + * + * @see ConcatenatingMediaSource.removeMediaSource + * @param index index of [ManagedMediaSource] to update + * @param source new [ManagedMediaSource] to use + * @param handler the [Handler] to run `finalizingAction` + * @param finalizingAction a [Runnable] which is executed immediately + * after the media source has been removed from the playlist + */ + @Synchronized + fun update(index: Int, source: ManagedMediaSource, + handler: Handler?, + finalizingAction: Runnable?) { + 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)!!) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java deleted file mode 100644 index 92d4403c8..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.java +++ /dev/null @@ -1,53 +0,0 @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.kt b/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.kt new file mode 100644 index 000000000..efe522f18 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/PlaceholderMediaSource.kt @@ -0,0 +1,42 @@ +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 + +internal class PlaceholderMediaSource private constructor() : CompositeMediaSource(), ManagedMediaSource { + public override fun getMediaItem(): MediaItem { + return MEDIA_ITEM + } + + override fun onChildSourceInfoRefreshed(id: Void?, + mediaSource: MediaSource, + timeline: Timeline) { + /* Do nothing, no timeline updates or error will stall playback */ + } + + public override fun createPeriod(id: MediaSource.MediaPeriodId, allocator: Allocator, + startPositionUs: Long): MediaPeriod { + return null + } + + public override fun releasePeriod(mediaPeriod: MediaPeriod) {} + public override fun shouldBeReplacedWith(newIdentity: PlayQueueItem, + isInterruptable: Boolean): Boolean { + return true + } + + public override fun isStreamEqual(stream: PlayQueueItem): Boolean { + return false + } + + companion object { + val COPY: PlaceholderMediaSource = PlaceholderMediaSource() + private val MEDIA_ITEM: MediaItem = PlaceholderTag.Companion.EMPTY.withExtras(COPY).asMediaItem() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.java deleted file mode 100644 index b3abcd0b5..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.java +++ /dev/null @@ -1,187 +0,0 @@ -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(R.string.exo_controls_previous_description), baseActionIcon); - - case NotificationConstants.NEXT: - return new NotificationActionData(ACTION_PLAY_NEXT, - ctx.getString(R.string.exo_controls_next_description), baseActionIcon); - - case NotificationConstants.REWIND: - return new NotificationActionData(ACTION_FAST_REWIND, - ctx.getString(R.string.exo_controls_rewind_description), baseActionIcon); - - case NotificationConstants.FORWARD: - return new NotificationActionData(ACTION_FAST_FORWARD, - ctx.getString(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(R.string.exo_controls_previous_description), - R.drawable.exo_notification_previous); - } else { - return new NotificationActionData(ACTION_FAST_REWIND, - ctx.getString(R.string.exo_controls_rewind_description), - 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(R.string.exo_controls_next_description), - R.drawable.exo_notification_next); - } else { - return new NotificationActionData(ACTION_FAST_FORWARD, - ctx.getString(R.string.exo_controls_fastforward_description), - 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(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(R.string.exo_controls_pause_description), - R.drawable.exo_notification_pause); - } else { - return new NotificationActionData(ACTION_PLAY_PAUSE, - ctx.getString(R.string.exo_controls_play_description), - R.drawable.exo_notification_play); - } - - case NotificationConstants.REPEAT: - if (player.getRepeatMode() == REPEAT_MODE_ALL) { - return new NotificationActionData(ACTION_REPEAT, - ctx.getString(R.string.exo_controls_repeat_all_description), - R.drawable.exo_media_action_repeat_all); - } else if (player.getRepeatMode() == REPEAT_MODE_ONE) { - return new NotificationActionData(ACTION_REPEAT, - ctx.getString(R.string.exo_controls_repeat_one_description), - R.drawable.exo_media_action_repeat_one); - } else /* player.getRepeatMode() == REPEAT_MODE_OFF */ { - return new NotificationActionData(ACTION_REPEAT, - ctx.getString(R.string.exo_controls_repeat_off_description), - R.drawable.exo_media_action_repeat_off); - } - - case NotificationConstants.SHUFFLE: - if (player.getPlayQueue() != null && player.getPlayQueue().isShuffled()) { - return new NotificationActionData(ACTION_SHUFFLE, - ctx.getString(R.string.exo_controls_shuffle_on_description), - R.drawable.exo_controls_shuffle_on); - } else { - return new NotificationActionData(ACTION_SHUFFLE, - ctx.getString(R.string.exo_controls_shuffle_off_description), - 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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.kt b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.kt new file mode 100644 index 000000000..ab57a2480 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.kt @@ -0,0 +1,153 @@ +package org.schabi.newpipe.player.notification + +import android.annotation.SuppressLint +import android.content.Context +import androidx.annotation.DrawableRes +import org.schabi.newpipe.R +import org.schabi.newpipe.player.Player +import java.util.Objects + +class NotificationActionData(private val action: String, private val name: String, + @field:DrawableRes @param:DrawableRes private val icon: Int) { + fun action(): String { + return action + } + + fun name(): String { + return name + } + + @DrawableRes + fun icon(): Int { + return icon + } + + public override fun equals(obj: Any?): Boolean { + return ((obj is NotificationActionData) + && (action == obj.action) && (name == obj.name) && (icon == obj.icon)) + } + + public override fun hashCode(): Int { + return Objects.hash(action, name, icon) + } + + companion object { + @SuppressLint("PrivateResource") // we currently use Exoplayer's internal strings and icons + fun fromNotificationActionEnum( + player: Player, + @NotificationConstants.Action selectedAction: Int + ): NotificationActionData? { + val baseActionIcon: Int = NotificationConstants.ACTION_ICONS.get(selectedAction) + val ctx: Context = player.getContext() + when (selectedAction) { + NotificationConstants.PREVIOUS -> return NotificationActionData(NotificationConstants.ACTION_PLAY_PREVIOUS, + ctx.getString(R.string.exo_controls_previous_description), baseActionIcon) + + NotificationConstants.NEXT -> return NotificationActionData(NotificationConstants.ACTION_PLAY_NEXT, + ctx.getString(R.string.exo_controls_next_description), baseActionIcon) + + NotificationConstants.REWIND -> return NotificationActionData(NotificationConstants.ACTION_FAST_REWIND, + ctx.getString(R.string.exo_controls_rewind_description), baseActionIcon) + + NotificationConstants.FORWARD -> return NotificationActionData(NotificationConstants.ACTION_FAST_FORWARD, + ctx.getString(R.string.exo_controls_fastforward_description), + baseActionIcon) + + NotificationConstants.SMART_REWIND_PREVIOUS -> if (player.getPlayQueue() != null && player.getPlayQueue()!!.size() > 1) { + return NotificationActionData(NotificationConstants.ACTION_PLAY_PREVIOUS, + ctx.getString(R.string.exo_controls_previous_description), + R.drawable.exo_notification_previous) + } else { + return NotificationActionData(NotificationConstants.ACTION_FAST_REWIND, + ctx.getString(R.string.exo_controls_rewind_description), + R.drawable.exo_controls_rewind) + } + + NotificationConstants.SMART_FORWARD_NEXT -> if (player.getPlayQueue() != null && player.getPlayQueue()!!.size() > 1) { + return NotificationActionData(NotificationConstants.ACTION_PLAY_NEXT, + ctx.getString(R.string.exo_controls_next_description), + R.drawable.exo_notification_next) + } else { + return NotificationActionData(NotificationConstants.ACTION_FAST_FORWARD, + ctx.getString(R.string.exo_controls_fastforward_description), + R.drawable.exo_controls_fastforward) + } + + NotificationConstants.PLAY_PAUSE_BUFFERING -> { + if ((player.getCurrentState() == Player.Companion.STATE_PREFLIGHT + ) || (player.getCurrentState() == Player.Companion.STATE_BLOCKED + ) || (player.getCurrentState() == Player.Companion.STATE_BUFFERING)) { + return NotificationActionData(NotificationConstants.ACTION_PLAY_PAUSE, + ctx.getString(R.string.notification_action_buffering), + R.drawable.ic_hourglass_top) + } + if (player.getCurrentState() == Player.Companion.STATE_COMPLETED) { + return NotificationActionData(NotificationConstants.ACTION_PLAY_PAUSE, + ctx.getString(R.string.exo_controls_pause_description), + R.drawable.ic_replay) + } else if ((player.isPlaying() + || (player.getCurrentState() == Player.Companion.STATE_PREFLIGHT + ) || (player.getCurrentState() == Player.Companion.STATE_BLOCKED + ) || (player.getCurrentState() == Player.Companion.STATE_BUFFERING))) { + return NotificationActionData(NotificationConstants.ACTION_PLAY_PAUSE, + ctx.getString(R.string.exo_controls_pause_description), + R.drawable.exo_notification_pause) + } else { + return NotificationActionData(NotificationConstants.ACTION_PLAY_PAUSE, + ctx.getString(R.string.exo_controls_play_description), + R.drawable.exo_notification_play) + } + } + + NotificationConstants.PLAY_PAUSE -> if (player.getCurrentState() == Player.Companion.STATE_COMPLETED) { + return NotificationActionData(NotificationConstants.ACTION_PLAY_PAUSE, + ctx.getString(R.string.exo_controls_pause_description), + R.drawable.ic_replay) + } else if ((player.isPlaying() + || (player.getCurrentState() == Player.Companion.STATE_PREFLIGHT + ) || (player.getCurrentState() == Player.Companion.STATE_BLOCKED + ) || (player.getCurrentState() == Player.Companion.STATE_BUFFERING))) { + return NotificationActionData(NotificationConstants.ACTION_PLAY_PAUSE, + ctx.getString(R.string.exo_controls_pause_description), + R.drawable.exo_notification_pause) + } else { + return NotificationActionData(NotificationConstants.ACTION_PLAY_PAUSE, + ctx.getString(R.string.exo_controls_play_description), + R.drawable.exo_notification_play) + } + + NotificationConstants.REPEAT -> if (player.getRepeatMode() == com.google.android.exoplayer2.Player.REPEAT_MODE_ALL) { + return NotificationActionData(NotificationConstants.ACTION_REPEAT, + ctx.getString(R.string.exo_controls_repeat_all_description), + R.drawable.exo_media_action_repeat_all) + } else if (player.getRepeatMode() == com.google.android.exoplayer2.Player.REPEAT_MODE_ONE) { + return NotificationActionData(NotificationConstants.ACTION_REPEAT, + ctx.getString(R.string.exo_controls_repeat_one_description), + R.drawable.exo_media_action_repeat_one) + } else /* player.getRepeatMode() == REPEAT_MODE_OFF */ { + return NotificationActionData(NotificationConstants.ACTION_REPEAT, + ctx.getString(R.string.exo_controls_repeat_off_description), + R.drawable.exo_media_action_repeat_off) + } + + NotificationConstants.SHUFFLE -> if (player.getPlayQueue() != null && player.getPlayQueue()!!.isShuffled()) { + return NotificationActionData(NotificationConstants.ACTION_SHUFFLE, + ctx.getString(R.string.exo_controls_shuffle_on_description), + R.drawable.exo_controls_shuffle_on) + } else { + return NotificationActionData(NotificationConstants.ACTION_SHUFFLE, + ctx.getString(R.string.exo_controls_shuffle_off_description), + R.drawable.exo_controls_shuffle_off) + } + + NotificationConstants.CLOSE -> return NotificationActionData(NotificationConstants.ACTION_CLOSE, ctx.getString(R.string.close), + R.drawable.ic_close) + + NotificationConstants.NOTHING -> // do nothing + return null + + else -> return null + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java deleted file mode 100644 index b9607f7ea..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java +++ /dev/null @@ -1,186 +0,0 @@ -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, - R.drawable.exo_icon_previous, - R.drawable.exo_icon_next, - R.drawable.exo_icon_rewind, - R.drawable.exo_icon_fastforward, - R.drawable.exo_icon_previous, - R.drawable.exo_icon_next, - R.drawable.ic_pause, - R.drawable.ic_hourglass_top, - R.drawable.exo_icon_repeat_all, - 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(R.string.exo_controls_previous_description); - case NEXT: - return context.getString(R.string.exo_controls_next_description); - case REWIND: - return context.getString(R.string.exo_controls_rewind_description); - case FORWARD: - return context.getString(R.string.exo_controls_fastforward_description); - case SMART_REWIND_PREVIOUS: - return Localization.concatenateStrings( - context.getString(R.string.exo_controls_rewind_description), - context.getString(R.string.exo_controls_previous_description)); - case SMART_FORWARD_NEXT: - return Localization.concatenateStrings( - context.getString(R.string.exo_controls_fastforward_description), - context.getString(R.string.exo_controls_next_description)); - case PLAY_PAUSE: - return Localization.concatenateStrings( - context.getString(R.string.exo_controls_play_description), - context.getString(R.string.exo_controls_pause_description)); - case PLAY_PAUSE_BUFFERING: - return Localization.concatenateStrings( - context.getString(R.string.exo_controls_play_description), - context.getString(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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.kt b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.kt new file mode 100644 index 000000000..c493448ff --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.kt @@ -0,0 +1,137 @@ +package org.schabi.newpipe.player.notification + +import android.content.Context +import android.content.SharedPreferences +import androidx.annotation.DrawableRes +import androidx.annotation.IntDef +import org.schabi.newpipe.App +import org.schabi.newpipe.R +import org.schabi.newpipe.util.Localization +import java.util.SortedSet +import java.util.TreeSet + +object NotificationConstants { + /*////////////////////////////////////////////////////////////////////////// + // Intent actions + ////////////////////////////////////////////////////////////////////////// */ + private val BASE_ACTION: String = App.Companion.PACKAGE_NAME + ".player.MainPlayer." + val ACTION_CLOSE: String = BASE_ACTION + "CLOSE" + val ACTION_PLAY_PAUSE: String = BASE_ACTION + ".player.MainPlayer.PLAY_PAUSE" + val ACTION_REPEAT: String = BASE_ACTION + ".player.MainPlayer.REPEAT" + val ACTION_PLAY_NEXT: String = BASE_ACTION + ".player.MainPlayer.ACTION_PLAY_NEXT" + val ACTION_PLAY_PREVIOUS: String = BASE_ACTION + ".player.MainPlayer.ACTION_PLAY_PREVIOUS" + val ACTION_FAST_REWIND: String = BASE_ACTION + ".player.MainPlayer.ACTION_FAST_REWIND" + val ACTION_FAST_FORWARD: String = BASE_ACTION + ".player.MainPlayer.ACTION_FAST_FORWARD" + val ACTION_SHUFFLE: String = BASE_ACTION + ".player.MainPlayer.ACTION_SHUFFLE" + val ACTION_RECREATE_NOTIFICATION: String = BASE_ACTION + ".player.MainPlayer.ACTION_RECREATE_NOTIFICATION" + val NOTHING: Int = 0 + val PREVIOUS: Int = 1 + val NEXT: Int = 2 + val REWIND: Int = 3 + val FORWARD: Int = 4 + val SMART_REWIND_PREVIOUS: Int = 5 + val SMART_FORWARD_NEXT: Int = 6 + val PLAY_PAUSE: Int = 7 + val PLAY_PAUSE_BUFFERING: Int = 8 + val REPEAT: Int = 9 + val SHUFFLE: Int = 10 + val CLOSE: Int = 11 + + @Action + val ALL_ACTIONS: IntArray = intArrayOf(NOTHING, PREVIOUS, NEXT, REWIND, FORWARD, + SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT, + SHUFFLE, CLOSE) + + @DrawableRes + val ACTION_ICONS: IntArray = intArrayOf( + 0, + R.drawable.exo_icon_previous, + R.drawable.exo_icon_next, + R.drawable.exo_icon_rewind, + R.drawable.exo_icon_fastforward, + R.drawable.exo_icon_previous, + R.drawable.exo_icon_next, + R.drawable.ic_pause, + R.drawable.ic_hourglass_top, + R.drawable.exo_icon_repeat_all, + R.drawable.exo_icon_shuffle_on, + R.drawable.ic_close) + + @Action + val SLOT_DEFAULTS: IntArray = intArrayOf( + SMART_REWIND_PREVIOUS, + PLAY_PAUSE_BUFFERING, + SMART_FORWARD_NEXT, + REPEAT, + CLOSE) + val SLOT_PREF_KEYS: IntArray = intArrayOf( + 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) + val SLOT_COMPACT_DEFAULTS: List = listOf(0, 1, 2) + val SLOT_COMPACT_PREF_KEYS: IntArray = intArrayOf( + R.string.notification_slot_compact_0_key, + R.string.notification_slot_compact_1_key, + R.string.notification_slot_compact_2_key) + + fun getActionName(context: Context, @Action action: Int): String { + when (action) { + PREVIOUS -> return context.getString(R.string.exo_controls_previous_description) + NEXT -> return context.getString(R.string.exo_controls_next_description) + REWIND -> return context.getString(R.string.exo_controls_rewind_description) + FORWARD -> return context.getString(R.string.exo_controls_fastforward_description) + SMART_REWIND_PREVIOUS -> return Localization.concatenateStrings( + context.getString(R.string.exo_controls_rewind_description), + context.getString(R.string.exo_controls_previous_description)) + + SMART_FORWARD_NEXT -> return Localization.concatenateStrings( + context.getString(R.string.exo_controls_fastforward_description), + context.getString(R.string.exo_controls_next_description)) + + PLAY_PAUSE -> return Localization.concatenateStrings( + context.getString(R.string.exo_controls_play_description), + context.getString(R.string.exo_controls_pause_description)) + + PLAY_PAUSE_BUFFERING -> return Localization.concatenateStrings( + context.getString(R.string.exo_controls_play_description), + context.getString(R.string.exo_controls_pause_description), + context.getString(R.string.notification_action_buffering)) + + REPEAT -> return context.getString(R.string.notification_action_repeat) + SHUFFLE -> return context.getString(R.string.notification_action_shuffle) + CLOSE -> return context.getString(R.string.close) + NOTHING -> return context.getString(R.string.notification_action_nothing) + else -> 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 + */ + fun getCompactSlotsFromPreferences( + context: Context, + sharedPreferences: SharedPreferences?): Collection { + val compactSlots: SortedSet = TreeSet() + for (i in 0..2) { + val compactSlot: Int = sharedPreferences!!.getInt( + context.getString(SLOT_COMPACT_PREF_KEYS.get(i)), Int.MAX_VALUE) + if (compactSlot == Int.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 + } + + @Retention(AnnotationRetention.SOURCE) + @IntDef([NOTHING, PREVIOUS, NEXT, REWIND, FORWARD, SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT, SHUFFLE, CLOSE]) + annotation class Action() +} diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationPlayerUi.java deleted file mode 100644 index 75b27545c..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationPlayerUi.java +++ /dev/null @@ -1,119 +0,0 @@ -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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationPlayerUi.kt b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationPlayerUi.kt new file mode 100644 index 000000000..c2bdac94d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationPlayerUi.kt @@ -0,0 +1,97 @@ +package org.schabi.newpipe.player.notification + +import android.content.Intent +import android.graphics.Bitmap +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.helper.PlayerHelper.MinimizeMode +import org.schabi.newpipe.player.ui.PlayerUi + +class NotificationPlayerUi(player: Player) : PlayerUi(player) { + private val notificationUtil: NotificationUtil + + init { + notificationUtil = NotificationUtil(player) + } + + public override fun destroy() { + super.destroy() + notificationUtil.cancelNotificationAndStopForeground() + } + + public override fun onThumbnailLoaded(bitmap: Bitmap?) { + super.onThumbnailLoaded(bitmap) + notificationUtil.updateThumbnail() + } + + public override fun onBlocked() { + super.onBlocked() + notificationUtil.createNotificationIfNeededAndUpdate(false) + } + + public override fun onPlaying() { + super.onPlaying() + notificationUtil.createNotificationIfNeededAndUpdate(false) + } + + public override fun onBuffering() { + super.onBuffering() + if (notificationUtil.shouldUpdateBufferingSlot()) { + notificationUtil.createNotificationIfNeededAndUpdate(false) + } + } + + public override fun onPaused() { + super.onPaused() + + // Remove running notification when user does not want minimization to background or popup + if ((PlayerHelper.getMinimizeOnExitAction(context) == MinimizeMode.Companion.MINIMIZE_ON_EXIT_MODE_NONE + && player.videoPlayerSelected())) { + notificationUtil.cancelNotificationAndStopForeground() + } else { + notificationUtil.createNotificationIfNeededAndUpdate(false) + } + } + + public override fun onPausedSeek() { + super.onPausedSeek() + notificationUtil.createNotificationIfNeededAndUpdate(false) + } + + public override fun onCompleted() { + super.onCompleted() + notificationUtil.createNotificationIfNeededAndUpdate(false) + } + + public override fun onRepeatModeChanged(repeatMode: @com.google.android.exoplayer2.Player.RepeatMode Int) { + super.onRepeatModeChanged(repeatMode) + notificationUtil.createNotificationIfNeededAndUpdate(false) + } + + public override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + super.onShuffleModeEnabledChanged(shuffleModeEnabled) + notificationUtil.createNotificationIfNeededAndUpdate(false) + } + + public override fun onBroadcastReceived(intent: Intent) { + super.onBroadcastReceived(intent) + if ((NotificationConstants.ACTION_RECREATE_NOTIFICATION == intent.getAction())) { + notificationUtil.createNotificationIfNeededAndUpdate(true) + } + } + + public override fun onMetadataChanged(info: StreamInfo) { + super.onMetadataChanged(info) + notificationUtil.createNotificationIfNeededAndUpdate(true) + } + + public override fun onPlayQueueEdited() { + super.onPlayQueueEdited() + notificationUtil.createNotificationIfNeededAndUpdate(false) + } + + fun createNotificationAndStartForeground() { + notificationUtil.createNotificationAndStartForeground() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java deleted file mode 100644 index 30420b0c7..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java +++ /dev/null @@ -1,302 +0,0 @@ -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.PendingIntent; -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.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(); - 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); - notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()); - } - } - - private synchronized NotificationCompat.Builder createNotification() { - if (DEBUG) { - Log.d(TAG, "createNotification()"); - } - notificationManager = NotificationManagerCompat.from(player.getContext()); - final NotificationCompat.Builder builder = - new NotificationCompat.Builder(player.getContext(), - player.getContext().getString(R.string.notification_channel_id)); - final MediaStyle mediaStyle = new MediaStyle(); - - // setup media style (compact notification slots and media session) - 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 - builder.setStyle(mediaStyle) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setCategory(NotificationCompat.CATEGORY_TRANSPORT) - .setShowWhen(false) - .setSmallIcon(R.drawable.ic_newpipe_triangle_white) - .setColor(ContextCompat.getColor(player.getContext(), - R.color.dark_background_color)) - .setColorized(player.getPrefs().getBoolean( - player.getContext().getString(R.string.notification_colorize_key), true)) - .setDeleteIntent(PendingIntentCompat.getBroadcast(player.getContext(), - NOTIFICATION_ID, new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT, false)); - - // 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 void createNotificationAndStartForeground() { - if (notificationBuilder == null) { - notificationBuilder = createNotification(); - } - updateNotification(); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - player.getService().startForeground(NOTIFICATION_ID, notificationBuilder.build(), - ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK); - } else { - player.getService().startForeground(NOTIFICATION_ID, notificationBuilder.build()); - } - } - - public void cancelNotificationAndStopForeground() { - ServiceCompat.stopForeground(player.getService(), ServiceCompat.STOP_FOREGROUND_REMOVE); - - if (notificationManager != null) { - notificationManager.cancel(NOTIFICATION_ID); - } - notificationManager = null; - notificationBuilder = null; - } - - - ///////////////////////////////////////////////////// - // 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, 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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.kt b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.kt new file mode 100644 index 000000000..9c5d67b35 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.kt @@ -0,0 +1,271 @@ +package org.schabi.newpipe.player.notification + +import android.annotation.SuppressLint +import android.app.PendingIntent +import android.content.Intent +import android.content.pm.ServiceInfo +import android.graphics.Bitmap +import android.os.Build +import android.support.v4.media.session.MediaSessionCompat +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 org.schabi.newpipe.MainActivity +import org.schabi.newpipe.R +import org.schabi.newpipe.player.Player +import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi +import org.schabi.newpipe.util.NavigationHelper +import java.util.Objects +import java.util.Optional +import java.util.function.Consumer +import java.util.function.Function +import java.util.function.ToIntFunction +import kotlin.math.min + +/** + * This is a utility class for player notifications. + */ +class NotificationUtil(private val player: Player) { + @NotificationConstants.Action + private val notificationSlots: IntArray = NotificationConstants.SLOT_DEFAULTS.clone() + private var notificationManager: NotificationManagerCompat? = null + private var notificationBuilder: NotificationCompat.Builder? = null + ///////////////////////////////////////////////////// + // 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 + */ + @Synchronized + fun createNotificationIfNeededAndUpdate(forceRecreate: Boolean) { + if (forceRecreate || notificationBuilder == null) { + notificationBuilder = createNotification() + } + updateNotification() + notificationManager!!.notify(NOTIFICATION_ID, notificationBuilder!!.build()) + } + + @Synchronized + fun updateThumbnail() { + if (notificationBuilder != null) { + if (DEBUG) { + Log.d(TAG, ("updateThumbnail() called with thumbnail = [" + Integer.toHexString( + Optional.ofNullable(player.getThumbnail()).map(Function({ o: Bitmap? -> Objects.hashCode(o) })).orElse(0)) + + "], title = [" + player.getVideoTitle() + "]")) + } + setLargeIcon(notificationBuilder!!) + notificationManager!!.notify(NOTIFICATION_ID, notificationBuilder!!.build()) + } + } + + @Synchronized + private fun createNotification(): NotificationCompat.Builder { + if (DEBUG) { + Log.d(TAG, "createNotification()") + } + notificationManager = NotificationManagerCompat.from(player.getContext()) + val builder: NotificationCompat.Builder = NotificationCompat.Builder(player.getContext(), + player.getContext().getString(R.string.notification_channel_id)) + val mediaStyle: androidx.media.app.NotificationCompat.MediaStyle = androidx.media.app.NotificationCompat.MediaStyle() + + // setup media style (compact notification slots and media session) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + // notification actions are ignored on Android 13+, and are replaced by code in + // MediaSessionPlayerUi + val compactSlots: IntArray = initializeNotificationSlots() + mediaStyle.setShowActionsInCompactView(*compactSlots) + } + player.UIs() + .get((MediaSessionPlayerUi::class.java)) + .flatMap(Function?>({ obj: MediaSessionPlayerUi? -> obj!!.getSessionToken() })) + .ifPresent(Consumer({ token: MediaSessionCompat.Token? -> mediaStyle.setMediaSession(token) })) + + // setup notification builder + builder.setStyle(mediaStyle) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setCategory(NotificationCompat.CATEGORY_TRANSPORT) + .setShowWhen(false) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setColor(ContextCompat.getColor(player.getContext(), + R.color.dark_background_color)) + .setColorized(player.getPrefs().getBoolean( + player.getContext().getString(R.string.notification_colorize_key), true)) + .setDeleteIntent(PendingIntentCompat.getBroadcast(player.getContext(), + NOTIFICATION_ID, Intent(NotificationConstants.ACTION_CLOSE), PendingIntent.FLAG_UPDATE_CURRENT, false)) + + // 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. + */ + @Synchronized + private fun 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())!!, PendingIntent.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") + fun shouldUpdateBufferingSlot(): Boolean { + 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.get(1) == NotificationConstants.PLAY_PAUSE_BUFFERING + && notificationBuilder!!.mActions.get(1).actionIntent != null) + || (notificationSlots.get(2) == NotificationConstants.PLAY_PAUSE_BUFFERING + && notificationBuilder!!.mActions.get(2).actionIntent != null)) + } + + fun createNotificationAndStartForeground() { + if (notificationBuilder == null) { + notificationBuilder = createNotification() + } + updateNotification() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + player.getService().startForeground(NOTIFICATION_ID, notificationBuilder!!.build(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) + } else { + player.getService().startForeground(NOTIFICATION_ID, notificationBuilder!!.build()) + } + } + + fun cancelNotificationAndStopForeground() { + ServiceCompat.stopForeground(player.getService(), ServiceCompat.STOP_FOREGROUND_REMOVE) + if (notificationManager != null) { + notificationManager!!.cancel(NOTIFICATION_ID) + } + notificationManager = null + notificationBuilder = null + } + ///////////////////////////////////////////////////// + // 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 fun initializeNotificationSlots(): IntArray { + val settingsCompactSlots: Collection? = NotificationConstants.getCompactSlotsFromPreferences(player.getContext(), player.getPrefs()) + val adjustedCompactSlots: MutableList = ArrayList() + var nonNothingIndex: Int = 0 + for (i in 0..4) { + notificationSlots.get(i) = player.getPrefs().getInt( + player.getContext().getString(NotificationConstants.SLOT_PREF_KEYS.get(i)), + NotificationConstants.SLOT_DEFAULTS.get(i)) + if (notificationSlots.get(i) != NotificationConstants.NOTHING) { + if (settingsCompactSlots!!.contains(i)) { + adjustedCompactSlots.add(nonNothingIndex) + } + nonNothingIndex += 1 + } + } + return adjustedCompactSlots.stream().mapToInt(ToIntFunction({ obj: Int -> obj.toInt() })).toArray() + } + + @SuppressLint("RestrictedApi") + private fun updateActions(builder: NotificationCompat.Builder?) { + builder!!.mActions.clear() + for (i in 0..4) { + addAction(builder, notificationSlots.get(i)) + } + } + + private fun addAction(builder: NotificationCompat.Builder?, + @NotificationConstants.Action slot: Int) { + val data: NotificationActionData? = NotificationActionData.Companion.fromNotificationActionEnum(player, slot) + if (data == null) { + return + } + val intent: PendingIntent? = PendingIntentCompat.getBroadcast(player.getContext(), + NOTIFICATION_ID, Intent(data.action()), PendingIntent.FLAG_UPDATE_CURRENT, false) + builder!!.addAction(NotificationCompat.Action(data.icon(), data.name(), intent)) + } + + private fun getIntentForNotification(): Intent? { + 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 + val intent: Intent = NavigationHelper.getPlayerIntent( + player.getContext(), MainActivity::class.java, null, true) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.setAction(Intent.ACTION_MAIN) + intent.addCategory(Intent.CATEGORY_LAUNCHER) + return intent + } + } + + ///////////////////////////////////////////////////// + // BITMAP + ///////////////////////////////////////////////////// + private fun setLargeIcon(builder: NotificationCompat.Builder) { + val showThumbnail: Boolean = player.getPrefs().getBoolean( + player.getContext().getString(R.string.show_thumbnail_key), true) + val thumbnail: Bitmap? = player.getThumbnail() + if (thumbnail == null || !showThumbnail) { + // since the builder is reused, make sure the thumbnail is unset if there is not one + builder.setLargeIcon(null as Bitmap?) + return + } + val scaleImageToSquareAspectRatio: Boolean = 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 fun getBitmapWithSquareAspectRatio(bitmap: Bitmap): Bitmap { + // Find the smaller dimension and then take a center portion of the image that + // has that size. + val w: Int = bitmap.getWidth() + val h: Int = bitmap.getHeight() + val dstSize: Int = min(w.toDouble(), h.toDouble()).toInt() + val x: Int = (w - dstSize) / 2 + val y: Int = (h - dstSize) / 2 + return Bitmap.createBitmap(bitmap, x, y, dstSize, dstSize) + } + + companion object { + private val TAG: String = NotificationUtil::class.java.getSimpleName() + private val DEBUG: Boolean = Player.Companion.DEBUG + private val NOTIFICATION_ID: Int = 123789 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java deleted file mode 100644 index 88d7145bc..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java +++ /dev/null @@ -1,608 +0,0 @@ -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.events.MoveEvent; -import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent; -import org.schabi.newpipe.player.playqueue.events.RemoveEvent; -import org.schabi.newpipe.player.playqueue.events.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; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.kt b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.kt new file mode 100644 index 000000000..e4859424b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.kt @@ -0,0 +1,527 @@ +package org.schabi.newpipe.player.playback + +import android.os.Handler +import android.util.Log +import androidx.collection.ArraySet +import com.google.android.exoplayer2.source.MediaSource +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.functions.Consumer +import io.reactivex.rxjava3.functions.Predicate +import io.reactivex.rxjava3.internal.subscriptions.EmptySubscription +import io.reactivex.rxjava3.schedulers.Schedulers +import io.reactivex.rxjava3.subjects.PublishSubject +import org.reactivestreams.Subscriber +import org.reactivestreams.Subscription +import org.schabi.newpipe.extractor.exceptions.ExtractionException +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.player.mediaitem.MediaItemTag +import org.schabi.newpipe.player.mediasource.FailedMediaSource +import org.schabi.newpipe.player.mediasource.FailedMediaSource.MediaSourceResolutionException +import org.schabi.newpipe.player.mediasource.FailedMediaSource.StreamInfoLoadException +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.events.MoveEvent +import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent +import org.schabi.newpipe.player.playqueue.events.PlayQueueEventType +import org.schabi.newpipe.player.playqueue.events.RemoveEvent +import org.schabi.newpipe.player.playqueue.events.ReorderEvent +import org.schabi.newpipe.util.ServiceHelper +import java.util.Collections +import java.util.Optional +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.function.Supplier +import kotlin.math.max +import kotlin.math.min + +class MediaSourceManager private constructor(listener: PlaybackListener, + playQueue: PlayQueue, + loadDebounceMillis: Long, + playbackNearEndGapMillis: Long, + progressUpdateIntervalMillis: Long) { + private val TAG: String = "MediaSourceManager@" + hashCode() + private val playbackListener: PlaybackListener + private val playQueue: PlayQueue + + /** + * Determines the gap time between the playback position and the playback duration which + * the [.getEdgeIntervalSignal] begins to request loading. + * + * @see .progressUpdateIntervalMillis + */ + private val playbackNearEndGapMillis: Long + + /** + * Determines the interval which the [.getEdgeIntervalSignal] waits for between + * each request for loading, once [.playbackNearEndGapMillis] has reached. + */ + private val progressUpdateIntervalMillis: Long + private val nearEndIntervalSignal: Observable + + /** + * 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 val loadDebounceMillis: Long + private val debouncedLoader: Disposable + private val debouncedSignal: PublishSubject + private var playQueueReactor: Subscription + private val loaderReactor: CompositeDisposable + private val loadingItems: MutableSet + private val isBlocked: AtomicBoolean + private var playlist: ManagedMediaSourcePlaylist + private val removeMediaSourceHandler: Handler = Handler() + + constructor(listener: PlaybackListener, + playQueue: PlayQueue) : this(listener, playQueue, 400L, /*playbackNearEndGapMillis=*/ + TimeUnit.MILLISECONDS.convert(30, TimeUnit.SECONDS), /*progressUpdateIntervalMillis*/ + TimeUnit.MILLISECONDS.convert(2, TimeUnit.SECONDS)) + + init { + if (playQueue.getBroadcastReceiver() == null) { + throw IllegalArgumentException("Play Queue has not been initialized.") + } + if (playbackNearEndGapMillis < progressUpdateIntervalMillis) { + throw IllegalArgumentException(("Playback end gap=[" + playbackNearEndGapMillis + + " ms] must be longer than update interval=[ " + progressUpdateIntervalMillis + + " ms] for them to be useful.")) + } + playbackListener = listener + this.playQueue = playQueue + this.playbackNearEndGapMillis = playbackNearEndGapMillis + this.progressUpdateIntervalMillis = progressUpdateIntervalMillis + nearEndIntervalSignal = edgeIntervalSignal + this.loadDebounceMillis = loadDebounceMillis + debouncedSignal = PublishSubject.create() + debouncedLoader = getDebouncedLoader() + playQueueReactor = EmptySubscription.INSTANCE + loaderReactor = CompositeDisposable() + isBlocked = AtomicBoolean(false) + playlist = ManagedMediaSourcePlaylist() + loadingItems = Collections.synchronizedSet(ArraySet()) + playQueue.getBroadcastReceiver() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(reactor) + } + /*////////////////////////////////////////////////////////////////////////// + // Exposed Methods + ////////////////////////////////////////////////////////////////////////// */ + /** + * Dispose the manager and releases all message buses and loaders. + */ + fun dispose() { + if (PlayQueue.Companion.DEBUG) { + Log.d(TAG, "close() called.") + } + debouncedSignal.onComplete() + debouncedLoader.dispose() + playQueueReactor.cancel() + loaderReactor.dispose() + } + + private val reactor: Subscriber + /*////////////////////////////////////////////////////////////////////////// + // Event Reactor + ////////////////////////////////////////////////////////////////////////// */private get() { + return object : Subscriber { + public override fun onSubscribe(d: Subscription) { + playQueueReactor.cancel() + playQueueReactor = d + playQueueReactor.request(1) + } + + public override fun onNext(playQueueMessage: PlayQueueEvent) { + onPlayQueueChanged(playQueueMessage) + } + + public override fun onError(e: Throwable) {} + public override fun onComplete() {} + } + } + + private fun onPlayQueueChanged(event: PlayQueueEvent) { + if (playQueue.isEmpty() && playQueue.isComplete()) { + playbackListener.onPlaybackShutdown() + return + } + when (event.type()) { + PlayQueueEventType.INIT, PlayQueueEventType.ERROR -> { + maybeBlock() + populateSources() + } + + PlayQueueEventType.APPEND -> populateSources() + PlayQueueEventType.SELECT -> maybeRenewCurrentIndex() + PlayQueueEventType.REMOVE -> { + val removeEvent: RemoveEvent = event as RemoveEvent + playlist.remove(removeEvent.getRemoveIndex()) + } + + PlayQueueEventType.MOVE -> { + val moveEvent: MoveEvent = event as MoveEvent + playlist.move(moveEvent.getFromIndex(), moveEvent.getToIndex()) + } + + PlayQueueEventType.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 + val reorderEvent: ReorderEvent = event as ReorderEvent + playlist.move(reorderEvent.getFromSelectedIndex(), + reorderEvent.getToSelectedIndex()) + } + + PlayQueueEventType.RECOVERY -> {} + else -> {} + } + when (event.type()) { + PlayQueueEventType.INIT, PlayQueueEventType.REORDER, PlayQueueEventType.ERROR, PlayQueueEventType.SELECT -> loadImmediate() // low frequency, critical events + PlayQueueEventType.APPEND, PlayQueueEventType.REMOVE, PlayQueueEventType.MOVE, PlayQueueEventType.RECOVERY -> loadDebounced() // high frequency or noncritical events + else -> loadDebounced() + } + when (event.type()) { + PlayQueueEventType.APPEND, PlayQueueEventType.REMOVE, PlayQueueEventType.MOVE, PlayQueueEventType.REORDER -> playbackListener.onPlayQueueEdited() + } + if (!isPlayQueueReady) { + maybeBlock() + playQueue.fetch() + } + playQueueReactor.request(1) + } + + private val isPlayQueueReady: Boolean + /*////////////////////////////////////////////////////////////////////////// + // Playback Locking + ////////////////////////////////////////////////////////////////////////// */private get() { + val isWindowLoaded: Boolean = playQueue.size() - playQueue.getIndex() > WINDOW_SIZE + return playQueue.isComplete() || isWindowLoaded + } + private val isPlaybackReady: Boolean + private get() { + if (playlist.size() != playQueue.size()) { + return false + } + val mediaSource: ManagedMediaSource? = playlist.get(playQueue.getIndex()) + val playQueueItem: PlayQueueItem? = playQueue.getItem() + if (mediaSource == null || playQueueItem == null) { + return false + } + return mediaSource.isStreamEqual(playQueueItem) + } + + private fun maybeBlock() { + if (PlayQueue.Companion.DEBUG) { + Log.d(TAG, "maybeBlock() called.") + } + if (isBlocked.get()) { + return + } + playbackListener.onPlaybackBlock() + resetSources() + isBlocked.set(true) + } + + private fun maybeUnblock(): Boolean { + if (PlayQueue.Companion.DEBUG) { + Log.d(TAG, "maybeUnblock() called.") + } + if (isBlocked.get()) { + isBlocked.set(false) + playbackListener.onPlaybackUnblock(playlist.getParentMediaSource()) + return true + } + return false + } + + /*////////////////////////////////////////////////////////////////////////// + // Metadata Synchronization + ////////////////////////////////////////////////////////////////////////// */ + private fun maybeSync(wasBlocked: Boolean) { + if (PlayQueue.Companion.DEBUG) { + Log.d(TAG, "maybeSync() called.") + } + val currentItem: PlayQueueItem? = playQueue.getItem() + if (isBlocked.get() || currentItem == null) { + return + } + playbackListener.onPlaybackSynchronize(currentItem, wasBlocked) + } + + @Synchronized + private fun maybeSynchronizePlayer() { + if (isPlayQueueReady && isPlaybackReady) { + val isBlockReleased: Boolean = maybeUnblock() + maybeSync(isBlockReleased) + } + } + + private val edgeIntervalSignal: Observable + /*////////////////////////////////////////////////////////////////////////// + // MediaSource Loading + ////////////////////////////////////////////////////////////////////////// */private get() { + return Observable.interval(progressUpdateIntervalMillis, + TimeUnit.MILLISECONDS, AndroidSchedulers.mainThread()) + .filter(Predicate({ ignored: Long? -> playbackListener.isApproachingPlaybackEdge(playbackNearEndGapMillis) })) + } + + private fun getDebouncedLoader(): Disposable { + return debouncedSignal.mergeWith(nearEndIntervalSignal) + .debounce(loadDebounceMillis, TimeUnit.MILLISECONDS) + .subscribeOn(Schedulers.single()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer({ timestamp: Long? -> loadImmediate() })) + } + + private fun loadDebounced() { + debouncedSignal.onNext(System.currentTimeMillis()) + } + + private fun loadImmediate() { + if (PlayQueue.Companion.DEBUG) { + Log.d(TAG, "MediaSource - loadImmediate() called") + } + val 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 (item: PlayQueueItem in itemsToLoad.neighbors) { + maybeLoadItem(item) + } + } + + private fun maybeLoadItem(item: PlayQueueItem) { + if (PlayQueue.Companion.DEBUG) { + Log.d(TAG, "maybeLoadItem() called.") + } + if (playQueue.indexOf(item) >= playlist.size()) { + return + } + if (!loadingItems.contains(item) && isCorrectionNeeded(item)) { + if (PlayQueue.Companion.DEBUG) { + Log.d(TAG, ("MediaSource - Loading=[" + item.getTitle() + "] " + + "with url=[" + item.getUrl() + "]")) + } + loadingItems.add(item) + val loader: Disposable = getLoadedMediaSource(item) + .observeOn(AndroidSchedulers.mainThread()) /* No exception handling since getLoadedMediaSource guarantees nonnull return */ + .subscribe(Consumer({ mediaSource: ManagedMediaSource -> onMediaSourceReceived(item, mediaSource) })) + loaderReactor.add(loader) + } + } + + private fun getLoadedMediaSource(stream: PlayQueueItem): Single { + return stream.getStream() + .map(io.reactivex.rxjava3.functions.Function({ streamInfo: StreamInfo -> + Optional + .ofNullable(playbackListener.sourceOf(stream, streamInfo)) + .flatMap(java.util.function.Function>({ source: MediaSource -> + MediaItemTag.Companion.from(source.getMediaItem()) + .map(java.util.function.Function({ tag: MediaItemTag -> + val serviceId: Int = streamInfo.getServiceId() + val expiration: Long = (System.currentTimeMillis() + + ServiceHelper.getCacheExpirationMillis(serviceId)) + LoadedMediaSource(source, tag, stream, + expiration) + })) + }) + ) + .orElseGet(Supplier({ + val message: String = ("Unable to resolve source from stream info. " + + "URL: " + stream.getUrl() + + ", audio count: " + streamInfo.getAudioStreams().size + + ", video count: " + streamInfo.getVideoOnlyStreams().size + + ", " + streamInfo.getVideoStreams().size) + FailedMediaSource.Companion.of(stream, + MediaSourceResolutionException(message)) + })) + }) + ) + .onErrorReturn(io.reactivex.rxjava3.functions.Function({ throwable: Throwable? -> + if (throwable is ExtractionException) { + return@onErrorReturn FailedMediaSource.Companion.of(stream, StreamInfoLoadException(throwable)) + } + // Non-source related error expected here (e.g. network), + // should allow retry shortly after the error. + val allowRetryIn: Long = TimeUnit.MILLISECONDS.convert(3, + TimeUnit.SECONDS) + FailedMediaSource.Companion.of(stream, Exception(throwable), allowRetryIn) + })) + } + + private fun onMediaSourceReceived(item: PlayQueueItem, + mediaSource: ManagedMediaSource) { + if (PlayQueue.Companion.DEBUG) { + Log.d(TAG, ("MediaSource - Loaded=[" + item.getTitle() + + "] with url=[" + item.getUrl() + "]")) + } + loadingItems.remove(item) + val itemIndex: Int = playQueue.indexOf(item) + // Only update the playlist timeline for items at the current index or after. + if (isCorrectionNeeded(item)) { + if (PlayQueue.Companion.DEBUG) { + Log.d(TAG, ("MediaSource - Updating index=[" + itemIndex + "] with " + + "title=[" + item.getTitle() + "] at url=[" + item.getUrl() + "]")) + } + playlist.update(itemIndex, mediaSource, removeMediaSourceHandler, Runnable({ maybeSynchronizePlayer() })) + } + } + + /** + * Checks if the corresponding MediaSource in + * [com.google.android.exoplayer2.source.ConcatenatingMediaSource] + * for a given [PlayQueueItem] needs replacement, either due to gapless playback + * readiness or playlist desynchronization. + * + * + * If the given [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 + * [ManagedMediaSource]. + * + * + * @param item [PlayQueueItem] to check + * @return whether a correction is needed + */ + private fun isCorrectionNeeded(item: PlayQueueItem): Boolean { + val index: Int = playQueue.indexOf(item) + val mediaSource: ManagedMediaSource? = playlist.get(index) + return mediaSource != null && mediaSource.shouldBeReplacedWith(item, + index != playQueue.getIndex()) + } + + /** + * Checks if the current playing index contains an expired [ManagedMediaSource]. + * If so, the expired source is replaced by a dummy [ManagedMediaSource] and + * [.loadImmediate] is called to reload the current item. + *



+ * If not, then the media source at the current index is ready for playback, and + * [.maybeSynchronizePlayer] is called. + *



+ * Under both cases, [.maybeSync] will be called to ensure the listener + * is up-to-date. + */ + private fun maybeRenewCurrentIndex() { + val currentIndex: Int = playQueue.getIndex() + val currentItem: PlayQueueItem? = playQueue.getItem() + val currentSource: ManagedMediaSource? = playlist.get(currentIndex) + if (currentItem == null || currentSource == null) { + return + } + if (!currentSource.shouldBeReplacedWith(currentItem, true)) { + maybeSynchronizePlayer() + return + } + if (PlayQueue.Companion.DEBUG) { + Log.d(TAG, ("MediaSource - Reloading currently playing, " + + "index=[" + currentIndex + "], item=[" + currentItem.getTitle() + "]")) + } + playlist.invalidate(currentIndex, removeMediaSourceHandler, Runnable({ loadImmediate() })) + } + + private fun maybeClearLoaders() { + if (PlayQueue.Companion.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 fun resetSources() { + if (PlayQueue.Companion.DEBUG) { + Log.d(TAG, "resetSources() called.") + } + playlist = ManagedMediaSourcePlaylist() + } + + private fun populateSources() { + if (PlayQueue.Companion.DEBUG) { + Log.d(TAG, "populateSources() called.") + } + while (playlist.size() < playQueue.size()) { + playlist.expand() + } + } + + private class ItemsToLoad internal constructor(val center: PlayQueueItem, + val neighbors: Collection) + + companion object { + /** + * 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 + */ + private val WINDOW_SIZE: Int = 1 + + /** + * Determines the maximum number of disposables allowed in the [.loaderReactor]. + * Once exceeded, new calls to [.loadImmediate] will evict all disposables in the + * [.loaderReactor] in order to load a new set of items. + * + * @see .loadImmediate + * @see .maybeLoadItem + */ + private val MAXIMUM_LOADER_SIZE: Int = WINDOW_SIZE * 2 + 1 + + /*////////////////////////////////////////////////////////////////////////// + // Manager Helpers + ////////////////////////////////////////////////////////////////////////// */ + private fun getItemsToLoad(playQueue: PlayQueue): ItemsToLoad? { + // The current item has higher priority + val currentIndex: Int = playQueue.getIndex() + val currentItem: PlayQueueItem? = 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. + val leftBound: Int = max(0.0, (currentIndex - WINDOW_SIZE).toDouble()).toInt() + val rightLimit: Int = currentIndex + WINDOW_SIZE + 1 + val rightBound: Int = min(playQueue.size().toDouble(), rightLimit.toDouble()).toInt() + val neighbors: MutableSet = ArraySet( + playQueue.getStreams().subList(leftBound, rightBound)) + + // Do a round robin + val excess: Int = rightLimit - playQueue.size() + if (excess >= 0) { + neighbors.addAll(playQueue.getStreams() + .subList(0, min(playQueue.size().toDouble(), excess.toDouble()).toInt())) + } + neighbors.remove(currentItem) + return ItemsToLoad(currentItem, neighbors) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java b/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.kt similarity index 68% rename from app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java rename to app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.kt index 737607001..21b7b788c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/PlaybackListener.kt @@ -1,90 +1,92 @@ -package org.schabi.newpipe.player.playback; +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 -import com.google.android.exoplayer2.source.MediaSource; - -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; - -public interface PlaybackListener { +open 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); + fun isApproachingPlaybackEdge(timeToEndMillis: Long): Boolean /** * 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(); + fun 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); + fun 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); + fun onPlaybackSynchronize(item: PlayQueueItem, wasBlocked: Boolean) /** * 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} + * @return the corresponding [MediaSource] */ - @Nullable - MediaSource sourceOf(PlayQueueItem item, StreamInfo info); + fun sourceOf(item: PlayQueueItem?, info: StreamInfo): MediaSource? /** * 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(); + fun 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(); + fun onPlayQueueEdited() } diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java b/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java deleted file mode 100644 index da6cb36d4..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java +++ /dev/null @@ -1,62 +0,0 @@ -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; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.kt b/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.kt new file mode 100644 index 000000000..825e3b88a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.kt @@ -0,0 +1,52 @@ +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 + */ +class SurfaceHolderCallback(private val context: Context, private val player: Player?) : SurfaceHolder.Callback { + private var placeholderSurface: PlaceholderSurface? = null + public override fun surfaceCreated(holder: SurfaceHolder) { + player!!.setVideoSurface(holder.getSurface()) + } + + public override fun surfaceChanged(holder: SurfaceHolder, + format: Int, + width: Int, + height: Int) { + } + + public override fun surfaceDestroyed(holder: SurfaceHolder) { + if (placeholderSurface == null) { + placeholderSurface = PlaceholderSurface.newInstanceV17(context, false) + } + player!!.setVideoSurface(placeholderSurface) + } + + fun release() { + if (placeholderSurface != null) { + placeholderSurface!!.release() + placeholderSurface = null + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java deleted file mode 100644 index 33ec390a5..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java +++ /dev/null @@ -1,152 +0,0 @@ -package org.schabi.newpipe.player.playqueue; - -import android.util.Log; - -import androidx.annotation.NonNull; - -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; - Page nextPage; - - private transient Disposable fetchReactor; - - protected AbstractInfoPlayQueue(final T info) { - this(info.getServiceId(), info.getUrl(), info.getNextPage(), - info.getRelatedItems() - .stream() - .filter(StreamInfoItem.class::isInstance) - .map(StreamInfoItem.class::cast) - .collect(Collectors.toList()), - 0); - } - - 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()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.kt new file mode 100644 index 000000000..3db411d8f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.kt @@ -0,0 +1,122 @@ +package org.schabi.newpipe.player.playqueue + +import android.util.Log +import io.reactivex.rxjava3.core.SingleObserver +import io.reactivex.rxjava3.disposables.Disposable +import org.schabi.newpipe.extractor.InfoItem +import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage +import org.schabi.newpipe.extractor.ListInfo +import org.schabi.newpipe.extractor.Page +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import java.util.function.Function +import java.util.stream.Collectors + +abstract class AbstractInfoPlayQueue?> protected constructor(val serviceId: Int, + val baseUrl: String?, + var nextPage: Page?, + streams: List, + index: Int) : PlayQueue(index, extractListItems(streams)) { + var isInitial: Boolean + override var isComplete: Boolean + private set + + @Transient + private var fetchReactor: Disposable? = null + + protected constructor(info: T) : this(info!!.getServiceId(), info.getUrl(), info.getNextPage(), + info.getRelatedItems() + .stream() + .filter({ obj: Any? -> StreamInfoItem::class.java.isInstance(obj) }) + .map({ obj: Any? -> StreamInfoItem::class.java.cast(obj) }) + .collect(Collectors.toList()), + 0) + + init { + isInitial = streams.isEmpty() + isComplete = !isInitial && !Page.isValid(nextPage) + } + + protected abstract val tag: String + val headListObserver: SingleObserver + get() { + return object : SingleObserver { + public override fun onSubscribe(d: Disposable) { + if (isComplete || !isInitial || ((fetchReactor != null + && !fetchReactor!!.isDisposed()))) { + d.dispose() + } else { + fetchReactor = d + } + } + + public override fun onSuccess(result: T) { + isInitial = false + if (!result!!.hasNextPage()) { + isComplete = true + } + nextPage = result.getNextPage() + append(extractListItems(result.getRelatedItems() + .stream() + .filter({ obj: Any? -> StreamInfoItem::class.java.isInstance(obj) }) + .map({ obj: Any? -> StreamInfoItem::class.java.cast(obj) }) + .collect(Collectors.toList()))) + fetchReactor!!.dispose() + fetchReactor = null + } + + public override fun onError(e: Throwable) { + Log.e(tag, "Error fetching more playlist, marking playlist as complete.", e) + isComplete = true + notifyChange() + } + } + } + val nextPageObserver: SingleObserver> + get() { + return object : SingleObserver> { + public override fun onSubscribe(d: Disposable) { + if (isComplete || isInitial || ((fetchReactor != null + && !fetchReactor!!.isDisposed()))) { + d.dispose() + } else { + fetchReactor = d + } + } + + public override fun onSuccess( + result: InfoItemsPage) { + if (!result.hasNextPage()) { + isComplete = true + } + nextPage = result.getNextPage() + append(extractListItems(result.getItems() + .stream() + .filter({ obj: Any? -> StreamInfoItem::class.java.isInstance(obj) }) + .map({ obj: Any? -> StreamInfoItem::class.java.cast(obj) }) + .collect(Collectors.toList()))) + fetchReactor!!.dispose() + fetchReactor = null + } + + public override fun onError(e: Throwable) { + Log.e(tag, "Error fetching more playlist, marking playlist as complete.", e) + isComplete = true + notifyChange() + } + } + } + + public override fun dispose() { + super.dispose() + if (fetchReactor != null) { + fetchReactor!!.dispose() + } + fetchReactor = null + } + + companion object { + private fun extractListItems(infoItems: List): List { + return infoItems.stream().map(Function({ item: StreamInfoItem -> PlayQueueItem(item) })).collect(Collectors.toList()) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java deleted file mode 100644 index a9eb2a19c..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java +++ /dev/null @@ -1,53 +0,0 @@ -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()); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.kt new file mode 100644 index 000000000..d3ba2ea3a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.kt @@ -0,0 +1,34 @@ +package org.schabi.newpipe.player.playqueue + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.schedulers.Schedulers +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 + +class ChannelTabPlayQueue @JvmOverloads constructor(serviceId: Int, + val linkHandler: ListLinkHandler?, + nextPage: Page? = null, + streams: List = emptyList(), + index: Int = 0) : AbstractInfoPlayQueue(serviceId, linkHandler!!.getUrl(), nextPage, streams, index) { + protected override val tag: String + protected get() { + return "ChannelTabPlayQueue@" + Integer.toHexString(hashCode()) + } + + public override fun fetch() { + if (isInitial) { + ExtractorHelper.getChannelTab(serviceId, linkHandler, false) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getHeadListObserver()) + } else { + ExtractorHelper.getMoreChannelTabItems(serviceId, linkHandler, nextPage) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getNextPageObserver()) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java deleted file mode 100644 index cfa2ab316..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java +++ /dev/null @@ -1,561 +0,0 @@ -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.events.AppendEvent; -import org.schabi.newpipe.player.playqueue.events.ErrorEvent; -import org.schabi.newpipe.player.playqueue.events.InitEvent; -import org.schabi.newpipe.player.playqueue.events.MoveEvent; -import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent; -import org.schabi.newpipe.player.playqueue.events.RecoveryEvent; -import org.schabi.newpipe.player.playqueue.events.RemoveEvent; -import org.schabi.newpipe.player.playqueue.events.ReorderEvent; -import org.schabi.newpipe.player.playqueue.events.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.BehaviorSubject; - -/** - * PlayQueue is responsible for keeping track of a list of streams and the index of - * the stream that should be currently playing. - *

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

- *

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

- */ -public abstract class PlayQueue implements Serializable { - 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 BehaviorSubject 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 = BehaviorSubject.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())); - } - - /** - * 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.getServiceId() != otherStream.getServiceId() - || !stream.getUrl().equals(otherStream.getUrl())) { - 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); - } - } -} - diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt new file mode 100644 index 000000000..d137d7fad --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.kt @@ -0,0 +1,548 @@ +package org.schabi.newpipe.player.playqueue + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.BackpressureStrategy +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.subjects.BehaviorSubject +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.player.playqueue.events.AppendEvent +import org.schabi.newpipe.player.playqueue.events.ErrorEvent +import org.schabi.newpipe.player.playqueue.events.InitEvent +import org.schabi.newpipe.player.playqueue.events.MoveEvent +import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent +import org.schabi.newpipe.player.playqueue.events.RecoveryEvent +import org.schabi.newpipe.player.playqueue.events.RemoveEvent +import org.schabi.newpipe.player.playqueue.events.ReorderEvent +import org.schabi.newpipe.player.playqueue.events.SelectEvent +import java.io.Serializable +import java.util.Collections +import java.util.concurrent.atomic.AtomicInteger + +/** + * 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. + * + */ +abstract class PlayQueue internal constructor(index: Int, startWith: List?) : Serializable { + private val queueIndex: AtomicInteger + private val history: MutableList = ArrayList() + private var backup: MutableList? = null + private var streams: MutableList + + @Transient + private var eventBroadcast: BehaviorSubject? = null + + /** + * 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 + */ + @Transient + var broadcastReceiver: Flowable? = null + private set + + @Transient + var isDisposed: Boolean = false + private set + + init { + streams = ArrayList(startWith) + if (streams.size > index) { + history.add(streams.get(index)) + } + queueIndex = AtomicInteger(index) + } + /*////////////////////////////////////////////////////////////////////////// + // Playlist actions + ////////////////////////////////////////////////////////////////////////// */ + /** + * Initializes the play queue message buses. + * + * + * Also starts a self reporter for logging if debug mode is enabled. + * + */ + fun init() { + eventBroadcast = BehaviorSubject.create() + broadcastReceiver = eventBroadcast!!.toFlowable(BackpressureStrategy.BUFFER) + .observeOn(AndroidSchedulers.mainThread()) + .startWithItem(InitEvent()) + } + + /** + * Dispose the play queue by stopping all message buses. + */ + open fun dispose() { + if (eventBroadcast != null) { + eventBroadcast!!.onComplete() + } + eventBroadcast = null + broadcastReceiver = null + isDisposed = 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 + */ + @JvmField + abstract val isComplete: Boolean + + /** + * Load partial queue in the background, does nothing if the queue is complete. + */ + abstract fun fetch() + + /*////////////////////////////////////////////////////////////////////////// + // Readonly ops + ////////////////////////////////////////////////////////////////////////// */ + @set:Synchronized + var index: Int + /** + * @return the current index that should be played + */ + get() { + 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 [SelectEvent] if the index is not the current playing index. + * + * + * @param index the index to be set + */ + set(index) { + val oldIndex: Int = this.index + val newIndex: Int + 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(SelectEvent(oldIndex, newIndex)) + } + val item: PlayQueueItem? + /** + * @return the current item that should be played, or null if the queue is empty + */ + get() { + return getItem(index) + } + + /** + * @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 + */ + fun getItem(index: Int): PlayQueueItem? { + 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 + */ + fun indexOf(item: PlayQueueItem): Int { + return streams.indexOf(item) + } + + /** + * @return the current size of play queue. + */ + fun size(): Int { + return streams.size + } + + val isEmpty: Boolean + /** + * Checks if the play queue is empty. + * + * @return whether the play queue is empty + */ + get() { + return streams.isEmpty() + } + val isShuffled: Boolean + /** + * Determines if the current play queue is shuffled. + * + * @return whether the play queue is shuffled + */ + get() { + return backup != null + } + + /** + * @return an immutable view of the play queue + */ + fun getStreams(): List { + return Collections.unmodifiableList(streams) + } + /*////////////////////////////////////////////////////////////////////////// + // Write ops + ////////////////////////////////////////////////////////////////////////// */ + /** + * Changes the current playing index by an offset amount. + * + * + * Will emit a [SelectEvent] if offset is non-zero. + * + * + * @param offset the offset relative to the current index + */ + @Synchronized + fun offsetIndex(offset: Int) { + index = index + offset + } + + /** + * Notifies that a change has occurred. + */ + @Synchronized + fun notifyChange() { + broadcast(AppendEvent(0)) + } + + /** + * Appends the given [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 [AppendEvent] on any given context. + * + * + * @param items [PlayQueueItem]s to append + */ + @Synchronized + fun append(items: List) { + val itemList: List = ArrayList(items) + if (isShuffled) { + backup!!.addAll(itemList) + Collections.shuffle(itemList) + } + if ((!streams.isEmpty() && streams.get(streams.size - 1)!!.isAutoQueued() + && !itemList.get(0)!!.isAutoQueued())) { + streams.removeAt(streams.size - 1) + } + streams.addAll(itemList) + broadcast(AppendEvent(itemList.size)) + } + + /** + * Removes the item at the given index from the play queue. + * + * + * The current playing index will decrement if it is greater than the index being removed. + * On cases where the current playing index exceeds the playlist range, it is set to 0. + * + * + * + * Will emit a [RemoveEvent] if the index is within the play queue index range. + * + * + * @param index the index of the item to remove + */ + @Synchronized + fun remove(index: Int) { + if (index >= streams.size || index < 0) { + return + } + removeInternal(index) + broadcast(RemoveEvent(index, this.index)) + } + + /** + * 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. + * + */ + @Synchronized + fun error() { + val oldIndex: Int = index + queueIndex.incrementAndGet() + if (streams.size > queueIndex.get()) { + history.add(streams.get(queueIndex.get())) + } + broadcast(ErrorEvent(oldIndex, index)) + } + + @Synchronized + private fun removeInternal(removeIndex: Int) { + val currentIndex: Int = queueIndex.get() + val size: Int = 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.removeAt(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 + */ + @Synchronized + fun move(source: Int, target: Int) { + if (source < 0 || target < 0) { + return + } + if (source >= streams.size || target >= streams.size) { + return + } + val current: Int = index + if (source == current) { + queueIndex.set(target) + } else if (source < current && target >= current) { + queueIndex.decrementAndGet() + } else if (source > current && target <= current) { + queueIndex.incrementAndGet() + } + val playQueueItem: PlayQueueItem? = streams.removeAt(source) + playQueueItem.setAutoQueued(false) + streams.add(target, playQueueItem) + broadcast(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 + */ + @Synchronized + fun setRecovery(index: Int, position: Long) { + if (index < 0 || index >= streams.size) { + return + } + streams.get(index).setRecoveryPosition(position) + broadcast(RecoveryEvent(index, position)) + } + + /** + * Revoke the recovery record of the item at the index. + * + * + * Broadcasts a recovery event. + * + * + * @param index index of the item + */ + @Synchronized + fun unsetRecovery(index: Int) { + setRecovery(index, PlayQueueItem.Companion.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 [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) + */ + @Synchronized + fun 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 = ArrayList(streams) + } + // Can't shuffle a list that's empty or only has one element + if (size() <= 2) { + return + } + val originalIndex: Int = index + val currentItem: PlayQueueItem? = item + 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(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 [ReorderEvent] if a backup exists. + * + */ + @Synchronized + fun unshuffle() { + if (backup == null) { + return + } + val originIndex: Int = index + val current: PlayQueueItem? = item + streams = backup + backup = null + val newIndex: Int = 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(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 + */ + @Synchronized + fun previous(): Boolean { + if (history.size <= 1) { + return false + } + history.removeAt(history.size - 1) + val last: PlayQueueItem = (history.removeAt(history.size - 1))!! + index = 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 + */ + fun equalStreams(other: PlayQueue?): Boolean { + if (other == null) { + return false + } + if (size() != other.size()) { + return false + } + for (i in 0 until size()) { + val stream: PlayQueueItem? = streams.get(i) + val otherStream: PlayQueueItem? = other.streams.get(i) + // Check is based on serviceId and URL + if ((stream.getServiceId() != otherStream.getServiceId() + || !(stream.getUrl() == otherStream.getUrl()))) { + return false + } + } + return true + } + + fun equalStreamsAndIndex(other: PlayQueue?): Boolean { + if (equalStreams(other)) { + return other!!.index == index //NOSONAR: other is not null + } + return false + } + + /*////////////////////////////////////////////////////////////////////////// + // Rx Broadcast + ////////////////////////////////////////////////////////////////////////// */ + private fun broadcast(event: PlayQueueEvent) { + if (eventBroadcast != null) { + eventBroadcast!!.onNext(event) + } + } + + companion object { + val DEBUG: Boolean = MainActivity.Companion.DEBUG + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java deleted file mode 100644 index dd95fb4d5..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java +++ /dev/null @@ -1,228 +0,0 @@ -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.events.AppendEvent; -import org.schabi.newpipe.player.playqueue.events.ErrorEvent; -import org.schabi.newpipe.player.playqueue.events.MoveEvent; -import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent; -import org.schabi.newpipe.player.playqueue.events.RemoveEvent; -import org.schabi.newpipe.player.playqueue.events.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; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.kt new file mode 100644 index 000000000..117c8ee52 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.kt @@ -0,0 +1,206 @@ +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.recyclerview.widget.RecyclerView +import io.reactivex.rxjava3.core.Observer +import io.reactivex.rxjava3.disposables.Disposable +import org.schabi.newpipe.R +import org.schabi.newpipe.player.playqueue.events.AppendEvent +import org.schabi.newpipe.player.playqueue.events.ErrorEvent +import org.schabi.newpipe.player.playqueue.events.MoveEvent +import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent +import org.schabi.newpipe.player.playqueue.events.PlayQueueEventType +import org.schabi.newpipe.player.playqueue.events.RemoveEvent +import org.schabi.newpipe.player.playqueue.events.SelectEvent +import org.schabi.newpipe.util.FallbackViewHolder + +/** + * Created by Christian Schabesberger on 01.08.16. + * + * + * Copyright (C) Christian Schabesberger 2016 @mailbox.org> + * 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 //www.gnu.org/licenses/>. + * + */ +class PlayQueueAdapter(context: Context?, playQueue: PlayQueue) : RecyclerView.Adapter() { + private val playQueueItemBuilder: PlayQueueItemBuilder + private val playQueue: PlayQueue + private var showFooter: Boolean = false + private var footer: View? = null + private var playQueueReactor: Disposable? = null + + init { + if (playQueue.getBroadcastReceiver() == null) { + throw IllegalStateException("Play Queue has not been initialized.") + } + playQueueItemBuilder = PlayQueueItemBuilder(context) + this.playQueue = playQueue + playQueue.getBroadcastReceiver().toObservable().subscribe(reactor) + } + + private val reactor: Observer + private get() { + return object : Observer { + public override fun onSubscribe(d: Disposable) { + if (playQueueReactor != null) { + playQueueReactor!!.dispose() + } + playQueueReactor = d + } + + public override fun onNext(playQueueMessage: PlayQueueEvent) { + if (playQueueReactor != null) { + onPlayQueueChanged(playQueueMessage) + } + } + + public override fun onError(e: Throwable) {} + public override fun onComplete() { + dispose() + } + } + } + + private fun onPlayQueueChanged(message: PlayQueueEvent) { + when (message.type()) { + PlayQueueEventType.RECOVERY -> {} + PlayQueueEventType.SELECT -> { + val selectEvent: SelectEvent = message as SelectEvent + notifyItemChanged(selectEvent.getOldIndex()) + notifyItemChanged(selectEvent.getNewIndex()) + } + + PlayQueueEventType.APPEND -> { + val appendEvent: AppendEvent = message as AppendEvent + notifyItemRangeInserted(playQueue.size(), appendEvent.getAmount()) + } + + PlayQueueEventType.ERROR -> { + val errorEvent: ErrorEvent = message as ErrorEvent + notifyItemChanged(errorEvent.getErrorIndex()) + notifyItemChanged(errorEvent.getQueueIndex()) + } + + PlayQueueEventType.REMOVE -> { + val removeEvent: RemoveEvent = message as RemoveEvent + notifyItemRemoved(removeEvent.getRemoveIndex()) + notifyItemChanged(removeEvent.getQueueIndex()) + } + + PlayQueueEventType.MOVE -> { + val moveEvent: MoveEvent = message as MoveEvent + notifyItemMoved(moveEvent.getFromIndex(), moveEvent.getToIndex()) + } + + PlayQueueEventType.INIT, PlayQueueEventType.REORDER -> notifyDataSetChanged() + else -> notifyDataSetChanged() + } + } + + fun dispose() { + if (playQueueReactor != null) { + playQueueReactor!!.dispose() + } + playQueueReactor = null + } + + fun setSelectedListener(listener: PlayQueueItemBuilder.OnSelectedListener?) { + playQueueItemBuilder.setOnSelectedListener(listener) + } + + fun unsetSelectedListener() { + playQueueItemBuilder.setOnSelectedListener(null) + } + + fun setFooter(footer: View?) { + this.footer = footer + notifyItemChanged(playQueue.size()) + } + + fun showFooter(show: Boolean) { + showFooter = show + notifyItemChanged(playQueue.size()) + } + + val items: List + get() { + return playQueue.getStreams() + } + + public override fun getItemCount(): Int { + var count: Int = playQueue.getStreams().size + if (footer != null && showFooter) { + count++ + } + return count + } + + public override fun getItemViewType(position: Int): Int { + if ((footer != null) && (position == playQueue.getStreams().size) && showFooter) { + return FOOTER_VIEW_TYPE_ID + } + return ITEM_VIEW_TYPE_ID + } + + public override fun onCreateViewHolder(parent: ViewGroup, + type: Int): RecyclerView.ViewHolder { + when (type) { + FOOTER_VIEW_TYPE_ID -> return HFHolder(footer) + ITEM_VIEW_TYPE_ID -> return PlayQueueItemHolder(LayoutInflater.from(parent.getContext()) + .inflate(R.layout.play_queue_item, parent, false)) + + else -> { + Log.e(TAG, "Attempting to create view holder with undefined type: " + type) + return FallbackViewHolder(View(parent.getContext())) + } + } + } + + public override fun onBindViewHolder(holder: RecyclerView.ViewHolder, + position: Int) { + if (holder is PlayQueueItemHolder) { + val itemHolder: PlayQueueItemHolder = holder + + // Build the list item + playQueueItemBuilder + .buildStreamInfoItem(itemHolder, playQueue.getStreams().get(position)) + + // Check if the current item should be selected/highlighted + val isSelected: Boolean = playQueue.getIndex() == position + itemHolder.itemView.setSelected(isSelected) + } else if (holder is HFHolder && (position == playQueue.getStreams().size + ) && (footer != null) && showFooter) { + holder.view = footer + } + } + + class HFHolder(var view: View?) : RecyclerView.ViewHolder((view)!!) + companion object { + private val TAG: String = PlayQueueAdapter::class.java.toString() + private val ITEM_VIEW_TYPE_ID: Int = 0 + private val FOOTER_VIEW_TYPE_ID: Int = 1 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java deleted file mode 100644 index 759c51267..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java +++ /dev/null @@ -1,142 +0,0 @@ -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; - - 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; - } - - @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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt new file mode 100644 index 000000000..86bea15e2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.kt @@ -0,0 +1,60 @@ +package org.schabi.newpipe.player.playqueue + +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.functions.Consumer +import io.reactivex.rxjava3.schedulers.Schedulers +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 + +class PlayQueueItem private constructor(name: String?, url: String?, + val serviceId: Int, val duration: Long, + val thumbnails: List, uploader: String?, + val uploaderUrl: String, val streamType: StreamType) : Serializable { + val title: String + @JvmField + val url: String + val uploader: String + + //////////////////////////////////////////////////////////////////////////// + // Item States, keep external access out + //////////////////////////////////////////////////////////////////////////// + var isAutoQueued: Boolean = false + /*package-private*/ var recoveryPosition: Long + var error: Throwable? = null + private set + + internal constructor(info: StreamInfo) : this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(), + info.getThumbnails(), info.getUploaderName(), + info.getUploaderUrl(), info.getStreamType()) { + if (info.getStartPosition() > 0) { + recoveryPosition = info.getStartPosition() * 1000 + } + } + + internal constructor(item: StreamInfoItem) : this(item.getName(), item.getUrl(), item.getServiceId(), item.getDuration(), + item.getThumbnails(), item.getUploaderName(), + item.getUploaderUrl(), item.getStreamType()) + + init { + title = if (name != null) name else EMPTY_STRING + this.url = if (url != null) url else EMPTY_STRING + this.uploader = if (uploader != null) uploader else EMPTY_STRING + recoveryPosition = RECOVERY_UNSET + } + + val stream: Single + get() { + return ExtractorHelper.getStreamInfo(serviceId, url, false) + .subscribeOn(Schedulers.io()) + .doOnError(Consumer({ throwable: Throwable? -> error = throwable })) + } + + companion object { + val RECOVERY_UNSET: Long = Long.MIN_VALUE + private val EMPTY_STRING: String = "" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.java deleted file mode 100644 index 066f92c26..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.java +++ /dev/null @@ -1,73 +0,0 @@ -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.image.PicassoHelper; -import org.schabi.newpipe.util.ServiceHelper; - -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); - } - - PicassoHelper.loadThumbnail(item.getThumbnails()).into(holder.itemThumbnailView); - - 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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.kt new file mode 100644 index 000000000..572c20423 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.kt @@ -0,0 +1,66 @@ +package org.schabi.newpipe.player.playqueue + +import android.content.Context +import android.text.TextUtils +import android.view.MotionEvent +import android.view.View +import android.view.View.OnLongClickListener +import android.view.View.OnTouchListener +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.ServiceHelper +import org.schabi.newpipe.util.image.PicassoHelper + +class PlayQueueItemBuilder(context: Context?) { + private var onItemClickListener: OnSelectedListener? = null + fun setOnSelectedListener(listener: OnSelectedListener?) { + onItemClickListener = listener + } + + fun buildStreamInfoItem(holder: PlayQueueItemHolder, item: PlayQueueItem?) { + 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) + } + PicassoHelper.loadThumbnail(item.getThumbnails()).into(holder.itemThumbnailView) + holder.itemRoot.setOnClickListener(View.OnClickListener({ view: View? -> + if (onItemClickListener != null) { + onItemClickListener!!.selected(item, view) + } + })) + holder.itemRoot.setOnLongClickListener(OnLongClickListener({ view: View? -> + if (onItemClickListener != null) { + onItemClickListener!!.held((item)!!, view) + return@setOnLongClickListener true + } + false + })) + holder.itemHandle.setOnTouchListener(getOnTouchListener(holder)) + } + + private fun getOnTouchListener(holder: PlayQueueItemHolder): OnTouchListener { + return OnTouchListener({ view: View, motionEvent: MotionEvent -> + view.performClick() + if ((motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN + && onItemClickListener != null)) { + onItemClickListener!!.onStartDrag(holder) + } + false + }) + } + + open interface OnSelectedListener { + fun selected(item: PlayQueueItem?, view: View?) + fun held(item: PlayQueueItem, view: View?) + fun onStartDrag(viewHolder: PlayQueueItemHolder?) + } + + companion object { + private val TAG: String = PlayQueueItemBuilder::class.java.toString() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemHolder.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemHolder.kt similarity index 53% rename from app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemHolder.java rename to app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemHolder.kt index 1f2537baa..e24839c70 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemHolder.kt @@ -1,54 +1,52 @@ -package org.schabi.newpipe.player.playqueue; +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; +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 + * + * + * Copyright (C) Christian Schabesberger 2016 @mailbox.org> * 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 . - *

+ * along with NewPipe. If not, see //www.gnu.org/licenses/>. + * */ +class PlayQueueItemHolder internal constructor(v: View) : RecyclerView.ViewHolder(v) { + val itemVideoTitleView: TextView + val itemDurationView: TextView + val itemAdditionalDetailsView: TextView + val itemThumbnailView: ImageView + val itemHandle: ImageView + val itemRoot: View -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); + init { + 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) } } diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTouchCallback.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTouchCallback.java deleted file mode 100644 index 6e2792d4f..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTouchCallback.java +++ /dev/null @@ -1,61 +0,0 @@ -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()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTouchCallback.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTouchCallback.kt new file mode 100644 index 000000000..674acee71 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTouchCallback.kt @@ -0,0 +1,52 @@ +package org.schabi.newpipe.player.playqueue + +import androidx.core.math.MathUtils +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import kotlin.math.abs +import kotlin.math.sign + +abstract class PlayQueueItemTouchCallback() : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, ItemTouchHelper.RIGHT) { + abstract fun onMove(sourceIndex: Int, targetIndex: Int) + abstract fun onSwiped(index: Int) + public override fun interpolateOutOfBoundsScroll(recyclerView: RecyclerView, + viewSize: Int, + viewSizeOutOfBounds: Int, + totalSize: Int, + msSinceStartScroll: Long): Int { + val standardSpeed: Int = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, + viewSizeOutOfBounds, totalSize, msSinceStartScroll) + val clampedAbsVelocity: Int = MathUtils.clamp(abs(standardSpeed.toDouble()), + MINIMUM_INITIAL_DRAG_VELOCITY, MAXIMUM_INITIAL_DRAG_VELOCITY) + return clampedAbsVelocity * sign(viewSizeOutOfBounds.toDouble()).toInt() + } + + public override fun onMove(recyclerView: RecyclerView, + source: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder): Boolean { + if (source.getItemViewType() != target.getItemViewType()) { + return false + } + val sourceIndex: Int = source.getLayoutPosition() + val targetIndex: Int = target.getLayoutPosition() + onMove(sourceIndex, targetIndex) + return true + } + + public override fun isLongPressDragEnabled(): Boolean { + return false + } + + public override fun isItemViewSwipeEnabled(): Boolean { + return true + } + + public override fun onSwiped(viewHolder: RecyclerView.ViewHolder, swipeDir: Int) { + onSwiped(viewHolder.getBindingAdapterPosition()) + } + + companion object { + private val MINIMUM_INITIAL_DRAG_VELOCITY: Int = 10 + private val MAXIMUM_INITIAL_DRAG_VELOCITY: Int = 25 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java deleted file mode 100644 index 01883d7d9..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.java +++ /dev/null @@ -1,46 +0,0 @@ -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 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()); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.kt new file mode 100644 index 000000000..73c63b03e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlaylistPlayQueue.kt @@ -0,0 +1,36 @@ +package org.schabi.newpipe.player.playqueue + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.schedulers.Schedulers +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 + +class PlaylistPlayQueue : AbstractInfoPlayQueue { + constructor(info: PlaylistInfo) : super(info) + constructor(serviceId: Int, + url: String?, + nextPage: Page?, + streams: List, + index: Int) : super(serviceId, url, nextPage, streams, index) + + protected override val tag: String + protected get() { + return "PlaylistPlayQueue@" + Integer.toHexString(hashCode()) + } + + public override fun fetch() { + if (isInitial) { + ExtractorHelper.getPlaylistInfo(serviceId, baseUrl, false) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getHeadListObserver()) + } else { + ExtractorHelper.getMorePlaylistItems(serviceId, baseUrl, nextPage) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getNextPageObserver()) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java deleted file mode 100644 index 0eb0f235a..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java +++ /dev/null @@ -1,47 +0,0 @@ -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.ArrayList; -import java.util.List; - -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 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) { - final List playQueueItems = new ArrayList<>(items.size()); - for (final StreamInfoItem item : items) { - playQueueItems.add(new PlayQueueItem(item)); - } - return playQueueItems; - } - - @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 - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.kt new file mode 100644 index 000000000..dfc7476d9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.kt @@ -0,0 +1,34 @@ +package org.schabi.newpipe.player.playqueue + +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem + +class SinglePlayQueue : PlayQueue { + constructor(item: StreamInfoItem) : super(0, java.util.List.of(PlayQueueItem(item))) + constructor(info: StreamInfo?) : super(0, java.util.List.of(PlayQueueItem((info)!!))) + constructor(info: StreamInfo, startPosition: Long) : super(0, java.util.List.of(PlayQueueItem(info))) { + getItem().setRecoveryPosition(startPosition) + } + + constructor(items: List, index: Int) : super(index, playQueueItemsOf(items)) + + override val isComplete: Boolean + get() { + return true + } + + public override fun fetch() { + // Item was already passed in constructor. + // No further items need to be fetched as this is a PlayQueue with only one item + } + + companion object { + private fun playQueueItemsOf(items: List): List { + val playQueueItems: MutableList = ArrayList(items.size) + for (item: StreamInfoItem in items) { + playQueueItems.add(PlayQueueItem(item)) + } + return playQueueItems + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/AppendEvent.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/AppendEvent.java deleted file mode 100644 index cc922dbb1..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/AppendEvent.java +++ /dev/null @@ -1,18 +0,0 @@ -package org.schabi.newpipe.player.playqueue.events; - -public class AppendEvent implements PlayQueueEvent { - private final int amount; - - public AppendEvent(final int amount) { - this.amount = amount; - } - - @Override - public PlayQueueEventType type() { - return PlayQueueEventType.APPEND; - } - - public int getAmount() { - return amount; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/AppendEvent.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/AppendEvent.kt new file mode 100644 index 000000000..cc52c6345 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/AppendEvent.kt @@ -0,0 +1,8 @@ +package org.schabi.newpipe.player.playqueue.events + +class AppendEvent(val amount: Int) : PlayQueueEvent { + + public override fun type(): PlayQueueEventType? { + return PlayQueueEventType.APPEND + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/ErrorEvent.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/ErrorEvent.java deleted file mode 100644 index 7b7e39212..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/ErrorEvent.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.schabi.newpipe.player.playqueue.events; - -public class ErrorEvent implements PlayQueueEvent { - private final int errorIndex; - private final int queueIndex; - - public ErrorEvent(final int errorIndex, final int queueIndex) { - this.errorIndex = errorIndex; - this.queueIndex = queueIndex; - } - - @Override - public PlayQueueEventType type() { - return PlayQueueEventType.ERROR; - } - - public int getErrorIndex() { - return errorIndex; - } - - public int getQueueIndex() { - return queueIndex; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/ErrorEvent.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/ErrorEvent.kt new file mode 100644 index 000000000..b3c936d08 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/ErrorEvent.kt @@ -0,0 +1,8 @@ +package org.schabi.newpipe.player.playqueue.events + +class ErrorEvent(val errorIndex: Int, val queueIndex: Int) : PlayQueueEvent { + + public override fun type(): PlayQueueEventType? { + return PlayQueueEventType.ERROR + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/InitEvent.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/InitEvent.java deleted file mode 100644 index 559975b35..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/InitEvent.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.schabi.newpipe.player.playqueue.events; - -public class InitEvent implements PlayQueueEvent { - @Override - public PlayQueueEventType type() { - return PlayQueueEventType.INIT; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/InitEvent.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/InitEvent.kt new file mode 100644 index 000000000..ed7378a63 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/InitEvent.kt @@ -0,0 +1,7 @@ +package org.schabi.newpipe.player.playqueue.events + +class InitEvent() : PlayQueueEvent { + public override fun type(): PlayQueueEventType? { + return PlayQueueEventType.INIT + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/MoveEvent.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/MoveEvent.java deleted file mode 100644 index 55d198923..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/MoveEvent.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.schabi.newpipe.player.playqueue.events; - -public class MoveEvent implements PlayQueueEvent { - private final int fromIndex; - private final int toIndex; - - public MoveEvent(final int oldIndex, final int newIndex) { - this.fromIndex = oldIndex; - this.toIndex = newIndex; - } - - @Override - public PlayQueueEventType type() { - return PlayQueueEventType.MOVE; - } - - public int getFromIndex() { - return fromIndex; - } - - public int getToIndex() { - return toIndex; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/MoveEvent.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/MoveEvent.kt new file mode 100644 index 000000000..d9132f51b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/MoveEvent.kt @@ -0,0 +1,8 @@ +package org.schabi.newpipe.player.playqueue.events + +class MoveEvent(val fromIndex: Int, val toIndex: Int) : PlayQueueEvent { + + public override fun type(): PlayQueueEventType? { + return PlayQueueEventType.MOVE + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/PlayQueueEvent.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/PlayQueueEvent.java deleted file mode 100644 index 431053e7b..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/PlayQueueEvent.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.schabi.newpipe.player.playqueue.events; - -import java.io.Serializable; - -public interface PlayQueueEvent extends Serializable { - PlayQueueEventType type(); -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/PlayQueueEvent.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/PlayQueueEvent.kt new file mode 100644 index 000000000..18f6d19e5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/PlayQueueEvent.kt @@ -0,0 +1,7 @@ +package org.schabi.newpipe.player.playqueue.events + +import java.io.Serializable + +open interface PlayQueueEvent : Serializable { + fun type(): PlayQueueEventType? +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/PlayQueueEventType.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/PlayQueueEventType.kt similarity index 84% rename from app/src/main/java/org/schabi/newpipe/player/playqueue/events/PlayQueueEventType.java rename to app/src/main/java/org/schabi/newpipe/player/playqueue/events/PlayQueueEventType.kt index 1cc710c7b..300935cb9 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/PlayQueueEventType.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/PlayQueueEventType.kt @@ -1,6 +1,6 @@ -package org.schabi.newpipe.player.playqueue.events; +package org.schabi.newpipe.player.playqueue.events -public enum PlayQueueEventType { +enum class PlayQueueEventType { INIT, // sent when the index is changed @@ -24,4 +24,3 @@ public enum PlayQueueEventType { // sent when the item at index has caused an exception ERROR } - diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/RecoveryEvent.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/RecoveryEvent.java deleted file mode 100644 index 6f21b36cd..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/RecoveryEvent.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.schabi.newpipe.player.playqueue.events; - -public class RecoveryEvent implements PlayQueueEvent { - private final int index; - private final long position; - - public RecoveryEvent(final int index, final long position) { - this.index = index; - this.position = position; - } - - @Override - public PlayQueueEventType type() { - return PlayQueueEventType.RECOVERY; - } - - public int getIndex() { - return index; - } - - public long getPosition() { - return position; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/RecoveryEvent.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/RecoveryEvent.kt new file mode 100644 index 000000000..edeae04a0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/RecoveryEvent.kt @@ -0,0 +1,8 @@ +package org.schabi.newpipe.player.playqueue.events + +class RecoveryEvent(val index: Int, val position: Long) : PlayQueueEvent { + + public override fun type(): PlayQueueEventType? { + return PlayQueueEventType.RECOVERY + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/RemoveEvent.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/RemoveEvent.java deleted file mode 100644 index a5872906d..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/RemoveEvent.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.schabi.newpipe.player.playqueue.events; - -public class RemoveEvent implements PlayQueueEvent { - private final int removeIndex; - private final int queueIndex; - - public RemoveEvent(final int removeIndex, final int queueIndex) { - this.removeIndex = removeIndex; - this.queueIndex = queueIndex; - } - - @Override - public PlayQueueEventType type() { - return PlayQueueEventType.REMOVE; - } - - public int getQueueIndex() { - return queueIndex; - } - - public int getRemoveIndex() { - return removeIndex; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/RemoveEvent.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/RemoveEvent.kt new file mode 100644 index 000000000..1fbe26ddd --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/RemoveEvent.kt @@ -0,0 +1,8 @@ +package org.schabi.newpipe.player.playqueue.events + +class RemoveEvent(val removeIndex: Int, val queueIndex: Int) : PlayQueueEvent { + + public override fun type(): PlayQueueEventType? { + return PlayQueueEventType.REMOVE + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/ReorderEvent.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/ReorderEvent.java deleted file mode 100644 index 4f4f14756..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/ReorderEvent.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.schabi.newpipe.player.playqueue.events; - -public class ReorderEvent implements PlayQueueEvent { - private final int fromSelectedIndex; - private final int toSelectedIndex; - - public ReorderEvent(final int fromSelectedIndex, final int toSelectedIndex) { - this.fromSelectedIndex = fromSelectedIndex; - this.toSelectedIndex = toSelectedIndex; - } - - @Override - public PlayQueueEventType type() { - return PlayQueueEventType.REORDER; - } - - public int getFromSelectedIndex() { - return fromSelectedIndex; - } - - public int getToSelectedIndex() { - return toSelectedIndex; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/ReorderEvent.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/ReorderEvent.kt new file mode 100644 index 000000000..962225cb4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/ReorderEvent.kt @@ -0,0 +1,8 @@ +package org.schabi.newpipe.player.playqueue.events + +class ReorderEvent(val fromSelectedIndex: Int, val toSelectedIndex: Int) : PlayQueueEvent { + + public override fun type(): PlayQueueEventType? { + return PlayQueueEventType.REORDER + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/SelectEvent.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/SelectEvent.java deleted file mode 100644 index 95e344211..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/SelectEvent.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.schabi.newpipe.player.playqueue.events; - -public class SelectEvent implements PlayQueueEvent { - private final int oldIndex; - private final int newIndex; - - public SelectEvent(final int oldIndex, final int newIndex) { - this.oldIndex = oldIndex; - this.newIndex = newIndex; - } - - @Override - public PlayQueueEventType type() { - return PlayQueueEventType.SELECT; - } - - public int getOldIndex() { - return oldIndex; - } - - public int getNewIndex() { - return newIndex; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/events/SelectEvent.kt b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/SelectEvent.kt new file mode 100644 index 000000000..de3b7d7c1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/events/SelectEvent.kt @@ -0,0 +1,8 @@ +package org.schabi.newpipe.player.playqueue.events + +class SelectEvent(val oldIndex: Int, val newIndex: Int) : PlayQueueEvent { + + public override fun type(): PlayQueueEventType? { + return PlayQueueEventType.SELECT + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java deleted file mode 100644 index 2d4404b2a..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java +++ /dev/null @@ -1,103 +0,0 @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.kt b/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.kt new file mode 100644 index 000000000..ec6dde8b5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.kt @@ -0,0 +1,68 @@ +package org.schabi.newpipe.player.resolver + +import android.content.Context +import android.util.Log +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.player.resolver.PlaybackResolver.ResolverException +import org.schabi.newpipe.util.ListHelper + +class AudioPlaybackResolver(private val context: Context, + private val dataSource: PlayerDataSource) : PlaybackResolver { + var audioTrack: String? = null + + /** + * Get a media source providing audio. If a service has no separate [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 + */ + public override fun resolve(info: StreamInfo): MediaSource? { + val liveSource: MediaSource? = PlaybackResolver.Companion.maybeBuildLiveMediaSource(dataSource, info) + if (liveSource != null) { + return liveSource + } + val audioStreams: List? = ListHelper.getFilteredAudioStreams(context, info.getAudioStreams()) + val stream: Stream? + val tag: MediaItemTag + if (!audioStreams!!.isEmpty()) { + val audioIndex: Int = ListHelper.getAudioFormatIndex(context, audioStreams, audioTrack) + stream = getStreamForIndex(audioIndex, (audioStreams)) + tag = StreamInfoTag.Companion.of(info, (audioStreams), audioIndex) + } else { + val videoStreams: List = ListHelper.getPlayableStreams(info.getVideoStreams(), info.getServiceId()) + if (!videoStreams.isEmpty()) { + val index: Int = ListHelper.getDefaultResolutionIndex(context, videoStreams) + stream = getStreamForIndex(index, videoStreams) + tag = StreamInfoTag.Companion.of(info) + } else { + return null + } + } + try { + return PlaybackResolver.Companion.buildMediaSource( + dataSource, stream, info, PlaybackResolver.Companion.cacheKeyOf(info, stream), tag) + } catch (e: ResolverException) { + Log.e(TAG, "Unable to create audio source", e) + return null + } + } + + fun getStreamForIndex(index: Int, streams: List): Stream? { + if (index >= 0 && index < streams.size) { + return streams.get(index) + } + return null + } + + companion object { + private val TAG: String = AudioPlaybackResolver::class.java.getSimpleName() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java deleted file mode 100644 index e204b8372..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java +++ /dev/null @@ -1,559 +0,0 @@ -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); - if (!info.getHlsUrl().isEmpty()) { - return buildLiveMediaSource(dataSource, info.getHlsUrl(), C.CONTENT_TYPE_HLS, tag); - } else if (!info.getDashMpdUrl().isEmpty()) { - return buildLiveMediaSource( - dataSource, info.getDashMpdUrl(), C.CONTENT_TYPE_DASH, 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: - 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 -} diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.kt b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.kt new file mode 100644 index 000000000..77363142b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.kt @@ -0,0 +1,549 @@ +package org.schabi.newpipe.player.resolver + +import android.net.Uri +import android.util.Log +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.MediaItem.LiveConfiguration +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.player.resolver.PlaybackResolver.ResolverException +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 [Resolver] with [StreamInfo] as source and + * [MediaSource] as product. It contains many static methods that can be used by classes + * implementing this interface, and nothing else. + */ +open interface PlaybackResolver : Resolver { + //endregion + //region Resolver exception + class ResolverException : Exception { + constructor(message: String?) : super(message) + constructor(message: String?, cause: Throwable?) : super(message, cause) + } //endregion + + companion object { + //region Cache key generation + private fun commonCacheKeyOf(info: StreamInfo, + stream: Stream, + resolutionOrBitrateUnknown: Boolean): StringBuilder { + // stream info service id + val cacheKey: StringBuilder = 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) + val 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 [video stream][VideoStream]. + * + * + * + * 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 [stream info][StreamInfo], to distinguish between streams with + * the same features but coming from different stream infos + * @param videoStream the [video stream][VideoStream] for which the cache key should be + * created + * @return a key to be used to store the cache of the provided [video stream][VideoStream] + */ + fun cacheKeyOf(info: StreamInfo, videoStream: VideoStream): String? { + val resolutionUnknown: Boolean = (videoStream.getResolution() == VideoStream.RESOLUTION_UNKNOWN) + val cacheKey: StringBuilder = 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 [audio stream][AudioStream], 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 [stream info][StreamInfo], to distinguish between streams with + * the same features but coming from different stream infos + * @param audioStream the [audio stream][AudioStream] for which the cache key should be + * created + * @return a key to be used to store the cache of the provided [audio stream][AudioStream] + */ + fun cacheKeyOf(info: StreamInfo, audioStream: AudioStream): String? { + val averageBitrateUnknown: Boolean = audioStream.getAverageBitrate() == AudioStream.UNKNOWN_BITRATE + val cacheKey: StringBuilder = 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 [Stream] to handle [AudioStream] or [VideoStream] + * transparently. For more info see [.cacheKeyOf] or + * [.cacheKeyOf]. + * + * @param info the [stream info][StreamInfo], to distinguish between streams with + * the same features but coming from different stream infos + * @param stream the [Stream] ([AudioStream] or [VideoStream]) + * for which the cache key should be created + * @return a key to be used to store the cache of the provided [Stream] + */ + fun cacheKeyOf(info: StreamInfo, stream: Stream?): String? { + if (stream is AudioStream) { + return cacheKeyOf(info, stream) + } else if (stream is VideoStream) { + return cacheKeyOf(info, stream) + } + throw RuntimeException("no audio or video stream. That should never happen") + } + + //endregion + //region Live media sources + fun maybeBuildLiveMediaSource(dataSource: PlayerDataSource, + info: StreamInfo): MediaSource? { + if (!StreamTypeUtil.isLiveStream(info.getStreamType())) { + return null + } + try { + val tag: StreamInfoTag = StreamInfoTag.Companion.of(info) + if (!info.getHlsUrl().isEmpty()) { + return buildLiveMediaSource(dataSource, info.getHlsUrl(), C.CONTENT_TYPE_HLS, tag) + } else if (!info.getDashMpdUrl().isEmpty()) { + return buildLiveMediaSource( + dataSource, info.getDashMpdUrl(), C.CONTENT_TYPE_DASH, tag) + } + } catch (e: Exception) { + Log.w(TAG, "Error when generating live media source, falling back to standard sources", + e) + } + return null + } + + @Throws(ResolverException::class) + fun buildLiveMediaSource(dataSource: PlayerDataSource, + sourceUrl: String?, + type: @C.ContentType Int, + metadata: MediaItemTag?): MediaSource? { + val factory: MediaSource.Factory? + when (type) { + C.CONTENT_TYPE_SS -> factory = dataSource.getLiveSsMediaSourceFactory() + C.CONTENT_TYPE_DASH -> factory = dataSource.getLiveDashMediaSourceFactory() + C.CONTENT_TYPE_HLS -> factory = dataSource.getLiveHlsMediaSourceFactory() + C.CONTENT_TYPE_OTHER, C.CONTENT_TYPE_RTSP -> throw ResolverException("Unsupported type: " + type) + else -> throw ResolverException("Unsupported type: " + type) + } + return factory!!.createMediaSource( + MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(sourceUrl)) + .setLiveConfiguration( + LiveConfiguration.Builder() + .setTargetOffsetMs(PlayerDataSource.Companion.LIVE_STREAM_EDGE_GAP_MILLIS.toLong()) + .build()) + .build()) + } + + //endregion + //region Generic media sources + @Throws(ResolverException::class) + fun buildMediaSource(dataSource: PlayerDataSource, + stream: Stream?, + streamInfo: StreamInfo, + cacheKey: String?, + metadata: MediaItemTag): MediaSource? { + if (streamInfo.getService() === ServiceList.YouTube) { + return createYoutubeMediaSource(stream, streamInfo, dataSource, cacheKey, metadata) + } + val deliveryMethod: DeliveryMethod = stream!!.getDeliveryMethod() + when (deliveryMethod) { + DeliveryMethod.PROGRESSIVE_HTTP -> return buildProgressiveMediaSource(dataSource, stream, cacheKey, metadata) + DeliveryMethod.DASH -> return buildDashMediaSource(dataSource, stream, cacheKey, metadata) + DeliveryMethod.HLS -> return buildHlsMediaSource(dataSource, stream, cacheKey, metadata) + DeliveryMethod.SS -> return buildSSMediaSource(dataSource, stream, cacheKey, metadata) + else -> throw ResolverException("Unsupported delivery type: " + deliveryMethod) + } + } + + @Throws(ResolverException::class) + private fun buildProgressiveMediaSource( + dataSource: PlayerDataSource, + stream: Stream?, + cacheKey: String?, + metadata: MediaItemTag): ProgressiveMediaSource { + if (!stream!!.isUrl()) { + throw ResolverException("Non URI progressive contents are not supported") + } + throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()) + return dataSource.getProgressiveMediaSourceFactory().createMediaSource( + MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()) + } + + @Throws(ResolverException::class) + private fun buildDashMediaSource(dataSource: PlayerDataSource, + stream: Stream?, + cacheKey: String?, + metadata: MediaItemTag): DashMediaSource { + if (stream!!.isUrl()) { + throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()) + return dataSource.getDashMediaSourceFactory().createMediaSource( + MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()) + } + try { + return dataSource.getDashMediaSourceFactory().createMediaSource( + createDashManifest(stream.getContent(), stream), + MediaItem.Builder() + .setTag(metadata) + .setUri(manifestUrlToUri(stream.getManifestUrl())) + .setCustomCacheKey(cacheKey) + .build()) + } catch (e: IOException) { + throw ResolverException( + "Could not create a DASH media source/manifest from the manifest text", e) + } + } + + @Throws(IOException::class) + private fun createDashManifest(manifestContent: String, + stream: Stream?): DashManifest { + return DashManifestParser().parse(manifestUrlToUri(stream!!.getManifestUrl()), + ByteArrayInputStream(manifestContent.toByteArray(StandardCharsets.UTF_8))) + } + + @Throws(ResolverException::class) + private fun buildHlsMediaSource(dataSource: PlayerDataSource, + stream: Stream?, + cacheKey: String?, + metadata: MediaItemTag): HlsMediaSource { + if (stream!!.isUrl()) { + throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()) + return dataSource.getHlsMediaSourceFactory(null).createMediaSource( + MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()) + } + val hlsDataSourceFactoryBuilder: NonUriHlsDataSourceFactory.Builder = NonUriHlsDataSourceFactory.Builder() + hlsDataSourceFactoryBuilder.setPlaylistString(stream.getContent()) + return dataSource.getHlsMediaSourceFactory(hlsDataSourceFactoryBuilder) + .createMediaSource(MediaItem.Builder() + .setTag(metadata) + .setUri(manifestUrlToUri(stream.getManifestUrl())) + .setCustomCacheKey(cacheKey) + .build()) + } + + @Throws(ResolverException::class) + private fun buildSSMediaSource(dataSource: PlayerDataSource, + stream: Stream?, + cacheKey: String?, + metadata: MediaItemTag): SsMediaSource { + if (stream!!.isUrl()) { + throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()) + return dataSource.getSSMediaSourceFactory().createMediaSource( + MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()) + } + val manifestUri: Uri = manifestUrlToUri(stream.getManifestUrl()) + val smoothStreamingManifest: SsManifest + try { + val smoothStreamingManifestInput: ByteArrayInputStream = ByteArrayInputStream( + stream.getContent().toByteArray(StandardCharsets.UTF_8)) + smoothStreamingManifest = SsManifestParser().parse(manifestUri, + smoothStreamingManifestInput) + } catch (e: IOException) { + throw ResolverException("Error when parsing manual SS manifest", e) + } + return dataSource.getSSMediaSourceFactory().createMediaSource( + smoothStreamingManifest, + MediaItem.Builder() + .setTag(metadata) + .setUri(manifestUri) + .setCustomCacheKey(cacheKey) + .build()) + } + + //endregion + //region YouTube media sources + @Throws(ResolverException::class) + private fun createYoutubeMediaSource(stream: Stream?, + streamInfo: StreamInfo, + dataSource: PlayerDataSource, + cacheKey: String?, + metadata: MediaItemTag): MediaSource { + if (!(stream is AudioStream || stream is VideoStream)) { + throw ResolverException(("Generation of YouTube DASH manifest for " + + stream!!.javaClass.getSimpleName() + " is not supported")) + } + val 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 { + val itagItem: ItagItem = Objects.requireNonNull(stream.getItagItem()) + val manifestString: String = YoutubePostLiveStreamDvrDashManifestCreator + .fromPostLiveStreamDvrStreamingUrl(stream.getContent(), + itagItem, + itagItem.getTargetDurationSec(), + streamInfo.getDuration()) + return buildYoutubeManualDashMediaSource(dataSource, + createDashManifest(manifestString, stream), stream, cacheKey, + metadata) + } catch (e: CreationException) { + throw ResolverException( + "Error when generating the DASH manifest of YouTube ended live stream", e) + } catch (e: IOException) { + throw ResolverException( + "Error when generating the DASH manifest of YouTube ended live stream", e) + } catch (e: NullPointerException) { + throw ResolverException( + "Error when generating the DASH manifest of YouTube ended live stream", e) + } + } else { + throw ResolverException( + "DASH manifest generation of YouTube livestreams is not supported") + } + } + + @Throws(ResolverException::class) + private fun createYoutubeMediaSourceOfVideoStreamType( + dataSource: PlayerDataSource, + stream: Stream, + streamInfo: StreamInfo, + cacheKey: String?, + metadata: MediaItemTag): MediaSource { + val deliveryMethod: DeliveryMethod = stream.getDeliveryMethod() + when (deliveryMethod) { + DeliveryMethod.PROGRESSIVE_HTTP -> if (((stream is VideoStream && stream.isVideoOnly()) + || stream is AudioStream)) { + try { + val manifestString: String = YoutubeProgressiveDashManifestCreator + .fromProgressiveStreamingUrl(stream.getContent(), + Objects.requireNonNull(stream.getItagItem()), + streamInfo.getDuration()) + return buildYoutubeManualDashMediaSource(dataSource, + createDashManifest(manifestString, stream), stream, cacheKey, + metadata) + } catch (e: CreationException) { + 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) + } catch (e: IOException) { + 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) + } catch (e: NullPointerException) { + 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) + } + + DeliveryMethod.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 { + val manifestString: String = YoutubeOtfDashManifestCreator + .fromOtfStreamingUrl(stream.getContent(), + Objects.requireNonNull(stream.getItagItem()), + streamInfo.getDuration()) + return buildYoutubeManualDashMediaSource(dataSource, + createDashManifest(manifestString, stream), stream, cacheKey, + metadata) + } catch (e: CreationException) { + Log.e(TAG, + "Error when generating the DASH manifest of YouTube OTF stream", e) + throw ResolverException( + "Error when generating the DASH manifest of YouTube OTF stream", e) + } catch (e: IOException) { + Log.e(TAG, + "Error when generating the DASH manifest of YouTube OTF stream", e) + throw ResolverException( + "Error when generating the DASH manifest of YouTube OTF stream", e) + } catch (e: NullPointerException) { + Log.e(TAG, + "Error when generating the DASH manifest of YouTube OTF stream", e) + throw ResolverException( + "Error when generating the DASH manifest of YouTube OTF stream", e) + } + return dataSource.getYoutubeHlsMediaSourceFactory().createMediaSource( + MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()) + } + + DeliveryMethod.HLS -> return dataSource.getYoutubeHlsMediaSourceFactory().createMediaSource( + MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()) + + else -> throw ResolverException(("Unsupported delivery method for YouTube contents: " + + deliveryMethod)) + } + } + + private fun buildYoutubeManualDashMediaSource( + dataSource: PlayerDataSource, + dashManifest: DashManifest, + stream: Stream, + cacheKey: String?, + metadata: MediaItemTag): DashMediaSource { + return dataSource.getYoutubeDashMediaSourceFactory().createMediaSource(dashManifest, + MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()) + } + + private fun buildYoutubeProgressiveMediaSource( + dataSource: PlayerDataSource, + stream: Stream, + cacheKey: String?, + metadata: MediaItemTag): ProgressiveMediaSource { + return dataSource.getYoutubeProgressiveMediaSourceFactory() + .createMediaSource(MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()) + } + + //endregion + //region Utils + private fun manifestUrlToUri(manifestUrl: String?): Uri { + return Uri.parse(Objects.requireNonNullElse(manifestUrl, "")) + } + + @Throws(ResolverException::class) + private fun throwResolverExceptionIfUrlNullOrEmpty(url: String?) { + if (url == null) { + throw ResolverException("Null stream URL") + } else if (url.isEmpty()) { + throw ResolverException("Empty stream URL") + } + } + + val TAG: String = PlaybackResolver::class.java.getSimpleName() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/Resolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/Resolver.java deleted file mode 100644 index a3e1db5b4..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/Resolver.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.schabi.newpipe.player.resolver; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -public interface Resolver { - @Nullable - Product resolve(@NonNull Source source); -} diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/Resolver.kt b/app/src/main/java/org/schabi/newpipe/player/resolver/Resolver.kt new file mode 100644 index 000000000..6f9be0f23 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/Resolver.kt @@ -0,0 +1,5 @@ +package org.schabi.newpipe.player.resolver + +open interface Resolver { + fun resolve(source: Source): Product? +} diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java deleted file mode 100644 index 670c13934..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java +++ /dev/null @@ -1,204 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.kt b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.kt new file mode 100644 index 000000000..9f5dccaba --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.kt @@ -0,0 +1,149 @@ +package org.schabi.newpipe.player.resolver + +import android.content.Context +import android.net.Uri +import android.util.Log +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.C.RoleFlags +import com.google.android.exoplayer2.MediaItem.SubtitleConfiguration +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.player.resolver.PlaybackResolver.ResolverException +import org.schabi.newpipe.util.ListHelper +import java.util.Optional +import java.util.function.Function + +class VideoPlaybackResolver(private val context: Context, + private val dataSource: PlayerDataSource, + private val qualityResolver: QualityResolver) : PlaybackResolver { + private var streamSourceType: SourceType? = null + var playbackQuality: String? = null + var audioTrack: String? = null + + enum class SourceType { + LIVE_STREAM, + VIDEO_WITH_SEPARATED_AUDIO, + VIDEO_WITH_AUDIO_OR_AUDIO_ONLY + } + + public override fun resolve(info: StreamInfo): MediaSource? { + val liveSource: MediaSource? = PlaybackResolver.Companion.maybeBuildLiveMediaSource(dataSource, info) + if (liveSource != null) { + streamSourceType = SourceType.LIVE_STREAM + return liveSource + } + val mediaSources: MutableList = ArrayList() + + // Create video stream source + val videoStreamsList: List = ListHelper.getSortedStreamVideosList(context, + ListHelper.getPlayableStreams(info.getVideoStreams(), info.getServiceId()), + ListHelper.getPlayableStreams(info.getVideoOnlyStreams(), info.getServiceId()), false, true) + val audioStreamsList: List? = ListHelper.getFilteredAudioStreams(context, info.getAudioStreams()) + val videoIndex: Int + if (videoStreamsList.isEmpty()) { + videoIndex = -1 + } else if (playbackQuality == null) { + videoIndex = qualityResolver.getDefaultResolutionIndex(videoStreamsList) + } else { + videoIndex = qualityResolver.getOverrideResolutionIndex(videoStreamsList, + playbackQuality) + } + val audioIndex: Int = ListHelper.getAudioFormatIndex(context, audioStreamsList, audioTrack) + val tag: MediaItemTag = StreamInfoTag.Companion.of(info, videoStreamsList, videoIndex, (audioStreamsList)!!, audioIndex) + val video: VideoStream? = tag.getMaybeQuality() + .map(Function({ getSelectedVideoStream() })) + .orElse(null) + val audio: AudioStream? = tag.getMaybeAudioTrack() + .map(Function({ getSelectedAudioStream() })) + .orElse(null) + if (video != null) { + try { + val streamSource: MediaSource? = PlaybackResolver.Companion.buildMediaSource( + dataSource, video, info, PlaybackResolver.Companion.cacheKeyOf(info, video), tag) + mediaSources.add(streamSource) + } catch (e: ResolverException) { + 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 { + val audioSource: MediaSource? = PlaybackResolver.Companion.buildMediaSource( + dataSource, audio, info, PlaybackResolver.Companion.cacheKeyOf(info, audio), tag) + mediaSources.add(audioSource) + streamSourceType = SourceType.VIDEO_WITH_SEPARATED_AUDIO + } catch (e: ResolverException) { + 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 + val subtitlesStreams: List? = info.getSubtitles() + if (subtitlesStreams != null) { + // Torrent and non URL subtitles are not supported by ExoPlayer + val nonTorrentAndUrlStreams: List = ListHelper.getUrlAndNonTorrentStreams( + subtitlesStreams) + for (subtitle: SubtitlesStream? in nonTorrentAndUrlStreams) { + val mediaFormat: MediaFormat? = subtitle!!.getFormat() + if (mediaFormat != null) { + val textRoleFlag: @RoleFlags Int = if (subtitle.isAutoGenerated()) C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND else C.ROLE_FLAG_CAPTION + val textMediaItem: SubtitleConfiguration = SubtitleConfiguration.Builder( + Uri.parse(subtitle.getContent())) + .setMimeType(mediaFormat.getMimeType()) + .setRoleFlags(textRoleFlag) + .setLanguage(PlayerHelper.captionLanguageOf(context, (subtitle))) + .build() + val textSource: MediaSource = dataSource.getSingleSampleMediaSourceFactory() + .createMediaSource(textMediaItem, C.TIME_UNSET) + mediaSources.add(textSource) + } + } + } + if (mediaSources.size == 1) { + return mediaSources.get(0) + } else { + return MergingMediaSource(true, *mediaSources.toTypedArray()) + } + } + + /** + * Returns the last resolved [StreamInfo]'s [source type][SourceType]. + * + * @return [Optional.empty] if nothing was resolved, otherwise the [SourceType] + * of the last resolved [StreamInfo] inside an [Optional] + */ + fun getStreamSourceType(): Optional { + return Optional.ofNullable(streamSourceType) + } + + open interface QualityResolver { + fun getDefaultResolutionIndex(sortedVideos: List): Int + fun getOverrideResolutionIndex(sortedVideos: List, playbackQuality: String?): Int + } + + companion object { + private val TAG: String = VideoPlaybackResolver::class.java.getSimpleName() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHelper.java b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHelper.java deleted file mode 100644 index 28856d606..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHelper.java +++ /dev/null @@ -1,102 +0,0 @@ -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(); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHelper.kt b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHelper.kt new file mode 100644 index 000000000..9d9c202c9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHelper.kt @@ -0,0 +1,80 @@ +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.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.util.function.IntSupplier + +/** + * Helper for the seekbar preview. + */ +object 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 + val TAG: String = "SeekbarPrevThumbHelper" + + //////////////////////////////////////////////////////////////////////////// + // Settings Resolution + /////////////////////////////////////////////////////////////////////////// + @SeekbarPreviewThumbnailType + fun getSeekbarPreviewThumbnailType(context: Context): Int { + val type: String? = PreferenceManager.getDefaultSharedPreferences(context).getString( + context.getString(R.string.seekbar_preview_thumbnail_key), "") + if ((type == context.getString(R.string.seekbar_preview_thumbnail_none))) { + return SeekbarPreviewThumbnailType.NONE + } else if ((type == context.getString(R.string.seekbar_preview_thumbnail_low_quality))) { + return SeekbarPreviewThumbnailType.LOW_QUALITY + } else { + return SeekbarPreviewThumbnailType.HIGH_QUALITY // default + } + } + + fun tryResizeAndSetSeekbarPreviewThumbnail( + context: Context, + previewThumbnail: Bitmap?, + currentSeekbarPreviewThumbnail: ImageView, + baseViewWidthSupplier: IntSupplier) { + if (previewThumbnail == null) { + currentSeekbarPreviewThumbnail.setVisibility(View.GONE) + return + } + currentSeekbarPreviewThumbnail.setVisibility(View.VISIBLE) + + // Resize original bitmap + try { + val srcWidth: Int = if (previewThumbnail.getWidth() > 0) previewThumbnail.getWidth() else 1 + val newWidth: Int = 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)) + val scaleFactor: Float = newWidth.toFloat() / srcWidth + val newHeight: Int = (previewThumbnail.getHeight() * scaleFactor).toInt() + currentSeekbarPreviewThumbnail.setImageBitmap(BitmapCompat + .createScaledBitmap(previewThumbnail, newWidth, newHeight, null, true)) + } catch (ex: Exception) { + Log.e(TAG, "Failed to resize and set seekbar preview thumbnail", ex) + currentSeekbarPreviewThumbnail.setVisibility(View.GONE) + } finally { + previewThumbnail.recycle() + } + } + + @Retention(AnnotationRetention.SOURCE) + @IntDef([SeekbarPreviewThumbnailType.HIGH_QUALITY, SeekbarPreviewThumbnailType.LOW_QUALITY, SeekbarPreviewThumbnailType.NONE]) + annotation class SeekbarPreviewThumbnailType() { + companion object { + val HIGH_QUALITY: Int = 0 + val LOW_QUALITY: Int = 1 + val NONE: Int = 2 + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java deleted file mode 100644 index 26065de15..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java +++ /dev/null @@ -1,216 +0,0 @@ -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.extractor.stream.Frameset; -import org.schabi.newpipe.util.image.PicassoHelper; - -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, () -> { - // It can happen, that the original bitmap could not be downloaded - // In such a case - we don't want a NullPointer - simply return null - if (srcBitMap == null) { - return null; - } - - // Cut out the corresponding bitmap form the "srcBitMap" - return Bitmap.createBitmap(srcBitMap, bounds[1], bounds[2], - frameset.getFrameWidth(), frameset.getFrameHeight()); - }); - - 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()); - } - } - - @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 your are not running on the main-Thread this will otherwise hang - final Bitmap bitmap = PicassoHelper.loadSeekbarThumbnailPreview(url).get(); - - 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()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.kt b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.kt new file mode 100644 index 000000000..04688c38b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.kt @@ -0,0 +1,187 @@ +package org.schabi.newpipe.player.seekbarpreview + +import android.content.Context +import android.graphics.Bitmap +import android.util.Log +import androidx.collection.SparseArrayCompat +import com.google.common.base.Stopwatch +import org.schabi.newpipe.extractor.stream.Frameset +import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType +import org.schabi.newpipe.util.image.PicassoHelper +import java.util.Optional +import java.util.UUID +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.function.Supplier +import java.util.function.ToIntFunction +import kotlin.math.abs + +class SeekbarPreviewThumbnailHolder() { + // Key = Position of the picture in milliseconds + // Supplier = Supplies the bitmap for that position + private val seekbarPreviewData: SparseArrayCompat> = SparseArrayCompat() + + // This ensures that if the reset is still undergoing + // and another reset starts, only the last reset is processed + private var currentUpdateRequestIdentifier: UUID = UUID.randomUUID() + fun resetFrom(context: Context, framesets: List) { + val seekbarPreviewType: Int = SeekbarPreviewThumbnailHelper.getSeekbarPreviewThumbnailType(context) + val updateRequestIdentifier: UUID = UUID.randomUUID() + currentUpdateRequestIdentifier = updateRequestIdentifier + val executorService: ExecutorService = Executors.newSingleThreadExecutor() + executorService.submit(Runnable({ + try { + resetFromAsync(seekbarPreviewType, framesets, updateRequestIdentifier) + } catch (ex: Exception) { + 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 fun resetFromAsync(seekbarPreviewType: Int, framesets: List, + updateRequestIdentifier: UUID) { + Log.d(TAG, "Clearing seekbarPreviewData") + synchronized(seekbarPreviewData, { seekbarPreviewData.clear() }) + if (seekbarPreviewType == SeekbarPreviewThumbnailType.Companion.NONE) { + Log.d(TAG, "Not processing seekbarPreviewData due to settings") + return + } + val 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 fun getFrameSetForType(framesets: List, + seekbarPreviewType: Int): Frameset? { + if (seekbarPreviewType == SeekbarPreviewThumbnailType.Companion.HIGH_QUALITY) { + Log.d(TAG, "Strategy for seekbarPreviewData: high quality") + return framesets.stream() + .max(Comparator.comparingInt(ToIntFunction({ fs: Frameset? -> fs!!.getFrameHeight() * fs.getFrameWidth() }))) + .orElse(null) + } else { + Log.d(TAG, "Strategy for seekbarPreviewData: low quality") + return framesets.stream() + .min(Comparator.comparingInt(ToIntFunction({ fs: Frameset? -> fs!!.getFrameHeight() * fs.getFrameWidth() }))) + .orElse(null) + } + } + + private fun generateDataFrom(frameset: Frameset, updateRequestIdentifier: UUID) { + Log.d(TAG, "Starting generation of seekbarPreviewData") + val sw: Stopwatch? = if (Log.isLoggable(TAG, Log.DEBUG)) Stopwatch.createStarted() else null + var currentPosMs: Int = 0 + var pos: Int = 1 + val urlFrameCount: Int = frameset.getFramesPerPageX() * frameset.getFramesPerPageY() + + // Process each url in the frameset + for (url: String in frameset.getUrls()) { + // get the bitmap + val srcBitMap: Bitmap? = getBitMapFrom(url) + + // The data is not added directly to "seekbarPreviewData" due to + // concurrency and checks for "updateRequestIdentifier" + val generatedDataForUrl: SparseArrayCompat> = SparseArrayCompat(urlFrameCount) + + // The bitmap consists of several images, which we process here + // foreach frame in the returned bitmap + for (i in 0 until urlFrameCount) { + // Frames outside the video length are skipped + if (pos > frameset.getTotalCount()) { + break + } + + // Get the bounds where the frame is found + val bounds: IntArray = frameset.getFrameBoundsAt(currentPosMs.toLong()) + generatedDataForUrl.put(currentPosMs, Supplier({ + + // It can happen, that the original bitmap could not be downloaded + // In such a case - we don't want a NullPointer - simply return null + if (srcBitMap == null) { + return@put null + } + Bitmap.createBitmap(srcBitMap, bounds.get(1), bounds.get(2), + frameset.getFrameWidth(), frameset.getFrameHeight()) + })) + 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 fun getBitMapFrom(url: String?): Bitmap? { + if (url == null) { + Log.w(TAG, "url is null; This should never happen") + return null + } + val sw: Stopwatch? = if (Log.isLoggable(TAG, Log.DEBUG)) Stopwatch.createStarted() else 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 your are not running on the main-Thread this will otherwise hang + val bitmap: Bitmap = PicassoHelper.loadSeekbarThumbnailPreview(url).get() + if (sw != null) { + Log.d(TAG, ("Download of bitmap for seekbarPreview from '" + url + "' took " + + sw.stop())) + } + return bitmap + } catch (ex: Exception) { + Log.w(TAG, ("Failed to get bitmap for seekbarPreview from url='" + url + + "' in time"), ex) + return null + } + } + + private fun isRequestIdentifierCurrent(requestIdentifier: UUID): Boolean { + return (currentUpdateRequestIdentifier == requestIdentifier) + } + + fun getBitmapAt(positionInMs: Int): Optional { + // Get the frame supplier closest to the requested position + var closestFrame: Supplier = Supplier({ null }) + synchronized(seekbarPreviewData, { + var min: Int = Int.MAX_VALUE + for (i in 0 until seekbarPreviewData.size()) { + val pos: Int = abs((seekbarPreviewData.keyAt(i) - positionInMs).toDouble()).toInt() + if (pos < min) { + closestFrame = seekbarPreviewData.valueAt(i) + min = pos + } + } + }) + return Optional.ofNullable(closestFrame.get()) + } + + companion object { + // 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 + val TAG: String = "SeekbarPrevThumbHolder" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java deleted file mode 100644 index 03f90a344..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java +++ /dev/null @@ -1,987 +0,0 @@ -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.DisplayMetrics; -import android.util.Log; -import android.util.TypedValue; -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.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; - -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()); - } - - @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.titleTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE); - binding.channelTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE); - } - - @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.useVideoSource(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.useVideoSource(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) { - final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); - final int minimumLength = Math.min(metrics.heightPixels, metrics.widthPixels); - final float captionRatioInverse = 20f + 4f * (1.0f - captionScale); - binding.subtitleView.setFixedTextSize( - TypedValue.COMPLEX_UNIT_PX, minimumLength / captionRatioInverse); - } - //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) { - int nearestPosition = 0; - final List segments = player.getCurrentStreamInfo() - .map(StreamInfo::getStreamSegments) - .orElse(Collections.emptyList()); - - for (int i = 0; i < segments.size(); i++) { - if (segments.get(i).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(); - int before = 0; - int after = 0; - - final List streams = playQueue.getStreams(); - final int nStreams = streams.size(); - - for (int i = 0; i < nStreams; i++) { - if (i < currentStream) { - before += streams.get(i).getDuration(); - } else { - after += streams.get(i).getDuration(); - } - } - - before *= 1000; - after *= 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.titleTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE); - binding.channelTextView.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 -} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.kt b/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.kt new file mode 100644 index 000000000..1e0263288 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.kt @@ -0,0 +1,838 @@ +package org.schabi.newpipe.player.ui + +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.DisplayMetrics +import android.util.Log +import android.util.TypedValue +import android.view.KeyEvent +import android.view.View +import android.view.View.OnLayoutChangeListener +import android.view.View.OnLongClickListener +import android.view.ViewGroup +import android.view.ViewParent +import android.view.Window +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.LinearLayout +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.content.res.AppCompatResources +import androidx.fragment.app.FragmentManager +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import com.google.android.exoplayer2.video.VideoSize +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.QueueItemMenuUtil +import org.schabi.newpipe.R +import org.schabi.newpipe.databinding.PlayerBinding +import org.schabi.newpipe.extractor.ServiceList +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.StreamSegmentAdapter.StreamSegmentListener +import org.schabi.newpipe.info_list.StreamSegmentItem +import org.schabi.newpipe.ktx.AnimationType +import org.schabi.newpipe.ktx.animate +import org.schabi.newpipe.local.dialog.PlaylistDialog +import org.schabi.newpipe.local.feed.FeedFragment.Companion.newInstance +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.helper.PlayerHelper.MinimizeMode +import org.schabi.newpipe.player.mediaitem.MediaItemTag +import org.schabi.newpipe.player.notification.NotificationConstants +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.Objects +import java.util.Optional +import java.util.function.Consumer +import java.util.function.Function +import java.util.function.Predicate +import kotlin.math.max +import kotlin.math.min + +class MainPlayerUi /*////////////////////////////////////////////////////////////////////////// + // Constructor, setup, destroy + ////////////////////////////////////////////////////////////////////////// */ +//region Constructor, setup, destroy +(player: Player, + playerBinding: PlayerBinding) : VideoPlayerUi(player, playerBinding), OnLayoutChangeListener { + override var isFullscreen: Boolean = false + private set + var isVerticalVideo: Boolean = false + private set + private var fragmentIsVisible: Boolean = false + private var settingsContentObserver: ContentObserver? = null + private var playQueueAdapter: PlayQueueAdapter? = null + private var segmentAdapter: StreamSegmentAdapter? = null + private var isQueueVisible: Boolean = false + private var areSegmentsVisible: Boolean = false + + // fullscreen player + private var itemTouchHelper: ItemTouchHelper? = null + + /** + * 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 [ ][VideoDetailFragment.openVideoPlayer] 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 fun directlyOpenFullscreenIfNeeded() { + if ((PlayerHelper.isStartMainPlayerFullscreenEnabled(player.getService()) + && DeviceUtils.isTablet(player.getService()) + && PlayerHelper.globalScreenOrientationLocked(player.getService()))) { + player.getFragmentListener().ifPresent(Consumer({ obj: PlayerServiceEventListener? -> obj!!.onScreenRotationButtonClicked() })) + } + } + + public override fun 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() + } + } + + public override fun buildGestureListener(): BasePlayerGestureListener { + return MainPlayerGestureListener(this) + } + + override fun initListeners() { + super.initListeners() + binding!!.screenRotationButton.setOnClickListener(makeOnClickListener(Runnable({ + // 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 && PlayerHelper.globalScreenOrientationLocked(context))) { + player.getFragmentListener() + .ifPresent(Consumer({ obj: PlayerServiceEventListener? -> obj!!.onScreenRotationButtonClicked() })) + } else { + toggleFullscreen() + } + }))) + binding!!.queueButton.setOnClickListener(View.OnClickListener({ v: View? -> onQueueClicked() })) + binding!!.segmentsButton.setOnClickListener(View.OnClickListener({ v: View? -> onSegmentsClicked() })) + binding!!.addToPlaylistButton.setOnClickListener(View.OnClickListener({ v: View? -> + parentActivity.map(Function({ obj: AppCompatActivity? -> obj!!.getSupportFragmentManager() })) + .ifPresent(Consumer({ fragmentManager: FragmentManager -> PlaylistDialog.Companion.showForPlayQueue(player, fragmentManager) })) + })) + settingsContentObserver = object : ContentObserver(Handler(Looper.getMainLooper())) { + public override fun onChange(selfChange: Boolean) { + setupScreenRotationButton() + } + } + context.getContentResolver().registerContentObserver( + Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false, + settingsContentObserver) + binding!!.getRoot().addOnLayoutChangeListener(this) + binding!!.moreOptionsButton.setOnLongClickListener(OnLongClickListener({ v: View? -> + player.getFragmentListener() + .ifPresent(Consumer({ obj: PlayerServiceEventListener? -> obj!!.onMoreOptionsLongClicked() })) + hideControls(0, 0) + hideSystemUIIfNeeded() + true + })) + } + + override fun deinitListeners() { + super.deinitListeners() + binding!!.queueButton.setOnClickListener(null) + binding!!.segmentsButton.setOnClickListener(null) + binding!!.addToPlaylistButton.setOnClickListener(null) + context.getContentResolver().unregisterContentObserver((settingsContentObserver)!!) + binding!!.getRoot().removeOnLayoutChangeListener(this) + } + + public override fun initPlayback() { + super.initPlayback() + if (playQueueAdapter != null) { + playQueueAdapter!!.dispose() + } + playQueueAdapter = PlayQueueAdapter(context, + Objects.requireNonNull(player.getPlayQueue())) + segmentAdapter = StreamSegmentAdapter(streamSegmentListener) + } + + public override fun removeViewFromParent() { + // view was added to fragment + val parent: ViewParent = binding!!.getRoot().getParent() + if (parent is ViewGroup) { + parent.removeView(binding!!.getRoot()) + } + } + + public override fun destroy() { + super.destroy() + + // Exit from fullscreen when user closes the player via notification + if (isFullscreen) { + toggleFullscreen() + } + removeViewFromParent() + } + + public override fun destroyPlayer() { + super.destroyPlayer() + if (playQueueAdapter != null) { + playQueueAdapter!!.unsetSelectedListener() + playQueueAdapter!!.dispose() + } + } + + public override fun 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 fun initVideoPlayer() { + // restore last resize mode + setResizeMode(PlayerHelper.retrieveResizeModeFromPrefs(player)) + binding!!.getRoot().setLayoutParams(FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)) + } + + override fun 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 = ViewGroup.LayoutParams.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(if (isFullscreen) View.GONE else 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!!.titleTextView.setVisibility(if (isFullscreen) View.VISIBLE else View.GONE) + binding!!.channelTextView.setVisibility(if (isFullscreen) View.VISIBLE else View.GONE) + } + + override fun setupElementsSize(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 + public override fun onBroadcastReceived(intent: Intent) { + super.onBroadcastReceived(intent) + if ((Intent.ACTION_CONFIGURATION_CHANGED == 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 ((NotificationConstants.ACTION_PLAY_PAUSE == 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.Companion.ACTION_VIDEO_FRAGMENT_STOPPED == intent.getAction())) { + fragmentIsVisible = false + onFragmentStopped() + } else if ((VideoDetailFragment.Companion.ACTION_VIDEO_FRAGMENT_RESUMED == intent.getAction())) { + // Restore video source when user returns to the fragment + fragmentIsVisible = true + player.useVideoSource(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 + public override fun 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(Consumer({ obj: PlayerServiceEventListener? -> obj!!.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 fun onFragmentStopped() { + if (player.isPlaying() || player.isLoading()) { + when (PlayerHelper.getMinimizeOnExitAction(context)) { + MinimizeMode.Companion.MINIMIZE_ON_EXIT_MODE_BACKGROUND -> player.useVideoSource(false) + MinimizeMode.Companion.MINIMIZE_ON_EXIT_MODE_POPUP -> parentActivity.ifPresent(Consumer({ activity: AppCompatActivity? -> + player.setRecovery() + NavigationHelper.playOnPopupPlayer(activity, player.getPlayQueue(), true) + })) + + MinimizeMode.Companion.MINIMIZE_ON_EXIT_MODE_NONE -> player.pause() + else -> player.pause() + } + } + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Playback states + ////////////////////////////////////////////////////////////////////////// */ + //region Playback states + public override fun onUpdateProgress(currentProgress: Int, + duration: Int, + bufferPercent: Int) { + super.onUpdateProgress(currentProgress, duration, bufferPercent) + if (areSegmentsVisible) { + segmentAdapter!!.selectSegmentAt(getNearestStreamSegmentPosition(currentProgress.toLong())) + } + if (isQueueVisible) { + updateQueueTime(currentProgress) + } + } + + public override fun onPlaying() { + super.onPlaying() + checkLandscape() + } + + public override fun onCompleted() { + super.onCompleted() + if (isFullscreen) { + toggleFullscreen() + } + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Controls showing / hiding + ////////////////////////////////////////////////////////////////////////// */ + //region Controls showing / hiding + override fun showOrHideButtons() { + super.showOrHideButtons() + val playQueue: PlayQueue? = player.getPlayQueue() + if (playQueue == null) { + return + } + val showQueue: Boolean = !playQueue.getStreams().isEmpty() + val showSegment: Boolean = !player.getCurrentStreamInfo() + .map(Function({ obj: StreamInfo? -> obj!!.getStreamSegments() })) + .map(Function({ obj: List -> obj.isEmpty() })) + .orElse( /*no stream info=*/true) + binding!!.queueButton.setVisibility(if (showQueue) View.VISIBLE else View.GONE) + binding!!.queueButton.setAlpha(if (showQueue) 1.0f else 0.0f) + binding!!.segmentsButton.setVisibility(if (showSegment) View.VISIBLE else View.GONE) + binding!!.segmentsButton.setAlpha(if (showSegment) 1.0f else 0.0f) + } + + public override fun showSystemUIPartially() { + if (isFullscreen) { + parentActivity.map(Function({ obj: AppCompatActivity? -> obj!!.getWindow() })).ifPresent(Consumer({ window: Window -> + window.setStatusBarColor(Color.TRANSPARENT) + window.setNavigationBarColor(Color.TRANSPARENT) + val visibility: Int = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE + or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION) + window.getDecorView().setSystemUiVisibility(visibility) + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + })) + } + } + + public override fun hideSystemUIIfNeeded() { + player.getFragmentListener().ifPresent(Consumer({ obj: PlayerServiceEventListener? -> obj!!.hideSystemUiIfNeeded() })) + } + + /** + * Calculate the maximum allowed height for the [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 + * [.DETAIL_ROOT_MINIMUM_HEIGHT] free space for [R.id.detail_root] and + * additional space for the stream title text size ([R.id.detail_title_root_layout]). + * The text size is [.DETAIL_TITLE_TEXT_SIZE_TABLET] on tablets and + * [.DETAIL_TITLE_TEXT_SIZE_TV] on TVs, see [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 fun calculateMaxEndScreenThumbnailHeight(bitmap: Bitmap): Float { + val screenHeight: Int = context.getResources().getDisplayMetrics().heightPixels + if (DeviceUtils.isTv(context) && !isFullscreen) { + val videoInfoHeight: Int = (DeviceUtils.dpToPx(DETAIL_ROOT_MINIMUM_HEIGHT, context) + + DeviceUtils.spToPx(DETAIL_TITLE_TEXT_SIZE_TV, context)) + return min(bitmap.getHeight().toDouble(), (screenHeight - videoInfoHeight).toDouble()).toFloat() + } else if (DeviceUtils.isTablet(context) && isLandscape && !isFullscreen) { + val videoInfoHeight: Int = (DeviceUtils.dpToPx(DETAIL_ROOT_MINIMUM_HEIGHT, context) + + DeviceUtils.spToPx(DETAIL_TITLE_TEXT_SIZE_TABLET, context)) + return min(bitmap.getHeight().toDouble(), (screenHeight - videoInfoHeight).toDouble()).toFloat() + } else { // fullscreen player: max height is the device height + return min(bitmap.getHeight().toDouble(), screenHeight.toDouble()).toFloat() + } + } + + private fun showHideKodiButton() { + // show kodi button if it supports the current service and it is enabled in settings + val playQueue: PlayQueue? = player.getPlayQueue() + binding!!.playWithKodi.setVisibility(if ((playQueue != null) && (playQueue.getItem() != null + ) && KoreUtils.shouldShowPlayWithKodi(context, playQueue.getItem().getServiceId())) View.VISIBLE else View.GONE) + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Captions (text tracks) + ////////////////////////////////////////////////////////////////////////// */ + //region Captions (text tracks) + override fun setupSubtitleView(captionScale: Float) { + val metrics: DisplayMetrics = context.getResources().getDisplayMetrics() + val minimumLength: Int = min(metrics.heightPixels.toDouble(), metrics.widthPixels.toDouble()).toInt() + val captionRatioInverse: Float = 20f + 4f * (1.0f - captionScale) + binding!!.subtitleView.setFixedTextSize( + TypedValue.COMPLEX_UNIT_PX, minimumLength / captionRatioInverse) + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Gestures + ////////////////////////////////////////////////////////////////////////// */ + //region Gestures + public override fun onLayoutChange(view: View, l: Int, t: Int, r: Int, b: Int, + ol: Int, ot: Int, or: Int, ob: Int) { + 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. + val width: Int = r - l + val height: Int = b - t + val min: Int = min(width.toDouble(), height.toDouble()).toInt() + val maxGestureLength: Int = (min * 0.75).toInt() + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "maxGestureLength = " + maxGestureLength) + } + binding!!.volumeProgressBar.setMax(maxGestureLength) + binding!!.brightnessProgressBar.setMax(maxGestureLength) + setInitialGestureValues() + binding!!.itemsListPanel.getLayoutParams().height = height - binding!!.itemsListPanel.getTop() + } + } + + private fun setInitialGestureValues() { + if (player.getAudioReactor() != null) { + val currentVolumeNormalized: Float = (player.getAudioReactor().getVolume().toFloat() / player.getAudioReactor().getMaxVolume()) + binding!!.volumeProgressBar.setProgress((binding!!.volumeProgressBar.getMax() * currentVolumeNormalized).toInt()) + } + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Play queue, segments and streams + ////////////////////////////////////////////////////////////////////////// */ + //region Play queue, segments and streams + public override fun onMetadataChanged(info: StreamInfo) { + super.onMetadataChanged(info) + showHideKodiButton() + if (areSegmentsVisible) { + if (segmentAdapter!!.setItems(info)) { + val adapterPosition: Int = getNearestStreamSegmentPosition( + player.getExoPlayer()!!.getCurrentPosition()) + segmentAdapter!!.selectSegmentAt(adapterPosition) + binding!!.itemsList.scrollToPosition(adapterPosition) + } else { + closeItemsList() + } + } + } + + public override fun onPlayQueueEdited() { + super.onPlayQueueEdited() + showOrHideButtons() + } + + private fun 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() + binding!!.itemsListPanel.animate(true, VideoPlayerUi.Companion.DEFAULT_CONTROLS_DURATION, AnimationType.SLIDE_AND_ALPHA) + val playQueue: PlayQueue? = player.getPlayQueue() + if (playQueue != null) { + binding!!.itemsList.scrollToPosition(playQueue.getIndex()) + } + updateQueueTime(player.getExoPlayer()!!.getCurrentPosition().toInt()) + } + + private fun buildQueue() { + binding!!.itemsList.setAdapter(playQueueAdapter) + binding!!.itemsList.setClickable(true) + binding!!.itemsList.setLongClickable(true) + binding!!.itemsList.clearOnScrollListeners() + binding!!.itemsList.addOnScrollListener(queueScrollListener) + itemTouchHelper = ItemTouchHelper(itemTouchCallback) + itemTouchHelper!!.attachToRecyclerView(binding!!.itemsList) + playQueueAdapter!!.setSelectedListener(onSelectedListener) + binding!!.itemsListClose.setOnClickListener(View.OnClickListener({ view: View? -> closeItemsList() })) + } + + private fun 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() + binding!!.itemsListPanel.animate(true, VideoPlayerUi.Companion.DEFAULT_CONTROLS_DURATION, AnimationType.SLIDE_AND_ALPHA) + val adapterPosition: Int = getNearestStreamSegmentPosition( + player.getExoPlayer()!!.getCurrentPosition()) + segmentAdapter!!.selectSegmentAt(adapterPosition) + binding!!.itemsList.scrollToPosition(adapterPosition) + } + + private fun 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(Consumer({ info: StreamInfo? -> segmentAdapter!!.setItems((info)!!) })) + binding!!.shuffleButton.setVisibility(View.GONE) + binding!!.repeatButton.setVisibility(View.GONE) + binding!!.addToPlaylistButton.setVisibility(View.GONE) + binding!!.itemsListClose.setOnClickListener(View.OnClickListener({ view: View? -> closeItemsList() })) + } + + fun closeItemsList() { + if (isQueueVisible || areSegmentsVisible) { + isQueueVisible = false + areSegmentsVisible = false + if (itemTouchHelper != null) { + itemTouchHelper!!.attachToRecyclerView(null) + } + binding!!.itemsListPanel.animate(false, VideoPlayerUi.Companion.DEFAULT_CONTROLS_DURATION, AnimationType.SLIDE_AND_ALPHA, 0, Runnable({ // 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 val queueScrollListener: OnScrollBelowItemsListener + private get() { + return object : OnScrollBelowItemsListener() { + public override fun onScrolledDown(recyclerView: RecyclerView?) { + val playQueue: PlayQueue? = player.getPlayQueue() + if (playQueue != null && !playQueue.isComplete()) { + playQueue.fetch() + } else if (binding != null) { + binding!!.itemsList.clearOnScrollListeners() + } + } + } + } + private val streamSegmentListener: StreamSegmentListener + private get() { + return object : StreamSegmentListener { + public override fun onItemClick(item: StreamSegmentItem, seconds: Int) { + segmentAdapter!!.selectSegment(item) + player.seekTo(seconds * 1000L) + player.triggerProgressUpdate() + } + + public override fun onItemLongClick(item: StreamSegmentItem, seconds: Int) { + val currentMetadata: MediaItemTag? = player.getCurrentMetadata() + if ((currentMetadata == null + || currentMetadata.getServiceId() != ServiceList.YouTube.getServiceId())) { + return + } + val currentItem: PlayQueueItem? = player.getCurrentItem() + if (currentItem != null) { + var videoUrl: String? = player.getVideoUrl() + videoUrl += ("&t=" + seconds) + ShareUtils.shareText(context, currentItem.getTitle(), + videoUrl, currentItem.getThumbnails()) + } + } + } + } + + private fun getNearestStreamSegmentPosition(playbackPosition: Long): Int { + var nearestPosition: Int = 0 + val segments: List = player.getCurrentStreamInfo() + .map(Function({ obj: StreamInfo? -> obj!!.getStreamSegments() })) + .orElse(emptyList()) + for (i in segments.indices) { + if (segments.get(i).getStartTimeSeconds() * 1000L > playbackPosition) { + break + } + nearestPosition++ + } + return max(0.0, (nearestPosition - 1).toDouble()).toInt() + } + + private val itemTouchCallback: ItemTouchHelper.SimpleCallback + private get() { + return object : PlayQueueItemTouchCallback() { + public override fun onMove(sourceIndex: Int, targetIndex: Int) { + val playQueue: PlayQueue? = player.getPlayQueue() + if (playQueue != null) { + playQueue.move(sourceIndex, targetIndex) + } + } + + public override fun onSwiped(index: Int) { + val playQueue: PlayQueue? = player.getPlayQueue() + if (playQueue != null && index != -1) { + playQueue.remove(index) + } + } + } + } + private val onSelectedListener: PlayQueueItemBuilder.OnSelectedListener + private get() { + return object : PlayQueueItemBuilder.OnSelectedListener { + public override fun selected(item: PlayQueueItem?, view: View?) { + player.selectQueueItem(item) + } + + public override fun held(item: PlayQueueItem, view: View?) { + val playQueue: PlayQueue? = player.getPlayQueue() + val parentActivity: AppCompatActivity? = parentActivity.orElse(null) + if ((playQueue != null) && (parentActivity != null) && (playQueue.indexOf(item) != -1)) { + QueueItemMenuUtil.openPopupMenu(player.getPlayQueue(), item, view, true, + parentActivity.getSupportFragmentManager(), context) + } + } + + public override fun onStartDrag(viewHolder: PlayQueueItemHolder?) { + if (itemTouchHelper != null) { + itemTouchHelper!!.startDrag((viewHolder)!!) + } + } + } + } + + private fun updateQueueTime(currentTime: Int) { + val playQueue: PlayQueue? = player.getPlayQueue() + if (playQueue == null) { + return + } + val currentStream: Int = playQueue.getIndex() + var before: Int = 0 + var after: Int = 0 + val streams: List = playQueue.getStreams() + val nStreams: Int = streams.size + for (i in 0 until nStreams) { + if (i < currentStream) { + before += streams.get(i).getDuration().toInt() + } else { + after += streams.get(i).getDuration().toInt() + } + } + before *= 1000 + after *= 1000 + binding!!.itemsListHeaderDuration.setText(String.format("%s/%s", + PlayerHelper.getTimeString(currentTime + before), + PlayerHelper.getTimeString(before + after) + )) + } + + protected override val isAnyListViewOpen: Boolean + protected get() { + return isQueueVisible || areSegmentsVisible + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Click listeners + ////////////////////////////////////////////////////////////////////////// */ + //region Click listeners + override fun onPlaybackSpeedClicked() { + parentActivity.ifPresent(Consumer({ activity: AppCompatActivity? -> + PlaybackParameterDialog.Companion.newInstance(player.getPlaybackSpeed().toDouble(), + player.getPlaybackPitch().toDouble(), player.getPlaybackSkipSilence(), PlaybackParameterDialog.Callback({ speed: Float, pitch: Float, skipSilence: Boolean -> player.setPlaybackParameters(speed, pitch, skipSilence) })) + .show(activity!!.getSupportFragmentManager(), null) + })) + } + + public override fun onKeyDown(keyCode: Int): Boolean { + 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 fun setupScreenRotationButton() { + binding!!.screenRotationButton.setVisibility(if ((PlayerHelper.globalScreenOrientationLocked(context) + || isVerticalVideo || DeviceUtils.isTablet(context))) View.VISIBLE else View.GONE) + binding!!.screenRotationButton.setImageDrawable(AppCompatResources.getDrawable(context, + if (isFullscreen) R.drawable.ic_fullscreen_exit else R.drawable.ic_fullscreen)) + } + + public override fun onVideoSizeChanged(videoSize: VideoSize) { + super.onVideoSizeChanged(videoSize) + isVerticalVideo = videoSize.width < videoSize.height + if ((PlayerHelper.globalScreenOrientationLocked(context) + && isFullscreen + && (isLandscape == isVerticalVideo + ) && !DeviceUtils.isTv(context) + && !DeviceUtils.isTablet(context))) { + // set correct orientation + player.getFragmentListener().ifPresent(Consumer({ obj: PlayerServiceEventListener? -> obj!!.onScreenRotationButtonClicked() })) + } + setupScreenRotationButton() + } + + fun toggleFullscreen() { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "toggleFullscreen() called") + } + val fragmentListener: PlayerServiceEventListener? = 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!!.titleTextView.setVisibility(if (isFullscreen) View.VISIBLE else View.GONE) + binding!!.channelTextView.setVisibility(if (isFullscreen) View.VISIBLE else View.GONE) + binding!!.playerCloseButton.setVisibility(if (isFullscreen) View.GONE else View.VISIBLE) + setupScreenRotationButton() + } + + fun checkLandscape() { + // check if landscape is correct + val videoInLandscapeButNotInFullscreen: Boolean = (isLandscape + && !isFullscreen + && !player.isAudioOnly()) + val notPaused: Boolean = (player.getCurrentState() != Player.Companion.STATE_COMPLETED + && player.getCurrentState() != Player.Companion.STATE_PAUSED) + if ((videoInLandscapeButNotInFullscreen + && notPaused + && !DeviceUtils.isTablet(context))) { + toggleFullscreen() + } + } + + private val parentContext: Optional + //endregion + private get() { + return Optional.ofNullable(binding!!.getRoot().getParent()) + .filter(Predicate({ obj: ViewParent? -> ViewGroup::class.java.isInstance(obj) })) + .map(Function({ parent: ViewParent -> (parent as ViewGroup).getContext() })) + } + val parentActivity: Optional + get() { + return parentContext + .filter(Predicate({ obj: Context? -> AppCompatActivity::class.java.isInstance(obj) })) + .map(Function({ obj: Context? -> AppCompatActivity::class.java.cast(obj) })) + } + val isLandscape: Boolean + get() { + // DisplayMetrics from activity context knows about MultiWindow feature + // while DisplayMetrics from app context doesn't + return DeviceUtils.isLandscape(parentContext.orElse(player.getService())) + } //endregion + + companion object { + private val TAG: String = MainPlayerUi::class.java.getSimpleName() + + // see the Javadoc of calculateMaxEndScreenThumbnailHeight for information + private val DETAIL_ROOT_MINIMUM_HEIGHT: Int = 85 // dp + private val DETAIL_TITLE_TEXT_SIZE_TV: Int = 16 // sp + private val DETAIL_TITLE_TEXT_SIZE_TABLET: Int = 15 // sp + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUi.java deleted file mode 100644 index 57e2ec2a2..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUi.java +++ /dev/null @@ -1,212 +0,0 @@ -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) { - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUi.kt b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUi.kt new file mode 100644 index 000000000..888c61877 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUi.kt @@ -0,0 +1,160 @@ +package org.schabi.newpipe.player.ui + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import com.google.android.exoplayer2.PlaybackParameters +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 + +/** + * A player UI is a component that can seamlessly connect and disconnect from the [Player] and + * provide a user interface of some sort. Try to extend this class instead of adding more code to + * [Player]! + */ +abstract class PlayerUi protected constructor( + /** + * @return the player instance this UI was constructed with + */ + val player: Player) { + protected val context: Context + + /** + * @param player the player instance that will be usable throughout the lifetime of this UI; its + * context should already have been initialized + */ + init { + context = player.getContext() + } + + /** + * Called after the player received an intent and processed it. + */ + open fun 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. + */ + open fun 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. + */ + open fun 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 [ ][.destroy]. + */ + open fun 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. + */ + open fun 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. + */ + open fun 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. + */ + open fun 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 [ ][Player.setupBroadcastReceiver]. + * @param intent the broadcast intent received by the player + */ + open fun onBroadcastReceived(intent: Intent) {} + + /** + * Called when stream progress (i.e. the current time in the seekbar) or stream duration change. + * Will surely be called every [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 [ ][com.google.android.exoplayer2.BasePlayer.getBufferedPercentage] + */ + open fun onUpdateProgress(currentProgress: Int, + duration: Int, + bufferPercent: Int) { + } + + open fun onPrepared() {} + open fun onBlocked() {} + open fun onPlaying() {} + open fun onBuffering() {} + open fun onPaused() {} + open fun onPausedSeek() {} + open fun onCompleted() {} + open fun onRepeatModeChanged(repeatMode: @com.google.android.exoplayer2.Player.RepeatMode Int) {} + open fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {} + open fun onMuteUnmuteChanged(isMuted: Boolean) {} + + /** + * @see com.google.android.exoplayer2.Player.Listener.onTracksChanged + * @param currentTracks the available tracks information + */ + open fun onTextTracksChanged(currentTracks: Tracks) {} + + /** + * @see com.google.android.exoplayer2.Player.Listener.onPlaybackParametersChanged + * + * @param playbackParameters the new playback parameters + */ + open fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) {} + + /** + * @see com.google.android.exoplayer2.Player.Listener.onRenderedFirstFrame + */ + open fun onRenderedFirstFrame() {} + + /** + * @see com.google.android.exoplayer2.text.TextOutput.onCues + * + * @param cues the cues to pass to the subtitle view + */ + open fun onCues(cues: List) {} + + /** + * Called when the stream being played changes. + * @param info the [StreamInfo] metadata object, along with data about the selected and + * available video streams (to be used to build the resolution menus, for example) + */ + open fun onMetadataChanged(info: StreamInfo) {} + + /** + * 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 + */ + open fun onThumbnailLoaded(bitmap: Bitmap?) {} + + /** + * Called when the play queue was edited: a stream was appended, moved or removed. + */ + open fun onPlayQueueEdited() {} + + /** + * @param videoSize the new video size, useful to set the surface aspect ratio + * @see com.google.android.exoplayer2.Player.Listener.onVideoSizeChanged + */ + open fun onVideoSizeChanged(videoSize: VideoSize) {} +} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.java b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.java deleted file mode 100644 index 24fec3b8a..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.java +++ /dev/null @@ -1,90 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt new file mode 100644 index 000000000..28cb3d748 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt @@ -0,0 +1,85 @@ +package org.schabi.newpipe.player.ui + +import org.schabi.newpipe.player.ui.PlayerUiList +import java.util.List +import java.util.Optional +import java.util.function.Consumer +import java.util.function.Function +import java.util.function.Predicate + +class PlayerUiList(vararg initialPlayerUis: PlayerUi?) { + val playerUis: MutableList = ArrayList() + + /** + * Creates a [PlayerUiList] starting with the provided player uis. The provided player uis + * will not be prepared like those passed to [.addAndPrepare], because when + * the [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 [.call]. + * + * @param initialPlayerUis the player uis this list should start with; the order will be kept + */ + init { + 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 [ ][PlayerUi.getPlayer] will be used to query information about the player + * state + */ + fun addAndPrepare(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 [ ][Class.isInstance] method will be used, so even subclasses will be + * destroyed and removed + * @param the class type parameter + */ + fun destroyAll(playerUiType: Class) { + playerUis.stream() + .filter(Predicate({ obj: PlayerUi? -> playerUiType.isInstance(obj) })) + .forEach(Consumer({ playerUi: PlayerUi -> + playerUi.destroyPlayer() + playerUi.destroy() + })) + playerUis.removeIf(Predicate({ obj: PlayerUi? -> playerUiType.isInstance(obj) })) + } + + /** + * @param playerUiType the class of the player UI to return; the [ ][Class.isInstance] 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 [ ] otherwise + */ + operator fun get(playerUiType: Class): Optional { + return playerUis.stream() + .filter(Predicate({ obj: PlayerUi? -> playerUiType.isInstance(obj) })) + .map(Function({ obj: PlayerUi? -> playerUiType.cast(obj) })) + .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 + */ + fun call(consumer: Consumer?) { + playerUis.stream().forEachOrdered(consumer) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java deleted file mode 100644 index 90c24c0c6..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java +++ /dev/null @@ -1,592 +0,0 @@ -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; - -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 - 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(); - 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.useVideoSource(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.useVideoSource(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().stopService(); - } - }).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) { - final float captionRatio = (captionScale - 1.0f) / 5.0f + 1.0f; - binding.subtitleView.setFractionalTextSize( - SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * captionRatio); - } - - @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 -} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.kt b/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.kt new file mode 100644 index 000000000..e826d95c6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.kt @@ -0,0 +1,503 @@ +package org.schabi.newpipe.player.ui + +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.Insets +import android.graphics.PixelFormat +import android.graphics.Rect +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.WindowMetrics +import android.view.animation.AnticipateInterpolator +import android.widget.LinearLayout +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.MainActivity +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 kotlin.math.sqrt + +class PopupPlayerUi(player: Player, + playerBinding: PlayerBinding) : VideoPlayerUi(player, playerBinding) { + /*////////////////////////////////////////////////////////////////////////// + // Popup player + ////////////////////////////////////////////////////////////////////////// */ + var closeOverlayBinding: PlayerPopupCloseOverlayBinding? = null + private set + var isPopupClosing: Boolean = false + private set + + //endregion + var screenWidth: Int = 0 + private set + var screenHeight: Int = 0 + private set + var popupLayoutParams: WindowManager.LayoutParams? = null // null if player is not popup + private set + val windowManager: WindowManager? + + /*////////////////////////////////////////////////////////////////////////// + // Constructor, setup, destroy + ////////////////////////////////////////////////////////////////////////// */ + //region Constructor, setup, destroy + init { + windowManager = ContextCompat.getSystemService(context, WindowManager::class.java) + } + + public override fun setupAfterIntent() { + super.setupAfterIntent() + initPopup() + initPopupCloseOverlay() + } + + public override fun buildGestureListener(): BasePlayerGestureListener { + return PopupPlayerGestureListener(this) + } + + @SuppressLint("RtlHardcoded") + private fun initPopup() { + if (MainActivity.Companion.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 fun initPopupCloseOverlay() { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "initPopupCloseOverlay() called") + } + + // closeOverlayView is already added to windowManager + if (closeOverlayBinding != null) { + return + } + closeOverlayBinding = PlayerPopupCloseOverlayBinding.inflate(LayoutInflater.from(context)) + val closeOverlayLayoutParams: WindowManager.LayoutParams = buildCloseOverlayLayoutParams() + closeOverlayBinding!!.closeButton.setVisibility(View.GONE) + windowManager!!.addView(closeOverlayBinding!!.getRoot(), closeOverlayLayoutParams) + } + + override fun 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 = ViewGroup.LayoutParams.WRAP_CONTENT + binding!!.secondaryControls.setAlpha(1.0f) + binding!!.secondaryControls.setVisibility(View.VISIBLE) + binding!!.secondaryControls.setTranslationY(0f) + 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() + super.setupElementsVisibility() + } + + override fun setupElementsSize(resources: Resources) { + setupElementsSize( + 0, + 0, + resources.getDimensionPixelSize(R.dimen.player_popup_controls_padding), + resources.getDimensionPixelSize(R.dimen.player_popup_buttons_padding) + ) + } + + public override fun removeViewFromParent() { + // view was added by windowManager for popup player + windowManager!!.removeViewImmediate(binding!!.getRoot()) + } + + public override fun destroy() { + super.destroy() + removePopupFromView() + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Broadcast receiver + ////////////////////////////////////////////////////////////////////////// */ + //region Broadcast receiver + public override fun onBroadcastReceived(intent: Intent) { + super.onBroadcastReceived(intent) + if ((Intent.ACTION_CONFIGURATION_CHANGED == intent.getAction())) { + updateScreenSize() + changePopupSize(popupLayoutParams!!.width) + checkPopupPositionBounds() + } else if (player.isPlaying() || player.isLoading()) { + if ((Intent.ACTION_SCREEN_OFF == intent.getAction())) { + // Use only audio source when screen turns off while popup player is playing + player.useVideoSource(false) + } else if ((Intent.ACTION_SCREEN_ON == intent.getAction())) { + // Restore video source when screen turns on and user was watching video in popup + player.useVideoSource(true) + } + } + } + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Popup position and size + ////////////////////////////////////////////////////////////////////////// */ + //region Popup position and size + /** + * Check if [.popupLayoutParams]' position is within a arbitrary boundary + * that goes from (0, 0) to (screenWidth, screenHeight). + * + * + * If it's out of these boundaries, [.popupLayoutParams]' position is changed + * and `true` is returned to represent this change. + * + */ + fun checkPopupPositionBounds() { + if (MainActivity.Companion.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)) + } + + fun updateScreenSize() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val windowMetrics: WindowMetrics = windowManager!!.getCurrentWindowMetrics() + val bounds: Rect = windowMetrics.getBounds() + val windowInsets: WindowInsets = windowMetrics.getWindowInsets() + val insets: Insets = windowInsets.getInsetsIgnoringVisibility( + WindowInsets.Type.navigationBars() or WindowInsets.Type.displayCutout()) + screenWidth = bounds.width() - (insets.left + insets.right) + screenHeight = bounds.height() - (insets.top + insets.bottom) + } else { + val metrics: DisplayMetrics = DisplayMetrics() + windowManager!!.getDefaultDisplay().getMetrics(metrics) + screenWidth = metrics.widthPixels + screenHeight = metrics.heightPixels + } + if (MainActivity.Companion.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 + * [PlayerHelper.getMinimumVideoHeight] + */ + fun changePopupSize(width: Int) { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "changePopupSize() called with: width = [" + width + "]") + } + if (anyPopupViewIsNull()) { + return + } + val minimumWidth: Float = context.getResources().getDimension(R.dimen.popup_minimum_width) + val actualWidth: Int = MathUtils.clamp(width, minimumWidth.toInt(), screenWidth) + val actualHeight: Int = PlayerHelper.getMinimumVideoHeight(width.toFloat()).toInt() + if (MainActivity.Companion.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 fun calculateMaxEndScreenThumbnailHeight(bitmap: Bitmap): Float { + // 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().toFloat() + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Popup closing + ////////////////////////////////////////////////////////////////////////// */ + //region Popup closing + fun closePopup() { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "closePopup() called, isPopupClosing = " + isPopupClosing) + } + if (isPopupClosing) { + return + } + isPopupClosing = true + player.saveStreamProgressState() + windowManager!!.removeView(binding!!.getRoot()) + animatePopupOverlayAndFinishService() + } + + fun removePopupFromView() { + // wrap in try-catch since it could sometimes generate errors randomly + try { + if (popupHasParent()) { + windowManager!!.removeView(binding!!.getRoot()) + } + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Failed to remove popup from window manager", e) + } + try { + val closeOverlayHasParent: Boolean = (closeOverlayBinding != null + && closeOverlayBinding!!.getRoot().getParent() != null) + if (closeOverlayHasParent) { + windowManager!!.removeView(closeOverlayBinding!!.getRoot()) + } + } catch (e: IllegalArgumentException) { + Log.w(TAG, "Failed to remove popup overlay from window manager", e) + } + } + + private fun animatePopupOverlayAndFinishService() { + val targetTranslationY: Int = ((closeOverlayBinding!!.closeButton.getRootView().getHeight() + - closeOverlayBinding!!.closeButton.getY())).toInt() + closeOverlayBinding!!.closeButton.animate().setListener(null).cancel() + closeOverlayBinding!!.closeButton.animate() + .setInterpolator(AnticipateInterpolator()) + .translationY(targetTranslationY.toFloat()) + .setDuration(400) + .setListener(object : AnimatorListenerAdapter() { + public override fun onAnimationCancel(animation: Animator) { + end() + } + + public override fun onAnimationEnd(animation: Animator) { + end() + } + + private fun end() { + windowManager!!.removeView(closeOverlayBinding!!.getRoot()) + closeOverlayBinding = null + player.getService().stopService() + } + }).start() + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Playback states + ////////////////////////////////////////////////////////////////////////// */ + //region Playback states + private fun changePopupWindowFlags(flags: Int) { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "changePopupWindowFlags() called with: flags = [" + flags + "]") + } + if (!anyPopupViewIsNull()) { + popupLayoutParams!!.flags = flags + windowManager!!.updateViewLayout(binding!!.getRoot(), popupLayoutParams) + } + } + + public override fun onPlaying() { + super.onPlaying() + changePopupWindowFlags(ONGOING_PLAYBACK_WINDOW_FLAGS) + } + + public override fun onPaused() { + super.onPaused() + changePopupWindowFlags(IDLE_WINDOW_FLAGS) + } + + public override fun onCompleted() { + super.onCompleted() + changePopupWindowFlags(IDLE_WINDOW_FLAGS) + } + + override fun setupSubtitleView(captionScale: Float) { + val captionRatio: Float = (captionScale - 1.0f) / 5.0f + 1.0f + binding!!.subtitleView.setFractionalTextSize( + SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * captionRatio) + } + + override fun onPlaybackSpeedClicked() { + playbackSpeedPopupMenu!!.show() + isSomePopupMenuVisible = true + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Gestures + ////////////////////////////////////////////////////////////////////////// */ + //region Gestures + private fun distanceFromCloseButton(popupMotionEvent: MotionEvent): Int { + val closeOverlayButtonX: Int = (closeOverlayBinding!!.closeButton.getLeft() + + closeOverlayBinding!!.closeButton.getWidth() / 2) + val closeOverlayButtonY: Int = (closeOverlayBinding!!.closeButton.getTop() + + closeOverlayBinding!!.closeButton.getHeight() / 2) + val fingerX: Float = popupLayoutParams!!.x + popupMotionEvent.getX() + val fingerY: Float = popupLayoutParams!!.y + popupMotionEvent.getY() + return sqrt(((closeOverlayButtonX - fingerX).pow(2.0) + (closeOverlayButtonY - fingerY).pow(2.0))).toInt() + } + + private val closingRadius: Float + private get() { + val buttonRadius: Int = closeOverlayBinding!!.closeButton.getWidth() / 2 + // 20% wider than the button itself + return buttonRadius * 1.2f + } + + fun isInsideClosingRadius(popupMotionEvent: MotionEvent): Boolean { + return distanceFromCloseButton(popupMotionEvent) <= closingRadius + } + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Popup & closing overlay layout params + saving popup position and size + ////////////////////////////////////////////////////////////////////////// */ + //region Popup & closing overlay layout params + saving popup position and size + /** + * `screenWidth` and `screenHeight` must have been initialized. + * @return the popup starting layout params + */ + @SuppressLint("RtlHardcoded") + fun retrievePopupLayoutParamsFromPrefs(): WindowManager.LayoutParams { + val prefs: SharedPreferences = getPlayer().getPrefs() + val context: Context = getPlayer().getContext() + val popupRememberSizeAndPos: Boolean = prefs.getBoolean( + context.getString(R.string.popup_remember_size_pos_key), true) + val defaultSize: Float = context.getResources().getDimension(R.dimen.popup_default_width) + val popupWidth: Float = if (popupRememberSizeAndPos) prefs.getFloat(context.getString(R.string.popup_saved_width_key), defaultSize) else defaultSize + val popupHeight: Float = PlayerHelper.getMinimumVideoHeight(popupWidth) + val params: WindowManager.LayoutParams = WindowManager.LayoutParams(popupWidth.toInt(), popupHeight.toInt(), + popupLayoutParamType(), + IDLE_WINDOW_FLAGS, + PixelFormat.TRANSLUCENT) + params.gravity = Gravity.LEFT or Gravity.TOP + params.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE + val centerX: Int = (screenWidth / 2f - popupWidth / 2f).toInt() + val centerY: Int = (screenHeight / 2f - popupHeight / 2f).toInt() + params.x = if (popupRememberSizeAndPos) prefs.getInt(context.getString(R.string.popup_saved_x_key), centerX) else centerX + params.y = if (popupRememberSizeAndPos) prefs.getInt(context.getString(R.string.popup_saved_y_key), centerY) else centerY + return params + } + + fun savePopupPositionAndSizeToPrefs() { + if (popupLayoutParams != null) { + val context: Context = getPlayer().getContext() + getPlayer().getPrefs().edit() + .putFloat(context.getString(R.string.popup_saved_width_key), + popupLayoutParams!!.width.toFloat()) + .putInt(context.getString(R.string.popup_saved_x_key), + popupLayoutParams!!.x) + .putInt(context.getString(R.string.popup_saved_y_key), + popupLayoutParams!!.y) + .apply() + } + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Getters + ////////////////////////////////////////////////////////////////////////// */ + //region Getters + private fun popupHasParent(): Boolean { + return ((binding != null + ) && binding!!.getRoot().getLayoutParams() is WindowManager.LayoutParams + && (binding!!.getRoot().getParent() != null)) + } + + private fun anyPopupViewIsNull(): Boolean { + return (popupLayoutParams == null) || (windowManager == null + ) || (binding!!.getRoot().getParent() == null) + } + + companion object { + private val TAG: String = PopupPlayerUi::class.java.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 val MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER: Float = 0.8f + + /*////////////////////////////////////////////////////////////////////////// + // Popup player window manager + ////////////////////////////////////////////////////////////////////////// */ + val IDLE_WINDOW_FLAGS: Int = (WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) + val ONGOING_PLAYBACK_WINDOW_FLAGS: Int = (IDLE_WINDOW_FLAGS + or WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + + @SuppressLint("RtlHardcoded") + fun buildCloseOverlayLayoutParams(): WindowManager.LayoutParams { + val flags: Int = (WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE + or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) + val closeOverlayLayoutParams: WindowManager.LayoutParams = 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 or Gravity.TOP + closeOverlayLayoutParams.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE + return closeOverlayLayoutParams + } + + fun popupLayoutParamType(): Int { + return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) WindowManager.LayoutParams.TYPE_PHONE else WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java deleted file mode 100644 index b51aaa638..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java +++ /dev/null @@ -1,1620 +0,0 @@ -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(R.drawable.exo_controls_repeat_all); - } else if (repeatMode == REPEAT_MODE_ONE) { - binding.repeatButton.setImageResource(R.drawable.exo_controls_repeat_one); - } else /* repeatMode == REPEAT_MODE_OFF */ { - binding.repeatButton.setImageResource(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); - } - - 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); - 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 -} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.kt b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.kt new file mode 100644 index 000000000..abe10be66 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.kt @@ -0,0 +1,1393 @@ +package org.schabi.newpipe.player.ui + +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.view.View.OnLayoutChangeListener +import android.view.View.OnLongClickListener +import android.widget.LinearLayout +import android.widget.RelativeLayout +import android.widget.SeekBar +import android.widget.SeekBar.OnSeekBarChangeListener +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.OnApplyWindowInsetsListener +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.Tracks +import com.google.android.exoplayer2.source.TrackGroup +import com.google.android.exoplayer2.text.Cue +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.ResizeMode +import com.google.android.exoplayer2.ui.CaptionStyleCompat +import com.google.android.exoplayer2.video.VideoSize +import org.schabi.newpipe.App +import org.schabi.newpipe.MainActivity +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.StreamType +import org.schabi.newpipe.extractor.stream.VideoStream +import org.schabi.newpipe.fragments.detail.VideoDetailFragment +import org.schabi.newpipe.ktx.AnimationType +import org.schabi.newpipe.ktx.animate +import org.schabi.newpipe.ktx.animateRotation +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.PerformListener +import org.schabi.newpipe.views.player.PlayerFastSeekOverlay.PerformListener.FastSeekDirection +import java.util.Objects +import java.util.Optional +import java.util.function.Consumer +import java.util.function.Function +import java.util.function.IntSupplier +import java.util.function.Predicate +import java.util.stream.Collectors + +abstract class VideoPlayerUi protected constructor(player: Player, + playerBinding: PlayerBinding) : PlayerUi(player), OnSeekBarChangeListener, PopupMenu.OnMenuItemClickListener, PopupMenu.OnDismissListener { + private enum class PlayButtonAction { + PLAY, + PAUSE, + REPLAY + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Getters + ////////////////////////////////////////////////////////////////////////// */ + //region Getters + /*////////////////////////////////////////////////////////////////////////// + // Views + ////////////////////////////////////////////////////////////////////////// */ + var binding: PlayerBinding? + protected set + private val controlsVisibilityHandler: Handler = Handler(Looper.getMainLooper()) + private var surfaceHolderCallback: SurfaceHolderCallback? = null + var surfaceIsSetup: Boolean = false + var isSomePopupMenuVisible: Boolean = false + protected set + private var qualityPopupMenu: PopupMenu? = null + private var audioTrackPopupMenu: PopupMenu? = null + protected var playbackSpeedPopupMenu: PopupMenu? = null + private var captionPopupMenu: PopupMenu? = null + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Gestures + ////////////////////////////////////////////////////////////////////////// */ + var gestureDetector: GestureDetector? = null + private set + private var playerGestureListener: BasePlayerGestureListener? = null + private var onLayoutChangeListener: OnLayoutChangeListener? = null + private val seekbarPreviewThumbnailHolder: SeekbarPreviewThumbnailHolder = SeekbarPreviewThumbnailHolder() + + /*////////////////////////////////////////////////////////////////////////// + // Constructor, setup, destroy + ////////////////////////////////////////////////////////////////////////// */ + //region Constructor, setup, destroy + init { + binding = playerBinding + setupFromView() + } + + fun setupFromView() { + initViews() + initListeners() + setupPlayerSeekOverlay() + } + + private fun initViews() { + setupSubtitleView() + binding!!.resizeTextView + .setText(PlayerHelper.resizeTypeOf(context, binding!!.surfaceView.getResizeMode())) + binding!!.playbackSeekBar.getThumb() + .setColorFilter(PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN)) + binding!!.playbackSeekBar.getProgressDrawable() + .setColorFilter(PorterDuffColorFilter(Color.RED, PorterDuff.Mode.MULTIPLY)) + val themeWrapper: ContextThemeWrapper = ContextThemeWrapper(context, + R.style.DarkPopupMenu) + qualityPopupMenu = PopupMenu(themeWrapper, binding!!.qualityTextView) + audioTrackPopupMenu = PopupMenu(themeWrapper, binding!!.audioTrackTextView) + playbackSpeedPopupMenu = PopupMenu(context, binding!!.playbackSpeed) + captionPopupMenu = PopupMenu(themeWrapper, binding!!.captionTextView) + binding!!.progressBarLoadingPanel.getIndeterminateDrawable() + .setColorFilter(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 fun buildGestureListener(): BasePlayerGestureListener + protected open fun initListeners() { + binding!!.qualityTextView.setOnClickListener(makeOnClickListener(Runnable({ onQualityClicked() }))) + binding!!.audioTrackTextView.setOnClickListener( + makeOnClickListener(Runnable({ onAudioTracksClicked() }))) + binding!!.playbackSpeed.setOnClickListener(makeOnClickListener(Runnable({ onPlaybackSpeedClicked() }))) + binding!!.playbackSeekBar.setOnSeekBarChangeListener(this) + binding!!.captionTextView.setOnClickListener(makeOnClickListener(Runnable({ onCaptionClicked() }))) + binding!!.resizeTextView.setOnClickListener(makeOnClickListener(Runnable({ onResizeClicked() }))) + binding!!.playbackLiveSync.setOnClickListener(makeOnClickListener(Runnable({ player.seekToDefault() }))) + playerGestureListener = buildGestureListener() + gestureDetector = GestureDetector(context, playerGestureListener!!) + binding!!.getRoot().setOnTouchListener(playerGestureListener) + binding!!.repeatButton.setOnClickListener(View.OnClickListener({ v: View? -> onRepeatClicked() })) + binding!!.shuffleButton.setOnClickListener(View.OnClickListener({ v: View? -> onShuffleClicked() })) + binding!!.playPauseButton.setOnClickListener(makeOnClickListener(Runnable({ player.playPause() }))) + binding!!.playPreviousButton.setOnClickListener(makeOnClickListener(Runnable({ player.playPrevious() }))) + binding!!.playNextButton.setOnClickListener(makeOnClickListener(Runnable({ player.playNext() }))) + binding!!.moreOptionsButton.setOnClickListener( + makeOnClickListener(Runnable({ onMoreOptionsClicked() }))) + binding!!.share.setOnClickListener(makeOnClickListener(Runnable({ + val currentItem: PlayQueueItem? = player.getCurrentItem() + if (currentItem != null) { + ShareUtils.shareText(context, currentItem.getTitle(), + player.getVideoUrlAtCurrentTime(), currentItem.getThumbnails()) + } + }))) + binding!!.share.setOnLongClickListener(OnLongClickListener({ v: View? -> + ShareUtils.copyToClipboard(context, player.getVideoUrlAtCurrentTime()) + true + })) + binding!!.fullScreenButton.setOnClickListener(makeOnClickListener(Runnable({ + player.setRecovery() + NavigationHelper.playOnMainPlayer(context, + Objects.requireNonNull(player.getPlayQueue()), true) + }))) + binding!!.playWithKodi.setOnClickListener(makeOnClickListener(Runnable({ onPlayWithKodiClicked() }))) + binding!!.openInBrowser.setOnClickListener(makeOnClickListener(Runnable({ onOpenInBrowserClicked() }))) + binding!!.playerCloseButton.setOnClickListener(makeOnClickListener(Runnable({ // set package to this app's package to prevent the intent from being seen outside + context.sendBroadcast(Intent(VideoDetailFragment.Companion.ACTION_HIDE_MAIN_PLAYER) + .setPackage(App.Companion.PACKAGE_NAME)) + }) + )) + binding!!.switchMute.setOnClickListener(makeOnClickListener(Runnable({ player.toggleMute() }))) + ViewCompat.setOnApplyWindowInsetsListener(binding!!.itemsListPanel, OnApplyWindowInsetsListener({ view: View, windowInsets: WindowInsetsCompat -> + val cutout: Insets = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()) + if (!(cutout == Insets.NONE)) { + view.setPadding(cutout.left, cutout.top, cutout.right, cutout.bottom) + } + 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 = OnLayoutChangeListener({ v: View, left: Int, top: Int, right: Int, bottom: Int, oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int -> + 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. + val fastSeekParams: RelativeLayout.LayoutParams = binding!!.fastSeekOverlay.getLayoutParams() as RelativeLayout.LayoutParams + fastSeekParams.leftMargin = -v.getPaddingRight() + fastSeekParams.topMargin = -v.getPaddingBottom() + fastSeekParams.rightMargin = -v.getPaddingLeft() + fastSeekParams.bottomMargin = -v.getPaddingTop() + }) + binding!!.playbackControlRoot.addOnLayoutChangeListener(onLayoutChangeListener) + } + + protected open fun 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 fun setupPlayerSeekOverlay() { + binding!!.fastSeekOverlay + .seekSecondsSupplier({ PlayerHelper.retrieveSeekDurationFromPreferences(player) / 1000 }) + .performListener(object : PerformListener { + public override fun onDoubleTap() { + binding!!.fastSeekOverlay.animate(true, SEEK_OVERLAY_DURATION.toLong()) + } + + public override fun onDoubleTapEnd() { + binding!!.fastSeekOverlay.animate(false, SEEK_OVERLAY_DURATION.toLong()) + } + + public override fun getFastSeekDirection( + portion: DisplayPortion + ): FastSeekDirection { + 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() == Player.Companion.STATE_COMPLETED + || player.getExoPlayer()!!.getCurrentPosition() + >= player.getExoPlayer()!!.getDuration())) { + return FastSeekDirection.NONE + } + return FastSeekDirection.FORWARD + } + /* portion == DisplayPortion.MIDDLE */return FastSeekDirection.NONE + } + + public override fun seek(forward: Boolean) { + playerGestureListener!!.keepInDoubleTapMode() + if (forward) { + player.fastForward() + } else { + player.fastRewind() + } + } + }) + playerGestureListener!!.doubleTapControls(binding!!.fastSeekOverlay) + } + + fun deinitPlayerSeekOverlay() { + binding!!.fastSeekOverlay + .seekSecondsSupplier(null) + .performListener(null) + } + + public override fun setupAfterIntent() { + super.setupAfterIntent() + setupElementsVisibility() + setupElementsSize(context.getResources()) + binding!!.getRoot().setVisibility(View.VISIBLE) + binding!!.playPauseButton.requestFocus() + } + + public override fun initPlayer() { + super.initPlayer() + setupVideoSurfaceIfNeeded() + } + + public override fun initPlayback() { + super.initPlayback() + + // #6825 - Ensure that the shuffle-button is in the correct state on the UI + setShuffleButton(player.getExoPlayer()!!.getShuffleModeEnabled()) + } + + abstract fun removeViewFromParent() + public override fun destroyPlayer() { + super.destroyPlayer() + clearVideoSurface() + } + + public override fun destroy() { + super.destroy() + binding!!.endScreen.setImageDrawable(null) + deinitPlayerSeekOverlay() + deinitListeners() + } + + protected open fun setupElementsVisibility() { + setMuteButton(player.isMuted()) + binding!!.moreOptionsButton.animateRotation(DEFAULT_CONTROLS_DURATION, 0) + } + + protected abstract fun setupElementsSize(resources: Resources) + protected fun setupElementsSize(buttonsMinWidth: Int, + playerTopPad: Int, + controlsPad: Int, + buttonsPad: Int) { + 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 + public override fun onBroadcastReceived(intent: Intent) { + super.onBroadcastReceived(intent) + if ((Intent.ACTION_CONFIGURATION_CHANGED == 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. + * + */ + public override fun onThumbnailLoaded(bitmap: Bitmap?) { + super.onThumbnailLoaded(bitmap) + updateEndScreenThumbnail(bitmap) + } + + private fun updateEndScreenThumbnail(thumbnail: Bitmap?) { + if (thumbnail == null) { + // remove end screen thumbnail + binding!!.endScreen.setImageDrawable(null) + return + } + val endScreenHeight: Float = calculateMaxEndScreenThumbnailHeight(thumbnail) + val endScreenBitmap: Bitmap = BitmapCompat.createScaledBitmap( + thumbnail, (thumbnail.getWidth() / (thumbnail.getHeight() / endScreenHeight)).toInt(), endScreenHeight.toInt(), + null, + true) + if (MainActivity.Companion.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 fun calculateMaxEndScreenThumbnailHeight(bitmap: Bitmap): Float + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Progress loop and updates + ////////////////////////////////////////////////////////////////////////// */ + //region Progress loop and updates + public override fun onUpdateProgress(currentProgress: Int, + duration: Int, + bufferPercent: Int) { + if (duration != binding!!.playbackSeekBar.getMax()) { + setVideoDurationToControls(duration) + } + if (player.getCurrentState() != Player.Companion.STATE_PAUSED) { + updatePlayBackElementsCurrentDuration(currentProgress) + } + if (player.isLoading() || bufferPercent > 90) { + binding!!.playbackSeekBar.setSecondaryProgress((binding!!.playbackSeekBar.getMax() * (bufferPercent.toFloat() / 100)).toInt()) + } + if (MainActivity.Companion.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 fun updatePlayBackElementsCurrentDuration(currentProgress: Int) { + // Don't set seekbar progress while user is seeking + if (player.getCurrentState() != Player.Companion.STATE_PAUSED_SEEK) { + binding!!.playbackSeekBar.setProgress(currentProgress) + } + binding!!.playbackCurrentTime.setText(PlayerHelper.getTimeString(currentProgress)) + } + + /** + * Sets the video duration time into all control components (e.g. seekbar). + * + * @param duration the video duration, in milliseconds + */ + private fun setVideoDurationToControls(duration: Int) { + binding!!.playbackEndTime.setText(PlayerHelper.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)) + } + + // seekbar listener + public override fun onProgressChanged(seekBar: SeekBar, progress: Int, + fromUser: Boolean) { + // Currently we don't need method execution when fromUser is false + if (!fromUser) { + return + } + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, ("onProgressChanged() called with: " + + "seekBar = [" + seekBar + "], progress = [" + progress + "]")) + } + binding!!.currentDisplaySeek.setText(PlayerHelper.getTimeString(progress)) + + // Seekbar Preview Thumbnail + SeekbarPreviewThumbnailHelper.tryResizeAndSetSeekbarPreviewThumbnail( + player.getContext(), + seekbarPreviewThumbnailHolder.getBitmapAt(progress).orElse(null), + binding!!.currentSeekbarPreviewThumbnail, IntSupplier({ binding!!.subtitleView.getWidth() })) + adjustSeekbarPreviewContainer() + } + + private fun 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 + val currentSeekbarLeft: Int = (binding!!.playbackSeekBar.getLeft() + + binding!!.playbackSeekBar.getPaddingLeft() + + binding!!.playbackSeekBar.getThumb().getBounds().left) + + // Calculate the (unchecked) left position of the container + val uncheckedContainerLeft: Int = currentSeekbarLeft - (binding!!.seekbarPreviewContainer.getWidth() / 2) + + // Fix the position so it's within the boundaries + val checkedContainerLeft: Int = MathUtils.clamp(uncheckedContainerLeft, + 0, (binding!!.playbackWindowRoot.getWidth() + - binding!!.seekbarPreviewContainer.getWidth())) + + // See also: https://stackoverflow.com/a/23249734 + val params: LinearLayout.LayoutParams = LinearLayout.LayoutParams( + binding!!.seekbarPreviewContainer.getLayoutParams()) + params.setMarginStart(checkedContainerLeft) + binding!!.seekbarPreviewContainer.setLayoutParams(params) + } catch (ex: Exception) { + Log.e(TAG, "Failed to adjust seekbarPreviewContainer", ex) + // Fallback - position in the middle + binding!!.bottomSeekbarPreviewLayout.setGravity(Gravity.CENTER) + } + } + + // seekbar listener + public override fun onStartTrackingTouch(seekBar: SeekBar) { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "onStartTrackingTouch() called with: seekBar = [" + seekBar + "]") + } + if (player.getCurrentState() != Player.Companion.STATE_PAUSED_SEEK) { + player.changeState(Player.Companion.STATE_PAUSED_SEEK) + } + showControls(0) + binding!!.currentDisplaySeek.animate(true, DEFAULT_CONTROLS_DURATION, AnimationType.SCALE_AND_ALPHA) + binding!!.currentSeekbarPreviewThumbnail.animate(true, DEFAULT_CONTROLS_DURATION, AnimationType.SCALE_AND_ALPHA) + } + + // seekbar listener + public override fun onStopTrackingTouch(seekBar: SeekBar) { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "onStopTrackingTouch() called with: seekBar = [" + seekBar + "]") + } + player.seekTo(seekBar.getProgress().toLong()) + if (player.getExoPlayer()!!.getDuration() == seekBar.getProgress().toLong()) { + player.getExoPlayer()!!.play() + } + binding!!.playbackCurrentTime.setText(PlayerHelper.getTimeString(seekBar.getProgress())) + binding!!.currentDisplaySeek.animate(false, 200, AnimationType.SCALE_AND_ALPHA) + binding!!.currentSeekbarPreviewThumbnail.animate(false, 200, AnimationType.SCALE_AND_ALPHA) + if (player.getCurrentState() == Player.Companion.STATE_PAUSED_SEEK) { + player.changeState(Player.Companion.STATE_BUFFERING) + } + if (!player.isProgressLoopRunning()) { + player.startProgressLoop() + } + showControlsThenHide() + } + + val isControlsVisible: Boolean + //endregion + get() { + return binding != null && binding!!.playbackControlRoot.getVisibility() == View.VISIBLE + } + + fun showControlsThenHide() { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "showControlsThenHide() called") + } + showOrHideButtons() + showSystemUIPartially() + val hideTime: Long = if (binding!!.playbackControlRoot.isInTouchMode()) DEFAULT_CONTROLS_HIDE_TIME else DPAD_CONTROLS_HIDE_TIME + showHideShadow(true, DEFAULT_CONTROLS_DURATION) + binding!!.playbackControlRoot.animate(true, DEFAULT_CONTROLS_DURATION, AnimationType.ALPHA, 0, Runnable({ hideControls(DEFAULT_CONTROLS_DURATION, hideTime) })) + } + + fun showControls(duration: Long) { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "showControls() called") + } + showOrHideButtons() + showSystemUIPartially() + controlsVisibilityHandler.removeCallbacksAndMessages(null) + showHideShadow(true, duration) + binding!!.playbackControlRoot.animate(true, duration) + } + + fun hideControls(duration: Long, delay: Long) { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, ("hideControls() called with: duration = [" + duration + + "], delay = [" + delay + "]")) + } + showOrHideButtons() + controlsVisibilityHandler.removeCallbacksAndMessages(null) + controlsVisibilityHandler.postDelayed(Runnable({ + showHideShadow(false, duration) + binding!!.playbackControlRoot.animate(false, duration, AnimationType.ALPHA, 0, Runnable({ hideSystemUIIfNeeded() })) + }), delay) + } + + fun showHideShadow(show: Boolean, duration: Long) { + binding!!.playbackControlsShadow.animate(show, duration, AnimationType.ALPHA, 0, null) + binding!!.playerTopShadow.animate(show, duration, AnimationType.ALPHA, 0, null) + binding!!.playerBottomShadow.animate(show, duration, AnimationType.ALPHA, 0, null) + } + + protected open fun showOrHideButtons() { + val playQueue: PlayQueue? = player.getPlayQueue() + if (playQueue == null) { + return + } + val showPrev: Boolean = playQueue.getIndex() != 0 + val showNext: Boolean = playQueue.getIndex() + 1 != playQueue.getStreams().size + binding!!.playPreviousButton.setVisibility(if (showPrev) View.VISIBLE else View.INVISIBLE) + binding!!.playPreviousButton.setAlpha(if (showPrev) 1.0f else 0.0f) + binding!!.playNextButton.setVisibility(if (showNext) View.VISIBLE else View.INVISIBLE) + binding!!.playNextButton.setAlpha(if (showNext) 1.0f else 0.0f) + } + + protected open fun showSystemUIPartially() { + // system UI is really changed only by MainPlayerUi, so overridden there + } + + protected open fun hideSystemUIIfNeeded() { + // system UI is really changed only by MainPlayerUi, so overridden there + } + + protected open val isAnyListViewOpen: Boolean + protected get() { + // only MainPlayerUi has list views for the queue and for segments, so overridden there + return false + } + open val isFullscreen: Boolean + get() { + // only MainPlayerUi can be in fullscreen, so overridden there + return false + } + + /** + * Update the play/pause button ([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 fun updatePlayPauseButton(action: PlayButtonAction) { + val button: AppCompatImageButton = binding!!.playPauseButton + when (action) { + PlayButtonAction.PLAY -> { + button.setContentDescription(context.getString(R.string.play)) + button.setImageResource(R.drawable.ic_play_arrow) + } + + PlayButtonAction.PAUSE -> { + button.setContentDescription(context.getString(R.string.pause)) + button.setImageResource(R.drawable.ic_pause) + } + + PlayButtonAction.REPLAY -> { + button.setContentDescription(context.getString(R.string.replay)) + button.setImageResource(R.drawable.ic_replay) + } + } + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Playback states + ////////////////////////////////////////////////////////////////////////// */ + //region Playback states + public override fun onPrepared() { + super.onPrepared() + setVideoDurationToControls(player.getExoPlayer()!!.getDuration().toInt()) + binding!!.playbackSpeed.setText(PlayerHelper.formatSpeed(player.getPlaybackSpeed().toDouble())) + } + + public override fun 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(PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN)) + binding!!.loadingPanel.setBackgroundColor(Color.BLACK) + binding!!.loadingPanel.animate(true, 0) + binding!!.surfaceForeground.animate(true, 100) + updatePlayPauseButton(PlayButtonAction.PLAY) + animatePlayButtons(false, 100) + binding!!.getRoot().setKeepScreenOn(false) + } + + public override fun onPlaying() { + super.onPlaying() + updateStreamRelatedViews() + binding!!.playbackSeekBar.setEnabled(true) + binding!!.playbackSeekBar.getThumb() + .setColorFilter(PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN)) + binding!!.loadingPanel.setVisibility(View.GONE) + binding!!.currentDisplaySeek.animate(false, 200, AnimationType.SCALE_AND_ALPHA) + binding!!.playPauseButton.animate(false, 80, AnimationType.SCALE_AND_ALPHA, 0, Runnable({ + updatePlayPauseButton(PlayButtonAction.PAUSE) + animatePlayButtons(true, 200) + if (!isAnyListViewOpen) { + binding!!.playPauseButton.requestFocus() + } + })) + binding!!.getRoot().setKeepScreenOn(true) + } + + public override fun onBuffering() { + super.onBuffering() + binding!!.loadingPanel.setBackgroundColor(Color.TRANSPARENT) + binding!!.loadingPanel.setVisibility(View.VISIBLE) + binding!!.getRoot().setKeepScreenOn(true) + } + + public override fun 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) + binding!!.playPauseButton.animate(false, 80, AnimationType.SCALE_AND_ALPHA, 0, Runnable({ + updatePlayPauseButton(PlayButtonAction.PLAY) + animatePlayButtons(true, 200) + if (!isAnyListViewOpen) { + binding!!.playPauseButton.requestFocus() + } + })) + } + binding!!.getRoot().setKeepScreenOn(false) + } + + public override fun onPausedSeek() { + super.onPausedSeek() + animatePlayButtons(false, 100) + binding!!.getRoot().setKeepScreenOn(true) + } + + public override fun onCompleted() { + super.onCompleted() + binding!!.playPauseButton.animate(false, 0, AnimationType.SCALE_AND_ALPHA, 0, Runnable({ + 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) + binding!!.currentDisplaySeek.animate(false, 200, AnimationType.SCALE_AND_ALPHA) + binding!!.loadingPanel.setVisibility(View.GONE) + binding!!.surfaceForeground.animate(true, 100) + } + + private fun animatePlayButtons(show: Boolean, duration: Long) { + binding!!.playPauseButton.animate(show, duration, AnimationType.SCALE_AND_ALPHA) + val playQueue: PlayQueue? = player.getPlayQueue() + if (playQueue == null) { + return + } + if (!show || playQueue.getIndex() > 0) { + binding!!.playPreviousButton.animate(show, duration, AnimationType.SCALE_AND_ALPHA) + } + if (!show || playQueue.getIndex() + 1 < playQueue.getStreams().size) { + binding!!.playNextButton.animate(show, duration, AnimationType.SCALE_AND_ALPHA) + } + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Repeat, shuffle, mute + ////////////////////////////////////////////////////////////////////////// */ + //region Repeat, shuffle, mute + fun onRepeatClicked() { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "onRepeatClicked() called") + } + player.cycleNextRepeatMode() + } + + fun onShuffleClicked() { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "onShuffleClicked() called") + } + player.toggleShuffleModeEnabled() + } + + public override fun onRepeatModeChanged(repeatMode: @com.google.android.exoplayer2.Player.RepeatMode Int) { + super.onRepeatModeChanged(repeatMode) + if (repeatMode == com.google.android.exoplayer2.Player.REPEAT_MODE_ALL) { + binding!!.repeatButton.setImageResource(R.drawable.exo_controls_repeat_all) + } else if (repeatMode == com.google.android.exoplayer2.Player.REPEAT_MODE_ONE) { + binding!!.repeatButton.setImageResource(R.drawable.exo_controls_repeat_one) + } else /* repeatMode == REPEAT_MODE_OFF */ { + binding!!.repeatButton.setImageResource(R.drawable.exo_controls_repeat_off) + } + } + + public override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + super.onShuffleModeEnabledChanged(shuffleModeEnabled) + setShuffleButton(shuffleModeEnabled) + } + + public override fun onMuteUnmuteChanged(isMuted: Boolean) { + super.onMuteUnmuteChanged(isMuted) + setMuteButton(isMuted) + } + + private fun setMuteButton(isMuted: Boolean) { + binding!!.switchMute.setImageDrawable(AppCompatResources.getDrawable(context, if (isMuted) R.drawable.ic_volume_off else R.drawable.ic_volume_up)) + } + + private fun setShuffleButton(shuffled: Boolean) { + binding!!.shuffleButton.setImageAlpha(if (shuffled) 255 else 77) + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Other player listeners + ////////////////////////////////////////////////////////////////////////// */ + //region Other player listeners + public override fun onPlaybackParametersChanged(playbackParameters: PlaybackParameters) { + super.onPlaybackParametersChanged(playbackParameters) + binding!!.playbackSpeed.setText(PlayerHelper.formatSpeed(playbackParameters.speed.toDouble())) + } + + public override fun onRenderedFirstFrame() { + super.onRenderedFirstFrame() + //TODO check if this causes black screen when switching to fullscreen + binding!!.surfaceForeground.animate(false, DEFAULT_CONTROLS_DURATION) + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Metadata & stream related views + ////////////////////////////////////////////////////////////////////////// */ + //region Metadata & stream related views + public override fun onMetadataChanged(info: StreamInfo) { + super.onMetadataChanged(info) + updateStreamRelatedViews() + binding!!.titleTextView.setText(info.getName()) + binding!!.channelTextView.setText(info.getUploaderName()) + seekbarPreviewThumbnailHolder.resetFrom(player.getContext(), info.getPreviewFrames()) + } + + private fun updateStreamRelatedViews() { + player.getCurrentStreamInfo().ifPresent(Consumer({ info: StreamInfo? -> + 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) + when (info!!.getStreamType()) { + StreamType.AUDIO_STREAM, StreamType.POST_LIVE_AUDIO_STREAM -> { + binding!!.surfaceView.setVisibility(View.GONE) + binding!!.endScreen.setVisibility(View.VISIBLE) + binding!!.playbackEndTime.setVisibility(View.VISIBLE) + } + + StreamType.AUDIO_LIVE_STREAM -> { + binding!!.surfaceView.setVisibility(View.GONE) + binding!!.endScreen.setVisibility(View.VISIBLE) + binding!!.playbackLiveSync.setVisibility(View.VISIBLE) + } + + StreamType.LIVE_STREAM -> { + binding!!.surfaceView.setVisibility(View.VISIBLE) + binding!!.endScreen.setVisibility(View.GONE) + binding!!.playbackLiveSync.setVisibility(View.VISIBLE) + } + + StreamType.VIDEO_STREAM, StreamType.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) + binding!!.endScreen.setVisibility(View.GONE) + binding!!.playbackEndTime.setVisibility(View.VISIBLE) + } + + else -> { + binding!!.endScreen.setVisibility(View.GONE) + binding!!.playbackEndTime.setVisibility(View.VISIBLE) + } + } + 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 fun buildQualityMenu() { + if (qualityPopupMenu == null) { + return + } + qualityPopupMenu!!.getMenu().removeGroup(POPUP_MENU_ID_QUALITY) + val availableStreams: List? = Optional.ofNullable(player.getCurrentMetadata()) + .flatMap(Function>({ obj: MediaItemTag -> obj.getMaybeQuality() })) + .map?>(Function?>({ getSortedVideoStreams() })) + .orElse(null) + if (availableStreams == null) { + return + } + for (i in availableStreams.indices) { + val 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(Consumer({ s: VideoStream? -> binding!!.qualityTextView.setText(s!!.getResolution()) })) + } + + private fun buildAudioTrackMenu() { + if (audioTrackPopupMenu == null) { + return + } + audioTrackPopupMenu!!.getMenu().removeGroup(POPUP_MENU_ID_AUDIO_TRACK) + val availableStreams: List? = Optional.ofNullable(player.getCurrentMetadata()) + .flatMap(Function>({ obj: MediaItemTag -> obj.getMaybeAudioTrack() })) + .map?>(Function?>({ getAudioStreams() })) + .orElse(null) + if (availableStreams == null || availableStreams.size < 2) { + return + } + for (i in availableStreams.indices) { + val audioStream: AudioStream = availableStreams.get(i) + audioTrackPopupMenu!!.getMenu().add(POPUP_MENU_ID_AUDIO_TRACK, i, Menu.NONE, + Localization.audioTrackName(context, audioStream)) + } + player.getSelectedAudioStream() + .ifPresent(Consumer({ s: AudioStream? -> + binding!!.audioTrackTextView.setText( + Localization.audioTrackName(context, s)) + })) + binding!!.audioTrackTextView.setVisibility(View.VISIBLE) + audioTrackPopupMenu!!.setOnMenuItemClickListener(this) + audioTrackPopupMenu!!.setOnDismissListener(this) + } + + private fun buildPlaybackSpeedMenu() { + if (playbackSpeedPopupMenu == null) { + return + } + playbackSpeedPopupMenu!!.getMenu().removeGroup(POPUP_MENU_ID_PLAYBACK_SPEED) + for (i in PLAYBACK_SPEEDS.indices) { + playbackSpeedPopupMenu!!.getMenu().add(POPUP_MENU_ID_PLAYBACK_SPEED, i, Menu.NONE, + PlayerHelper.formatSpeed(PLAYBACK_SPEEDS.get(i).toDouble())) + } + binding!!.playbackSpeed.setText(PlayerHelper.formatSpeed(player.getPlaybackSpeed().toDouble())) + playbackSpeedPopupMenu!!.setOnMenuItemClickListener(this) + playbackSpeedPopupMenu!!.setOnDismissListener(this) + } + + private fun buildCaptionMenu(availableLanguages: List) { + if (captionPopupMenu == null) { + return + } + captionPopupMenu!!.getMenu().removeGroup(POPUP_MENU_ID_CAPTION) + captionPopupMenu!!.setOnDismissListener(this) + + // Add option for turning off caption + val captionOffItem: MenuItem = captionPopupMenu!!.getMenu().add(POPUP_MENU_ID_CAPTION, + 0, Menu.NONE, R.string.caption_none) + captionOffItem.setOnMenuItemClickListener(MenuItem.OnMenuItemClickListener({ menuItem: MenuItem? -> + val textRendererIndex: Int = player.getCaptionRendererIndex() + if (textRendererIndex != Player.Companion.RENDERER_UNAVAILABLE) { + player.getTrackSelector().setParameters(player.getTrackSelector() + .buildUponParameters().setRendererDisabled(textRendererIndex, true)) + } + player.getPrefs().edit() + .remove(context.getString(R.string.caption_user_set_key)).apply() + true + })) + + // Add all available captions + for (i in availableLanguages.indices) { + val captionLanguage: String = (availableLanguages.get(i))!! + val captionItem: MenuItem = captionPopupMenu!!.getMenu().add(POPUP_MENU_ID_CAPTION, + i + 1, Menu.NONE, captionLanguage) + captionItem.setOnMenuItemClickListener(MenuItem.OnMenuItemClickListener({ menuItem: MenuItem? -> + val textRendererIndex: Int = player.getCaptionRendererIndex() + if (textRendererIndex != Player.Companion.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() + } + true + })) + } + captionPopupMenu!!.setOnDismissListener(this) + + // apply caption language from previous user preference + val textRendererIndex: Int = player.getCaptionRendererIndex() + if (textRendererIndex == Player.Companion.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. + val userPreferredLanguage: String? = 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. + val selectedPreferredLanguages: List = 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 fun onPlaybackSpeedClicked() + private fun onQualityClicked() { + qualityPopupMenu!!.show() + isSomePopupMenuVisible = true + player.getSelectedVideoStream() + .map(Function({ s: VideoStream? -> MediaFormat.getNameById(s!!.getFormatId()) + " " + s.getResolution() })) + .ifPresent(Consumer({ text: String? -> binding!!.qualityTextView.setText(text) })) + } + + private fun onAudioTracksClicked() { + audioTrackPopupMenu!!.show() + isSomePopupMenuVisible = true + } + + /** + * Called when an item of the quality selector or the playback speed selector is selected. + */ + public override fun onMenuItemClick(menuItem: MenuItem): Boolean { + if (MainActivity.Companion.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) { + val speedIndex: Int = menuItem.getItemId() + val speed: Float = PLAYBACK_SPEEDS.get(speedIndex) + player.setPlaybackSpeed(speed) + binding!!.playbackSpeed.setText(PlayerHelper.formatSpeed(speed.toDouble())) + } + return false + } + + private fun onQualityItemClick(menuItem: MenuItem) { + val menuItemIndex: Int = menuItem.getItemId() + val currentMetadata: MediaItemTag? = player.getCurrentMetadata() + if (currentMetadata == null || currentMetadata.getMaybeQuality().isEmpty()) { + return + } + val quality: MediaItemTag.Quality = currentMetadata.getMaybeQuality().get() + val availableStreams: List = quality.getSortedVideoStreams() + val selectedStreamIndex: Int = quality.getSelectedVideoStreamIndex() + if (selectedStreamIndex == menuItemIndex || availableStreams.size <= menuItemIndex) { + return + } + val newResolution: String = availableStreams.get(menuItemIndex)!!.getResolution() + player.setPlaybackQuality(newResolution) + binding!!.qualityTextView.setText(menuItem.getTitle()) + } + + private fun onAudioTrackItemClick(menuItem: MenuItem) { + val menuItemIndex: Int = menuItem.getItemId() + val currentMetadata: MediaItemTag? = player.getCurrentMetadata() + if (currentMetadata == null || currentMetadata.getMaybeAudioTrack().isEmpty()) { + return + } + val audioTrack: MediaItemTag.AudioTrack = currentMetadata.getMaybeAudioTrack().get() + val availableStreams: List = audioTrack.getAudioStreams() + val selectedStreamIndex: Int = audioTrack.getSelectedAudioStreamIndex() + if (selectedStreamIndex == menuItemIndex || availableStreams.size <= menuItemIndex) { + return + } + val newAudioTrack: String? = availableStreams.get(menuItemIndex)!!.getAudioTrackId() + player.setAudioTrack(newAudioTrack) + binding!!.audioTrackTextView.setText(menuItem.getTitle()) + } + + /** + * Called when some popup menu is dismissed. + */ + public override fun onDismiss(menu: PopupMenu?) { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "onDismiss() called with: menu = [" + menu + "]") + } + isSomePopupMenuVisible = false //TODO check if this works + player.getSelectedVideoStream() + .ifPresent(Consumer({ s: VideoStream? -> binding!!.qualityTextView.setText(s!!.getResolution()) })) + if (player.isPlaying()) { + hideControls(DEFAULT_CONTROLS_DURATION, 0) + hideSystemUIIfNeeded() + } + } + + private fun onCaptionClicked() { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "onCaptionClicked() called") + } + captionPopupMenu!!.show() + isSomePopupMenuVisible = true + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Captions (text tracks) + ////////////////////////////////////////////////////////////////////////// */ + //region Captions (text tracks) + public override fun onTextTracksChanged(currentTracks: Tracks) { + super.onTextTracksChanged(currentTracks) + val trackTypeTextSupported: Boolean = (!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 + val textTracks: List = currentTracks + .getGroups() + .stream() + .filter(Predicate({ trackGroupInfo: Tracks.Group -> C.TRACK_TYPE_TEXT == trackGroupInfo.getType() })) + .collect(Collectors.toList()) + val availableLanguages: List = textTracks.stream() + .map(Function({ Tracks.Group.getMediaTrackGroup() })) + .filter(Predicate({ textTrack: TrackGroup -> textTrack.length > 0 })) + .map(Function({ textTrack: TrackGroup -> textTrack.getFormat(0).language })) + .collect(Collectors.toList()) + + // Find selected text track + val selectedTracks: Optional = textTracks.stream() + .filter(Predicate({ Tracks.Group.isSelected() })) + .filter(Predicate({ info: Tracks.Group -> info.getMediaTrackGroup().length >= 1 })) + .map(Function({ info: Tracks.Group -> 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( + if (availableLanguages.isEmpty()) View.GONE else View.VISIBLE) + } + + public override fun onCues(cues: List) { + super.onCues(cues) + binding!!.subtitleView.setCues(cues) + } + + private fun setupSubtitleView() { + setupSubtitleView(PlayerHelper.getCaptionScale(context)) + val captionStyle: CaptionStyleCompat = PlayerHelper.getCaptionStyle(context) + binding!!.subtitleView.setApplyEmbeddedStyles(captionStyle == CaptionStyleCompat.DEFAULT) + binding!!.subtitleView.setStyle(captionStyle) + } + + protected abstract fun setupSubtitleView(captionScale: Float) + //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 fun makeOnClickListener(runnable: Runnable): View.OnClickListener { + return View.OnClickListener({ v: View -> + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "onClick() called with: v = [" + v + "]") + } + runnable.run() + + // Manages the player controls after handling the view click. + if (player.getCurrentState() == Player.Companion.STATE_COMPLETED) { + return@OnClickListener + } + controlsVisibilityHandler.removeCallbacksAndMessages(null) + showHideShadow(true, DEFAULT_CONTROLS_DURATION) + binding!!.playbackControlRoot.animate(true, DEFAULT_CONTROLS_DURATION, AnimationType.ALPHA, 0, Runnable({ + if (player.getCurrentState() == Player.Companion.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) + } + } + })) + }) + } + + open fun onKeyDown(keyCode: Int): Boolean { + when (keyCode) { + KeyEvent.KEYCODE_BACK -> if (DeviceUtils.isTv(context) && isControlsVisible) { + hideControls(0, 0) + return true + } + + KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT, 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() == Player.Companion.STATE_BLOCKED) { + return true + } + if (isControlsVisible) { + hideControls(DEFAULT_CONTROLS_DURATION, DPAD_CONTROLS_HIDE_TIME) + } else { + binding!!.playPauseButton.requestFocus() + showControlsThenHide() + showSystemUIPartially() + return true + } + } + + else -> {} + } + return false + } + + private fun onMoreOptionsClicked() { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "onMoreOptionsClicked() called") + } + val isMoreControlsVisible: Boolean = binding!!.secondaryControls.getVisibility() == View.VISIBLE + binding!!.moreOptionsButton.animateRotation(DEFAULT_CONTROLS_DURATION, if (isMoreControlsVisible) 0 else 180) + binding!!.secondaryControls.animate(!isMoreControlsVisible, DEFAULT_CONTROLS_DURATION, AnimationType.SLIDE_AND_ALPHA, 0, Runnable({ + // 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 fun onPlayWithKodiClicked() { + if (player.getCurrentMetadata() != null) { + player.pause() + KoreUtils.playWithKore(context, Uri.parse(player.getVideoUrl())) + } + } + + private fun onOpenInBrowserClicked() { + player.getCurrentStreamInfo().ifPresent(Consumer({ streamInfo: StreamInfo? -> ShareUtils.openUrlInBrowser(player.getContext(), streamInfo!!.getOriginalUrl()) })) + } + + //endregion + /*////////////////////////////////////////////////////////////////////////// + // Video size + ////////////////////////////////////////////////////////////////////////// */ + //region Video size + protected fun setResizeMode(resizeMode: @ResizeMode Int) { + binding!!.surfaceView.setResizeMode(resizeMode) + binding!!.resizeTextView.setText(PlayerHelper.resizeTypeOf(context, resizeMode)) + } + + fun onResizeClicked() { + setResizeMode(PlayerHelper.nextResizeModeAndSaveToPrefs(player, binding!!.surfaceView.getResizeMode())) + } + + public override fun onVideoSizeChanged(videoSize: VideoSize) { + super.onVideoSizeChanged(videoSize) + binding!!.surfaceView.setAspectRatio((videoSize.width.toFloat()) / 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. + */ + fun 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 = 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 fun 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(Consumer({ obj: ExoPlayer -> obj.clearVideoSurface() })) + surfaceIsSetup = false + } + + companion object { + private val TAG: String = VideoPlayerUi::class.java.getSimpleName() + + // time constants + val DEFAULT_CONTROLS_DURATION: Long = 300 // 300 millis + val DEFAULT_CONTROLS_HIDE_TIME: Long = 2000 // 2 Seconds + val DPAD_CONTROLS_HIDE_TIME: Long = 7000 // 7 Seconds + val SEEK_OVERLAY_DURATION: Int = 450 // 450 millis + + // other constants (TODO remove playback speeds and use normal menu for popup, too) + private val PLAYBACK_SPEEDS: FloatArray = floatArrayOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f) + + /*////////////////////////////////////////////////////////////////////////// + // Popup menus ("popup" means that they pop up, not that they belong to the popup player) + ////////////////////////////////////////////////////////////////////////// */ + private val POPUP_MENU_ID_QUALITY: Int = 69 + private val POPUP_MENU_ID_AUDIO_TRACK: Int = 70 + private val POPUP_MENU_ID_PLAYBACK_SPEED: Int = 79 + private val POPUP_MENU_ID_CAPTION: Int = 89 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java deleted file mode 100644 index ef0e8670c..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java +++ /dev/null @@ -1,83 +0,0 @@ -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()); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.kt new file mode 100644 index 000000000..c7ca75077 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.kt @@ -0,0 +1,70 @@ +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.ThemeHelper + +class AppearanceSettingsFragment() : BasePreferenceFragment() { + public override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResourceRegistry() + val themeKey: String = getString(R.string.theme_key) + // the key of the active theme when settings were opened (or recreated after theme change) + val startThemeKey: String? = defaultPreferences + .getString(themeKey, getString(R.string.default_theme_value)) + val autoDeviceThemeKey: String = getString(R.string.auto_device_theme_key) + findPreference(themeKey)!!.setOnPreferenceChangeListener(Preference.OnPreferenceChangeListener({ preference: Preference?, newValue: Any -> + if ((newValue.toString() == autoDeviceThemeKey)) { + Toast.makeText(getContext(), getString(R.string.select_night_theme_toast), + Toast.LENGTH_LONG).show() + } + applyThemeChange(startThemeKey, themeKey, newValue) + false + })) + val nightThemeKey: String = getString(R.string.night_theme_key) + if ((startThemeKey == autoDeviceThemeKey)) { + val startNightThemeKey: String? = defaultPreferences + .getString(nightThemeKey, getString(R.string.default_night_theme_value)) + findPreference(nightThemeKey)!!.setOnPreferenceChangeListener(Preference.OnPreferenceChangeListener({ preference: Preference?, newValue: Any -> + applyThemeChange(startNightThemeKey, nightThemeKey, newValue) + false + })) + } else { + // disable the night theme selection + val 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))) + } + } + } + + public override fun onPreferenceTreeClick(preference: Preference): Boolean { + if ((getString(R.string.caption_settings_key) == preference.getKey())) { + try { + startActivity(Intent(Settings.ACTION_CAPTIONING_SETTINGS)) + } catch (e: ActivityNotFoundException) { + Toast.makeText(getActivity(), R.string.general_error, Toast.LENGTH_SHORT).show() + } + } + return super.onPreferenceTreeClick(preference) + } + + private fun applyThemeChange(beginningThemeKey: String?, + themeKey: String, + newValue: Any) { + defaultPreferences!!.edit().putBoolean(KEY_THEME_CHANGE, true).apply() + defaultPreferences!!.edit().putString(themeKey, newValue.toString()).apply() + ThemeHelper.setDayNightMode(requireContext(), newValue.toString()) + if (!(newValue == beginningThemeKey) && getActivity() != null) { + // if it's not the current theme + ActivityCompat.recreate((getActivity())!!) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java deleted file mode 100644 index bc24fbe81..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java +++ /dev/null @@ -1,271 +0,0 @@ -package org.schabi.newpipe.settings; - -import static org.schabi.newpipe.extractor.utils.Utils.isBlank; -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - -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.core.content.ContextCompat; -import androidx.preference.Preference; -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.NewPipeDatabase; -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorUtil; -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.File; -import java.io.IOException; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; -import java.util.Objects; - -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 ContentSettingsManager manager; - private String importExportDataPathKey; - private final ActivityResultLauncher requestImportPathLauncher = - registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), - this::requestImportPathResult); - private final ActivityResultLauncher requestExportPathLauncher = - registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), - this::requestExportPathResult); - - - @Override - public void onCreatePreferences(@Nullable final Bundle savedInstanceState, - @Nullable final String rootKey) { - final File homeDir = ContextCompat.getDataDir(requireContext()); - Objects.requireNonNull(homeDir); - manager = new ContentSettingsManager(new NewPipeFileLocator(homeDir)); - manager.deleteSettingsFile(); - - 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 = findPreference(getString(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 - assert resetSettings != null; - 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; - }); - } - - private void requestExportPathResult(final ActivityResult result) { - assureCorrectAppLanguage(requireContext()); - 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) { - assureCorrectAppLanguage(requireContext()); - 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 { - //checkpoint before export - NewPipeDatabase.checkpoint(); - - 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) { - ErrorUtil.showUiErrorSnackbar(this, "Exporting database", e); - } - } - - 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 { - if (!manager.ensureDbDirectoryExists()) { - throw new IOException("Could not create databases dir"); - } - - 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. - if (manager.extractSettings(file)) { - new androidx.appcompat.app.AlertDialog.Builder(requireContext()) - .setTitle(R.string.import_settings) - .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); - manager.loadSharedPreferences(prefs); - cleanImport(context, prefs); - finishImport(importDataUri); - }) - .show(); - } else { - finishImport(importDataUri); - } - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Importing database", e); - } - } - - /** - * 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 system. - * - * @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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.kt new file mode 100644 index 000000000..2b00f61ed --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.kt @@ -0,0 +1,235 @@ +package org.schabi.newpipe.settings + +import android.app.Activity +import android.app.AlertDialog +import android.content.Context +import android.content.DialogInterface +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.ActivityResultCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.core.content.ContextCompat +import androidx.preference.Preference +import androidx.preference.PreferenceManager +import org.schabi.newpipe.NewPipeDatabase +import org.schabi.newpipe.R +import org.schabi.newpipe.error.ErrorUtil.Companion.showUiErrorSnackbar +import org.schabi.newpipe.extractor.utils.Utils +import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard +import org.schabi.newpipe.streams.io.StoredFileHelper +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.ZipHelper +import java.io.File +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.Objects + +class BackupRestoreSettingsFragment() : BasePreferenceFragment() { + private val exportDateFormat: SimpleDateFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US) + private var manager: ContentSettingsManager? = null + private var importExportDataPathKey: String? = null + private val requestImportPathLauncher: ActivityResultLauncher = registerForActivityResult(StartActivityForResult(), ActivityResultCallback({ result: ActivityResult -> requestImportPathResult(result) })) + private val requestExportPathLauncher: ActivityResultLauncher = registerForActivityResult(StartActivityForResult(), ActivityResultCallback({ result: ActivityResult -> requestExportPathResult(result) })) + public override fun onCreatePreferences(savedInstanceState: Bundle?, + rootKey: String?) { + val homeDir: File? = ContextCompat.getDataDir(requireContext()) + Objects.requireNonNull(homeDir) + manager = ContentSettingsManager(NewPipeFileLocator((homeDir)!!)) + manager!!.deleteSettingsFile() + importExportDataPathKey = getString(R.string.import_export_data_path) + addPreferencesFromResourceRegistry() + val importDataPreference: Preference = requirePreference(R.string.import_data) + importDataPreference.setOnPreferenceClickListener(Preference.OnPreferenceClickListener({ p: Preference? -> + NoFileManagerSafeGuard.launchSafe( + requestImportPathLauncher, + StoredFileHelper.Companion.getPicker(requireContext(), + ZIP_MIME_TYPE, getImportExportDataUri()), + TAG, + getContext() + ) + true + })) + val exportDataPreference: Preference = requirePreference(R.string.export_data) + exportDataPreference.setOnPreferenceClickListener(Preference.OnPreferenceClickListener({ p: Preference? -> + NoFileManagerSafeGuard.launchSafe( + requestExportPathLauncher, + StoredFileHelper.Companion.getNewPicker(requireContext(), + "NewPipeData-" + exportDateFormat.format(Date()) + ".zip", + ZIP_MIME_TYPE, getImportExportDataUri()), + TAG, + getContext() + ) + true + })) + val resetSettings: Preference? = findPreference(getString(R.string.reset_settings)) + assert(resetSettings != null) + resetSettings!!.setOnPreferenceClickListener(Preference.OnPreferenceClickListener({ preference: Preference? -> + // Show Alert Dialogue + val builder: AlertDialog.Builder = AlertDialog.Builder(getContext()) + builder.setMessage(R.string.reset_all_settings) + builder.setCancelable(true) + builder.setPositiveButton(R.string.ok, DialogInterface.OnClickListener({ dialogInterface: DialogInterface?, i: Int -> + // Deletes all shared preferences xml files. + val sharedPreferences: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + sharedPreferences.edit().clear().apply() + // Restarts the app + if (getActivity() == null) { + return@setPositiveButton + } + NavigationHelper.restartApp(getActivity()) + })) + builder.setNegativeButton(R.string.cancel, DialogInterface.OnClickListener({ dialogInterface: DialogInterface?, i: Int -> })) + val alertDialog: AlertDialog = builder.create() + alertDialog.show() + true + })) + } + + private fun requestExportPathResult(result: ActivityResult) { + Localization.assureCorrectAppLanguage(requireContext()) + if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { + // will be saved only on success + val lastExportDataUri: Uri? = result.getData()!!.getData() + val file: StoredFileHelper = StoredFileHelper( + requireContext(), result.getData()!!.getData(), ZIP_MIME_TYPE) + exportDatabase(file, lastExportDataUri) + } + } + + private fun requestImportPathResult(result: ActivityResult) { + Localization.assureCorrectAppLanguage(requireContext()) + if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { + // will be saved only on success + val lastImportDataUri: Uri? = result.getData()!!.getData() + val file: StoredFileHelper = StoredFileHelper( + requireContext(), result.getData()!!.getData(), ZIP_MIME_TYPE) + androidx.appcompat.app.AlertDialog.Builder(requireActivity()) + .setMessage(R.string.override_current_data) + .setPositiveButton(R.string.ok, DialogInterface.OnClickListener({ d: DialogInterface?, id: Int -> importDatabase(file, lastImportDataUri) })) + .setNegativeButton(R.string.cancel, DialogInterface.OnClickListener({ d: DialogInterface, id: Int -> d.cancel() })) + .show() + } + } + + private fun exportDatabase(file: StoredFileHelper, exportDataUri: Uri?) { + try { + //checkpoint before export + NewPipeDatabase.checkpoint() + val preferences: SharedPreferences = 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 (e: Exception) { + showUiErrorSnackbar(this, "Exporting database", e) + } + } + + private fun importDatabase(file: StoredFileHelper, importDataUri: Uri?) { + // check if file is supported + if (!ZipHelper.isValidZipFile(file)) { + Toast.makeText(requireContext(), R.string.no_valid_zip_file, Toast.LENGTH_SHORT) + .show() + return + } + try { + if (!manager!!.ensureDbDirectoryExists()) { + throw IOException("Could not create databases dir") + } + 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. + if (manager!!.extractSettings(file)) { + androidx.appcompat.app.AlertDialog.Builder(requireContext()) + .setTitle(R.string.import_settings) + .setNegativeButton(R.string.cancel, DialogInterface.OnClickListener({ dialog: DialogInterface, which: Int -> + dialog.dismiss() + finishImport(importDataUri) + })) + .setPositiveButton(R.string.ok, DialogInterface.OnClickListener({ dialog: DialogInterface, which: Int -> + dialog.dismiss() + val context: Context = requireContext() + val prefs: SharedPreferences = PreferenceManager + .getDefaultSharedPreferences(context) + manager!!.loadSharedPreferences(prefs) + cleanImport(context, prefs) + finishImport(importDataUri) + })) + .show() + } else { + finishImport(importDataUri) + } + } catch (e: Exception) { + showUiErrorSnackbar(this, "Importing database", e) + } + } + + /** + * 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 fun cleanImport(context: Context, + prefs: SharedPreferences) { + // Check if media tunnelling needs to be disabled automatically, + // if it was disabled automatically in the imported preferences. + val tunnelingKey: String = context.getString(R.string.disable_media_tunneling_key) + val automaticTunnelingKey: String = 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 + val wasMediaTunnelingDisabledAutomatically: Boolean = (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 system. + * + * @param importDataUri The import path to save + */ + private fun finishImport(importDataUri: Uri?) { + // save import path only on success + saveLastImportExportDataUri(importDataUri) + // restart app to properly load db + NavigationHelper.restartApp(requireActivity()) + } + + private fun getImportExportDataUri(): Uri? { + val path: String? = defaultPreferences!!.getString(importExportDataPathKey, null) + return if (Utils.isBlank(path)) null else Uri.parse(path) + } + + private fun saveLastImportExportDataUri(importExportDataUri: Uri?) { + val editor: SharedPreferences.Editor = defaultPreferences!!.edit() + .putString(importExportDataPathKey, importExportDataUri.toString()) + editor.apply() + } + + companion object { + private val ZIP_MIME_TYPE: String = "application/zip" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java b/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java deleted file mode 100644 index 619579f3a..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java +++ /dev/null @@ -1,56 +0,0 @@ -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 Preference requirePreference(@StringRes final int resId) { - final Preference preference = findPreference(getString(resId)); - Objects.requireNonNull(preference); - return preference; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.kt new file mode 100644 index 000000000..861772d31 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.kt @@ -0,0 +1,48 @@ +package org.schabi.newpipe.settings + +import android.content.SharedPreferences +import android.os.Bundle +import android.view.View +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 + +abstract class BasePreferenceFragment() : PreferenceFragmentCompat() { + protected val TAG: String = javaClass.getSimpleName() + "@" + Integer.toHexString(hashCode()) + var defaultPreferences: SharedPreferences? = null + public override fun onCreate(savedInstanceState: Bundle?) { + defaultPreferences = PreferenceManager.getDefaultSharedPreferences(requireActivity()) + super.onCreate(savedInstanceState) + } + + protected fun addPreferencesFromResourceRegistry() { + addPreferencesFromResource( + SettingsResourceRegistry.Companion.getInstance().getPreferencesResId(this.javaClass)) + } + + public override fun onViewCreated(rootView: View, + savedInstanceState: Bundle?) { + super.onViewCreated(rootView, savedInstanceState) + setDivider(null) + ThemeHelper.setTitleToAppCompatActivity(getActivity(), getPreferenceScreen().getTitle()) + } + + public override fun onResume() { + super.onResume() + ThemeHelper.setTitleToAppCompatActivity(getActivity(), getPreferenceScreen().getTitle()) + } + + fun requirePreference(@StringRes resId: Int): Preference { + val preference: Preference? = findPreference(getString(resId)) + Objects.requireNonNull(preference) + return (preference)!! + } + + companion object { + protected val DEBUG: Boolean = MainActivity.Companion.DEBUG + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java deleted file mode 100644 index ec2bed67a..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java +++ /dev/null @@ -1,91 +0,0 @@ -package org.schabi.newpipe.settings; - -import android.content.Context; -import android.os.Bundle; -import android.util.Log; -import android.widget.Toast; - -import androidx.preference.Preference; - -import org.schabi.newpipe.DownloaderImpl; -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.localization.ContentCountry; -import org.schabi.newpipe.extractor.localization.Localization; -import org.schabi.newpipe.util.image.ImageStrategy; -import org.schabi.newpipe.util.image.PicassoHelper; -import org.schabi.newpipe.util.image.PreferredImageQuality; - -import java.io.IOException; - -public class ContentSettingsFragment extends BasePreferenceFragment { - private String youtubeRestrictedModeEnabledKey; - - private Localization initialSelectedLocalization; - private ContentCountry initialSelectedContentCountry; - private String initialLanguage; - - @Override - public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled); - - addPreferencesFromResourceRegistry(); - - initialSelectedLocalization = org.schabi.newpipe.util.Localization - .getPreferredLocalization(requireContext()); - initialSelectedContentCountry = org.schabi.newpipe.util.Localization - .getPreferredContentCountry(requireContext()); - initialLanguage = defaultPreferences.getString(getString(R.string.app_language_key), "en"); - - final Preference imageQualityPreference = requirePreference(R.string.image_quality_key); - imageQualityPreference.setOnPreferenceChangeListener( - (preference, newValue) -> { - ImageStrategy.setPreferredImageQuality(PreferredImageQuality - .fromPreferenceKey(requireContext(), (String) newValue)); - try { - PicassoHelper.clearCache(preference.getContext()); - Toast.makeText(preference.getContext(), - R.string.thumbnail_cache_wipe_complete_notice, Toast.LENGTH_SHORT) - .show(); - } catch (final IOException e) { - Log.e(TAG, "Unable to clear Picasso cache", e); - } - 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 Localization selectedLocalization = org.schabi.newpipe.util.Localization - .getPreferredLocalization(requireContext()); - final ContentCountry selectedContentCountry = org.schabi.newpipe.util.Localization - .getPreferredContentCountry(requireContext()); - final String selectedLanguage = - defaultPreferences.getString(getString(R.string.app_language_key), "en"); - - if (!selectedLocalization.equals(initialSelectedLocalization) - || !selectedContentCountry.equals(initialSelectedContentCountry) - || !selectedLanguage.equals(initialLanguage)) { - Toast.makeText(requireContext(), R.string.localization_changes_requires_app_restart, - Toast.LENGTH_LONG).show(); - - NewPipe.setupLocalization(selectedLocalization, selectedContentCountry); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.kt new file mode 100644 index 000000000..bcafe8619 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.kt @@ -0,0 +1,70 @@ +package org.schabi.newpipe.settings + +import android.content.Context +import android.os.Bundle +import android.util.Log +import android.widget.Toast +import androidx.preference.Preference +import org.schabi.newpipe.DownloaderImpl +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.localization.ContentCountry +import org.schabi.newpipe.extractor.localization.Localization +import org.schabi.newpipe.util.image.ImageStrategy +import org.schabi.newpipe.util.image.PicassoHelper +import org.schabi.newpipe.util.image.PreferredImageQuality +import java.io.IOException + +class ContentSettingsFragment() : BasePreferenceFragment() { + private var youtubeRestrictedModeEnabledKey: String? = null + private var initialSelectedLocalization: Localization? = null + private var initialSelectedContentCountry: ContentCountry? = null + private var initialLanguage: String? = null + public override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled) + addPreferencesFromResourceRegistry() + initialSelectedLocalization = org.schabi.newpipe.util.Localization.getPreferredLocalization(requireContext()) + initialSelectedContentCountry = org.schabi.newpipe.util.Localization.getPreferredContentCountry(requireContext()) + initialLanguage = defaultPreferences!!.getString(getString(R.string.app_language_key), "en") + val imageQualityPreference: Preference = requirePreference(R.string.image_quality_key) + imageQualityPreference.setOnPreferenceChangeListener( + Preference.OnPreferenceChangeListener({ preference: Preference, newValue: Any? -> + ImageStrategy.setPreferredImageQuality(PreferredImageQuality.Companion.fromPreferenceKey(requireContext(), newValue as String?)) + try { + PicassoHelper.clearCache(preference.getContext()) + Toast.makeText(preference.getContext(), + R.string.thumbnail_cache_wipe_complete_notice, Toast.LENGTH_SHORT) + .show() + } catch (e: IOException) { + Log.e(TAG, "Unable to clear Picasso cache", e) + } + true + })) + } + + public override fun onPreferenceTreeClick(preference: Preference): Boolean { + if ((preference.getKey() == youtubeRestrictedModeEnabledKey)) { + val context: Context? = getContext() + if (context != null) { + DownloaderImpl.Companion.getInstance()!!.updateYoutubeRestrictedModeCookies(context) + } else { + Log.w(TAG, "onPreferenceTreeClick: null context") + } + } + return super.onPreferenceTreeClick(preference) + } + + public override fun onDestroy() { + super.onDestroy() + val selectedLocalization: Localization? = org.schabi.newpipe.util.Localization.getPreferredLocalization(requireContext()) + val selectedContentCountry: ContentCountry? = org.schabi.newpipe.util.Localization.getPreferredContentCountry(requireContext()) + val selectedLanguage: String? = defaultPreferences!!.getString(getString(R.string.app_language_key), "en") + if ((!(selectedLocalization == initialSelectedLocalization) + || !(selectedContentCountry == initialSelectedContentCountry) + || !(selectedLanguage == initialLanguage))) { + Toast.makeText(requireContext(), R.string.localization_changes_requires_app_restart, + Toast.LENGTH_LONG).show() + NewPipe.setupLocalization(selectedLocalization, selectedContentCountry) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java deleted file mode 100644 index d78ade49d..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java +++ /dev/null @@ -1,117 +0,0 @@ -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 org.schabi.newpipe.util.image.PicassoHelper; - -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 = - findPreference(getString(R.string.allow_heap_dumping_key)); - final Preference showMemoryLeaksPreference = - findPreference(getString(R.string.show_memory_leaks_key)); - final Preference showImageIndicatorsPreference = - findPreference(getString(R.string.show_image_indicators_key)); - final Preference checkNewStreamsPreference = - findPreference(getString(R.string.check_new_streams_key)); - final Preference crashTheAppPreference = - findPreference(getString(R.string.crash_the_app_key)); - final Preference showErrorSnackbarPreference = - findPreference(getString(R.string.show_error_snackbar_key)); - final Preference createErrorNotificationPreference = - findPreference(getString(R.string.create_error_notification_key)); - - assert allowHeapDumpingPreference != null; - assert showMemoryLeaksPreference != null; - assert showImageIndicatorsPreference != null; - assert checkNewStreamsPreference != null; - assert crashTheAppPreference != null; - assert showErrorSnackbarPreference != null; - assert createErrorNotificationPreference != null; - - 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); - } - - showImageIndicatorsPreference.setOnPreferenceChangeListener((preference, newValue) -> { - PicassoHelper.setIndicatorsEnabled((Boolean) newValue); - return true; - }); - - 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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.kt new file mode 100644 index 000000000..1cd0a979e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.kt @@ -0,0 +1,96 @@ +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.Companion.createNotification +import org.schabi.newpipe.error.ErrorUtil.Companion.showUiErrorSnackbar +import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.local.feed.notifications.NotificationWorker.Companion.runNow +import org.schabi.newpipe.util.image.PicassoHelper +import java.util.Optional + +class DebugSettingsFragment() : BasePreferenceFragment() { + public override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResourceRegistry() + val allowHeapDumpingPreference: Preference? = findPreference(getString(R.string.allow_heap_dumping_key)) + val showMemoryLeaksPreference: Preference? = findPreference(getString(R.string.show_memory_leaks_key)) + val showImageIndicatorsPreference: Preference? = findPreference(getString(R.string.show_image_indicators_key)) + val checkNewStreamsPreference: Preference? = findPreference(getString(R.string.check_new_streams_key)) + val crashTheAppPreference: Preference? = findPreference(getString(R.string.crash_the_app_key)) + val showErrorSnackbarPreference: Preference? = findPreference(getString(R.string.show_error_snackbar_key)) + val createErrorNotificationPreference: Preference? = findPreference(getString(R.string.create_error_notification_key)) + assert(allowHeapDumpingPreference != null) + assert(showMemoryLeaksPreference != null) + assert(showImageIndicatorsPreference != null) + assert(checkNewStreamsPreference != null) + assert(crashTheAppPreference != null) + assert(showErrorSnackbarPreference != null) + assert(createErrorNotificationPreference != null) + val optBVLeakCanary: Optional = getBVDLeakCanary() + allowHeapDumpingPreference!!.setEnabled(optBVLeakCanary.isPresent()) + showMemoryLeaksPreference!!.setEnabled(optBVLeakCanary.isPresent()) + if (optBVLeakCanary.isPresent()) { + val pdLeakCanary: DebugSettingsBVDLeakCanaryAPI = optBVLeakCanary.get() + showMemoryLeaksPreference.setOnPreferenceClickListener(Preference.OnPreferenceClickListener({ preference: Preference? -> + startActivity((pdLeakCanary.getNewLeakDisplayActivityIntent())!!) + true + })) + } else { + allowHeapDumpingPreference.setSummary(R.string.leak_canary_not_available) + showMemoryLeaksPreference.setSummary(R.string.leak_canary_not_available) + } + showImageIndicatorsPreference!!.setOnPreferenceChangeListener(Preference.OnPreferenceChangeListener({ preference: Preference?, newValue: Any -> + PicassoHelper.setIndicatorsEnabled(newValue as Boolean) + true + })) + checkNewStreamsPreference!!.setOnPreferenceClickListener(Preference.OnPreferenceClickListener({ preference: Preference -> + runNow(preference.getContext()) + true + })) + crashTheAppPreference!!.setOnPreferenceClickListener(Preference.OnPreferenceClickListener({ preference: Preference? -> throw RuntimeException(DUMMY) })) + showErrorSnackbarPreference!!.setOnPreferenceClickListener(Preference.OnPreferenceClickListener({ preference: Preference? -> + showUiErrorSnackbar(this@DebugSettingsFragment, + DUMMY, RuntimeException(DUMMY)) + true + })) + createErrorNotificationPreference!!.setOnPreferenceClickListener(Preference.OnPreferenceClickListener({ preference: Preference? -> + createNotification(requireContext(), + ErrorInfo(RuntimeException(DUMMY), UserAction.UI_ERROR, DUMMY)) + true + })) + } + + /** + * Tries to find the [DebugSettingsBVDLeakCanaryAPI.IMPL_CLASS] and loads it if available. + * @return An [Optional] which is empty if the implementation class couldn't be loaded. + */ + private fun getBVDLeakCanary(): Optional { + try { + // Try to find the implementation of the LeakCanary API + return Optional.of(Class.forName(DebugSettingsBVDLeakCanaryAPI.IMPL_CLASS) + .getDeclaredConstructor() + .newInstance() as DebugSettingsBVDLeakCanaryAPI) + } catch (e: Exception) { + 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 + */ + open interface DebugSettingsBVDLeakCanaryAPI { + fun getNewLeakDisplayActivityIntent(): Intent? + + companion object { + val IMPL_CLASS: String = "org.schabi.newpipe.settings.DebugSettingsBVDLeakCanary" + } + } + + companion object { + private val DUMMY: String = "Dummy" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java deleted file mode 100644 index 472db6afe..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java +++ /dev/null @@ -1,282 +0,0 @@ -package org.schabi.newpipe.settings; - -import static org.schabi.newpipe.extractor.utils.Utils.decodeUrlUtf8; -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - -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; -import java.io.UnsupportedEncodingException; -import java.net.URI; - -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) { - String rawUri = defaultPreferences.getString(prefKey, null); - if (rawUri == null || rawUri.isEmpty()) { - target.setSummary(getString(defaultString)); - return; - } - - if (rawUri.charAt(0) == File.separatorChar) { - target.setSummary(rawUri); - return; - } - if (rawUri.startsWith(ContentResolver.SCHEME_FILE)) { - target.setSummary(new File(URI.create(rawUri)).getPath()); - return; - } - - try { - rawUri = decodeUrlUtf8(rawUri); - } catch (final UnsupportedEncodingException e) { - // nothing to do - } - - target.setSummary(rawUri); - } - - 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) { - assureCorrectAppLanguage(getContext()); - - 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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.kt new file mode 100644 index 000000000..7a8257d9f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.kt @@ -0,0 +1,246 @@ +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.ActivityResultCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.StringRes +import androidx.appcompat.app.AlertDialog +import androidx.preference.Preference +import androidx.preference.SwitchPreferenceCompat +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.utils.Utils +import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard +import org.schabi.newpipe.streams.io.StoredDirectoryHelper +import org.schabi.newpipe.util.FilePickerActivityHelper +import org.schabi.newpipe.util.Localization +import java.io.File +import java.io.IOException +import java.io.UnsupportedEncodingException +import java.net.URI + +class DownloadSettingsFragment() : BasePreferenceFragment() { + private var downloadPathVideoPreference: String? = null + private var downloadPathAudioPreference: String? = null + private var storageUseSafPreference: String? = null + private var prefPathVideo: Preference? = null + private var prefPathAudio: Preference? = null + private var prefStorageAsk: Preference? = null + private var ctx: Context? = null + private val requestDownloadVideoPathLauncher: ActivityResultLauncher = registerForActivityResult( + StartActivityForResult(), ActivityResultCallback({ result: ActivityResult -> requestDownloadVideoPathResult(result) })) + private val requestDownloadAudioPathLauncher: ActivityResultLauncher = registerForActivityResult( + StartActivityForResult(), ActivityResultCallback({ result: ActivityResult -> requestDownloadAudioPathResult(result) })) + + public override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResourceRegistry() + downloadPathVideoPreference = getString(R.string.download_path_video_key) + downloadPathAudioPreference = getString(R.string.download_path_audio_key) + storageUseSafPreference = getString(R.string.storage_use_saf) + val downloadStorageAsk: String = getString(R.string.downloads_storage_ask) + prefPathVideo = findPreference(downloadPathVideoPreference!!) + prefPathAudio = findPreference(downloadPathAudioPreference!!) + prefStorageAsk = findPreference(downloadStorageAsk) + val prefUseSaf: SwitchPreferenceCompat? = 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.OnPreferenceChangeListener({ preference: Preference?, value: Any? -> + updatePathPickers(!value as Boolean) + true + })) + } + + public override fun onAttach(context: Context) { + super.onAttach(context) + ctx = context + } + + public override fun onDetach() { + super.onDetach() + ctx = null + prefStorageAsk!!.setOnPreferenceChangeListener(null) + } + + private fun updatePreferencesSummary() { + showPathInSummary(downloadPathVideoPreference, R.string.download_path_summary, + prefPathVideo) + showPathInSummary(downloadPathAudioPreference, R.string.download_path_audio_summary, + prefPathAudio) + } + + private fun showPathInSummary(prefKey: String?, @StringRes defaultString: Int, + target: Preference?) { + var rawUri: String? = defaultPreferences!!.getString(prefKey, null) + if (rawUri == null || rawUri.isEmpty()) { + target!!.setSummary(getString(defaultString)) + return + } + if (rawUri.get(0) == File.separatorChar) { + target!!.setSummary(rawUri) + return + } + if (rawUri.startsWith(ContentResolver.SCHEME_FILE)) { + target!!.setSummary(File(URI.create(rawUri)).getPath()) + return + } + try { + rawUri = Utils.decodeUrlUtf8(rawUri) + } catch (e: UnsupportedEncodingException) { + // nothing to do + } + target!!.setSummary(rawUri) + } + + private fun isFileUri(path: String): Boolean { + return path.get(0) == File.separatorChar || path.startsWith(ContentResolver.SCHEME_FILE) + } + + private fun hasInvalidPath(prefKey: String): Boolean { + val value: String? = defaultPreferences!!.getString(prefKey, null) + return value == null || value.isEmpty() + } + + private fun updatePathPickers(enabled: Boolean) { + prefPathVideo!!.setEnabled(enabled) + prefPathAudio!!.setEnabled(enabled) + } + + // FIXME: after releasing the old path, all downloads created on the folder becomes inaccessible + private fun forgetSAFTree(context: Context, oldPath: String?) { + if (IGNORE_RELEASE_ON_OLD_PATH) { + return + } + if ((oldPath == null) || oldPath.isEmpty() || isFileUri(oldPath)) { + return + } + try { + val uri: Uri = Uri.parse(oldPath) + context.getContentResolver() + .releasePersistableUriPermission(uri, StoredDirectoryHelper.Companion.PERMISSION_FLAGS) + context.revokeUriPermission(uri, StoredDirectoryHelper.Companion.PERMISSION_FLAGS) + Log.i(TAG, "Revoke old path permissions success on " + oldPath) + } catch (err: Exception) { + Log.e(TAG, "Error revoking old path permissions on " + oldPath, err) + } + } + + private fun showMessageDialog(@StringRes title: Int, @StringRes message: Int) { + AlertDialog.Builder((ctx)!!) + .setTitle(title) + .setMessage(message) + .setPositiveButton(getString(R.string.ok), null) + .show() + } + + public override fun onPreferenceTreeClick(preference: Preference): Boolean { + if (BasePreferenceFragment.Companion.DEBUG) { + Log.d(TAG, ("onPreferenceTreeClick() called with: " + + "preference = [" + preference + "]")) + } + val key: String = preference.getKey() + if ((key == 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 == downloadPathVideoPreference)) { + launchDirectoryPicker(requestDownloadVideoPathLauncher) + } else if ((key == downloadPathAudioPreference)) { + launchDirectoryPicker(requestDownloadAudioPathLauncher) + } else { + return super.onPreferenceTreeClick(preference) + } + return true + } + + private fun launchDirectoryPicker(launcher: ActivityResultLauncher) { + NoFileManagerSafeGuard.launchSafe( + launcher, + StoredDirectoryHelper.Companion.getPicker(ctx), + TAG, + ctx + ) + } + + private fun requestDownloadVideoPathResult(result: ActivityResult) { + requestDownloadPathResult(result, downloadPathVideoPreference) + } + + private fun requestDownloadAudioPathResult(result: ActivityResult) { + requestDownloadPathResult(result, downloadPathAudioPreference) + } + + private fun requestDownloadPathResult(result: ActivityResult, key: String?) { + Localization.assureCorrectAppLanguage(getContext()) + if (result.getResultCode() != Activity.RESULT_OK) { + return + } + var 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) + val context: Context = requireContext() + forgetSAFTree(context, defaultPreferences!!.getString(key, "")) + if (!FilePickerActivityHelper.Companion.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.Companion.PERMISSION_FLAGS) + val mainStorage: StoredDirectoryHelper = StoredDirectoryHelper(context, uri, null) + Log.i(TAG, "Acquiring tree success from " + uri.toString()) + if (!mainStorage.canWrite()) { + throw IOException("No write permissions on " + uri.toString()) + } + } catch (err: IOException) { + Log.e(TAG, "Error acquiring tree from " + uri.toString(), err) + showMessageDialog(R.string.general_error, R.string.no_available_dir) + return + } + } else { + val target: File = com.nononsenseapps.filepicker.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() + } + + companion object { + val IGNORE_RELEASE_ON_OLD_PATH: Boolean = true + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/ExoPlayerSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ExoPlayerSettingsFragment.java deleted file mode 100644 index 14dd0c409..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/ExoPlayerSettingsFragment.java +++ /dev/null @@ -1,45 +0,0 @@ -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; - }); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/ExoPlayerSettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/ExoPlayerSettingsFragment.kt new file mode 100644 index 000000000..bdf4cfc93 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/ExoPlayerSettingsFragment.kt @@ -0,0 +1,33 @@ +package org.schabi.newpipe.settings + +import android.content.SharedPreferences +import android.os.Bundle +import androidx.preference.Preference +import androidx.preference.PreferenceManager +import androidx.preference.SwitchPreferenceCompat +import org.schabi.newpipe.R + +class ExoPlayerSettingsFragment() : BasePreferenceFragment() { + public override fun onCreatePreferences(savedInstanceState: Bundle?, + rootKey: String?) { + addPreferencesFromResourceRegistry() + val disabledMediaTunnelingAutomaticallyKey: String = getString(R.string.disabled_media_tunneling_automatically_key) + val disableMediaTunnelingPref: SwitchPreferenceCompat = requirePreference(R.string.disable_media_tunneling_key) as SwitchPreferenceCompat + val prefs: SharedPreferences = PreferenceManager + .getDefaultSharedPreferences(requireContext()) + val mediaTunnelingAutomaticallyDisabled: Boolean = prefs.getInt(disabledMediaTunnelingAutomaticallyKey, -1) == 1 + val summaryText: String = getString(R.string.disable_media_tunneling_summary) + disableMediaTunnelingPref.setSummary(if (mediaTunnelingAutomaticallyDisabled) summaryText + " " + getString(R.string.disable_media_tunneling_automatic_info) else summaryText) + disableMediaTunnelingPref.setOnPreferenceChangeListener(Preference.OnPreferenceChangeListener({ p: Preference, enabled: Any -> + if ((java.lang.Boolean.FALSE == 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) + } + true + })) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java deleted file mode 100644 index 9bc9058c8..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java +++ /dev/null @@ -1,159 +0,0 @@ -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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.kt new file mode 100644 index 000000000..0da893a3e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.kt @@ -0,0 +1,165 @@ +package org.schabi.newpipe.settings + +import android.content.Context +import android.content.DialogInterface +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.preference.Preference +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.functions.Consumer +import org.schabi.newpipe.DownloaderImpl +import org.schabi.newpipe.R +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.ErrorUtil.Companion.openActivity +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 + +class HistorySettingsFragment() : BasePreferenceFragment() { + private var cacheWipeKey: String? = null + private var viewsHistoryClearKey: String? = null + private var playbackStatesClearKey: String? = null + private var searchHistoryClearKey: String? = null + private var recordManager: HistoryRecordManager? = null + private var disposables: CompositeDisposable? = null + public override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + 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 = HistoryRecordManager(getActivity()) + disposables = CompositeDisposable() + val clearCookiePref: Preference = requirePreference(R.string.clear_cookie_key) + clearCookiePref.setOnPreferenceClickListener(Preference.OnPreferenceClickListener({ preference: Preference? -> + defaultPreferences!!.edit() + .putString(getString(R.string.recaptcha_cookies_key), "").apply() + DownloaderImpl.Companion.getInstance()!!.setCookie(ReCaptchaActivity.Companion.RECAPTCHA_COOKIES_KEY, "") + Toast.makeText(getActivity(), R.string.recaptcha_cookies_cleared, + Toast.LENGTH_SHORT).show() + clearCookiePref.setEnabled(false) + true + })) + if (defaultPreferences!!.getString(getString(R.string.recaptcha_cookies_key), "")!!.isEmpty()) { + clearCookiePref.setEnabled(false) + } + } + + public override fun onPreferenceTreeClick(preference: Preference): Boolean { + if ((preference.getKey() == cacheWipeKey)) { + InfoCache.Companion.getInstance().clearCache() + Toast.makeText(requireContext(), + R.string.metadata_cache_wipe_complete_notice, Toast.LENGTH_SHORT).show() + } else if ((preference.getKey() == viewsHistoryClearKey)) { + openDeleteWatchHistoryDialog(requireContext(), recordManager, disposables) + } else if ((preference.getKey() == playbackStatesClearKey)) { + openDeletePlaybackStatesDialog(requireContext(), recordManager, disposables) + } else if ((preference.getKey() == searchHistoryClearKey)) { + openDeleteSearchHistoryDialog(requireContext(), recordManager, disposables) + } else { + return super.onPreferenceTreeClick(preference) + } + return true + } + + companion object { + private fun getDeletePlaybackStatesDisposable( + context: Context, recordManager: HistoryRecordManager?): Disposable { + return recordManager!!.deleteCompleteStreamStateHistory() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + Consumer({ howManyDeleted: Int? -> + Toast.makeText(context, + R.string.watch_history_states_deleted, Toast.LENGTH_SHORT).show() + }), + Consumer({ throwable: Throwable? -> + openActivity(context, + ErrorInfo((throwable)!!, UserAction.DELETE_FROM_HISTORY, + "Delete playback states")) + })) + } + + private fun getWholeStreamHistoryDisposable( + context: Context, recordManager: HistoryRecordManager?): Disposable { + return recordManager!!.deleteWholeStreamHistory() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + Consumer({ howManyDeleted: Int? -> + Toast.makeText(context, + R.string.watch_history_deleted, Toast.LENGTH_SHORT).show() + }), + Consumer({ throwable: Throwable? -> + openActivity(context, + ErrorInfo((throwable)!!, UserAction.DELETE_FROM_HISTORY, + "Delete from history")) + })) + } + + private fun getRemoveOrphanedRecordsDisposable( + context: Context, recordManager: HistoryRecordManager?): Disposable { + return recordManager!!.removeOrphanedRecords() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + Consumer({ howManyDeleted: Int? -> }), + Consumer({ throwable: Throwable? -> + openActivity(context, + ErrorInfo((throwable)!!, UserAction.DELETE_FROM_HISTORY, + "Clear orphaned records")) + })) + } + + private fun getDeleteSearchHistoryDisposable( + context: Context, recordManager: HistoryRecordManager?): Disposable { + return recordManager!!.deleteCompleteSearchHistory() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + Consumer({ howManyDeleted: Int? -> + Toast.makeText(context, + R.string.search_history_deleted, Toast.LENGTH_SHORT).show() + }), + Consumer({ throwable: Throwable? -> + openActivity(context, + ErrorInfo((throwable)!!, UserAction.DELETE_FROM_HISTORY, + "Delete search history")) + })) + } + + fun openDeleteWatchHistoryDialog(context: Context, + recordManager: HistoryRecordManager?, + disposables: CompositeDisposable?) { + AlertDialog.Builder(context) + .setTitle(R.string.delete_view_history_alert) + .setNegativeButton(R.string.cancel, (DialogInterface.OnClickListener({ dialog: DialogInterface, which: Int -> dialog.dismiss() }))) + .setPositiveButton(R.string.delete, (DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> + disposables!!.add(getDeletePlaybackStatesDisposable(context, recordManager)) + disposables.add(getWholeStreamHistoryDisposable(context, recordManager)) + disposables.add(getRemoveOrphanedRecordsDisposable(context, recordManager)) + }))) + .show() + } + + fun openDeletePlaybackStatesDialog(context: Context, + recordManager: HistoryRecordManager?, + disposables: CompositeDisposable?) { + AlertDialog.Builder(context) + .setTitle(R.string.delete_playback_states_alert) + .setNegativeButton(R.string.cancel, (DialogInterface.OnClickListener({ dialog: DialogInterface, which: Int -> dialog.dismiss() }))) + .setPositiveButton(R.string.delete, (DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> disposables!!.add(getDeletePlaybackStatesDisposable(context, recordManager)) }))) + .show() + } + + fun openDeleteSearchHistoryDialog(context: Context, + recordManager: HistoryRecordManager?, + disposables: CompositeDisposable?) { + AlertDialog.Builder(context) + .setTitle(R.string.delete_search_history_alert) + .setNegativeButton(R.string.cancel, (DialogInterface.OnClickListener({ dialog: DialogInterface, which: Int -> dialog.dismiss() }))) + .setPositiveButton(R.string.delete, (DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> disposables!!.add(getDeleteSearchHistoryDisposable(context, recordManager)) }))) + .show() + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java deleted file mode 100644 index 32e33d55b..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java +++ /dev/null @@ -1,71 +0,0 @@ -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( - findPreference(getString(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( - findPreference(getString(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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.kt new file mode 100644 index 000000000..7dc1d1d76 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.kt @@ -0,0 +1,60 @@ +package org.schabi.newpipe.settings + +import android.os.Bundle +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.R +import org.schabi.newpipe.util.ReleaseVersionUtil.isReleaseApk + +class MainSettingsFragment() : BasePreferenceFragment() { + private var settingsActivity: SettingsActivity? = null + public override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResourceRegistry() + setHasOptionsMenu(true) // Otherwise onCreateOptionsMenu is not called + + // Check if the app is updatable + if (!isReleaseApk) { + getPreferenceScreen().removePreference( + (findPreference(getString(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( + (findPreference(getString(R.string.debug_pref_screen_key)))!!) + } + } + + public override fun onCreateOptionsMenu( + menu: Menu, + inflater: MenuInflater + ) { + super.onCreateOptionsMenu(menu, inflater) + + // -- Link settings activity and register menu -- + settingsActivity = getActivity() as SettingsActivity? + inflater.inflate(R.menu.menu_settings_main_fragment, menu) + val menuSearchItem: MenuItem = menu.getItem(0) + settingsActivity!!.setMenuSearchItem(menuSearchItem) + menuSearchItem.setOnMenuItemClickListener(MenuItem.OnMenuItemClickListener({ ev: MenuItem? -> + settingsActivity!!.setSearchActive(true) + true + })) + } + + public override fun onDestroy() { + // Unlink activity so that we don't get memory problems + if (settingsActivity != null) { + settingsActivity!!.setMenuSearchItem(null) + settingsActivity = null + } + super.onDestroy() + } + + companion object { + val DEBUG: Boolean = MainActivity.Companion.DEBUG + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java deleted file mode 100644 index 421440ea7..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java +++ /dev/null @@ -1,187 +0,0 @@ -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.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 - SettingMigrations.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) { - // There's a FireOS bug which prevents SAF open/close dialogs from being confirmed with a - // remote (see #6455). - if (DeviceUtils.isFireTv()) { - return false; - } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - return true; - } - - 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.getApp().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(); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.kt b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.kt new file mode 100644 index 000000000..4ff515b82 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.kt @@ -0,0 +1,165 @@ +package org.schabi.newpipe.settings + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import android.os.Environment +import androidx.annotation.StringRes +import androidx.preference.PreferenceManager +import org.schabi.newpipe.App +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.utils.Utils +import org.schabi.newpipe.util.DeviceUtils +import java.io.File + +/* +* 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. + */ +object NewPipeSettings { + fun initSettings(context: Context) { + // first run migrations, then setDefaultValues, since the latter requires the correct types + SettingMigrations.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) + } + + fun saveDefaultVideoDownloadDirectory(context: Context?) { + saveDefaultDirectory(context, R.string.download_path_video_key, + Environment.DIRECTORY_MOVIES) + } + + fun saveDefaultAudioDownloadDirectory(context: Context?) { + saveDefaultDirectory(context, R.string.download_path_audio_key, + Environment.DIRECTORY_MUSIC) + } + + private fun saveDefaultDirectory(context: Context?, keyID: Int, + defaultDirectoryName: String) { + if (!useStorageAccessFramework(context)) { + val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences((context)!!) + val key: String = context!!.getString(keyID) + val downloadPath: String? = prefs.getString(key, null) + if (!Utils.isNullOrEmpty(downloadPath)) { + return + } + val spEditor: SharedPreferences.Editor = prefs.edit() + spEditor.putString(key, getNewPipeChildFolderPathForDir(getDir(defaultDirectoryName))) + spEditor.apply() + } + } + + fun getDir(defaultDirectoryName: String?): File { + return File(Environment.getExternalStorageDirectory(), defaultDirectoryName) + } + + private fun getNewPipeChildFolderPathForDir(dir: File): String { + return File(dir, "NewPipe").toURI().toString() + } + + fun useStorageAccessFramework(context: Context?): Boolean { + // There's a FireOS bug which prevents SAF open/close dialogs from being confirmed with a + // remote (see #6455). + if (DeviceUtils.isFireTv()) { + return false + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return true + } + val key: String = context!!.getString(R.string.storage_use_saf) + val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences((context)) + return prefs.getBoolean(key, true) + } + + private fun showSearchSuggestions(context: Context, + sharedPreferences: SharedPreferences, + @StringRes key: Int): Boolean { + val enabledSearchSuggestions: Set? = 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)) + } + } + + fun showLocalSearchSuggestions(context: Context, + sharedPreferences: SharedPreferences): Boolean { + return showSearchSuggestions(context, sharedPreferences, + R.string.show_local_search_suggestions_key) + } + + fun showRemoteSearchSuggestions(context: Context, + sharedPreferences: SharedPreferences): Boolean { + return showSearchSuggestions(context, sharedPreferences, + R.string.show_remote_search_suggestions_key) + } + + private fun disableMediaTunnelingIfNecessary(context: Context) { + val prefs: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + val disabledTunnelingKey: String = context.getString(R.string.disable_media_tunneling_key) + val disabledTunnelingAutomaticallyKey: String = context.getString(R.string.disabled_media_tunneling_automatically_key) + val blacklistVersionKey: String = context.getString(R.string.media_tunneling_device_blacklist_version) + val lastMediaTunnelingUpdate: Int = prefs.getInt(blacklistVersionKey, 0) + val wasDeviceBlacklistUpdated: Boolean = DeviceUtils.MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION != lastMediaTunnelingUpdate + val wasMediaTunnelingEnabledByUser: Boolean = (prefs.getInt(disabledTunnelingAutomaticallyKey, -1) == 0 + && !prefs.getBoolean(disabledTunnelingKey, false)) + if ((App.Companion.getApp().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 + */ + fun setMediaTunneling(context: Context) { + val prefs: SharedPreferences = 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() + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java b/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java deleted file mode 100644 index 1158b3d83..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java +++ /dev/null @@ -1,413 +0,0 @@ -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 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()); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.kt new file mode 100644 index 000000000..6ecd653bc --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.kt @@ -0,0 +1,363 @@ +package org.schabi.newpipe.settings + +import android.annotation.SuppressLint +import android.content.Context +import android.content.DialogInterface +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.View.OnTouchListener +import android.view.ViewGroup +import android.widget.CompoundButton +import android.widget.RadioButton +import android.widget.Toast +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 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.functions.Consumer +import io.reactivex.rxjava3.schedulers.Schedulers +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.PeertubeHelper +import org.schabi.newpipe.util.ThemeHelper +import java.util.Collections +import java.util.concurrent.Callable +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.sign + +class PeertubeInstanceListFragment() : Fragment() { + private var selectedInstance: PeertubeInstance? = null + private var savedInstanceListKey: String? = null + private var instanceListAdapter: InstanceListAdapter? = null + private var binding: FragmentInstanceListBinding? = null + private var sharedPreferences: SharedPreferences? = null + private var disposables: CompositeDisposable? = CompositeDisposable() + + /*////////////////////////////////////////////////////////////////////////// + // Lifecycle + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + savedInstanceListKey = getString(R.string.peertube_instance_list_key) + selectedInstance = PeertubeHelper.getCurrentInstance() + setHasOptionsMenu(true) + } + + public override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + binding = FragmentInstanceListBinding.inflate(inflater, container, false) + return binding!!.getRoot() + } + + public override fun onViewCreated(rootView: View, + savedInstanceState: Bundle?) { + super.onViewCreated(rootView, savedInstanceState) + binding!!.instanceHelpTV.setText(getString(R.string.peertube_instance_url_help, + getString(R.string.peertube_instance_list_url))) + binding!!.addInstanceButton.setOnClickListener(View.OnClickListener({ v: View? -> showAddItemDialog(requireContext()) })) + binding!!.instances.setLayoutManager(LinearLayoutManager(requireContext())) + val itemTouchHelper: ItemTouchHelper = ItemTouchHelper(getItemTouchCallback()) + itemTouchHelper.attachToRecyclerView(binding!!.instances) + instanceListAdapter = InstanceListAdapter(requireContext(), itemTouchHelper) + binding!!.instances.setAdapter(instanceListAdapter) + instanceListAdapter!!.submitList(PeertubeHelper.getInstanceList(requireContext())) + } + + public override fun onResume() { + super.onResume() + ThemeHelper.setTitleToAppCompatActivity(getActivity(), + getString(R.string.peertube_instance_url_title)) + } + + public override fun onPause() { + super.onPause() + saveChanges() + } + + public override fun onDestroy() { + super.onDestroy() + if (disposables != null) { + disposables!!.clear() + } + disposables = null + } + + public override fun onDestroyView() { + binding = null + super.onDestroyView() + } + + /*////////////////////////////////////////////////////////////////////////// + // Menu + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreateOptionsMenu(menu: Menu, + inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.menu_chooser_fragment, menu) + } + + public override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.getItemId() == R.id.menu_item_restore_default) { + restoreDefaults() + return true + } + return super.onOptionsItemSelected(item) + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + ////////////////////////////////////////////////////////////////////////// */ + private fun selectInstance(instance: PeertubeInstance?) { + selectedInstance = PeertubeHelper.selectInstance(instance, requireContext()) + sharedPreferences!!.edit().putBoolean(KEY_MAIN_PAGE_CHANGE, true).apply() + } + + private fun saveChanges() { + val jsonWriter: JsonStringWriter = JsonWriter.string().`object`().array("instances") + for (instance: PeertubeInstance? in instanceListAdapter!!.getCurrentList()) { + jsonWriter.`object`() + jsonWriter.value("name", instance!!.getName()) + jsonWriter.value("url", instance.getUrl()) + jsonWriter.end() + } + val jsonToSave: String = jsonWriter.end().end().done() + sharedPreferences!!.edit().putString(savedInstanceListKey, jsonToSave).apply() + } + + private fun restoreDefaults() { + val context: Context = requireContext() + AlertDialog.Builder(context) + .setTitle(R.string.restore_defaults) + .setMessage(R.string.restore_defaults_confirmation) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.ok, DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> + sharedPreferences!!.edit().remove(savedInstanceListKey).apply() + selectInstance(PeertubeInstance.DEFAULT_INSTANCE) + instanceListAdapter!!.submitList(PeertubeHelper.getInstanceList(context)) + })) + .show() + } + + private fun showAddItemDialog(c: Context) { + val dialogBinding: DialogEditTextBinding = DialogEditTextBinding.inflate(getLayoutInflater()) + dialogBinding.dialogEditText.setInputType( + InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI) + dialogBinding.dialogEditText.setHint(R.string.peertube_instance_add_help) + 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, DialogInterface.OnClickListener({ dialog1: DialogInterface?, which: Int -> + val url: String = dialogBinding.dialogEditText.getText().toString() + addInstance(url) + })) + .show() + } + + private fun addInstance(url: String) { + val cleanUrl: String? = cleanUrl(url) + if (cleanUrl == null) { + return + } + binding!!.loadingProgressBar.setVisibility(View.VISIBLE) + val disposable: Disposable = Single.fromCallable(Callable({ + val instance: PeertubeInstance = PeertubeInstance(cleanUrl) + instance.fetchInstanceMetaData() + instance + })).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer({ instance: PeertubeInstance -> + binding!!.loadingProgressBar.setVisibility(View.GONE) + add(instance) + }), Consumer({ e: Throwable? -> + binding!!.loadingProgressBar.setVisibility(View.GONE) + Toast.makeText(getActivity(), R.string.peertube_instance_add_fail, + Toast.LENGTH_SHORT).show() + })) + disposables!!.add(disposable) + } + + private fun cleanUrl(url: String): String? { + var cleanUrl: String = url.trim({ it <= ' ' }) + // if protocol not present, add https + if (!cleanUrl.startsWith("http")) { + cleanUrl = "https://" + cleanUrl + } + // remove trailing slash + cleanUrl = cleanUrl.replace("/$".toRegex(), "") + // 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 (instance: PeertubeInstance? in instanceListAdapter!!.getCurrentList()) { + if ((instance!!.getUrl() == cleanUrl)) { + Toast.makeText(getActivity(), R.string.peertube_instance_add_exists, + Toast.LENGTH_SHORT).show() + return null + } + } + return cleanUrl + } + + private fun add(instance: PeertubeInstance) { + val list: ArrayList = ArrayList(instanceListAdapter!!.getCurrentList()) + list.add(instance) + instanceListAdapter!!.submitList(list) + } + + private fun getItemTouchCallback(): ItemTouchHelper.SimpleCallback { + return object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, + ItemTouchHelper.START or ItemTouchHelper.END) { + public override fun interpolateOutOfBoundsScroll(recyclerView: RecyclerView, + viewSize: Int, + viewSizeOutOfBounds: Int, + totalSize: Int, + msSinceStartScroll: Long): Int { + val standardSpeed: Int = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, + viewSizeOutOfBounds, totalSize, msSinceStartScroll) + val minimumAbsVelocity: Int = max(12.0, abs(standardSpeed.toDouble())).toInt() + return minimumAbsVelocity * sign(viewSizeOutOfBounds.toDouble()).toInt() + } + + public override fun onMove(recyclerView: RecyclerView, + source: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder): Boolean { + if ((source.getItemViewType() != target.getItemViewType() + || instanceListAdapter == null)) { + return false + } + val sourceIndex: Int = source.getBindingAdapterPosition() + val targetIndex: Int = target.getBindingAdapterPosition() + instanceListAdapter!!.swapItems(sourceIndex, targetIndex) + return true + } + + public override fun isLongPressDragEnabled(): Boolean { + return false + } + + public override fun isItemViewSwipeEnabled(): Boolean { + return true + } + + public override fun onSwiped(viewHolder: RecyclerView.ViewHolder, + swipeDir: Int) { + val position: Int = viewHolder.getBindingAdapterPosition() + // do not allow swiping the selected instance + if ((instanceListAdapter!!.getCurrentList().get(position)!!.getUrl() + == selectedInstance!!.getUrl())) { + instanceListAdapter!!.notifyItemChanged(position) + return + } + val list: ArrayList = ArrayList(instanceListAdapter!!.getCurrentList()) + list.removeAt(position) + if (list.isEmpty()) { + list.add(selectedInstance) + } + instanceListAdapter!!.submitList(list) + } + } + } + + /*////////////////////////////////////////////////////////////////////////// + // List Handling + ////////////////////////////////////////////////////////////////////////// */ + private inner class InstanceListAdapter internal constructor(context: Context?, private val itemTouchHelper: ItemTouchHelper?) : ListAdapter(PeertubeInstanceCallback()) { + private val inflater: LayoutInflater + private var lastChecked: RadioButton? = null + + init { + inflater = LayoutInflater.from(context) + } + + fun swapItems(fromPosition: Int, toPosition: Int) { + val list: ArrayList = ArrayList(getCurrentList()) + Collections.swap(list, fromPosition, toPosition) + submitList(list) + } + + public override fun onCreateViewHolder(parent: ViewGroup, + viewType: Int): TabViewHolder { + return TabViewHolder(ItemInstanceBinding.inflate(inflater, + parent, false)) + } + + public override fun onBindViewHolder(holder: TabViewHolder, + position: Int) { + holder.bind(position) + } + + internal inner class TabViewHolder(private val itemBinding: ItemInstanceBinding) : RecyclerView.ViewHolder(binding!!.getRoot()) { + @SuppressLint("ClickableViewAccessibility") + fun bind(position: Int) { + itemBinding.handle.setOnTouchListener(OnTouchListener({ view: View?, motionEvent: MotionEvent -> + if (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { + if (itemTouchHelper != null && getItemCount() > 1) { + itemTouchHelper.startDrag(this) + return@setOnTouchListener true + } + } + false + })) + val instance: PeertubeInstance? = getItem(position) + itemBinding.instanceName.setText(instance!!.getName()) + itemBinding.instanceUrl.setText(instance.getUrl()) + itemBinding.selectInstanceRB.setOnCheckedChangeListener(null) + if ((selectedInstance!!.getUrl() == instance.getUrl())) { + if (lastChecked != null && lastChecked !== itemBinding.selectInstanceRB) { + lastChecked!!.setChecked(false) + } + itemBinding.selectInstanceRB.setChecked(true) + lastChecked = itemBinding.selectInstanceRB + } + itemBinding.selectInstanceRB.setOnCheckedChangeListener(CompoundButton.OnCheckedChangeListener({ buttonView: CompoundButton?, isChecked: Boolean -> + 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 class PeertubeInstanceCallback() : DiffUtil.ItemCallback() { + public override fun areItemsTheSame(oldItem: PeertubeInstance, + newItem: PeertubeInstance): Boolean { + return (oldItem.getUrl() == newItem.getUrl()) + } + + public override fun areContentsTheSame(oldItem: PeertubeInstance, + newItem: PeertubeInstance): Boolean { + return ((oldItem.getName() == newItem.getName()) && (oldItem.getUrl() == newItem.getUrl())) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java deleted file mode 100644 index 37335421d..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java +++ /dev/null @@ -1,213 +0,0 @@ -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.image.PicassoHelper; -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 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 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)); - PicassoHelper.loadAvatar(entry.getAvatarUrl()).into(holder.thumbnailView); - } - - @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); - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.kt new file mode 100644 index 000000000..8122181ce --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.kt @@ -0,0 +1,181 @@ +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.fragment.app.DialogFragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +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 +import org.schabi.newpipe.R +import org.schabi.newpipe.database.subscription.SubscriptionEntity +import org.schabi.newpipe.error.ErrorUtil.Companion.showUiErrorSnackbar +import org.schabi.newpipe.local.subscription.SubscriptionManager +import org.schabi.newpipe.settings.SelectChannelFragment.SelectChannelAdapter.SelectChannelItemHolder +import org.schabi.newpipe.util.ThemeHelper +import org.schabi.newpipe.util.image.PicassoHelper +import java.util.Vector + +/** + * 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 //www.gnu.org/licenses/>. + * + */ +class SelectChannelFragment() : DialogFragment() { + private var onSelectedListener: OnSelectedListener? = null + private var onCancelListener: OnCancelListener? = null + private var progressBar: ProgressBar? = null + private var emptyView: TextView? = null + private var recyclerView: RecyclerView? = null + private var subscriptions: List = Vector() + fun setOnSelectedListener(listener: OnSelectedListener?) { + onSelectedListener = listener + } + + fun setOnCancelListener(listener: OnCancelListener?) { + onCancelListener = listener + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext())) + } + + public override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + val v: View = inflater.inflate(R.layout.select_channel_fragment, container, false) + recyclerView = v.findViewById(R.id.items_list) + recyclerView.setLayoutManager(LinearLayoutManager(getContext())) + val channelAdapter: SelectChannelAdapter = 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) + val subscriptionManager: SubscriptionManager = SubscriptionManager(requireContext()) + subscriptionManager.subscriptions().toObservable() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getSubscriptionObserver()) + return v + } + + /*////////////////////////////////////////////////////////////////////////// + // Handle actions + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCancel(dialogInterface: DialogInterface) { + super.onCancel(dialogInterface) + if (onCancelListener != null) { + onCancelListener!!.onCancel() + } + } + + private fun clickedItem(position: Int) { + if (onSelectedListener != null) { + val entry: SubscriptionEntity = subscriptions.get(position) + onSelectedListener!! + .onChannelSelected(entry.getServiceId(), entry.getUrl(), entry.getName()) + } + dismiss() + } + + /*////////////////////////////////////////////////////////////////////////// + // Item handling + ////////////////////////////////////////////////////////////////////////// */ + private fun displayChannels(newSubscriptions: List) { + subscriptions = newSubscriptions + progressBar!!.setVisibility(View.GONE) + if (newSubscriptions.isEmpty()) { + emptyView!!.setVisibility(View.VISIBLE) + return + } + recyclerView!!.setVisibility(View.VISIBLE) + } + + private fun getSubscriptionObserver(): Observer> { + return object : Observer> { + public override fun onSubscribe(disposable: Disposable) {} + public override fun onNext(newSubscriptions: List) { + displayChannels(newSubscriptions) + } + + public override fun onError(exception: Throwable) { + showUiErrorSnackbar(this@SelectChannelFragment, + "Loading subscription", exception) + } + + public override fun onComplete() {} + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Interfaces + ////////////////////////////////////////////////////////////////////////// */ + open interface OnSelectedListener { + fun onChannelSelected(serviceId: Int, url: String?, name: String?) + } + + open interface OnCancelListener { + fun onCancel() + } + + private inner class SelectChannelAdapter() : RecyclerView.Adapter() { + public override fun onCreateViewHolder(parent: ViewGroup, + viewType: Int): SelectChannelItemHolder { + val item: View = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.select_channel_item, parent, false) + return SelectChannelItemHolder(item) + } + + public override fun onBindViewHolder(holder: SelectChannelItemHolder, position: Int) { + val entry: SubscriptionEntity = subscriptions.get(position) + holder.titleView.setText(entry.getName()) + holder.view.setOnClickListener(View.OnClickListener({ view: View? -> clickedItem(position) })) + PicassoHelper.loadAvatar(entry.getAvatarUrl()).into(holder.thumbnailView) + } + + public override fun getItemCount(): Int { + return subscriptions.size + } + + inner class SelectChannelItemHolder internal constructor(val view: View) : RecyclerView.ViewHolder(v) { + val thumbnailView: ImageView + val titleView: TextView + + init { + thumbnailView = v.findViewById(R.id.itemThumbnailView) + titleView = v.findViewById(R.id.itemTitleView) + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java deleted file mode 100644 index 383390506..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java +++ /dev/null @@ -1,165 +0,0 @@ -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); - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.kt new file mode 100644 index 000000000..4dade5e91 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.kt @@ -0,0 +1,136 @@ +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.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.Companion.showUiErrorSnackbar +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.StreamingService +import org.schabi.newpipe.settings.SelectKioskFragment.SelectKioskAdapter.SelectKioskItemHolder +import org.schabi.newpipe.util.KioskTranslator +import org.schabi.newpipe.util.ServiceHelper +import org.schabi.newpipe.util.ThemeHelper +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 //www.gnu.org/licenses/>. + * + */ +class SelectKioskFragment() : DialogFragment() { + private var selectKioskAdapter: SelectKioskAdapter? = null + private var onSelectedListener: OnSelectedListener? = null + fun setOnSelectedListener(listener: OnSelectedListener?) { + onSelectedListener = listener + } + + /*////////////////////////////////////////////////////////////////////////// + // Init + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext())) + } + + public override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + val v: View = inflater.inflate(R.layout.select_kiosk_fragment, container, false) + val recyclerView: RecyclerView = v.findViewById(R.id.items_list) + recyclerView.setLayoutManager(LinearLayoutManager(getContext())) + try { + selectKioskAdapter = SelectKioskAdapter() + } catch (e: Exception) { + showUiErrorSnackbar(this, "Selecting kiosk", e) + } + recyclerView.setAdapter(selectKioskAdapter) + return v + } + + /*////////////////////////////////////////////////////////////////////////// + // Handle actions + ////////////////////////////////////////////////////////////////////////// */ + private fun clickedItem(entry: SelectKioskAdapter.Entry) { + if (onSelectedListener != null) { + onSelectedListener!!.onKioskSelected(entry.serviceId, entry.kioskId, entry.kioskName) + } + dismiss() + } + + /*////////////////////////////////////////////////////////////////////////// + // Interfaces + ////////////////////////////////////////////////////////////////////////// */ + open interface OnSelectedListener { + fun onKioskSelected(serviceId: Int, kioskId: String?, kioskName: String?) + } + + private inner class SelectKioskAdapter internal constructor() : RecyclerView.Adapter() { + private val kioskList: MutableList = Vector() + + init { + for (service: StreamingService in NewPipe.getServices()) { + for (kioskId: String? in service.getKioskList().getAvailableKiosks()) { + val name: String = String.format(getString(R.string.service_kiosk_string), + service.getServiceInfo().getName(), + KioskTranslator.getTranslatedKioskName(kioskId, getContext())) + kioskList.add(Entry(ServiceHelper.getIcon(service.getServiceId()), + service.getServiceId(), kioskId, name)) + } + } + } + + public override fun getItemCount(): Int { + return kioskList.size + } + + public override fun onCreateViewHolder(parent: ViewGroup, type: Int): SelectKioskItemHolder { + val item: View = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.select_kiosk_item, parent, false) + return SelectKioskItemHolder(item) + } + + public override fun onBindViewHolder(holder: SelectKioskItemHolder, position: Int) { + val entry: Entry = kioskList.get(position) + holder.titleView.setText(entry.kioskName) + holder.thumbnailView + .setImageDrawable(AppCompatResources.getDrawable(requireContext(), entry.icon)) + holder.view.setOnClickListener(View.OnClickListener({ view: View? -> clickedItem(entry) })) + } + + internal inner class Entry(val icon: Int, val serviceId: Int, val kioskId: String, val kioskName: String) + inner class SelectKioskItemHolder internal constructor(val view: View) : RecyclerView.ViewHolder(v) { + val thumbnailView: ImageView + val titleView: TextView + + init { + thumbnailView = v.findViewById(R.id.itemThumbnailView) + titleView = v.findViewById(R.id.itemTitleView) + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java deleted file mode 100644 index 36abef9e5..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java +++ /dev/null @@ -1,192 +0,0 @@ -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.PicassoHelper; - -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.name); - - } else if (selectedItem instanceof PlaylistRemoteEntity) { - final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem); - onSelectedListener.onRemotePlaylistSelected( - entry.getServiceId(), entry.getUrl(), entry.getName()); - } - } - dismiss(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Interfaces - //////////////////////////////////////////////////////////////////////////*/ - - public interface OnSelectedListener { - void onLocalPlaylistSelected(long id, String name); - void onRemotePlaylistSelected(int serviceId, String url, String name); - } - - private 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) { - final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem); - - holder.titleView.setText(entry.name); - holder.view.setOnClickListener(view -> clickedItem(position)); - PicassoHelper.loadPlaylistThumbnail(entry.thumbnailUrl).into(holder.thumbnailView); - - } else if (selectedItem instanceof PlaylistRemoteEntity) { - final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem); - - holder.titleView.setText(entry.getName()); - holder.view.setOnClickListener(view -> clickedItem(position)); - PicassoHelper.loadPlaylistThumbnail(entry.getThumbnailUrl()) - .into(holder.thumbnailView); - } - } - - @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); - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.kt new file mode 100644 index 000000000..1e64fb184 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.kt @@ -0,0 +1,159 @@ +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.ProgressBar +import android.widget.TextView +import androidx.fragment.app.DialogFragment +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.functions.Consumer +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.Companion.showSnackbar +import org.schabi.newpipe.error.UserAction +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.settings.SelectPlaylistFragment.SelectPlaylistAdapter.SelectPlaylistItemHolder +import org.schabi.newpipe.util.image.PicassoHelper +import java.util.Vector + +class SelectPlaylistFragment() : DialogFragment() { + private var onSelectedListener: OnSelectedListener? = null + private var progressBar: ProgressBar? = null + private var emptyView: TextView? = null + private var recyclerView: RecyclerView? = null + private var disposable: Disposable? = null + private var playlists: List? = Vector() + fun setOnSelectedListener(listener: OnSelectedListener?) { + onSelectedListener = listener + } + + /*////////////////////////////////////////////////////////////////////////// + // Fragment's Lifecycle + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + val v: View = 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(LinearLayoutManager(getContext())) + val playlistAdapter: SelectPlaylistAdapter = SelectPlaylistAdapter() + recyclerView.setAdapter(playlistAdapter) + loadPlaylists() + return v + } + + public override fun onDestroy() { + super.onDestroy() + if (disposable != null) { + disposable!!.dispose() + } + } + + /*////////////////////////////////////////////////////////////////////////// + // Load and display playlists + ////////////////////////////////////////////////////////////////////////// */ + private fun loadPlaylists() { + progressBar!!.setVisibility(View.VISIBLE) + recyclerView!!.setVisibility(View.GONE) + emptyView!!.setVisibility(View.GONE) + val database: AppDatabase = NewPipeDatabase.getInstance(requireContext()) + val localPlaylistManager: LocalPlaylistManager = LocalPlaylistManager(database) + val remotePlaylistManager: RemotePlaylistManager = RemotePlaylistManager(database) + disposable = MergedPlaylistManager.getMergedOrderedPlaylists(localPlaylistManager, remotePlaylistManager) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer?>({ newPlaylists: List? -> displayPlaylists(newPlaylists) }), Consumer({ e: Throwable? -> onError(e) })) + } + + private fun displayPlaylists(newPlaylists: List?) { + playlists = newPlaylists + progressBar!!.setVisibility(View.GONE) + emptyView!!.setVisibility(if (newPlaylists!!.isEmpty()) View.VISIBLE else View.GONE) + recyclerView!!.setVisibility(if (newPlaylists.isEmpty()) View.GONE else View.VISIBLE) + } + + protected fun onError(e: Throwable?) { + showSnackbar(requireActivity(), ErrorInfo((e)!!, + UserAction.UI_ERROR, "Loading playlists")) + } + + /*////////////////////////////////////////////////////////////////////////// + // Handle actions + ////////////////////////////////////////////////////////////////////////// */ + private fun clickedItem(position: Int) { + if (onSelectedListener != null) { + val selectedItem: LocalItem? = playlists!!.get(position) + if (selectedItem is PlaylistMetadataEntry) { + val entry: PlaylistMetadataEntry = selectedItem + onSelectedListener!!.onLocalPlaylistSelected(entry.getUid(), entry.name) + } else if (selectedItem is PlaylistRemoteEntity) { + val entry: PlaylistRemoteEntity = selectedItem + onSelectedListener!!.onRemotePlaylistSelected( + entry.getServiceId(), entry.getUrl(), entry.getName()) + } + } + dismiss() + } + + /*////////////////////////////////////////////////////////////////////////// + // Interfaces + ////////////////////////////////////////////////////////////////////////// */ + open interface OnSelectedListener { + fun onLocalPlaylistSelected(id: Long, name: String?) + fun onRemotePlaylistSelected(serviceId: Int, url: String?, name: String?) + } + + private inner class SelectPlaylistAdapter() : RecyclerView.Adapter() { + public override fun onCreateViewHolder(parent: ViewGroup, + viewType: Int): SelectPlaylistItemHolder { + val item: View = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.list_playlist_mini_item, parent, false) + return SelectPlaylistItemHolder(item) + } + + public override fun onBindViewHolder(holder: SelectPlaylistItemHolder, + position: Int) { + val selectedItem: PlaylistLocalItem? = playlists!!.get(position) + if (selectedItem is PlaylistMetadataEntry) { + val entry: PlaylistMetadataEntry = selectedItem + holder.titleView.setText(entry.name) + holder.view.setOnClickListener(View.OnClickListener({ view: View? -> clickedItem(position) })) + PicassoHelper.loadPlaylistThumbnail(entry.thumbnailUrl).into(holder.thumbnailView) + } else if (selectedItem is PlaylistRemoteEntity) { + val entry: PlaylistRemoteEntity = selectedItem + holder.titleView.setText(entry.getName()) + holder.view.setOnClickListener(View.OnClickListener({ view: View? -> clickedItem(position) })) + PicassoHelper.loadPlaylistThumbnail(entry.getThumbnailUrl()) + .into(holder.thumbnailView) + } + } + + public override fun getItemCount(): Int { + return playlists!!.size + } + + inner class SelectPlaylistItemHolder internal constructor(val view: View) : RecyclerView.ViewHolder(v) { + val thumbnailView: ImageView + val titleView: TextView + + init { + thumbnailView = v.findViewById(R.id.itemThumbnailView) + titleView = v.findViewById(R.id.itemTitleView) + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java b/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java deleted file mode 100644 index d731f2f5e..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java +++ /dev/null @@ -1,236 +0,0 @@ -package org.schabi.newpipe.settings; - -import android.content.Context; -import android.content.SharedPreferences; -import android.util.Log; - -import androidx.annotation.NonNull; -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.util.DeviceUtils; - -import java.util.Collections; -import java.util.HashSet; -import java.util.Set; - -import static org.schabi.newpipe.MainActivity.DEBUG; - -/** - * In order to add a migration, follow these steps, given 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 P to 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 should become P+1) - */ -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(); - } - }; - - public 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(); - } - }; - - /** - * 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, - }; - - /** - * Version number for preferences. Must be incremented every time a migration is necessary. - */ - private static final int VERSION = 6; - - - public 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.getApp().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); - - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.kt b/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.kt new file mode 100644 index 000000000..1ee684bc5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.kt @@ -0,0 +1,189 @@ +package org.schabi.newpipe.settings + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import androidx.preference.PreferenceManager +import org.schabi.newpipe.App +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.R +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.ErrorUtil.Companion.openActivity +import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.util.DeviceUtils +import java.util.Collections + +/** + * In order to add a migration, follow these steps, given P is the previous version:

+ * - in the class body add a new `MIGRATION_P_P+1 = new Migration(P, P+1) { ... }` and put in + * the `migrate()` method the code that need to be run when migrating from P to P+1

+ * - add `MIGRATION_P_P+1` at the end of [SettingMigrations.SETTING_MIGRATIONS]

+ * - increment [SettingMigrations.VERSION]'s value by 1 (so it should become P+1) + */ +object SettingMigrations { + private val TAG: String = SettingMigrations::class.java.toString() + private var sp: SharedPreferences? = null + private val MIGRATION_0_1: Migration = object : Migration(0, 1) { + public override fun migrate(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. + val editor: SharedPreferences.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 val MIGRATION_1_2: Migration = object : Migration(1, 2) { + protected override fun migrate(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. + val minimizeOnExitKey: String = context.getString(R.string.minimize_on_exit_key) + if ((sp!!.getString(minimizeOnExitKey, "") + == context.getString(R.string.minimize_on_exit_none_key))) { + val editor: SharedPreferences.Editor = sp!!.edit() + editor.putString(minimizeOnExitKey, + context.getString(R.string.minimize_on_exit_background_key)) + editor.apply() + } + } + } + private val MIGRATION_2_3: Migration = object : Migration(2, 3) { + protected override fun migrate(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 val MIGRATION_3_4: Migration = object : Migration(3, 4) { + protected override fun migrate(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 + val showSearchSuggestionsKey: String = context.getString(R.string.show_search_suggestions_key) + var addAllSearchSuggestionTypes: Boolean + try { + addAllSearchSuggestionTypes = sp!!.getBoolean(showSearchSuggestionsKey, true) + } catch (e: ClassCastException) { + // just in case it was not a boolean for some reason, let's consider it a "true" + addAllSearchSuggestionTypes = true + } + val showSearchSuggestionsValueList: Set = 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 val MIGRATION_4_5: Migration = object : Migration(4, 5) { + protected override fun migrate(context: Context) { + val brightness: Boolean = sp!!.getBoolean("brightness_gesture_control", true) + val volume: Boolean = sp!!.getBoolean("volume_gesture_control", true) + val editor: SharedPreferences.Editor = sp!!.edit() + editor.putString(context.getString(R.string.right_gesture_control_key), + context.getString(if (volume) R.string.volume_control_key else R.string.none_control_key)) + editor.putString(context.getString(R.string.left_gesture_control_key), + context.getString(if (brightness) R.string.brightness_control_key else R.string.none_control_key)) + editor.apply() + } + } + val MIGRATION_5_6: Migration = object : Migration(5, 6) { + protected override fun migrate(context: Context) { + val loadImages: Boolean = sp!!.getBoolean("download_thumbnail_key", true) + sp!!.edit() + .putString(context.getString(R.string.image_quality_key), + context.getString(if (loadImages) R.string.image_quality_default else R.string.image_quality_none_key)) + .apply() + } + } + + /** + * 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 val SETTING_MIGRATIONS: Array = arrayOf( + MIGRATION_0_1, + MIGRATION_1_2, + MIGRATION_2_3, + MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6) + + /** + * Version number for preferences. Must be incremented every time a migration is necessary. + */ + private val VERSION: Int = 6 + fun runMigrationsIfNeeded(context: Context) { + // setup migrations and check if there is something to do + sp = PreferenceManager.getDefaultSharedPreferences(context) + val lastPrefVersionKey: String = context.getString(R.string.last_used_preferences_version) + val lastPrefVersion: Int = sp.getInt(lastPrefVersionKey, 0) + + // no migration to run, already up to date + if (App.Companion.getApp().isFirstRun()) { + sp.edit().putInt(lastPrefVersionKey, VERSION).apply() + return + } else if (lastPrefVersion == VERSION) { + return + } + + // run migrations + var currentVersion: Int = lastPrefVersion + for (currentMigration: Migration in SETTING_MIGRATIONS) { + try { + if (currentMigration.shouldMigrate(currentVersion)) { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, ("Migrating preferences from version " + + currentVersion + " to " + currentMigration.newVersion)) + } + currentMigration.migrate(context) + currentVersion = currentMigration.newVersion + } + } catch (e: Exception) { + // save the version with the last successful migration and report the error + sp.edit().putInt(lastPrefVersionKey, currentVersion).apply() + openActivity(context, 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() + } + + abstract class Migration protected constructor(val oldVersion: Int, val newVersion: Int) { + /** + * @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. + */ + fun shouldMigrate(currentVersion: Int): Boolean { + return oldVersion >= currentVersion + } + + abstract fun migrate(context: Context) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java deleted file mode 100644 index 529e53442..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java +++ /dev/null @@ -1,393 +0,0 @@ -package org.schabi.newpipe.settings; - -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - -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.jakewharton.rxbinding4.widget.RxTextView; - -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; - -import icepick.Icepick; -import icepick.State; - -/* - * 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)); - assureCorrectAppLanguage(this); - - super.onCreate(savedInstanceBundle); - Icepick.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); - Icepick.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(); - assureCorrectAppLanguage(searchContext); - 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 -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.kt b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.kt new file mode 100644 index 000000000..d26400cab --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.kt @@ -0,0 +1,346 @@ +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.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.jakewharton.rxbinding4.widget.textChanges +import icepick.Icepick +import icepick.State +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.R +import org.schabi.newpipe.databinding.SettingsLayoutBinding +import org.schabi.newpipe.settings.SettingsResourceRegistry.SettingRegistryEntry +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.Localization +import org.schabi.newpipe.util.ReleaseVersionUtil.isReleaseApk +import org.schabi.newpipe.util.ThemeHelper +import org.schabi.newpipe.views.FocusOverlayView +import java.util.concurrent.TimeUnit +import java.util.function.Function +import java.util.function.Predicate + +/* +* 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 . +*/ +class SettingsActivity() : AppCompatActivity(), PreferenceFragmentCompat.OnPreferenceStartFragmentCallback, PreferenceSearchResultListener { + private var searchFragment: PreferenceSearchFragment? = null + private var menuSearchItem: MenuItem? = null + private var searchContainer: View? = null + private var searchEditText: EditText? = null + + // State + @State + var searchText: String? = null + + @State + var wasSearchActive: Boolean = false + override fun onCreate(savedInstanceBundle: Bundle?) { + setTheme(ThemeHelper.getSettingsThemeStyle(this)) + Localization.assureCorrectAppLanguage(this) + super.onCreate(savedInstanceBundle) + Icepick.restoreInstanceState(this, savedInstanceBundle) + val restored: Boolean = savedInstanceBundle != null + val settingsLayoutBinding: SettingsLayoutBinding = SettingsLayoutBinding.inflate(getLayoutInflater()) + setContentView(settingsLayoutBinding.getRoot()) + initSearch(settingsLayoutBinding, restored) + setSupportActionBar(settingsLayoutBinding.settingsToolbarLayout.toolbar) + if (restored) { + // Restore state + if (wasSearchActive) { + setSearchActive(true) + if (!TextUtils.isEmpty(searchText)) { + searchEditText!!.setText(searchText) + } + } + } else { + getSupportFragmentManager().beginTransaction() + .replace(R.id.settings_fragment_holder, MainSettingsFragment()) + .commit() + } + if (DeviceUtils.isTv(this)) { + FocusOverlayView.Companion.setupFocusObserver(this) + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + Icepick.saveInstanceState(this, outState) + } + + public override fun onCreateOptionsMenu(menu: Menu): Boolean { + val actionBar: ActionBar? = getSupportActionBar() + if (actionBar != null) { + actionBar.setDisplayHomeAsUpEnabled(true) + actionBar.setDisplayShowTitleEnabled(true) + } + return super.onCreateOptionsMenu(menu) + } + + public override fun onBackPressed() { + if (isSearchActive()) { + setSearchActive(false) + return + } + super.onBackPressed() + } + + public override fun onOptionsItemSelected(item: MenuItem): Boolean { + val id: Int = 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) + } + + public override fun onPreferenceStartFragment(caller: PreferenceFragmentCompat, + preference: Preference): Boolean { + showSettingsFragment(instantiateFragment((preference.getFragment())!!)) + return true + } + + private fun instantiateFragment(className: String): Fragment { + return getSupportFragmentManager() + .getFragmentFactory() + .instantiate(getClassLoader(), className) + } + + private fun showSettingsFragment(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 fun onDestroy() { + setMenuSearchItem(null) + searchFragment = null + super.onDestroy() + } + + /*////////////////////////////////////////////////////////////////////////// + // Search + ////////////////////////////////////////////////////////////////////////// */ + //region Search + private fun initSearch( + settingsLayoutBinding: SettingsLayoutBinding, + restored: Boolean + ) { + searchContainer = settingsLayoutBinding.settingsToolbarLayout.toolbar + .findViewById(R.id.toolbar_search_container) + + // Configure input field for search + searchEditText = searchContainer.findViewById(R.id.toolbar_search_edit_text) + searchEditText.textChanges() // Wait some time after the last input before actually searching + .debounce(200, TimeUnit.MILLISECONDS) + .subscribe(io.reactivex.rxjava3.functions.Consumer({ v: CharSequence? -> runOnUiThread(Runnable({ onSearchChanged() })) })) + + // Configure clear button + searchContainer.findViewById(R.id.toolbar_search_clear) + .setOnClickListener(View.OnClickListener({ ev: View? -> resetSearchText() })) + ensureSearchRepresentsApplicationState() + + // Build search configuration using SettingsResourceRegistry + val config: PreferenceSearchConfiguration = PreferenceSearchConfiguration() + + + // Build search items + val searchContext: Context = getApplicationContext() + Localization.assureCorrectAppLanguage(searchContext) + val parser: PreferenceParser = PreferenceParser(searchContext, config) + val searcher: PreferenceSearcher = PreferenceSearcher(config) + + // Find all searchable SettingsResourceRegistry fragments + SettingsResourceRegistry.Companion.getInstance().getAllEntries().stream() + .filter(Predicate({ isSearchable() })) // Get the resId + .map(Function({ getPreferencesResId() })) // Parse + .map?>(Function?>({ resId: Int -> parser.parse(resId) })) // Add it to the searcher + .forEach(java.util.function.Consumer?>({ items: List? -> searcher.add(items) })) + if (restored) { + searchFragment = getSupportFragmentManager() + .findFragmentByTag(PreferenceSearchFragment.Companion.NAME) as PreferenceSearchFragment? + 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 = 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 fun ensureSearchRepresentsApplicationState() { + // Check if the update settings are available + if (!isReleaseApk) { + SettingsResourceRegistry.Companion.getInstance() + .getEntryByPreferencesResId(R.xml.update_settings) + .setSearchable(false) + } + + // Hide debug preferences in RELEASE build variant + if (DEBUG) { + SettingsResourceRegistry.Companion.getInstance() + .getEntryByPreferencesResId(R.xml.debug_settings) + .setSearchable(true) + } + } + + fun setMenuSearchItem(menuSearchItem: MenuItem?) { + 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()) + } + } + + fun setSearchActive(active: Boolean) { + 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(if (active) View.VISIBLE else View.GONE) + if (menuSearchItem != null) { + menuSearchItem!!.setVisible(!active) + } + if (active) { + getSupportFragmentManager() + .beginTransaction() + .add(FRAGMENT_HOLDER_ID, (searchFragment)!!, PreferenceSearchFragment.Companion.NAME) + .addToBackStack(PreferenceSearchFragment.Companion.NAME) + .commit() + KeyboardUtil.showKeyboard(this, searchEditText) + } else if (searchFragment != null) { + hideSearchFragment() + getSupportFragmentManager() + .popBackStack( + PreferenceSearchFragment.Companion.NAME, + FragmentManager.POP_BACK_STACK_INCLUSIVE) + KeyboardUtil.hideKeyboard(this, searchEditText) + } + resetSearchText() + } + + private fun hideSearchFragment() { + getSupportFragmentManager().beginTransaction().remove((searchFragment)!!).commit() + } + + private fun resetSearchText() { + searchEditText!!.setText("") + } + + private fun isSearchActive(): Boolean { + return searchContainer!!.getVisibility() == View.VISIBLE + } + + private fun onSearchChanged() { + if (!isSearchActive()) { + return + } + if (searchFragment != null) { + searchText = searchEditText!!.getText().toString() + searchFragment!!.updateSearchResults(searchText!!) + } + } + + public override fun onSearchResultClicked(result: PreferenceSearchItem) { + if (DEBUG) { + Log.d(TAG, "onSearchResultClicked called result=" + result) + } + + // Hide the search + setSearchActive(false) + + // -- Highlight the result -- + // Find out which fragment class we need + val targetedFragmentClass: Class? = SettingsResourceRegistry.Companion.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 + var currentFragment: Fragment? = getSupportFragmentManager().findFragmentById(FRAGMENT_HOLDER_ID) + if (!(targetedFragmentClass == currentFragment!!.javaClass)) { + // If it's not the correct one display the correct one + currentFragment = instantiateFragment(targetedFragmentClass.getName()) + showSettingsFragment(currentFragment) + } + + // Run the highlighting + if (currentFragment is PreferenceFragmentCompat) { + PreferenceSearchResultHighlighter.highlight(result, currentFragment) + } + } //endregion + + companion object { + private val TAG: String = "SettingsActivity" + private val DEBUG: Boolean = MainActivity.Companion.DEBUG + + @IdRes + private val FRAGMENT_HOLDER_ID: Int = R.id.settings_fragment_holder + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java deleted file mode 100644 index 06e0a7c1e..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java +++ /dev/null @@ -1,152 +0,0 @@ -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()); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.kt b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.kt new file mode 100644 index 000000000..a45fc1f97 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.kt @@ -0,0 +1,136 @@ +package org.schabi.newpipe.settings + +import androidx.annotation.XmlRes +import androidx.fragment.app.Fragment +import org.schabi.newpipe.R +import java.util.Objects +import java.util.function.Predicate + +/** + * A registry that contains information about SettingsFragments. + *

+ * includes: + * + * * Class of the SettingsFragment + * * XML-Resource + * * ... + * + * + * E.g. used by the preference search. + */ +class SettingsResourceRegistry private constructor() { + private val registeredEntries: MutableSet = HashSet() + + init { + add(MainSettingsFragment::class.java, R.xml.main_settings).setSearchable(false) + add(AppearanceSettingsFragment::class.java, R.xml.appearance_settings) + add(ContentSettingsFragment::class.java, R.xml.content_settings) + add(DebugSettingsFragment::class.java, R.xml.debug_settings).setSearchable(false) + add(DownloadSettingsFragment::class.java, R.xml.download_settings) + add(HistorySettingsFragment::class.java, R.xml.history_settings) + add(NotificationSettingsFragment::class.java, R.xml.notifications_settings) + add(PlayerNotificationSettingsFragment::class.java, R.xml.player_notification_settings) + add(UpdateSettingsFragment::class.java, R.xml.update_settings) + add(VideoAudioSettingsFragment::class.java, R.xml.video_audio_settings) + add(ExoPlayerSettingsFragment::class.java, R.xml.exoplayer_settings) + add(BackupRestoreSettingsFragment::class.java, R.xml.backup_restore_settings) + } + + private fun add( + fragmentClass: Class, + @XmlRes preferencesResId: Int + ): SettingRegistryEntry { + val entry: SettingRegistryEntry = SettingRegistryEntry(fragmentClass, preferencesResId) + registeredEntries.add(entry) + return entry + } + + fun getEntryByFragmentClass( + fragmentClass: Class? + ): SettingRegistryEntry? { + Objects.requireNonNull(fragmentClass) + return registeredEntries.stream() + .filter(Predicate({ e: SettingRegistryEntry? -> Objects.equals(e!!.getFragmentClass(), fragmentClass) })) + .findFirst() + .orElse(null) + } + + fun getEntryByPreferencesResId(@XmlRes preferencesResId: Int): SettingRegistryEntry? { + return registeredEntries.stream() + .filter(Predicate({ e: SettingRegistryEntry? -> Objects.equals(e!!.getPreferencesResId(), preferencesResId) })) + .findFirst() + .orElse(null) + } + + fun getPreferencesResId(fragmentClass: Class): Int { + val entry: SettingRegistryEntry? = getEntryByFragmentClass(fragmentClass) + if (entry == null) { + return -1 + } + return entry.getPreferencesResId() + } + + fun getFragmentClass(@XmlRes preferencesResId: Int): Class? { + val entry: SettingRegistryEntry? = getEntryByPreferencesResId(preferencesResId) + if (entry == null) { + return null + } + return entry.getFragmentClass() + } + + fun getAllEntries(): Set { + return HashSet(registeredEntries) + } + + class SettingRegistryEntry( + fragmentClass: Class, + @field:XmlRes @param:XmlRes private val preferencesResId: Int + ) { + private val fragmentClass: Class + private var searchable: Boolean = true + + init { + this.fragmentClass = Objects.requireNonNull(fragmentClass) + } + + fun setSearchable(searchable: Boolean): SettingRegistryEntry { + this.searchable = searchable + return this + } + + fun getFragmentClass(): Class { + return fragmentClass + } + + fun getPreferencesResId(): Int { + return preferencesResId + } + + fun isSearchable(): Boolean { + return searchable + } + + public override fun equals(o: Any?): Boolean { + if (this === o) { + return true + } + if (o == null || javaClass != o.javaClass) { + return false + } + val that: SettingRegistryEntry = o as SettingRegistryEntry + return (getPreferencesResId() == that.getPreferencesResId() + && (getFragmentClass() == that.getFragmentClass())) + } + + public override fun hashCode(): Int { + return Objects.hash(getFragmentClass(), getPreferencesResId()) + } + } + + companion object { + private val INSTANCE: SettingsResourceRegistry = SettingsResourceRegistry() + fun getInstance(): SettingsResourceRegistry { + return INSTANCE + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java deleted file mode 100644 index b8d0aa556..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java +++ /dev/null @@ -1,76 +0,0 @@ -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(); - - findPreference(getString(R.string.update_app_key)) - .setOnPreferenceChangeListener(updatePreferenceChange); - findPreference(getString(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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.kt new file mode 100644 index 000000000..006cbc7ea --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.kt @@ -0,0 +1,73 @@ +package org.schabi.newpipe.settings + +import android.app.AlertDialog +import android.content.Context +import android.content.DialogInterface +import android.os.Bundle +import android.widget.Toast +import androidx.preference.Preference +import androidx.preference.PreferenceManager +import org.schabi.newpipe.NewVersionWorker.Companion.enqueueNewVersionCheckingWork +import org.schabi.newpipe.R + +class UpdateSettingsFragment() : BasePreferenceFragment() { + private val updatePreferenceChange: Preference.OnPreferenceChangeListener = Preference.OnPreferenceChangeListener({ p: Preference?, nVal: Any -> + val checkForUpdates: Boolean = nVal as Boolean + defaultPreferences!!.edit() + .putBoolean(getString(R.string.update_app_key), checkForUpdates) + .apply() + if (checkForUpdates) { + enqueueNewVersionCheckingWork(requireContext(), true) + } + true + }) + private val manualUpdateClick: Preference.OnPreferenceClickListener = Preference.OnPreferenceClickListener({ preference: Preference? -> + Toast.makeText(getContext(), R.string.checking_updates_toast, Toast.LENGTH_SHORT).show() + enqueueNewVersionCheckingWork(requireContext(), true) + true + }) + + public override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResourceRegistry() + findPreference(getString(R.string.update_app_key)) + .setOnPreferenceChangeListener(updatePreferenceChange) + findPreference(getString(R.string.manual_update_key)) + .setOnPreferenceClickListener(manualUpdateClick) + } + + companion object { + fun askForConsentToUpdateChecks(context: Context) { + 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), DialogInterface.OnClickListener({ d: DialogInterface, w: Int -> + d.dismiss() + setAutoUpdateCheckEnabled(context, true) + })) + .setNegativeButton(R.string.no, DialogInterface.OnClickListener({ d: DialogInterface, w: Int -> + d.dismiss() + // set explicitly to false, since the default is true on previous versions + setAutoUpdateCheckEnabled(context, false) + })) + .show() + } + + private fun setAutoUpdateCheckEnabled(context: Context, enabled: Boolean) { + 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 + */ + fun wasUserAskedForConsent(context: Context): Boolean { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.update_check_consent_key), false) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java deleted file mode 100644 index a1f563724..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java +++ /dev/null @@ -1,195 +0,0 @@ -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 = findPreference( - getString(R.string.default_resolution_key)); - final ListPreference defaultPopupResolution = findPreference( - getString(R.string.default_popup_resolution_key)); - final ListPreference mobileDataResolution = findPreference( - getString(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 = findPreference( - getString(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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.kt new file mode 100644 index 000000000..f22c90af3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.kt @@ -0,0 +1,177 @@ +package org.schabi.newpipe.settings + +import android.content.SharedPreferences +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +import android.content.res.Resources +import android.content.res.Resources.NotFoundException +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import android.text.format.DateUtils +import android.view.View +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 + +class VideoAudioSettingsFragment() : BasePreferenceFragment() { + private var listener: OnSharedPreferenceChangeListener? = null + public override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResourceRegistry() + updateSeekOptions() + updateResolutionOptions() + listener = OnSharedPreferenceChangeListener({ sharedPreferences: SharedPreferences, key: String? -> + + // 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) == key))) { + val newSetting: String? = sharedPreferences.getString(key, null) + if (((newSetting != null + ) && (newSetting == 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.OnClickListener({ view: View? -> PermissionHelper.checkSystemAlertWindowPermission(getContext()) })) + .show() + } + } else if ((getString(R.string.use_inexact_seek_key) == key)) { + updateSeekOptions() + } else if ((getString(R.string.show_higher_resolutions_key) == 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 fun updateResolutionOptions() { + val resources: Resources = getResources() + val showHigherResolutions: Boolean = getPreferenceManager().getSharedPreferences() + .getBoolean(resources.getString(R.string.show_higher_resolutions_key), false) + + // get sorted resolution lists + val resolutionListDescriptions: List? = ListHelper.getSortedResolutionList( + resources, + R.array.resolution_list_description, + R.array.high_resolution_list_descriptions, + showHigherResolutions) + val resolutionListValues: List? = ListHelper.getSortedResolutionList( + resources, + R.array.resolution_list_values, + R.array.high_resolution_list_values, + showHigherResolutions) + val limitDataUsageResolutionValues: List? = ListHelper.getSortedResolutionList( + resources, + R.array.limit_data_usage_values_list, + R.array.high_resolution_limit_data_usage_values_list, + showHigherResolutions) + val limitDataUsageResolutionDescriptions: List? = ListHelper.getSortedResolutionList(resources, + R.array.limit_data_usage_description_list, + R.array.high_resolution_list_descriptions, + showHigherResolutions) + + // get resolution preferences + val defaultResolution: ListPreference? = findPreference( + getString(R.string.default_resolution_key)) + val defaultPopupResolution: ListPreference? = findPreference( + getString(R.string.default_popup_resolution_key)) + val mobileDataResolution: ListPreference? = findPreference( + getString(R.string.limit_mobile_data_usage_key)) + + // update resolution preferences with new resolutions, entries & values for each + defaultResolution!!.setEntries(resolutionListDescriptions!!.toTypedArray()) + defaultResolution.setEntryValues(resolutionListValues!!.toTypedArray()) + defaultPopupResolution!!.setEntries(resolutionListDescriptions.toTypedArray()) + defaultPopupResolution.setEntryValues(resolutionListValues.toTypedArray()) + mobileDataResolution!!.setEntries( + limitDataUsageResolutionDescriptions!!.toTypedArray()) + mobileDataResolution.setEntryValues(limitDataUsageResolutionValues!!.toTypedArray()) + + // 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 fun updateSeekOptions() { + // initializing R.array.seek_duration_description to display the translation of seconds + val res: Resources = getResources() + val durationsValues: Array = res.getStringArray(R.array.seek_duration_value) + val displayedDurationValues: MutableList = LinkedList() + val displayedDescriptionValues: MutableList = LinkedList() + var currentDurationValue: Int + val inexactSeek: Boolean = getPreferenceManager().getSharedPreferences() + .getBoolean(res.getString(R.string.use_inexact_seek_key), false) + for (durationsValue: String in durationsValues) { + currentDurationValue = durationsValue.toInt() / DateUtils.SECOND_IN_MILLIS.toInt() + if (inexactSeek && currentDurationValue % 10 == 5) { + continue + } + displayedDurationValues.add(durationsValue) + try { + displayedDescriptionValues.add(String.format( + res.getQuantityString(R.plurals.seconds, + currentDurationValue), + currentDurationValue)) + } catch (ignored: NotFoundException) { + // if this happens, the translation is missing, + // and the english string will be displayed instead + } + } + val durations: ListPreference? = findPreference( + getString(R.string.seek_duration_key)) + durations!!.setEntryValues(displayedDurationValues.toTypedArray()) + durations.setEntries(displayedDescriptionValues.toTypedArray()) + val selectedDuration: Int = durations.getValue().toInt() + if (inexactSeek && selectedDuration / DateUtils.SECOND_IN_MILLIS.toInt() % 10 == 5) { + val newDuration: Int = selectedDuration / DateUtils.SECOND_IN_MILLIS.toInt() + 5 + durations.setValue((newDuration * DateUtils.SECOND_IN_MILLIS.toInt()).toString()) + val toast: Toast = Toast + .makeText(getContext(), + getString(R.string.new_seek_duration_toast, newDuration), + Toast.LENGTH_LONG) + toast.show() + } + } + + public override fun onResume() { + super.onResume() + getPreferenceManager().getSharedPreferences() + .registerOnSharedPreferenceChangeListener(listener) + } + + public override fun onPause() { + super.onPause() + getPreferenceManager().getSharedPreferences() + .unregisterOnSharedPreferenceChangeListener(listener) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java deleted file mode 100644 index 7dfddef20..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java +++ /dev/null @@ -1,117 +0,0 @@ -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(); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.kt b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.kt new file mode 100644 index 000000000..390e11c36 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.kt @@ -0,0 +1,96 @@ +package org.schabi.newpipe.settings.custom + +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.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.function.BiConsumer +import java.util.function.IntFunction +import java.util.stream.IntStream + +class NotificationActionsPreference(context: Context?, attrs: AttributeSet?) : Preference((context)!!, attrs) { + private var notificationSlots: Array? + private var compactSlots: MutableList? = null + + init { + setLayoutResource(R.layout.settings_notification) + } + + //////////////////////////////////////////////////////////////////////////// + // Lifecycle + //////////////////////////////////////////////////////////////////////////// + public override fun onBindViewHolder(holder: PreferenceViewHolder) { + super.onBindViewHolder(holder) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + (holder.itemView.findViewById(R.id.summary) as TextView) + .setText(R.string.notification_actions_summary_android13) + } + holder.itemView.setClickable(false) + setupActions(holder.itemView) + } + + public override fun onDetached() { + super.onDetached() + saveChanges() + // set package to this app's package to prevent the intent from being seen outside + getContext().sendBroadcast(Intent(NotificationConstants.ACTION_RECREATE_NOTIFICATION) + .setPackage(App.Companion.PACKAGE_NAME)) + } + + //////////////////////////////////////////////////////////////////////////// + // Setup + //////////////////////////////////////////////////////////////////////////// + private fun setupActions(view: View) { + compactSlots = ArrayList(NotificationConstants.getCompactSlotsFromPreferences( + getContext(), getSharedPreferences())) + notificationSlots = IntStream.range(0, 5) + .mapToObj(IntFunction({ i: Int -> + NotificationSlot(getContext(), getSharedPreferences(), i, view, + compactSlots.contains(i), BiConsumer({ i: Int, checkBox: CheckBox -> onToggleCompactSlot(i, checkBox) })) + })) + .toArray(IntFunction>({ _Dummy_.__Array__() })) + } + + private fun onToggleCompactSlot(i: Int, checkBox: CheckBox) { + if (checkBox.isChecked()) { + compactSlots!!.remove(i as Int?) + } 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 fun saveChanges() { + if (compactSlots != null && notificationSlots != null) { + val editor: SharedPreferences.Editor = getSharedPreferences()!!.edit() + for (i in 0..2) { + editor.putInt(getContext().getString( + NotificationConstants.SLOT_COMPACT_PREF_KEYS.get(i)), + ((if (i < compactSlots!!.size) compactSlots!!.get(i) else -1)!!)) + } + for (i in 0..4) { + editor.putInt(getContext().getString(NotificationConstants.SLOT_PREF_KEYS.get(i)), + notificationSlots!!.get(i).getSelectedAction()) + } + editor.apply() + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.java b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.java deleted file mode 100644 index 981ba3e75..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.java +++ /dev/null @@ -1,172 +0,0 @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.kt b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.kt new file mode 100644 index 000000000..af878b740 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.kt @@ -0,0 +1,150 @@ +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 + +internal class NotificationSlot(private val context: Context, + prefs: SharedPreferences?, + private val i: Int, + parentView: View, + isCompactSlotChecked: Boolean, + private val onToggleCompactSlot: BiConsumer) { + @NotificationConstants.Action + private var selectedAction: Int + private var icon: ImageView? = null + private var summary: TextView? = null + + init { + selectedAction = Objects.requireNonNull(prefs).getInt( + context.getString(NotificationConstants.SLOT_PREF_KEYS.get(i)), + NotificationConstants.SLOT_DEFAULTS.get(i)) + val view: View = parentView.findViewById(SLOT_ITEMS.get(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) + } + } + + fun setupTitle(view: View) { + (view.findViewById(R.id.notificationActionTitle) as TextView) + .setText(SLOT_TITLES.get(i)) + view.findViewById(R.id.notificationActionClickableArea).setOnClickListener( + View.OnClickListener({ v: View? -> openActionChooserDialog() })) + } + + fun setupCheckbox(view: View, isCompactSlotChecked: Boolean) { + val compactSlotCheckBox: CheckBox = 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( + View.OnClickListener({ v: View? -> onToggleCompactSlot.accept(i, compactSlotCheckBox) })) + } + + fun setupSelectedAction(view: View) { + icon = view.findViewById(R.id.notificationActionIcon) + summary = view.findViewById(R.id.notificationActionSummary) + updateInfo() + } + + fun updateInfo() { + if (NotificationConstants.ACTION_ICONS.get(selectedAction) == 0) { + icon!!.setImageDrawable(null) + } else { + icon!!.setImageDrawable(AppCompatResources.getDrawable(context, + NotificationConstants.ACTION_ICONS.get(selectedAction))) + } + summary!!.setText(NotificationConstants.getActionName(context, selectedAction)) + } + + fun openActionChooserDialog() { + val inflater: LayoutInflater = LayoutInflater.from(context) + val binding: SingleChoiceDialogViewBinding = SingleChoiceDialogViewBinding.inflate(inflater) + val alertDialog: AlertDialog = AlertDialog.Builder(context) + .setTitle(SLOT_TITLES.get(i)) + .setView(binding.getRoot()) + .setCancelable(true) + .create() + val radioButtonsClickListener: View.OnClickListener = View.OnClickListener({ v: View -> + selectedAction = NotificationConstants.ALL_ACTIONS.get(v.getId()) + updateInfo() + alertDialog.dismiss() + }) + for (id in NotificationConstants.ALL_ACTIONS.indices) { + val action: Int = NotificationConstants.ALL_ACTIONS.get(id) + val radioButton: RadioButton = ListRadioIconItemBinding.inflate(inflater) + .getRoot() + + // if present set action icon with correct color + val iconId: Int = NotificationConstants.ACTION_ICONS.get(action) + if (iconId != 0) { + radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, iconId, 0) + val color: ColorStateList = 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(RadioGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)) + radioButton.setOnClickListener(radioButtonsClickListener) + binding.list.addView(radioButton) + } + alertDialog.show() + if (DeviceUtils.isTv(context)) { + FocusOverlayView.Companion.setupFocusObserver(alertDialog) + } + } + + @NotificationConstants.Action + fun getSelectedAction(): Int { + return selectedAction + } + + companion object { + private val SLOT_ITEMS: IntArray = intArrayOf( + R.id.notificationAction0, + R.id.notificationAction1, + R.id.notificationAction2, + R.id.notificationAction3, + R.id.notificationAction4) + private val SLOT_TITLES: IntArray = intArrayOf( + 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) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.java deleted file mode 100644 index 68b0010c4..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.java +++ /dev/null @@ -1,96 +0,0 @@ -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; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.kt b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.kt new file mode 100644 index 000000000..0f8c3e435 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.kt @@ -0,0 +1,79 @@ +package org.schabi.newpipe.settings.preferencesearch + +import android.text.TextUtils +import android.util.Pair +import org.apache.commons.text.similarity.FuzzyScore +import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchConfiguration.PreferenceSearchFunction +import java.util.Locale +import java.util.function.Function +import java.util.function.Predicate +import java.util.function.ToDoubleFunction +import java.util.stream.Collectors +import java.util.stream.Stream + +class PreferenceFuzzySearchFunction() : PreferenceSearchFunction { + public override fun search( + allAvailable: Stream, + keyword: String + ): Stream { + val maxScore: Int = (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(Function({ item: PreferenceSearchItem? -> FuzzySearchGeneralDTO(item, keyword) })) + .filter(Predicate({ dto: FuzzySearchGeneralDTO -> dto.getScore() / maxScore >= 0.3f })) + .map(Function({ obj: FuzzySearchGeneralDTO -> obj.getItem() })) // Specific search - Used for determining order of search results + // Calculate a score based on specific search fields + .map(Function({ item: PreferenceSearchItem? -> FuzzySearchSpecificDTO(item, keyword) })) + .sorted(Comparator.comparingDouble(ToDoubleFunction({ obj: FuzzySearchSpecificDTO -> obj.getScore() })).reversed()) + .map(Function({ obj: FuzzySearchSpecificDTO -> obj.getItem() })) // Limit the amount of search results + .limit(20) + } + + internal class FuzzySearchGeneralDTO( + private val item: PreferenceSearchItem?, + keyword: String?) { + private val score: Float + + init { + score = FUZZY_SCORE.fuzzyScore( + TextUtils.join(";", item!!.getAllRelevantSearchFields()), + keyword).toFloat() + } + + fun getItem(): PreferenceSearchItem? { + return item + } + + fun getScore(): Float { + return score + } + } + + internal class FuzzySearchSpecificDTO(private val item: PreferenceSearchItem?, keyword: String?) { + private val score: Double + + init { + score = WEIGHT_MAP.entries.stream() + .map(Function({ entry: Map.Entry, Float> -> Pair(entry.key.apply(item), entry.value) })) + .filter(Predicate({ pair: Pair -> !pair.first.isEmpty() })) + .collect(Collectors.averagingDouble(ToDoubleFunction({ pair: Pair -> (FUZZY_SCORE.fuzzyScore(pair.first, keyword) * pair.second).toDouble() }))) + } + + fun getItem(): PreferenceSearchItem? { + return item + } + + fun getScore(): Double { + return score + } + + companion object { + private val WEIGHT_MAP: Map, Float> = java.util.Map.of(Function({ obj: PreferenceSearchItem? -> obj!!.getTitle() }), 1.5f, Function({ obj: PreferenceSearchItem? -> obj!!.getSummary() }), 1f, Function({ obj: PreferenceSearchItem? -> obj!!.getEntries() }), 1f + ) + } + } + + companion object { + private val FUZZY_SCORE: FuzzyScore = FuzzyScore(Locale.ROOT) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceParser.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceParser.java deleted file mode 100644 index b925e8b5f..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceParser.java +++ /dev/null @@ -1,193 +0,0 @@ -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()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceParser.kt b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceParser.kt new file mode 100644 index 000000000..2d54e86ec --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceParser.kt @@ -0,0 +1,175 @@ +package org.schabi.newpipe.settings.preferencesearch + +import android.content.Context +import android.text.TextUtils +import android.util.Log +import androidx.annotation.XmlRes +import androidx.preference.PreferenceManager +import org.schabi.newpipe.util.Localization +import org.xmlpull.v1.XmlPullParser +import java.util.Arrays + +/** + * Parses the corresponding preference-file(s). + */ +class PreferenceParser( + private val context: Context, + private val searchConfiguration: PreferenceSearchConfiguration +) { + private val allPreferences: Map + + init { + allPreferences = PreferenceManager.getDefaultSharedPreferences(context).getAll() + } + + fun parse( + @XmlRes resId: Int + ): List { + val results: MutableList = ArrayList() + val xpp: XmlPullParser = context.getResources().getXml(resId) + try { + xpp.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true) + xpp.setFeature(XmlPullParser.FEATURE_REPORT_NAMESPACE_ATTRIBUTES, true) + val breadcrumbs: MutableList = ArrayList() + while (xpp.getEventType() != XmlPullParser.END_DOCUMENT) { + if (xpp.getEventType() == XmlPullParser.START_TAG) { + val result: PreferenceSearchItem = parseSearchResult( + xpp, + Localization.concatenateStrings(" > ", breadcrumbs), + resId + ) + if ((!searchConfiguration.getParserIgnoreElements().contains(xpp.getName()) + && result.hasData() + && !("true" == 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(if (result.getTitle() == null) "" else result.getTitle()) + } + } else if ((xpp.getEventType() == XmlPullParser.END_TAG + && searchConfiguration.getParserContainerElements() + .contains(xpp.getName()))) { + breadcrumbs.removeAt(breadcrumbs.size - 1) + } + xpp.next() + } + } catch (e: Exception) { + Log.w(TAG, "Failed to parse resid=" + resId, e) + } + return results + } + + private fun getAttribute( + xpp: XmlPullParser, + attribute: String + ): String { + val nsSearchAttr: String? = getAttribute(xpp, NS_SEARCH, attribute) + if (nsSearchAttr != null) { + return nsSearchAttr + } + return getAttribute(xpp, NS_ANDROID, attribute) + } + + private fun getAttribute( + xpp: XmlPullParser, + namespace: String, + attribute: String + ): String { + return xpp.getAttributeValue(namespace, attribute) + } + + private fun parseSearchResult( + xpp: XmlPullParser, + breadcrumbs: String, + @XmlRes searchIndexItemResId: Int + ): PreferenceSearchItem { + val key: String = readString(getAttribute(xpp, "key")) + val entries: Array = readStringArray(getAttribute(xpp, "entries")) + val entryValues: Array = readStringArray(getAttribute(xpp, "entryValues")) + return PreferenceSearchItem( + key, + tryFillInPreferenceValue( + readString(getAttribute(xpp, "title")), + key, + entries, + entryValues), + tryFillInPreferenceValue( + readString(getAttribute(xpp, "summary")), + key, + entries, + entryValues), + TextUtils.join(",", entries), + breadcrumbs, + searchIndexItemResId + ) + } + + private fun readStringArray(s: String?): Array { + if (s == null) { + return arrayOfNulls(0) + } + if (s.startsWith("@")) { + try { + return context.getResources().getStringArray(s.substring(1).toInt()) + } catch (e: Exception) { + Log.w(TAG, "Unable to readStringArray from '" + s + "'", e) + } + } + return arrayOfNulls(0) + } + + private fun readString(s: String?): String { + if (s == null) { + return "" + } + if (s.startsWith("@")) { + try { + return context.getString(s.substring(1).toInt()) + } catch (e: Exception) { + Log.w(TAG, "Unable to readString from '" + s + "'", e) + } + } + return s + } + + private fun tryFillInPreferenceValue( + s: String?, + key: String?, + entries: Array, + entryValues: Array + ): String { + if (s == null) { + return "" + } + if (key == null) { + return s + } + + // Resolve value + var prefValue: Any? = allPreferences.get(key) + if (prefValue == null) { + return s + } + + /* + * Resolve ListPreference values + * + * entryValues = Values/Keys that are saved + * entries = Actual human readable names + */if (entries.size > 0 && entryValues.size == entries.size) { + val entryIndex: Int = Arrays.asList(*entryValues).indexOf(prefValue) + if (entryIndex != -1) { + prefValue = entries.get(entryIndex) + } + } + return String.format(s, prefValue.toString()) + } + + companion object { + private val TAG: String = "PreferenceParser" + private val NS_ANDROID: String = "http://schemas.android.com/apk/res/android" + private val NS_SEARCH: String = "http://schemas.android.com/apk/preferencesearch" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.java deleted file mode 100644 index d6e2021a1..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.java +++ /dev/null @@ -1,86 +0,0 @@ -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 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()); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.kt b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.kt new file mode 100644 index 000000000..57c56813b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.kt @@ -0,0 +1,59 @@ +package org.schabi.newpipe.settings.preferencesearch + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +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 + +internal class PreferenceSearchAdapter() : ListAdapter(PreferenceCallback()) { + private var onItemClickListener: Consumer? = null + public override fun onCreateViewHolder(parent: ViewGroup, + viewType: Int): PreferenceViewHolder { + return PreferenceViewHolder(SettingsPreferencesearchListItemResultBinding.inflate( + LayoutInflater.from(parent.getContext()), parent, false)) + } + + public override fun onBindViewHolder(holder: PreferenceViewHolder, position: Int) { + val item: PreferenceSearchItem? = 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(View.OnClickListener({ v: View? -> + if (onItemClickListener != null) { + onItemClickListener!!.accept(item) + } + })) + } + + fun setOnItemClickListener(onItemClickListener: Consumer?) { + this.onItemClickListener = onItemClickListener + } + + internal class PreferenceViewHolder(val binding: SettingsPreferencesearchListItemResultBinding) : RecyclerView.ViewHolder(binding.getRoot()) + private class PreferenceCallback() : DiffUtil.ItemCallback() { + public override fun areItemsTheSame(oldItem: PreferenceSearchItem, + newItem: PreferenceSearchItem): Boolean { + return (oldItem.getKey() == newItem.getKey()) + } + + public override fun areContentsTheSame(oldItem: PreferenceSearchItem, + newItem: PreferenceSearchItem): Boolean { + return (oldItem.getAllRelevantSearchFields() == newItem + .getAllRelevantSearchFields()) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java deleted file mode 100644 index 1ded181c8..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java +++ /dev/null @@ -1,42 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.kt b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.kt new file mode 100644 index 000000000..4ebc9d208 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.kt @@ -0,0 +1,37 @@ +package org.schabi.newpipe.settings.preferencesearch + +import androidx.preference.PreferenceCategory +import androidx.preference.PreferenceScreen +import java.util.Objects +import java.util.stream.Stream + +class PreferenceSearchConfiguration() { + private var searcher: PreferenceSearchFunction = PreferenceFuzzySearchFunction() + private val parserIgnoreElements: List = java.util.List.of( + PreferenceCategory::class.java.getSimpleName()) + private val parserContainerElements: List = java.util.List.of( + PreferenceCategory::class.java.getSimpleName(), + PreferenceScreen::class.java.getSimpleName()) + + fun setSearcher(searcher: PreferenceSearchFunction) { + this.searcher = Objects.requireNonNull(searcher) + } + + fun getSearcher(): PreferenceSearchFunction { + return searcher + } + + fun getParserIgnoreElements(): List { + return parserIgnoreElements + } + + fun getParserContainerElements(): List { + return parserContainerElements + } + + open fun interface PreferenceSearchFunction { + fun search( + allAvailable: Stream, + keyword: String): Stream + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java deleted file mode 100644 index 9d169d660..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java +++ /dev/null @@ -1,73 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.kt new file mode 100644 index 000000000..d6fa9059e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.kt @@ -0,0 +1,61 @@ +package org.schabi.newpipe.settings.preferencesearch + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import org.schabi.newpipe.databinding.SettingsPreferencesearchFragmentBinding +import java.util.function.Consumer + +/** + * Displays the search results. + */ +class PreferenceSearchFragment() : Fragment() { + private var searcher: PreferenceSearcher? = null + private var binding: SettingsPreferencesearchFragmentBinding? = null + private var adapter: PreferenceSearchAdapter? = null + fun setSearcher(searcher: PreferenceSearcher?) { + this.searcher = searcher + } + + public override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + binding = SettingsPreferencesearchFragmentBinding.inflate(inflater, container, false) + binding!!.searchResults.setLayoutManager(LinearLayoutManager(getContext())) + adapter = PreferenceSearchAdapter() + adapter!!.setOnItemClickListener(Consumer({ item: PreferenceSearchItem? -> onItemClicked((item)!!) })) + binding!!.searchResults.setAdapter(adapter) + return binding!!.getRoot() + } + + fun updateSearchResults(keyword: String) { + if (adapter == null || searcher == null) { + return + } + val results: List? = searcher!!.searchFor(keyword) + adapter!!.submitList(results) + setEmptyViewShown(results!!.isEmpty()) + } + + private fun setEmptyViewShown(shown: Boolean) { + binding!!.emptyStateView.setVisibility(if (shown) View.VISIBLE else View.GONE) + binding!!.searchResults.setVisibility(if (shown) View.GONE else View.VISIBLE) + } + + fun onItemClicked(item: PreferenceSearchItem) { + if (!(getActivity() is PreferenceSearchResultListener)) { + throw ClassCastException( + getActivity().toString() + " must implement SearchPreferenceResultListener") + } + (getActivity() as PreferenceSearchResultListener?)!!.onSearchResultClicked(item) + } + + companion object { + val NAME: String = PreferenceSearchFragment::class.java.getSimpleName() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.java deleted file mode 100644 index 33856326c..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.java +++ /dev/null @@ -1,102 +0,0 @@ -package org.schabi.newpipe.settings.preferencesearch; - -import androidx.annotation.NonNull; -import androidx.annotation.XmlRes; - -import java.util.List; -import java.util.Objects; - -/** - * Represents a preference-item inside the search. - */ -public class PreferenceSearchItem { - /** - * Key of the setting/preference. E.g. used inside {@link android.content.SharedPreferences}. - */ - @NonNull - private final String key; - /** - * Title of the setting, e.g. 'Default resolution' or 'Show higher resolutions'. - */ - @NonNull - private final String title; - /** - * Summary of the setting, e.g. '480p' or 'Only some devices can play 2k/4k'. - */ - @NonNull - private final String summary; - /** - * Possible entries of the setting, e.g. 480p,720p,... - */ - @NonNull - private final String entries; - /** - * Breadcrumbs - a hint where the setting is located e.g. 'Video and Audio > Player' - */ - @NonNull - private final String breadcrumbs; - /** - * The xml-resource where this item was found/built from. - */ - @XmlRes - private final int searchIndexItemResId; - - public PreferenceSearchItem( - @NonNull final String key, - @NonNull final String title, - @NonNull final String summary, - @NonNull final String entries, - @NonNull final String breadcrumbs, - @XmlRes final int searchIndexItemResId - ) { - this.key = Objects.requireNonNull(key); - this.title = Objects.requireNonNull(title); - this.summary = Objects.requireNonNull(summary); - this.entries = Objects.requireNonNull(entries); - this.breadcrumbs = Objects.requireNonNull(breadcrumbs); - this.searchIndexItemResId = searchIndexItemResId; - } - - @NonNull - public String getKey() { - return key; - } - - @NonNull - public String getTitle() { - return title; - } - - @NonNull - public String getSummary() { - return summary; - } - - @NonNull - public String getEntries() { - return entries; - } - - @NonNull - public String getBreadcrumbs() { - return breadcrumbs; - } - - public int getSearchIndexItemResId() { - return searchIndexItemResId; - } - - boolean hasData() { - return !key.isEmpty() && !title.isEmpty(); - } - - public List getAllRelevantSearchFields() { - return List.of(getTitle(), getSummary(), getEntries(), getBreadcrumbs()); - } - - @NonNull - @Override - public String toString() { - return "PreferenceItem: " + title + " " + summary + " " + key; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.kt b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.kt new file mode 100644 index 000000000..9221b4912 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.kt @@ -0,0 +1,88 @@ +package org.schabi.newpipe.settings.preferencesearch + +import androidx.annotation.XmlRes +import java.util.Objects + +/** + * Represents a preference-item inside the search. + */ +class PreferenceSearchItem( + key: String, + title: String, + summary: String, + entries: String, + breadcrumbs: String, + /** + * The xml-resource where this item was found/built from. + */ + @field:XmlRes @param:XmlRes private val searchIndexItemResId: Int +) { + /** + * Key of the setting/preference. E.g. used inside [android.content.SharedPreferences]. + */ + private val key: String + + /** + * Title of the setting, e.g. 'Default resolution' or 'Show higher resolutions'. + */ + private val title: String + + /** + * Summary of the setting, e.g. '480p' or 'Only some devices can play 2k/4k'. + */ + private val summary: String + + /** + * Possible entries of the setting, e.g. 480p,720p,... + */ + private val entries: String + + /** + * Breadcrumbs - a hint where the setting is located e.g. 'Video and Audio > Player' + */ + private val breadcrumbs: String + + init { + this.key = Objects.requireNonNull(key) + this.title = Objects.requireNonNull(title) + this.summary = Objects.requireNonNull(summary) + this.entries = Objects.requireNonNull(entries) + this.breadcrumbs = Objects.requireNonNull(breadcrumbs) + } + + fun getKey(): String { + return key + } + + fun getTitle(): String { + return title + } + + fun getSummary(): String { + return summary + } + + fun getEntries(): String { + return entries + } + + fun getBreadcrumbs(): String { + return breadcrumbs + } + + fun getSearchIndexItemResId(): Int { + return searchIndexItemResId + } + + fun hasData(): Boolean { + return !key.isEmpty() && !title.isEmpty() + } + + fun getAllRelevantSearchFields(): List { + return java.util.List.of(getTitle(), getSummary(), getEntries(), getBreadcrumbs()) + } + + public override fun toString(): String { + return "PreferenceItem: " + title + " " + summary + " " + key + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.java deleted file mode 100644 index 7eae5c128..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.java +++ /dev/null @@ -1,125 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.kt b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.kt new file mode 100644 index 000000000..f40255b32 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.kt @@ -0,0 +1,111 @@ +package org.schabi.newpipe.settings.preferencesearch + +import android.content.res.Resources.Theme +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.PreferencePositionCallback +import androidx.recyclerview.widget.RecyclerView +import org.schabi.newpipe.R + +object PreferenceSearchResultHighlighter { + private val TAG: String = "PrefSearchResHighlter" + + /** + * 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 + */ + fun highlight( + item: PreferenceSearchItem, + prefsFragment: PreferenceFragmentCompat + ) { + Handler(Looper.getMainLooper()).post(Runnable({ doHighlight(item, prefsFragment) })) + } + + private fun doHighlight( + item: PreferenceSearchItem, + prefsFragment: PreferenceFragmentCompat + ) { + val prefResult: Preference? = prefsFragment.findPreference(item.getKey()) + if (prefResult == null) { + Log.w(TAG, "Preference '" + item.getKey() + "' not found on '" + prefsFragment + "'") + return + } + val recyclerView: RecyclerView = prefsFragment.getListView() + val adapter: RecyclerView.Adapter<*>? = recyclerView.getAdapter() + if (adapter is PreferencePositionCallback) { + val position: Int = (adapter as PreferencePositionCallback) + .getPreferenceAdapterPosition(prefResult) + if (position != RecyclerView.NO_POSITION) { + recyclerView.scrollToPosition(position) + recyclerView.postDelayed(Runnable({ + val holder: RecyclerView.ViewHolder? = recyclerView.findViewHolderForAdapterPosition(position) + if (holder != null) { + val background: Drawable = holder.itemView.getBackground() + if (background is RippleDrawable) { + showRippleAnimation(background) + return@postDelayed + } + } + 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 fun highlightFallback( + prefsFragment: PreferenceFragmentCompat, + prefResult: Preference + ) { + // Get primary color from text for highlight icon + val typedValue: TypedValue = TypedValue() + val theme: Theme = prefsFragment.getActivity()!!.getTheme() + theme.resolveAttribute(android.R.attr.textColorPrimary, typedValue, true) + val arr: TypedArray = prefsFragment.getActivity() + .obtainStyledAttributes( + typedValue.data, intArrayOf(android.R.attr.textColorPrimary)) + val color: Int = arr.getColor(0, -0x1ac6cb) + arr.recycle() + + // Show highlight icon + val oldIcon: Drawable? = prefResult.getIcon() + val oldSpaceReserved: Boolean = prefResult.isIconSpaceReserved() + val highlightIcon: Drawable? = AppCompatResources.getDrawable( + prefsFragment.requireContext(), + R.drawable.ic_play_arrow) + highlightIcon!!.setColorFilter(PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)) + prefResult.setIcon(highlightIcon) + prefsFragment.scrollToPreference(prefResult) + Handler(Looper.getMainLooper()).postDelayed(Runnable({ + prefResult.setIcon(oldIcon) + prefResult.setIconSpaceReserved(oldSpaceReserved) + }), 1000) + } + + private fun showRippleAnimation(rippleDrawable: RippleDrawable) { + rippleDrawable.setState(intArrayOf(android.R.attr.state_pressed, android.R.attr.state_enabled)) + Handler(Looper.getMainLooper()) + .postDelayed(Runnable({ rippleDrawable.setState(intArrayOf()) }), 1000) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.java deleted file mode 100644 index 1f0636454..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.schabi.newpipe.settings.preferencesearch; - -import androidx.annotation.NonNull; - -public interface PreferenceSearchResultListener { - void onSearchResultClicked(@NonNull PreferenceSearchItem result); -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.kt b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.kt new file mode 100644 index 000000000..a146b3f79 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultListener.kt @@ -0,0 +1,5 @@ +package org.schabi.newpipe.settings.preferencesearch + +open interface PreferenceSearchResultListener { + fun onSearchResultClicked(result: PreferenceSearchItem) +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearcher.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearcher.java deleted file mode 100644 index b3efc8dd1..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearcher.java +++ /dev/null @@ -1,36 +0,0 @@ -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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearcher.kt b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearcher.kt new file mode 100644 index 000000000..1fc91b1f3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearcher.kt @@ -0,0 +1,24 @@ +package org.schabi.newpipe.settings.preferencesearch + +import android.text.TextUtils +import java.util.stream.Collectors + +class PreferenceSearcher(private val configuration: PreferenceSearchConfiguration) { + private val allEntries: MutableList = ArrayList() + fun add(items: List?) { + allEntries.addAll((items)!!) + } + + fun searchFor(keyword: String): List { + if (TextUtils.isEmpty(keyword)) { + return emptyList() + } + return configuration.getSearcher() + .search(allEntries.stream(), keyword) + .collect(Collectors.toList()) + } + + fun clear() { + allEntries.clear() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/package-info.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/package-info.java deleted file mode 100644 index 00929235e..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/package-info.java +++ /dev/null @@ -1,10 +0,0 @@ -/** - * 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; diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/package-info.kt b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/package-info.kt new file mode 100644 index 000000000..819ad9262 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/package-info.kt @@ -0,0 +1,1041 @@ +/** + * Contains classes for searching inside the preferences. + *

+ * This code is based on + * [ByteHamster/SearchPreference](https://github.com/ByteHamster/SearchPreference) + * (MIT license) but was heavily modified/refactored for our use. + * + * @author litetex + */ +package org.schabi.newpipe.settings.preferencesearch + +import org.schabi.newpipe.error.ErrorPanelHelper.Companion.getExceptionDescription +import okhttp3.OkHttpClient.Builder.cache +import okhttp3.OkHttpClient.Builder.callTimeout +import okhttp3.OkHttpClient.Builder.build +import okhttp3.OkHttpClient.cache +import okhttp3.Cache.delete +import org.schabi.newpipe.util.SavedState.prefixFileSaved +import org.schabi.newpipe.util.SavedState.pathFileSaved +import org.schabi.newpipe.database.stream.dao.StreamDAO.upsert +import org.schabi.newpipe.error.ErrorUtil.Companion.createNotification +import org.schabi.newpipe.error.ErrorUtil.Companion.showUiErrorSnackbar +import org.schabi.newpipe.local.feed.FeedFragment.Companion.newInstance +import org.schabi.newpipe.error.ErrorInfo.messageStringId +import org.schabi.newpipe.error.ErrorInfo.stackTraces +import org.schabi.newpipe.error.ErrorInfo.userAction +import org.schabi.newpipe.error.ErrorInfo.request +import org.schabi.newpipe.error.ErrorInfo.serviceName +import org.schabi.newpipe.error.ErrorUtil.Companion.openActivity +import org.acra.data.CrashReportData.getString +import org.schabi.newpipe.database.stream.model.StreamEntity.url +import org.schabi.newpipe.database.stream.model.StreamEntity.uid +import org.schabi.newpipe.database.playlist.PlaylistStreamEntry.streamEntity +import org.schabi.newpipe.database.stream.model.StreamEntity.title +import org.schabi.newpipe.database.stream.model.StreamEntity.uploader +import org.schabi.newpipe.database.stream.model.StreamEntity.serviceId +import org.schabi.newpipe.database.stream.model.StreamEntity.duration +import org.schabi.newpipe.database.playlist.PlaylistStreamEntry.progressMillis +import org.schabi.newpipe.database.stream.model.StreamEntity.thumbnailUrl +import org.schabi.newpipe.ktx.animate +import org.schabi.newpipe.database.stream.StreamStatisticsEntry.watchCount +import org.schabi.newpipe.database.stream.StreamStatisticsEntry.latestAccessDate +import org.schabi.newpipe.database.stream.StreamStatisticsEntry.streamEntity +import org.schabi.newpipe.database.stream.StreamStatisticsEntry.progressMillis +import androidx.room.RoomDatabase.runInTransaction +import org.schabi.newpipe.database.history.model.SearchHistoryEntry.hasEqualValues +import org.schabi.newpipe.database.history.model.SearchHistoryEntry.creationDate +import org.schabi.newpipe.database.stream.dao.StreamDAO.getStream +import org.schabi.newpipe.database.stream.StreamStatisticsEntry.streamId +import org.schabi.newpipe.database.playlist.PlaylistStreamEntry.streamId +import org.schabi.newpipe.database.stream.StreamStatisticsEntry.toStreamInfoItem +import org.schabi.newpipe.database.stream.dao.StreamDAO.upsertAll +import org.schabi.newpipe.database.playlist.PlaylistStreamEntry.toStreamInfoItem +import org.schabi.newpipe.ktx.isNetworkRelated +import org.schabi.newpipe.local.subscription.SubscriptionManager.subscriptionTable +import org.schabi.newpipe.database.subscription.SubscriptionDAO.getAll +import org.schabi.newpipe.local.subscription.SubscriptionManager.upsertAll +import org.schabi.newpipe.error.ErrorUtil.Companion.showSnackbar +import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling +import org.schabi.newpipe.ktx.animateHeight +import org.schabi.newpipe.info_list.StreamSegmentAdapter.selectSegmentAt +import org.schabi.newpipe.info_list.StreamSegmentAdapter.setItems +import org.schabi.newpipe.info_list.StreamSegmentAdapter.selectSegment +import org.schabi.newpipe.views.player.PlayerFastSeekOverlay.seekSecondsSupplier +import org.schabi.newpipe.views.player.PlayerFastSeekOverlay.performListener +import org.schabi.newpipe.player.gesture.BasePlayerGestureListener.endMultiDoubleTap +import org.schabi.newpipe.player.gesture.BasePlayerGestureListener.keepInDoubleTapMode +import org.schabi.newpipe.player.gesture.BasePlayerGestureListener.doubleTapControls +import org.schabi.newpipe.ktx.animateRotation +import org.schabi.newpipe.player.gesture.BasePlayerGestureListener.isDoubleTapping +import org.schabi.newpipe.ktx.hasAssignableCause +import org.acra.ACRA.isACRASenderServiceProcess +import org.acra.config.CoreConfigurationBuilder.withBuildConfigClass +import org.acra.ACRA.init +import androidx.sqlite.db.SupportSQLiteDatabase.execSQL +import androidx.sqlite.db.SupportSQLiteDatabase.beginTransaction +import androidx.sqlite.db.SupportSQLiteDatabase.setTransactionSuccessful +import androidx.sqlite.db.SupportSQLiteDatabase.endTransaction +import com.jakewharton.rxbinding4.widget.textChanges +import org.schabi.newpipe.util.ReleaseVersionUtil.isReleaseApk +import org.schabi.newpipe.local.feed.notifications.NotificationWorker.Companion.runNow +import org.schabi.newpipe.local.subscription.SubscriptionManager.subscriptions +import org.schabi.newpipe.NewVersionWorker.Companion.enqueueNewVersionCheckingWork +import org.schabi.newpipe.settings.ContentSettingsManager.deleteSettingsFile +import org.schabi.newpipe.settings.ContentSettingsManager.exportDatabase +import org.schabi.newpipe.settings.ContentSettingsManager.ensureDbDirectoryExists +import org.schabi.newpipe.settings.ContentSettingsManager.extractDb +import org.schabi.newpipe.settings.ContentSettingsManager.extractSettings +import org.schabi.newpipe.settings.ContentSettingsManager.loadSharedPreferences +import org.schabi.newpipe.ktx.isInterruptedCaused +import org.schabi.newpipe.ktx.slideUp +import org.schabi.newpipe.database.subscription.SubscriptionDAO.getSubscriptionFlowable +import org.schabi.newpipe.local.feed.notifications.NotificationHelper.Companion.areNewStreamsNotificationsEnabled +import org.schabi.newpipe.local.subscription.SubscriptionManager.insertSubscription +import org.schabi.newpipe.local.subscription.SubscriptionManager.deleteSubscription +import org.schabi.newpipe.local.subscription.SubscriptionManager.updateChannelInfo +import com.jakewharton.rxbinding4.view.clicks +import org.schabi.newpipe.ktx.animateBackgroundColor +import org.schabi.newpipe.ktx.animateTextColor +import org.schabi.newpipe.local.subscription.SubscriptionManager.updateNotificationMode +import org.schabi.newpipe.error.ErrorPanelHelper.dispose +import org.schabi.newpipe.error.ErrorPanelHelper.showError +import org.schabi.newpipe.error.ErrorPanelHelper.showTextError +import org.schabi.newpipe.error.ErrorPanelHelper.hide +import org.schabi.newpipe.error.ErrorPanelHelper.isVisible +import org.schabi.newpipe.local.feed.notifications.NotificationWorker.Companion.initialize +import okhttp3.OkHttpClient.Builder.readTimeout +import okhttp3.Request.Builder.method +import okhttp3.Request.Builder.url +import okhttp3.Request.Builder.addHeader +import okhttp3.Request.Builder.removeHeader +import okhttp3.Request.Builder.header +import okhttp3.OkHttpClient.newCall +import okhttp3.Request.Builder.build +import okhttp3.Call.execute +import okhttp3.Response.close +import okhttp3.ResponseBody.string +import okhttp3.HttpUrl.toString +import okhttp3.Headers.toMultimap +import org.schabi.newpipe.error.ErrorInfo.throwable +import androidx.lifecycle.Lifecycle.currentState +import androidx.lifecycle.Lifecycle.State.isAtLeast +import androidx.lifecycle.Lifecycle.addObserver +import androidx.lifecycle.Lifecycle.removeObserver +import org.schabi.newpipe.util.urlfinder.UrlFinder.Companion.firstUrlFromInput +import androidx.room.Room.databaseBuilder +import androidx.room.RoomDatabase.Builder.addMigrations +import androidx.room.RoomDatabase.Builder.build +import androidx.room.RoomDatabase.query +import androidx.room.RoomDatabase.close +import us.shandian.giga.get.MissionRecoveryInfo.toString +import org.schabi.newpipe.error.ErrorInfo.Companion.throwableToStringList +import us.shandian.giga.get.MissionRecoveryInfo.validateCondition +import us.shandian.giga.get.MissionRecoveryInfo.kind +import us.shandian.giga.get.MissionRecoveryInfo.desiredBitrate +import us.shandian.giga.get.MissionRecoveryInfo.format +import us.shandian.giga.get.MissionRecoveryInfo.isDesired2 +import us.shandian.giga.get.MissionRecoveryInfo.desired +import okio.ByteString.md5 +import okio.ByteString.sha1 +import okio.ByteString.hex +import androidx.viewpager.widget.PagerAdapter +import androidx.annotation.IntDef +import androidx.fragment.app.FragmentStatePagerAdapterMenuWorkaround +import android.view.ViewGroup +import android.os.Parcelable +import android.os.Bundle +import com.google.android.material.appbar.AppBarLayout +import org.schabi.newpipe.R +import androidx.coordinatorlayout.widget.CoordinatorLayout +import android.view.MotionEvent +import android.widget.OverScroller +import android.widget.TextView +import org.schabi.newpipe.util.text.LongPressLinkMovementMethod +import io.reactivex.rxjava3.disposables.CompositeDisposable +import org.schabi.newpipe.util.text.TextLinkifier +import androidx.core.text.HtmlCompat +import android.text.util.Linkify +import io.noties.markwon.Markwon +import io.noties.markwon.linkify.LinkifyPlugin +import io.reactivex.rxjava3.core.Single +import android.text.SpannableStringBuilder +import android.text.style.URLSpan +import org.schabi.newpipe.util.text.LongPressClickableSpan +import org.schabi.newpipe.util.text.UrlLongPressClickableSpan +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import org.schabi.newpipe.util.text.HashtagLongPressClickableSpan +import org.schabi.newpipe.util.text.TimestampExtractor +import org.schabi.newpipe.util.text.TimestampExtractor.TimestampMatchDTO +import org.schabi.newpipe.util.text.TimestampLongPressClickableSpan +import org.schabi.newpipe.util.text.TextEllipsizer +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.util.text.InternalUrlsHandler +import org.schabi.newpipe.extractor.StreamingService.LinkType +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.exceptions.ExtractionException +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.extractor.linkhandler.LinkHandlerFactory +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.player.playqueue.PlayQueue +import org.schabi.newpipe.player.playqueue.SinglePlayQueue +import org.schabi.newpipe.error.ErrorPanelHelper +import android.text.style.ClickableSpan +import org.schabi.newpipe.util.external_communication.ShareUtils +import android.view.View.OnTouchListener +import android.annotation.SuppressLint +import android.text.Spanned +import org.schabi.newpipe.util.text.TouchUtils +import org.schabi.newpipe.util.text.CommentTextOnTouchListener +import android.text.method.LinkMovementMethod +import android.text.Spannable +import android.view.ViewConfiguration +import android.text.method.MovementMethod +import android.os.Looper +import org.schabi.newpipe.util.image.PreferredImageQuality +import org.schabi.newpipe.util.image.ImageStrategy +import org.schabi.newpipe.extractor.Image.ResolutionLevel +import okhttp3.OkHttpClient +import com.squareup.picasso.Picasso +import org.schabi.newpipe.util.image.PicassoHelper +import com.squareup.picasso.OkHttp3Downloader +import kotlin.Throws +import com.squareup.picasso.RequestCreator +import androidx.core.graphics.BitmapCompat +import androidx.annotation.DrawableRes +import org.schabi.newpipe.util.debounce.DebounceSavable +import io.reactivex.rxjava3.subjects.PublishSubject +import org.schabi.newpipe.util.debounce.DebounceSaver +import io.reactivex.rxjava3.disposables.Disposable +import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.util.InfoCache +import org.schabi.newpipe.util.ServiceHelper +import org.schabi.newpipe.util.ZipHelper +import org.schabi.newpipe.streams.io.StoredFileHelper +import org.schabi.newpipe.streams.io.SharpInputStream +import org.schabi.newpipe.extractor.stream.AudioTrackType +import org.schabi.newpipe.extractor.stream.VideoStream +import org.schabi.newpipe.extractor.stream.DeliveryMethod +import android.content.SharedPreferences +import androidx.annotation.StringRes +import android.net.ConnectivityManager +import androidx.core.content.ContextCompat +import org.schabi.newpipe.util.StateSaver +import android.text.TextUtils +import org.schabi.newpipe.util.StateSaver.WriteRead +import org.schabi.newpipe.util.DeviceUtils +import org.schabi.newpipe.App +import android.content.pm.PackageManager +import android.app.UiModeManager +import android.os.BatteryManager +import android.hardware.input.InputManager +import android.view.InputDevice +import androidx.appcompat.app.AppCompatActivity +import android.view.WindowManager +import android.view.WindowMetrics +import android.view.WindowInsets +import org.schabi.newpipe.util.ThemeHelper +import androidx.annotation.StyleRes +import androidx.annotation.AttrRes +import android.graphics.drawable.Drawable +import androidx.appcompat.content.res.AppCompatResources +import android.app.Activity +import androidx.appcompat.app.AppCompatDelegate +import org.schabi.newpipe.info_list.ItemViewMode +import android.widget.EditText +import org.ocpsoft.prettytime.PrettyTime +import org.schabi.newpipe.extractor.localization.ContentCountry +import org.schabi.newpipe.extractor.ListExtractor +import android.icu.text.CompactDecimalFormat +import org.ocpsoft.prettytime.units.Decade +import org.schabi.newpipe.extractor.localization.DateWrapper +import android.util.DisplayMetrics +import androidx.annotation.PluralsRes +import org.schabi.newpipe.util.FilenameUtils +import com.grack.nanojson.JsonObject +import com.grack.nanojson.JsonParser +import com.grack.nanojson.JsonParserException +import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance +import androidx.recyclerview.widget.RecyclerView +import org.schabi.newpipe.util.PeertubeHelper +import com.grack.nanojson.JsonArray +import com.grack.nanojson.JsonStringWriter +import org.schabi.newpipe.util.SliderStrategy +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.util.StreamTypeUtil +import org.schabi.newpipe.util.SparseItemUtil +import android.widget.Toast +import io.reactivex.rxjava3.core.Completable +import org.schabi.newpipe.NewPipeDatabase +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.error.ErrorUtil +import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage +import org.schabi.newpipe.extractor.InfoItem +import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor +import org.schabi.newpipe.extractor.channel.ChannelInfo +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler +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.playlist.PlaylistInfo +import org.schabi.newpipe.extractor.kiosk.KioskInfo +import io.reactivex.rxjava3.core.Maybe +import io.reactivex.rxjava3.core.MaybeSource +import org.schabi.newpipe.extractor.MetaInfo +import org.schabi.newpipe.util.SerializedCache +import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs +import org.schabi.newpipe.util.ChannelTabHelper +import android.content.Intent +import org.schabi.newpipe.player.PlayerType +import org.schabi.newpipe.player.playqueue.PlayQueueItem +import org.schabi.newpipe.util.PermissionHelper +import org.schabi.newpipe.player.PlayerService +import org.schabi.newpipe.player.helper.PlayerHolder +import android.content.DialogInterface +import org.schabi.newpipe.fragments.MainFragment +import org.schabi.newpipe.fragments.list.search.SearchFragment +import org.schabi.newpipe.fragments.detail.VideoDetailFragment +import org.schabi.newpipe.player.helper.PlayerHelper +import org.schabi.newpipe.util.NavigationHelper.RunnableWithVideoDetailFragment +import org.schabi.newpipe.fragments.list.channel.ChannelFragment +import androidx.fragment.app.FragmentActivity +import org.schabi.newpipe.fragments.list.comments.CommentRepliesFragment +import org.schabi.newpipe.fragments.list.playlist.PlaylistFragment +import kotlin.jvm.JvmOverloads +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.local.feed.FeedFragment +import org.schabi.newpipe.local.bookmark.BookmarkFragment +import org.schabi.newpipe.local.subscription.SubscriptionFragment +import org.schabi.newpipe.fragments.list.kiosk.KioskFragment +import org.schabi.newpipe.local.playlist.LocalPlaylistFragment +import org.schabi.newpipe.local.history.StatisticsPlaylistFragment +import org.schabi.newpipe.local.subscription.SubscriptionsImportFragment +import org.schabi.newpipe.RouterActivity +import org.schabi.newpipe.about.AboutActivity +import org.schabi.newpipe.settings.SettingsActivity +import org.schabi.newpipe.download.DownloadActivity +import org.schabi.newpipe.player.PlayQueueActivity +import com.jakewharton.processphoenix.ProcessPhoenix +import org.schabi.newpipe.settings.NewPipeSettings +import androidx.core.app.ActivityCompat +import androidx.annotation.RequiresApi +import android.content.ActivityNotFoundException +import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder +import org.schabi.newpipe.util.PlayButtonHelper +import android.view.View.OnLongClickListener +import org.schabi.newpipe.util.AudioTrackAdapter.AudioTracksWrapper +import android.widget.BaseAdapter +import android.view.LayoutInflater +import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper +import androidx.collection.SparseArrayCompat +import org.schabi.newpipe.util.SecondaryStreamHelper +import android.widget.Spinner +import org.schabi.newpipe.extractor.stream.SubtitlesStream +import org.schabi.newpipe.DownloaderImpl +import org.schabi.newpipe.util.external_communication.KoreUtils +import android.content.pm.ResolveInfo +import android.content.ClipData +import androidx.core.content.FileProvider +import org.schabi.newpipe.util.NewPipeTextViewHelper +import com.nononsenseapps.filepicker.FilePickerActivity +import org.schabi.newpipe.util.FilePickerActivityHelper.CustomFilePickerFragment +import com.nononsenseapps.filepicker.AbstractFilePickerFragment +import android.os.Environment +import com.nononsenseapps.filepicker.FilePickerFragment +import androidx.recyclerview.widget.SortedList +import androidx.core.content.IntentCompat +import org.schabi.newpipe.error.ErrorActivity +import android.view.MenuInflater +import org.acra.sender.ReportSender +import org.acra.data.CrashReportData +import org.acra.ReportField +import org.schabi.newpipe.error.ReCaptchaActivity +import android.webkit.WebSettings +import android.webkit.WebViewClient +import android.webkit.WebView +import android.webkit.WebResourceRequest +import androidx.core.app.NavUtils +import com.google.auto.service.AutoService +import org.acra.sender.ReportSenderFactory +import org.acra.config.CoreConfiguration +import org.schabi.newpipe.error.AcraReportSender +import org.schabi.newpipe.local.dialog.PlaylistDialog +import org.schabi.newpipe.local.playlist.LocalPlaylistManager +import org.schabi.newpipe.local.dialog.PlaylistAppendDialog +import org.schabi.newpipe.local.dialog.PlaylistCreationDialog +import org.schabi.newpipe.local.LocalItemListAdapter +import org.schabi.newpipe.util.OnClickGesture +import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry +import androidx.recyclerview.widget.LinearLayoutManager +import org.schabi.newpipe.database.playlist.model.PlaylistEntity +import android.text.InputType +import org.schabi.newpipe.local.LocalItemBuilder +import org.schabi.newpipe.local.history.HistoryRecordManager +import org.schabi.newpipe.local.holder.LocalItemHolder +import org.schabi.newpipe.local.holder.PlaylistItemHolder +import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry +import org.schabi.newpipe.local.holder.LocalPlaylistItemHolder +import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity +import org.schabi.newpipe.local.holder.RemotePlaylistItemHolder +import org.schabi.newpipe.views.AnimatedProgressBar +import org.schabi.newpipe.database.playlist.PlaylistStreamEntry +import org.schabi.newpipe.util.DependentPreferenceHelper +import org.schabi.newpipe.database.stream.StreamStatisticsEntry +import org.schabi.newpipe.local.holder.LocalPlaylistStreamItemHolder +import org.schabi.newpipe.local.holder.LocalStatisticStreamItemHolder +import org.schabi.newpipe.local.history.HistoryEntryAdapter.OnHistoryItemClickListener +import org.schabi.newpipe.database.AppDatabase +import org.schabi.newpipe.database.stream.dao.StreamDAO +import org.schabi.newpipe.database.history.dao.StreamHistoryDAO +import org.schabi.newpipe.database.history.dao.SearchHistoryDAO +import org.schabi.newpipe.database.stream.dao.StreamStateDAO +import org.schabi.newpipe.database.stream.model.StreamStateEntity +import org.schabi.newpipe.database.history.model.StreamHistoryEntity +import io.reactivex.rxjava3.core.Flowable +import org.schabi.newpipe.database.history.model.StreamHistoryEntry +import org.schabi.newpipe.database.history.model.SearchHistoryEntry +import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity +import org.schabi.newpipe.local.BaseLocalListFragment +import org.schabi.newpipe.local.history.StatisticsPlaylistFragment.StatisticSortMode +import androidx.viewbinding.ViewBinding +import org.schabi.newpipe.settings.HistorySettingsFragment +import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry +import org.schabi.newpipe.info_list.dialog.StreamDialogEntry.StreamDialogEntryAction +import com.google.android.material.snackbar.Snackbar +import org.schabi.newpipe.database.playlist.PlaylistLocalItem +import org.schabi.newpipe.local.playlist.RemotePlaylistManager +import androidx.recyclerview.widget.ItemTouchHelper +import org.schabi.newpipe.database.LocalItem.LocalItemType +import org.schabi.newpipe.local.bookmark.MergedPlaylistManager +import org.schabi.newpipe.BaseFragment +import org.schabi.newpipe.local.holder.LocalBookmarkPlaylistItemHolder +import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder +import org.schabi.newpipe.database.playlist.dao.PlaylistDAO +import org.schabi.newpipe.database.playlist.dao.PlaylistStreamDAO +import org.schabi.newpipe.fragments.MainFragment.SelectedTabsPagerAdapter +import io.reactivex.rxjava3.core.SingleSource +import org.schabi.newpipe.database.playlist.dao.PlaylistRemoteDAO +import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException +import org.schabi.newpipe.local.subscription.services.ImportExportEventListener +import org.schabi.newpipe.local.subscription.services.ImportExportJsonHelper +import com.grack.nanojson.JsonAppendableWriter +import io.reactivex.rxjava3.processors.PublishProcessor +import androidx.core.app.NotificationManagerCompat +import android.os.IBinder +import org.schabi.newpipe.local.subscription.services.BaseImportExportService +import androidx.core.app.ServiceCompat +import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService +import org.schabi.newpipe.streams.io.SharpOutputStream +import org.schabi.newpipe.database.subscription.SubscriptionEntity +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService +import icepick.Icepick +import org.schabi.newpipe.local.subscription.ImportConfirmationDialog +import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.ActivityResultCallback +import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard +import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor +import androidx.core.text.util.LinkifyCompat +import org.schabi.newpipe.local.HeaderFooterHolder +import org.schabi.newpipe.local.holder.LocalPlaylistGridItemHolder +import org.schabi.newpipe.local.holder.LocalPlaylistCardItemHolder +import org.schabi.newpipe.local.holder.RemotePlaylistGridItemHolder +import org.schabi.newpipe.local.holder.RemotePlaylistCardItemHolder +import org.schabi.newpipe.local.holder.LocalPlaylistStreamGridItemHolder +import org.schabi.newpipe.local.holder.LocalPlaylistStreamCardItemHolder +import org.schabi.newpipe.local.holder.LocalStatisticStreamGridItemHolder +import org.schabi.newpipe.local.holder.LocalStatisticStreamCardItemHolder +import org.schabi.newpipe.util.FallbackViewHolder +import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup +import org.schabi.newpipe.fragments.BaseStateFragment +import org.schabi.newpipe.fragments.list.ListViewContract +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +import androidx.recyclerview.widget.GridLayoutManager +import android.widget.LinearLayout +import org.schabi.newpipe.views.CollapsibleView.ViewMode +import org.schabi.newpipe.views.CollapsibleView +import android.animation.ValueAnimator +import org.schabi.newpipe.views.CollapsibleView.StateListener +import android.view.View.MeasureSpec +import androidx.appcompat.widget.AppCompatEditText +import androidx.appcompat.widget.AppCompatTextView +import android.widget.TextView.BufferType +import android.view.ViewTreeObserver.OnGlobalFocusChangeListener +import android.view.ViewTreeObserver.OnDrawListener +import android.view.ViewTreeObserver.OnGlobalLayoutListener +import android.view.ViewTreeObserver.OnScrollChangedListener +import android.view.ViewTreeObserver.OnTouchModeChangeListener +import android.graphics.PixelFormat +import android.graphics.ColorFilter +import org.schabi.newpipe.views.FocusOverlayView +import android.view.ViewTreeObserver +import androidx.appcompat.view.WindowCallbackWrapper +import androidx.appcompat.widget.AppCompatSeekBar +import org.schabi.newpipe.views.FocusAwareSeekBar.NestedListener +import android.widget.SeekBar.OnSeekBarChangeListener +import android.widget.SeekBar +import android.widget.ProgressBar +import org.schabi.newpipe.views.AnimatedProgressBar.ProgressBarAnimation +import android.view.animation.Animation +import android.view.animation.AccelerateDecelerateInterpolator +import org.schabi.newpipe.views.NewPipeRecyclerView +import android.view.FocusFinder +import com.google.android.material.tabs.TabLayout +import org.schabi.newpipe.views.ScrollableTabLayout +import android.view.SurfaceView +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.ResizeMode +import androidx.core.view.WindowInsetsCompat +import androidx.drawerlayout.widget.DrawerLayout +import com.google.android.material.appbar.CollapsingToolbarLayout +import androidx.core.view.ViewCompat +import com.google.android.exoplayer2.Tracks +import com.google.android.exoplayer2.PlaybackParameters +import com.google.android.exoplayer2.text.Cue +import com.google.android.exoplayer2.video.VideoSize +import org.schabi.newpipe.player.ui.VideoPlayerUi +import android.view.View.OnLayoutChangeListener +import android.database.ContentObserver +import org.schabi.newpipe.player.playqueue.PlayQueueAdapter +import org.schabi.newpipe.info_list.StreamSegmentAdapter +import org.schabi.newpipe.player.event.PlayerServiceEventListener +import org.schabi.newpipe.player.gesture.BasePlayerGestureListener +import org.schabi.newpipe.player.gesture.MainPlayerGestureListener +import android.view.ViewParent +import android.widget.FrameLayout +import org.schabi.newpipe.player.notification.NotificationConstants +import org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode +import org.schabi.newpipe.extractor.stream.StreamSegment +import org.schabi.newpipe.player.ui.MainPlayerUi +import org.schabi.newpipe.ktx.AnimationType +import org.schabi.newpipe.fragments.OnScrollBelowItemsListener +import org.schabi.newpipe.info_list.StreamSegmentAdapter.StreamSegmentListener +import org.schabi.newpipe.info_list.StreamSegmentItem +import org.schabi.newpipe.player.mediaitem.MediaItemTag +import org.schabi.newpipe.player.playqueue.PlayQueueItemTouchCallback +import org.schabi.newpipe.QueueItemMenuUtil +import org.schabi.newpipe.player.playqueue.PlayQueueItemHolder +import org.schabi.newpipe.player.helper.PlaybackParameterDialog +import org.schabi.newpipe.player.ui.PlayerUi +import org.schabi.newpipe.player.gesture.PopupPlayerGestureListener +import org.schabi.newpipe.player.ui.PopupPlayerUi +import android.view.animation.AnticipateInterpolator +import android.animation.AnimatorListenerAdapter +import com.google.android.exoplayer2.ui.SubtitleView +import android.view.Gravity +import org.schabi.newpipe.player.playback.SurfaceHolderCallback +import android.view.GestureDetector +import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHolder +import android.graphics.PorterDuffColorFilter +import android.graphics.PorterDuff +import android.widget.RelativeLayout +import org.schabi.newpipe.views.player.PlayerFastSeekOverlay.PerformListener +import org.schabi.newpipe.player.gesture.DisplayPortion +import org.schabi.newpipe.views.player.PlayerFastSeekOverlay.PerformListener.FastSeekDirection +import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper +import org.schabi.newpipe.player.ui.VideoPlayerUi.PlayButtonAction +import androidx.appcompat.widget.AppCompatImageButton +import com.google.android.exoplayer2.C +import com.google.android.exoplayer2.source.TrackGroup +import com.google.android.exoplayer2.ui.CaptionStyleCompat +import com.google.android.exoplayer2.ExoPlayer +import org.schabi.newpipe.player.event.PlayerEventListener +import com.google.android.exoplayer2.PlaybackException +import android.os.PowerManager +import android.net.wifi.WifiManager +import android.os.PowerManager.WakeLock +import android.net.wifi.WifiManager.WifiLock +import android.media.AudioManager.OnAudioFocusChangeListener +import com.google.android.exoplayer2.analytics.AnalyticsListener +import android.media.AudioManager +import androidx.media.AudioFocusRequestCompat +import org.schabi.newpipe.player.helper.AudioReactor +import androidx.media.AudioManagerCompat +import android.animation.ValueAnimator.AnimatorUpdateListener +import com.google.android.exoplayer2.analytics.AnalyticsListener.EventTime +import android.media.audiofx.AudioEffect +import com.google.android.exoplayer2.upstream.TransferListener +import com.google.android.exoplayer2.upstream.cache.SimpleCache +import com.google.android.exoplayer2.upstream.DefaultDataSource +import com.google.android.exoplayer2.upstream.FileDataSource +import com.google.android.exoplayer2.upstream.cache.CacheDataSink +import com.google.android.exoplayer2.upstream.cache.CacheDataSource +import org.schabi.newpipe.player.helper.CacheFactory +import com.google.android.exoplayer2.util.MimeTypes +import org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType +import com.google.android.exoplayer2.SeekParameters +import com.google.android.exoplayer2.trackselection.ExoTrackSelection +import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection +import android.view.accessibility.CaptioningManager +import com.google.android.exoplayer2.source.ProgressiveMediaSource +import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener +import org.schabi.newpipe.player.helper.PlayerHolder.PlayerServiceConnection +import android.content.ServiceConnection +import android.content.ComponentName +import org.schabi.newpipe.player.PlayerService.LocalBinder +import com.google.android.exoplayer2.DefaultLoadControl +import org.schabi.newpipe.player.helper.PlayerDataSource +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeProgressiveDashManifestCreator +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeOtfDashManifestCreator +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubePostLiveStreamDvrDashManifestCreator +import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource +import com.google.android.exoplayer2.source.hls.HlsMediaSource +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistTracker +import com.google.android.exoplayer2.source.hls.HlsDataSourceFactory +import com.google.android.exoplayer2.upstream.LoadErrorHandlingPolicy +import com.google.android.exoplayer2.source.hls.playlist.HlsPlaylistParserFactory +import com.google.android.exoplayer2.source.hls.playlist.DefaultHlsPlaylistTracker +import com.google.android.exoplayer2.source.dash.DashMediaSource +import com.google.android.exoplayer2.source.smoothstreaming.DefaultSsChunkSource +import com.google.android.exoplayer2.source.SingleSampleMediaSource +import com.google.android.exoplayer2.source.dash.DefaultDashChunkSource +import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor +import com.google.android.exoplayer2.database.StandaloneDatabaseProvider +import org.schabi.newpipe.player.helper.PlayerSemitoneHelper +import com.google.android.exoplayer2.DefaultRenderersFactory +import com.google.android.exoplayer2.DefaultRenderersFactory.ExtensionRendererMode +import com.google.android.exoplayer2.mediacodec.MediaCodecSelector +import com.google.android.exoplayer2.video.VideoRendererEventListener +import org.schabi.newpipe.player.helper.CustomMediaCodecVideoRenderer +import android.widget.CompoundButton +import android.graphics.drawable.LayerDrawable +import android.widget.CheckBox +import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener +import org.schabi.newpipe.util.SliderStrategy.Quadratic +import com.google.android.exoplayer2.mediacodec.MediaCodecAdapter +import com.google.android.exoplayer2.video.MediaCodecVideoRenderer +import com.google.android.material.bottomsheet.BottomSheetBehavior +import com.google.android.exoplayer2.source.MediaSource +import org.schabi.newpipe.player.playback.PlaybackListener +import org.schabi.newpipe.player.mediasource.ManagedMediaSourcePlaylist +import io.reactivex.rxjava3.internal.subscriptions.EmptySubscription +import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent +import org.schabi.newpipe.player.playqueue.events.PlayQueueEventType +import org.schabi.newpipe.player.playqueue.events.RemoveEvent +import org.schabi.newpipe.player.playqueue.events.MoveEvent +import org.schabi.newpipe.player.playqueue.events.ReorderEvent +import org.schabi.newpipe.player.playback.MediaSourceManager +import org.schabi.newpipe.player.mediasource.ManagedMediaSource +import org.schabi.newpipe.player.playback.MediaSourceManager.ItemsToLoad +import org.schabi.newpipe.player.mediasource.LoadedMediaSource +import org.schabi.newpipe.player.mediasource.FailedMediaSource +import org.schabi.newpipe.player.mediasource.FailedMediaSource.MediaSourceResolutionException +import org.schabi.newpipe.player.mediasource.FailedMediaSource.StreamInfoLoadException +import android.view.SurfaceHolder +import com.google.android.exoplayer2.video.PlaceholderSurface +import org.schabi.newpipe.player.resolver.PlaybackResolver +import org.schabi.newpipe.player.mediaitem.StreamInfoTag +import org.schabi.newpipe.player.resolver.PlaybackResolver.ResolverException +import com.google.android.exoplayer2.MediaItem.LiveConfiguration +import com.google.android.exoplayer2.source.dash.manifest.DashManifest +import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser +import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest +import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifestParser +import org.schabi.newpipe.extractor.services.youtube.ItagItem +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.CreationException +import org.schabi.newpipe.player.resolver.AudioPlaybackResolver +import org.schabi.newpipe.player.resolver.VideoPlaybackResolver.QualityResolver +import org.schabi.newpipe.player.resolver.VideoPlaybackResolver.SourceType +import org.schabi.newpipe.player.resolver.VideoPlaybackResolver +import com.google.android.exoplayer2.C.RoleFlags +import com.google.android.exoplayer2.MediaItem.SubtitleConfiguration +import com.google.android.exoplayer2.source.MergingMediaSource +import org.schabi.newpipe.player.mediaitem.ExceptionTag +import com.google.android.exoplayer2.MediaItem.RequestMetadata +import com.google.android.exoplayer2.MediaItem.LocalConfiguration +import org.schabi.newpipe.player.mediaitem.PlaceholderTag +import io.reactivex.rxjava3.subjects.BehaviorSubject +import io.reactivex.rxjava3.core.BackpressureStrategy +import org.schabi.newpipe.player.playqueue.events.InitEvent +import org.schabi.newpipe.player.playqueue.events.SelectEvent +import org.schabi.newpipe.player.playqueue.events.AppendEvent +import org.schabi.newpipe.player.playqueue.events.ErrorEvent +import org.schabi.newpipe.player.playqueue.events.RecoveryEvent +import org.schabi.newpipe.player.playqueue.PlayQueueItemBuilder +import org.schabi.newpipe.player.playqueue.AbstractInfoPlayQueue +import org.schabi.newpipe.extractor.ListInfo +import io.reactivex.rxjava3.core.SingleObserver +import com.google.android.exoplayer2.upstream.HttpDataSource.RequestProperties +import com.google.android.exoplayer2.upstream.BaseDataSource +import com.google.android.exoplayer2.upstream.HttpDataSource +import org.schabi.newpipe.player.datasource.YoutubeHttpDataSource +import com.google.android.exoplayer2.upstream.DataSpec +import com.google.android.exoplayer2.upstream.HttpDataSource.HttpDataSourceException +import com.google.android.exoplayer2.upstream.HttpUtil +import com.google.android.exoplayer2.upstream.DataSourceException +import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidResponseCodeException +import com.google.android.exoplayer2.upstream.HttpDataSource.InvalidContentTypeException +import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper +import org.schabi.newpipe.player.datasource.NonUriHlsDataSourceFactory +import com.google.android.exoplayer2.upstream.ByteArrayDataSource +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector +import org.schabi.newpipe.player.helper.LoadController +import org.schabi.newpipe.player.ui.PlayerUiList +import android.content.IntentFilter +import io.reactivex.rxjava3.disposables.SerialDisposable +import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter +import org.schabi.newpipe.player.helper.CustomRenderersFactory +import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi +import org.schabi.newpipe.player.notification.NotificationPlayerUi +import com.squareup.picasso.Picasso.LoadedFrom +import com.google.android.exoplayer2.Player.PositionInfo +import com.google.android.exoplayer2.Player.DiscontinuityReason +import com.google.android.exoplayer2.text.CueGroup +import com.google.android.exoplayer2.Timeline +import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo +import com.google.android.exoplayer2.source.BaseMediaSource +import org.schabi.newpipe.player.mediasource.FailedMediaSource.FailedMediaSourceException +import com.google.android.exoplayer2.upstream.Allocator +import com.google.android.exoplayer2.source.MediaPeriod +import com.google.android.exoplayer2.source.SinglePeriodTimeline +import com.google.android.exoplayer2.source.SilenceMediaSource +import com.google.android.exoplayer2.source.WrappingMediaSource +import com.google.android.exoplayer2.source.CompositeMediaSource +import org.schabi.newpipe.player.mediasource.PlaceholderMediaSource +import com.google.android.exoplayer2.source.ConcatenatingMediaSource +import com.google.android.exoplayer2.source.ShuffleOrder.UnshuffledShuffleOrder +import android.support.v4.media.session.MediaSessionCompat +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.QueueNavigator +import android.support.v4.media.session.PlaybackStateCompat +import org.schabi.newpipe.player.mediasession.PlayQueueNavigator +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.MediaMetadataCompat +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector +import org.schabi.newpipe.player.notification.NotificationActionData +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.MediaButtonEventHandler +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.MediaMetadataProvider +import androidx.media.session.MediaButtonReceiver +import com.google.android.exoplayer2.ForwardingPlayer +import org.schabi.newpipe.player.mediasession.SessionConnectorActionProvider +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider +import androidx.core.app.PendingIntentCompat +import android.app.PendingIntent +import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType +import org.schabi.newpipe.extractor.stream.Frameset +import org.schabi.newpipe.player.AudioServiceLeakFix +import android.widget.ImageButton +import android.view.SubMenu +import android.content.ContextWrapper +import org.schabi.newpipe.streams.io.SharpStream +import androidx.documentfile.provider.DocumentFile +import org.schabi.newpipe.util.FilePickerActivityHelper +import android.content.ContentResolver +import us.shandian.giga.io.FileStream +import us.shandian.giga.io.FileStreamSAF +import android.provider.DocumentsContract +import org.schabi.newpipe.streams.io.StoredDirectoryHelper +import com.nononsenseapps.filepicker.AbstractFilePickerActivity +import android.os.storage.StorageManager +import android.os.storage.StorageVolume +import org.schabi.newpipe.streams.WebMReader.WebMTrack +import org.schabi.newpipe.streams.WebMReader +import org.schabi.newpipe.streams.WebMReader.SimpleBlock +import org.schabi.newpipe.streams.WebMReader.Cluster +import org.schabi.newpipe.streams.WebMWriter.ClusterInfo +import org.schabi.newpipe.streams.WebMWriter +import org.schabi.newpipe.streams.WebMWriter.KeyFrame +import org.schabi.newpipe.streams.Mp4DashReader.Moof +import org.schabi.newpipe.streams.Mp4DashReader +import org.schabi.newpipe.streams.Mp4DashReader.Moov +import org.schabi.newpipe.streams.Mp4DashReader.Trex +import org.schabi.newpipe.streams.Mp4DashReader.Mp4DashChunk +import org.schabi.newpipe.streams.Mp4DashReader.Traf +import org.schabi.newpipe.streams.Mp4DashReader.Tfhd +import org.schabi.newpipe.streams.Mp4DashReader.Trun +import org.schabi.newpipe.streams.Mp4DashReader.TrunEntry +import org.schabi.newpipe.streams.Mp4DashReader.Mvhd +import org.schabi.newpipe.streams.Mp4DashReader.Tkhd +import org.schabi.newpipe.streams.Mp4DashReader.Trak +import org.schabi.newpipe.streams.Mp4DashReader.Mdia +import org.schabi.newpipe.streams.Mp4DashReader.Hdlr +import org.schabi.newpipe.streams.Mp4DashReader.Elst +import org.schabi.newpipe.streams.Mp4DashReader.Minf +import org.schabi.newpipe.streams.Mp4DashReader.Mp4DashSample +import org.schabi.newpipe.streams.Mp4FromDashWriter +import org.schabi.newpipe.streams.Mp4FromDashWriter.TablesInfo +import org.schabi.newpipe.streams.OggFromWebMWriter +import org.schabi.newpipe.streams.SrtFromTtmlWriter +import org.jsoup.Jsoup +import org.jsoup.nodes.TextNode +import io.reactivex.rxjava3.plugins.RxJavaPlugins +import io.reactivex.rxjava3.exceptions.UndeliverableException +import io.reactivex.rxjava3.exceptions.CompositeException +import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException +import io.reactivex.rxjava3.exceptions.MissingBackpressureException +import org.acra.config.CoreConfigurationBuilder +import androidx.core.app.NotificationChannelCompat +import androidx.room.Dao +import org.schabi.newpipe.database.BasicDAO +import androidx.room.OnConflictStrategy +import androidx.room.ColumnInfo +import org.schabi.newpipe.database.history.dao.HistoryDAO +import androidx.room.RewriteQueriesToDropUnusedColumns +import androidx.room.PrimaryKey +import org.schabi.newpipe.database.subscription.NotificationMode +import org.schabi.newpipe.extractor.channel.ChannelInfoItem +import androidx.room.Delete +import org.schabi.newpipe.database.Migrations +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.room.TypeConverters +import org.schabi.newpipe.database.Converters +import org.schabi.newpipe.database.feed.model.FeedEntity +import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity +import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity +import androidx.room.RoomDatabase +import org.schabi.newpipe.database.feed.dao.FeedDAO +import org.schabi.newpipe.database.feed.dao.FeedGroupDAO +import org.schabi.newpipe.database.subscription.SubscriptionDAO +import org.schabi.newpipe.download.LoadingDialog +import android.widget.RadioGroup +import android.widget.AdapterView +import androidx.appcompat.view.menu.ActionMenuItemView +import org.schabi.newpipe.util.AudioTrackAdapter +import org.schabi.newpipe.util.StreamItemAdapter +import org.schabi.newpipe.download.DownloadDialog +import us.shandian.giga.service.DownloadManagerService +import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder +import androidx.annotation.IdRes +import android.text.Editable +import us.shandian.giga.service.MissionState +import us.shandian.giga.get.MissionRecoveryInfo +import us.shandian.giga.postprocessing.Postprocessing +import us.shandian.giga.ui.fragment.MissionsFragment +import org.schabi.newpipe.settings.tabs.Tab.BlankTab +import org.schabi.newpipe.settings.tabs.Tab.DefaultKioskTab +import org.schabi.newpipe.settings.tabs.Tab.SubscriptionsTab +import org.schabi.newpipe.settings.tabs.Tab.FeedTab +import org.schabi.newpipe.settings.tabs.Tab.BookmarksTab +import org.schabi.newpipe.settings.tabs.Tab.HistoryTab +import org.schabi.newpipe.settings.tabs.Tab.KioskTab +import org.schabi.newpipe.settings.tabs.Tab.ChannelTab +import org.schabi.newpipe.settings.tabs.Tab.PlaylistTab +import org.schabi.newpipe.fragments.BlankFragment +import org.schabi.newpipe.util.KioskTranslator +import org.schabi.newpipe.fragments.list.kiosk.DefaultKioskFragment +import org.schabi.newpipe.settings.tabs.TabsManager.SavedTabsChangeListener +import org.schabi.newpipe.settings.tabs.TabsJsonHelper +import org.schabi.newpipe.settings.tabs.TabsJsonHelper.InvalidJsonException +import org.schabi.newpipe.settings.tabs.TabsManager +import org.schabi.newpipe.settings.tabs.AddTabDialog.ChooseTabListItem +import org.schabi.newpipe.settings.tabs.AddTabDialog.DialogListAdapter +import androidx.appcompat.widget.AppCompatImageView +import org.schabi.newpipe.settings.tabs.ChooseTabsFragment.SelectedTabsAdapter +import com.google.android.material.floatingactionbutton.FloatingActionButton +import org.schabi.newpipe.settings.tabs.AddTabDialog +import org.schabi.newpipe.settings.SelectKioskFragment +import org.schabi.newpipe.settings.SelectChannelFragment +import org.schabi.newpipe.settings.SelectPlaylistFragment +import org.schabi.newpipe.settings.custom.NotificationSlot +import android.widget.RadioButton +import android.content.res.ColorStateList +import androidx.core.widget.TextViewCompat +import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchConfiguration +import androidx.annotation.XmlRes +import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchItem +import org.xmlpull.v1.XmlPullParser +import org.schabi.newpipe.settings.preferencesearch.PreferenceParser +import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchAdapter.PreferenceCallback +import androidx.recyclerview.widget.DiffUtil +import org.schabi.newpipe.settings.preferencesearch.PreferenceSearcher +import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchAdapter +import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchResultListener +import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchFragment +import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchConfiguration.PreferenceSearchFunction +import org.schabi.newpipe.settings.preferencesearch.PreferenceFuzzySearchFunction.FuzzySearchGeneralDTO +import org.schabi.newpipe.settings.preferencesearch.PreferenceFuzzySearchFunction.FuzzySearchSpecificDTO +import org.schabi.newpipe.settings.preferencesearch.PreferenceFuzzySearchFunction +import org.apache.commons.text.similarity.FuzzyScore +import androidx.preference.PreferenceFragmentCompat +import org.schabi.newpipe.settings.preferencesearch.PreferenceSearchResultHighlighter +import androidx.preference.PreferenceGroup.PreferencePositionCallback +import android.graphics.drawable.RippleDrawable +import android.content.res.Resources.Theme +import android.content.res.TypedArray +import org.schabi.newpipe.settings.SettingMigrations +import org.schabi.newpipe.settings.MainSettingsFragment +import org.schabi.newpipe.settings.SettingsResourceRegistry +import org.schabi.newpipe.settings.SettingsResourceRegistry.SettingRegistryEntry +import org.schabi.newpipe.util.ReleaseVersionUtil +import org.schabi.newpipe.util.KeyboardUtil +import org.schabi.newpipe.settings.SelectKioskFragment.SelectKioskAdapter +import org.schabi.newpipe.settings.SelectKioskFragment.SelectKioskAdapter.SelectKioskItemHolder +import org.schabi.newpipe.settings.BasePreferenceFragment +import org.schabi.newpipe.settings.DebugSettingsFragment.DebugSettingsBVDLeakCanaryAPI +import org.schabi.newpipe.local.feed.notifications.NotificationWorker +import org.schabi.newpipe.settings.DebugSettingsFragment +import org.schabi.newpipe.settings.SelectChannelFragment.SelectChannelAdapter +import org.schabi.newpipe.settings.SelectChannelFragment.SelectChannelAdapter.SelectChannelItemHolder +import org.schabi.newpipe.settings.SelectPlaylistFragment.SelectPlaylistAdapter +import org.schabi.newpipe.settings.SelectPlaylistFragment.SelectPlaylistAdapter.SelectPlaylistItemHolder +import org.schabi.newpipe.NewVersionWorker +import org.schabi.newpipe.settings.UpdateSettingsFragment +import androidx.preference.SwitchPreferenceCompat +import org.schabi.newpipe.settings.DownloadSettingsFragment +import org.schabi.newpipe.settings.AppearanceSettingsFragment +import org.schabi.newpipe.settings.ContentSettingsFragment +import org.schabi.newpipe.settings.NotificationSettingsFragment +import org.schabi.newpipe.settings.PlayerNotificationSettingsFragment +import org.schabi.newpipe.settings.VideoAudioSettingsFragment +import org.schabi.newpipe.settings.ExoPlayerSettingsFragment +import org.schabi.newpipe.settings.BackupRestoreSettingsFragment +import android.text.format.DateUtils +import android.content.res.Resources.NotFoundException +import org.schabi.newpipe.settings.PeertubeInstanceListFragment.InstanceListAdapter +import org.schabi.newpipe.settings.PeertubeInstanceListFragment.PeertubeInstanceCallback +import org.schabi.newpipe.settings.ContentSettingsManager +import org.schabi.newpipe.settings.NewPipeFileLocator +import org.schabi.newpipe.fragments.list.BaseListInfoFragment +import org.schabi.newpipe.extractor.services.media_ccc.extractors.MediaCCCLiveStreamKiosk +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory +import org.schabi.newpipe.extractor.kiosk.KioskList +import org.schabi.newpipe.fragments.list.BaseListFragment +import org.schabi.newpipe.fragments.BackPressable +import org.schabi.newpipe.fragments.list.search.SuggestionListAdapter +import android.text.TextWatcher +import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory +import org.schabi.newpipe.extractor.services.peertube.linkHandler.PeertubeSearchQueryHandlerFactory +import androidx.appcompat.widget.TooltipCompat +import android.view.View.OnFocusChangeListener +import org.schabi.newpipe.fragments.list.search.SuggestionListAdapter.OnSuggestionItemSelected +import org.schabi.newpipe.fragments.list.search.SuggestionItem +import android.text.style.CharacterStyle +import android.widget.TextView.OnEditorActionListener +import android.view.inputmethod.EditorInfo +import io.reactivex.rxjava3.core.ObservableSource +import org.schabi.newpipe.extractor.search.SearchExtractor.NothingFoundException +import android.text.Html +import org.schabi.newpipe.fragments.list.search.SuggestionListAdapter.SuggestionItemHolder +import org.schabi.newpipe.fragments.list.search.SuggestionListAdapter.SuggestionItemCallback +import org.schabi.newpipe.fragments.list.videos.RelatedItemsInfo +import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment +import org.schabi.newpipe.local.feed.notifications.NotificationHelper +import org.schabi.newpipe.fragments.list.channel.ChannelTabFragment +import org.schabi.newpipe.fragments.list.channel.ChannelAboutFragment +import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException +import org.schabi.newpipe.extractor.linkhandler.ReadyChannelTabListLinkHandler +import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue +import org.schabi.newpipe.fragments.detail.BaseDescriptionFragment +import org.schabi.newpipe.extractor.stream.StreamExtractor +import org.schabi.newpipe.fragments.list.comments.CommentsFragment +import org.schabi.newpipe.fragments.list.comments.CommentRepliesInfo +import androidx.constraintlayout.widget.ConstraintLayout +import com.google.android.material.shape.ShapeAppearanceModel +import com.google.android.material.shape.CornerFamily +import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue +import org.schabi.newpipe.info_list.InfoListAdapter +import org.schabi.newpipe.views.SuperScrollLayoutManager +import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem +import org.schabi.newpipe.fragments.list.BaseListFragment.DefaultItemListOnScrolledDownListener +import org.schabi.newpipe.fragments.ViewContract +import androidx.fragment.app.FragmentPagerAdapter +import org.schabi.newpipe.extractor.stream.StreamExtractor.Privacy +import org.schabi.newpipe.player.event.OnKeyDownListener +import com.google.android.material.bottomsheet.BottomSheetBehavior.BottomSheetCallback +import android.content.pm.ActivityInfo +import org.schabi.newpipe.fragments.detail.VideoDetailPlayerCrasher +import com.google.android.material.appbar.AppBarLayout.OnOffsetChangedListener +import org.schabi.newpipe.fragments.EmptyFragment +import org.schabi.newpipe.fragments.detail.DescriptionFragment +import org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability +import android.text.style.StyleSpan +import android.graphics.Typeface +import com.google.android.material.chip.Chip +import com.google.android.exoplayer2.ExoPlaybackException +import com.google.android.material.tabs.TabLayout.OnTabSelectedListener +import androidx.viewpager.widget.ViewPager +import androidx.annotation.ColorInt +import androidx.recyclerview.widget.StaggeredGridLayoutManager +import org.schabi.newpipe.info_list.dialog.StreamDialogEntry +import org.schabi.newpipe.info_list.dialog.InfoItemDialog +import org.schabi.newpipe.info_list.InfoItemBuilder +import org.schabi.newpipe.info_list.holder.StreamMiniInfoItemHolder +import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder +import org.schabi.newpipe.info_list.holder.InfoItemHolder +import org.schabi.newpipe.info_list.holder.CommentInfoItemHolder +import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder +import org.schabi.newpipe.info_list.holder.StreamInfoItemHolder +import org.schabi.newpipe.extractor.InfoItem.InfoType +import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder +import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder +import org.schabi.newpipe.info_list.holder.StreamGridInfoItemHolder +import org.schabi.newpipe.info_list.holder.StreamCardInfoItemHolder +import org.schabi.newpipe.info_list.holder.ChannelCardInfoItemHolder +import org.schabi.newpipe.info_list.holder.ChannelGridInfoItemHolder +import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder +import org.schabi.newpipe.info_list.holder.PlaylistCardInfoItemHolder +import org.schabi.newpipe.ExitActivity +import androidx.appcompat.app.ActionBarDrawerToggle +import androidx.drawerlayout.widget.DrawerLayout.SimpleDrawerListener +import com.google.android.material.navigation.NavigationView +import android.widget.ArrayAdapter +import androidx.core.view.GravityCompat +import androidx.fragment.app.FragmentContainerView +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException +import okhttp3.RequestBody +import okhttp3.ResponseBody +import org.schabi.newpipe.RouterActivity.ChoiceAvailabilityChecker +import org.schabi.newpipe.RouterActivity.AdapterChoiceItem +import android.content.DialogInterface.OnShowListener +import org.schabi.newpipe.RouterActivity.FetcherService +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import io.reactivex.rxjava3.core.SingleOnSubscribe +import io.reactivex.rxjava3.core.SingleEmitter +import io.reactivex.rxjava3.core.SingleTransformer +import org.schabi.newpipe.RouterActivity.PersistentFragment +import android.app.IntentService +import org.schabi.newpipe.util.urlfinder.UrlFinder +import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException +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.SoundCloudGoPlusContentException +import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException +import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException +import kotlin.concurrent.Volatile +import org.schabi.newpipe.PanicResponderActivity +import android.os.ParcelFileDescriptor +import us.shandian.giga.io.CircularFileWriter.OffsetChecker +import us.shandian.giga.io.ProgressReport +import us.shandian.giga.io.CircularFileWriter.WriteErrorHandle +import us.shandian.giga.io.CircularFileWriter.BufferedFile +import us.shandian.giga.io.CircularFileWriter +import us.shandian.giga.io.ChunkFileInputStream +import us.shandian.giga.ui.adapter.MissionAdapter +import us.shandian.giga.service.DownloadManager.MissionIterator +import us.shandian.giga.get.Mission +import us.shandian.giga.ui.common.Deleter +import androidx.core.os.HandlerCompat +import us.shandian.giga.get.FinishedMission +import us.shandian.giga.ui.common.ProgressDrawable +import us.shandian.giga.ui.adapter.MissionAdapter.ViewHolderItem +import us.shandian.giga.ui.adapter.MissionAdapter.RecoverHelper +import us.shandian.giga.ui.adapter.MissionAdapter.ViewHolderHeader +import us.shandian.giga.get.DownloadMission +import us.shandian.giga.service.DownloadManager.MissionItem +import android.app.NotificationManager +import android.view.HapticFeedbackConstants +import android.webkit.MimeTypeMap +import android.database.sqlite.SQLiteOpenHelper +import us.shandian.giga.get.sqlite.FinishedMissionStore +import android.database.sqlite.SQLiteDatabase +import android.content.ContentValues +import android.system.ErrnoException +import android.system.OsConstants +import us.shandian.giga.get.DownloadRunnableFallback +import us.shandian.giga.get.DownloadRunnable +import us.shandian.giga.get.DownloadInitializer +import us.shandian.giga.get.DownloadMissionRecover +import android.os.StatFs +import okio.ByteString +import org.schabi.newpipe.player.helper.LockManager +import android.graphics.BitmapFactory +import android.net.NetworkRequest +import android.net.NetworkInfo +import us.shandian.giga.postprocessing.TtmlConverter +import us.shandian.giga.postprocessing.WebMMuxer +import us.shandian.giga.postprocessing.Mp4FromDashMuxer +import us.shandian.giga.postprocessing.M4aNoDash +import us.shandian.giga.postprocessing.OggFromWebmDemuxer diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/AddTabDialog.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/AddTabDialog.java deleted file mode 100644 index e2e833fee..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/AddTabDialog.java +++ /dev/null @@ -1,97 +0,0 @@ -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; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/AddTabDialog.kt b/app/src/main/java/org/schabi/newpipe/settings/tabs/AddTabDialog.kt new file mode 100644 index 000000000..0e5bf75f0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/AddTabDialog.kt @@ -0,0 +1,71 @@ +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.appcompat.app.AlertDialog +import androidx.appcompat.widget.AppCompatImageView +import org.schabi.newpipe.R + +class AddTabDialog internal constructor(context: Context, items: Array, + actions: DialogInterface.OnClickListener) { + private val dialog: AlertDialog + + init { + dialog = AlertDialog.Builder(context) + .setTitle(context.getString(R.string.tab_choose)) + .setAdapter(DialogListAdapter(context, items), actions) + .create() + } + + fun show() { + dialog.show() + } + + internal class ChooseTabListItem(val tabId: Int, val itemName: String?, + @field:DrawableRes @param:DrawableRes val itemIcon: Int) { + constructor(context: Context, tab: Tab?) : this(tab!!.getTabId(), tab.getTabName(context), tab.getTabIconRes(context)) + } + + private class DialogListAdapter(context: Context, private val items: Array) : BaseAdapter() { + private val inflater: LayoutInflater + + @DrawableRes + private val fallbackIcon: Int + + init { + inflater = LayoutInflater.from(context) + fallbackIcon = R.drawable.ic_whatshot + } + + public override fun getCount(): Int { + return items.size + } + + public override fun getItem(position: Int): ChooseTabListItem { + return items.get(position) + } + + public override fun getItemId(position: Int): Long { + return getItem(position).tabId.toLong() + } + + public override fun getView(position: Int, view: View, parent: ViewGroup): View { + var convertView: View = view + if (convertView == null) { + convertView = inflater.inflate(R.layout.list_choose_tabs_dialog, parent, false) + } + val item: ChooseTabListItem = getItem(position) + val tabIconView: AppCompatImageView = convertView.findViewById(R.id.tabIcon) + val tabNameView: TextView = convertView.findViewById(R.id.tabName) + tabIconView.setImageResource(if (item.itemIcon > 0) item.itemIcon else fallbackIcon) + tabNameView.setText(item.itemName) + return convertView + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java deleted file mode 100644 index 289c824ba..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java +++ /dev/null @@ -1,418 +0,0 @@ -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.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; - 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; - 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()); - 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; - }; - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.kt new file mode 100644 index 000000000..7c0581e97 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.kt @@ -0,0 +1,362 @@ +package org.schabi.newpipe.settings.tabs + +import android.annotation.SuppressLint +import android.content.Context +import android.content.DialogInterface +import android.os.Bundle +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.View.OnTouchListener +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +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.Companion.showSnackbar +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.tabs.AddTabDialog.ChooseTabListItem +import org.schabi.newpipe.settings.tabs.Tab.ChannelTab +import org.schabi.newpipe.settings.tabs.Tab.KioskTab +import org.schabi.newpipe.settings.tabs.Tab.PlaylistTab +import org.schabi.newpipe.util.ServiceHelper +import org.schabi.newpipe.util.ThemeHelper +import java.util.Collections +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.sign + +class ChooseTabsFragment() : Fragment() { + private var tabsManager: TabsManager? = null + private val tabList: MutableList = ArrayList() + private var selectedTabsAdapter: SelectedTabsAdapter? = null + + /*////////////////////////////////////////////////////////////////////////// + // Lifecycle + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + tabsManager = TabsManager.Companion.getManager(requireContext()) + updateTabList() + setHasOptionsMenu(true) + } + + public override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_choose_tabs, container, false) + } + + public override fun onViewCreated(rootView: View, + savedInstanceState: Bundle?) { + super.onViewCreated(rootView, savedInstanceState) + initButton(rootView) + val listSelectedTabs: RecyclerView = rootView.findViewById(R.id.selectedTabs) + listSelectedTabs.setLayoutManager(LinearLayoutManager(requireContext())) + val itemTouchHelper: ItemTouchHelper = ItemTouchHelper(getItemTouchCallback()) + itemTouchHelper.attachToRecyclerView(listSelectedTabs) + selectedTabsAdapter = SelectedTabsAdapter(requireContext(), itemTouchHelper) + listSelectedTabs.setAdapter(selectedTabsAdapter) + } + + public override fun onResume() { + super.onResume() + ThemeHelper.setTitleToAppCompatActivity(getActivity(), + getString(R.string.main_page_content)) + } + + public override fun onPause() { + super.onPause() + saveChanges() + } + + /*////////////////////////////////////////////////////////////////////////// + // Menu + ////////////////////////////////////////////////////////////////////////// */ + public override fun onCreateOptionsMenu(menu: Menu, + inflater: MenuInflater) { + super.onCreateOptionsMenu(menu, inflater) + inflater.inflate(R.menu.menu_chooser_fragment, menu) + menu.findItem(R.id.menu_item_restore_default).setOnMenuItemClickListener(MenuItem.OnMenuItemClickListener({ item: MenuItem? -> + restoreDefaults() + true + })) + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + ////////////////////////////////////////////////////////////////////////// */ + private fun updateTabList() { + tabList.clear() + tabList.addAll((tabsManager!!.getTabs())!!) + } + + private fun saveChanges() { + tabsManager!!.saveTabs(tabList) + } + + private fun restoreDefaults() { + AlertDialog.Builder(requireContext()) + .setTitle(R.string.restore_defaults) + .setMessage(R.string.restore_defaults_confirmation) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.ok, DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> + tabsManager!!.resetTabs() + updateTabList() + selectedTabsAdapter!!.notifyDataSetChanged() + })) + .show() + } + + private fun initButton(rootView: View) { + val fab: FloatingActionButton = rootView.findViewById(R.id.addTabsButton) + fab.setOnClickListener(View.OnClickListener({ v: View? -> + val availableTabs: Array = getAvailableTabs(requireContext()) + if (availableTabs.size == 0) { + //Toast.makeText(requireContext(), "No available tabs", Toast.LENGTH_SHORT).show(); + return@setOnClickListener + } + val actionListener: DialogInterface.OnClickListener = DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> + val selected: ChooseTabListItem = availableTabs.get(which) + addTab(selected.tabId) + }) + AddTabDialog(requireContext(), availableTabs, actionListener) + .show() + })) + } + + private fun addTab(tab: Tab?) { + tabList.add(tab) + selectedTabsAdapter!!.notifyDataSetChanged() + } + + private fun addTab(tabId: Int) { + val type: Tab.Type? = Tab.Companion.typeFrom(tabId) + if (type == null) { + showSnackbar(this, + ErrorInfo(IllegalStateException("Tab id not found: " + tabId), + UserAction.SOMETHING_ELSE, "Choosing tabs on settings")) + return + } + when (type) { + Tab.Type.KIOSK -> { + val selectKioskFragment: SelectKioskFragment = SelectKioskFragment() + selectKioskFragment.setOnSelectedListener(SelectKioskFragment.OnSelectedListener({ serviceId: Int, kioskId: String?, kioskName: String? -> addTab(KioskTab(serviceId, kioskId)) })) + selectKioskFragment.show(getParentFragmentManager(), "select_kiosk") + return + } + + Tab.Type.CHANNEL -> { + val selectChannelFragment: SelectChannelFragment = SelectChannelFragment() + selectChannelFragment.setOnSelectedListener(SelectChannelFragment.OnSelectedListener({ serviceId: Int, url: String?, name: String? -> addTab(ChannelTab(serviceId, url, name)) })) + selectChannelFragment.show(getParentFragmentManager(), "select_channel") + return + } + + Tab.Type.PLAYLIST -> { + val selectPlaylistFragment: SelectPlaylistFragment = SelectPlaylistFragment() + selectPlaylistFragment.setOnSelectedListener( + object : SelectPlaylistFragment.OnSelectedListener { + public override fun onLocalPlaylistSelected(id: Long, name: String?) { + addTab(PlaylistTab(id, name)) + } + + public override fun onRemotePlaylistSelected( + serviceId: Int, url: String?, name: String?) { + addTab(PlaylistTab(serviceId, url, name)) + } + }) + selectPlaylistFragment.show(getParentFragmentManager(), "select_playlist") + return + } + + else -> addTab(type.getTab()) + } + } + + private fun getAvailableTabs(context: Context): Array { + val returnList: ArrayList = ArrayList() + for (type: Tab.Type in Tab.Type.entries) { + val tab: Tab? = type.getTab() + when (type) { + Tab.Type.BLANK -> if (!tabList.contains(tab)) { + returnList.add(ChooseTabListItem(tab!!.getTabId(), + getString(R.string.blank_page_summary), + tab.getTabIconRes(context))) + } + + Tab.Type.KIOSK -> returnList.add(ChooseTabListItem(tab!!.getTabId(), + getString(R.string.kiosk_page_summary), + R.drawable.ic_whatshot)) + + Tab.Type.CHANNEL -> returnList.add(ChooseTabListItem(tab!!.getTabId(), + getString(R.string.channel_page_summary), + tab.getTabIconRes(context))) + + Tab.Type.DEFAULT_KIOSK -> if (!tabList.contains(tab)) { + returnList.add(ChooseTabListItem(tab!!.getTabId(), + getString(R.string.default_kiosk_page_summary), + R.drawable.ic_whatshot)) + } + + Tab.Type.PLAYLIST -> returnList.add(ChooseTabListItem(tab!!.getTabId(), + getString(R.string.playlist_page_summary), + tab.getTabIconRes(context))) + + else -> if (!tabList.contains(tab)) { + returnList.add(ChooseTabListItem(context, tab)) + } + } + } + return returnList.toTypedArray() + } + + /*////////////////////////////////////////////////////////////////////////// + // List Handling + ////////////////////////////////////////////////////////////////////////// */ + private fun getItemTouchCallback(): ItemTouchHelper.SimpleCallback { + return object : ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP or ItemTouchHelper.DOWN, + ItemTouchHelper.START or ItemTouchHelper.END) { + public override fun interpolateOutOfBoundsScroll(recyclerView: RecyclerView, + viewSize: Int, + viewSizeOutOfBounds: Int, + totalSize: Int, + msSinceStartScroll: Long): Int { + val standardSpeed: Int = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, + viewSizeOutOfBounds, totalSize, msSinceStartScroll) + val minimumAbsVelocity: Int = max(12.0, abs(standardSpeed.toDouble())).toInt() + return minimumAbsVelocity * sign(viewSizeOutOfBounds.toDouble()).toInt() + } + + public override fun onMove(recyclerView: RecyclerView, + source: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder): Boolean { + if ((source.getItemViewType() != target.getItemViewType() + || selectedTabsAdapter == null)) { + return false + } + val sourceIndex: Int = source.getBindingAdapterPosition() + val targetIndex: Int = target.getBindingAdapterPosition() + selectedTabsAdapter!!.swapItems(sourceIndex, targetIndex) + return true + } + + public override fun isLongPressDragEnabled(): Boolean { + return false + } + + public override fun isItemViewSwipeEnabled(): Boolean { + return true + } + + public override fun onSwiped(viewHolder: RecyclerView.ViewHolder, + swipeDir: Int) { + val position: Int = viewHolder.getBindingAdapterPosition() + tabList.removeAt(position) + selectedTabsAdapter!!.notifyItemRemoved(position) + if (tabList.isEmpty()) { + tabList.add(Tab.Type.BLANK.getTab()) + selectedTabsAdapter!!.notifyItemInserted(0) + } + } + } + } + + private inner class SelectedTabsAdapter internal constructor(context: Context?, private val itemTouchHelper: ItemTouchHelper?) : RecyclerView.Adapter() { + private val inflater: LayoutInflater + + init { + inflater = LayoutInflater.from(context) + } + + fun swapItems(fromPosition: Int, toPosition: Int) { + Collections.swap(tabList, fromPosition, toPosition) + notifyItemMoved(fromPosition, toPosition) + } + + public override fun onCreateViewHolder( + parent: ViewGroup, viewType: Int): TabViewHolder { + val view: View = inflater.inflate(R.layout.list_choose_tabs, parent, false) + return TabViewHolder(view) + } + + public override fun onBindViewHolder( + holder: TabViewHolder, + position: Int) { + holder.bind(position, holder) + } + + public override fun getItemCount(): Int { + return tabList.size + } + + internal inner class TabViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + private val tabIconView: AppCompatImageView + private val tabNameView: TextView + private val handle: ImageView + + init { + tabNameView = itemView.findViewById(R.id.tabName) + tabIconView = itemView.findViewById(R.id.tabIcon) + handle = itemView.findViewById(R.id.handle) + } + + @SuppressLint("ClickableViewAccessibility") + fun bind(position: Int, holder: TabViewHolder) { + handle.setOnTouchListener(getOnTouchListener(holder)) + val tab: Tab = (tabList.get(position))!! + val type: Tab.Type? = Tab.Companion.typeFrom(tab.getTabId()) + if (type == null) { + return + } + tabNameView.setText(getTabName(type, tab)) + tabIconView.setImageResource(tab.getTabIconRes(requireContext())) + } + + private fun getTabName(type: Tab.Type, tab: Tab): String? { + when (type) { + Tab.Type.BLANK -> return getString(R.string.blank_page_summary) + Tab.Type.DEFAULT_KIOSK -> return getString(R.string.default_kiosk_page_summary) + Tab.Type.KIOSK -> return (ServiceHelper.getNameOfServiceById((tab as KioskTab).getKioskServiceId()) + + "/" + tab.getTabName(requireContext())) + + Tab.Type.CHANNEL -> return (ServiceHelper.getNameOfServiceById((tab as ChannelTab).getChannelServiceId()) + + "/" + tab.getTabName(requireContext())) + + Tab.Type.PLAYLIST -> { + val serviceId: Int = (tab as PlaylistTab).getPlaylistServiceId() + val serviceName: String = if (serviceId == -1) getString(R.string.local) else ServiceHelper.getNameOfServiceById(serviceId) + return serviceName + "/" + tab.getTabName(requireContext()) + } + + else -> return tab.getTabName(requireContext()) + } + } + + @SuppressLint("ClickableViewAccessibility") + private fun getOnTouchListener(item: RecyclerView.ViewHolder): OnTouchListener { + return OnTouchListener({ view: View?, motionEvent: MotionEvent -> + if (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { + if (itemTouchHelper != null && getItemCount() > 1) { + itemTouchHelper.startDrag(item) + return@OnTouchListener true + } + } + false + }) + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java deleted file mode 100644 index 7e3f5d0c8..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java +++ /dev/null @@ -1,655 +0,0 @@ -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); - } - } - - 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()); - - 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.channelName) - && 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; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.kt b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.kt new file mode 100644 index 000000000..6c43c4bb7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.kt @@ -0,0 +1,570 @@ +package org.schabi.newpipe.settings.tabs + +import android.content.Context +import androidx.annotation.DrawableRes +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.Companion.showSnackbar +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 + +abstract class Tab { + internal constructor() + internal constructor(jsonObject: JsonObject) { + readDataFromJson(jsonObject) + } + + abstract fun getTabId(): Int + abstract fun getTabName(context: Context): String? + @DrawableRes + abstract fun getTabIconRes(context: Context): Int + + /** + * Return a instance of the fragment that this tab represent. + * + * @param context Android app context + * @return the fragment this tab represents + */ + @Throws(ExtractionException::class) + abstract fun getFragment(context: Context?): Fragment + public override fun equals(obj: Any?): Boolean { + if (!(obj is Tab)) { + return false + } + return getTabId() == obj.getTabId() + } + + public override fun hashCode(): Int { + return Objects.hashCode(getTabId()) + } + + /*////////////////////////////////////////////////////////////////////////// + // JSON Handling + ////////////////////////////////////////////////////////////////////////// */ + fun writeJsonOn(jsonSink: JsonStringWriter) { + jsonSink.`object`() + jsonSink.value(JSON_TAB_ID_KEY, getTabId()) + writeDataToJson(jsonSink) + jsonSink.end() + } + + protected open fun writeDataToJson(writerSink: JsonStringWriter) { + // No-op + } + + protected open fun readDataFromJson(jsonObject: JsonObject) { + // No-op + } + + /*////////////////////////////////////////////////////////////////////////// + // Implementations + ////////////////////////////////////////////////////////////////////////// */ + enum class Type(private val tab: Tab) { + BLANK(BlankTab()), + DEFAULT_KIOSK(DefaultKioskTab()), + SUBSCRIPTIONS(SubscriptionsTab()), + FEED(FeedTab()), + BOOKMARKS(BookmarksTab()), + HISTORY(HistoryTab()), + KIOSK(KioskTab()), + CHANNEL(ChannelTab()), + PLAYLIST(PlaylistTab()); + + fun getTabId(): Int { + return tab.getTabId() + } + + fun getTab(): Tab { + return tab + } + } + + class BlankTab() : Tab() { + public override fun getTabId(): Int { + return ID + } + + public override fun getTabName(context: Context): String? { + // 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 + public override fun getTabIconRes(context: Context): Int { + return R.drawable.ic_crop_portrait + } + + public override fun getFragment(context: Context?): BlankFragment { + return BlankFragment() + } + + companion object { + val ID: Int = 0 + } + } + + class SubscriptionsTab() : Tab() { + public override fun getTabId(): Int { + return ID + } + + public override fun getTabName(context: Context): String? { + return context.getString(R.string.tab_subscriptions) + } + + @DrawableRes + public override fun getTabIconRes(context: Context): Int { + return R.drawable.ic_tv + } + + public override fun getFragment(context: Context?): SubscriptionFragment { + return SubscriptionFragment() + } + + companion object { + val ID: Int = 1 + } + } + + class FeedTab() : Tab() { + public override fun getTabId(): Int { + return ID + } + + public override fun getTabName(context: Context): String? { + return context.getString(R.string.fragment_feed_title) + } + + @DrawableRes + public override fun getTabIconRes(context: Context): Int { + return R.drawable.ic_subscriptions + } + + public override fun getFragment(context: Context?): FeedFragment { + return FeedFragment() + } + + companion object { + val ID: Int = 2 + } + } + + class BookmarksTab() : Tab() { + public override fun getTabId(): Int { + return ID + } + + public override fun getTabName(context: Context): String? { + return context.getString(R.string.tab_bookmarks) + } + + @DrawableRes + public override fun getTabIconRes(context: Context): Int { + return R.drawable.ic_bookmark + } + + public override fun getFragment(context: Context?): BookmarkFragment { + return BookmarkFragment() + } + + companion object { + val ID: Int = 3 + } + } + + class HistoryTab() : Tab() { + public override fun getTabId(): Int { + return ID + } + + public override fun getTabName(context: Context): String? { + return context.getString(R.string.title_activity_history) + } + + @DrawableRes + public override fun getTabIconRes(context: Context): Int { + return R.drawable.ic_history + } + + public override fun getFragment(context: Context?): StatisticsPlaylistFragment { + return StatisticsPlaylistFragment() + } + + companion object { + val ID: Int = 4 + } + } + + class KioskTab : Tab { + private var kioskServiceId: Int = 0 + private var kioskId: String? = null + + constructor() : this(-1, NO_ID) + constructor(kioskServiceId: Int, kioskId: String?) { + this.kioskServiceId = kioskServiceId + this.kioskId = kioskId + } + + constructor(jsonObject: JsonObject) : super(jsonObject) + + public override fun getTabId(): Int { + return ID + } + + public override fun getTabName(context: Context): String? { + return KioskTranslator.getTranslatedKioskName(kioskId, context) + } + + @DrawableRes + public override fun getTabIconRes(context: Context): Int { + val kioskIcon: Int = KioskTranslator.getKioskIcon(kioskId) + if (kioskIcon <= 0) { + throw IllegalStateException("Kiosk ID is not valid: \"" + kioskId + "\"") + } + return kioskIcon + } + + @Throws(ExtractionException::class) + public override fun getFragment(context: Context?): KioskFragment { + return KioskFragment.Companion.getInstance(kioskServiceId, kioskId) + } + + override fun writeDataToJson(writerSink: JsonStringWriter) { + writerSink.value(JSON_KIOSK_SERVICE_ID_KEY, kioskServiceId) + .value(JSON_KIOSK_ID_KEY, kioskId) + } + + override fun readDataFromJson(jsonObject: JsonObject) { + kioskServiceId = jsonObject.getInt(JSON_KIOSK_SERVICE_ID_KEY, -1) + kioskId = jsonObject.getString(JSON_KIOSK_ID_KEY, NO_ID) + } + + public override fun equals(obj: Any?): Boolean { + if (!(obj is KioskTab)) { + return false + } + val other: KioskTab = obj + return (super.equals(obj) + && (kioskServiceId == other.kioskServiceId + ) && (kioskId == other.kioskId)) + } + + public override fun hashCode(): Int { + return Objects.hash(getTabId(), kioskServiceId, kioskId) + } + + fun getKioskServiceId(): Int { + return kioskServiceId + } + + fun getKioskId(): String? { + return kioskId + } + + companion object { + val ID: Int = 5 + private val JSON_KIOSK_SERVICE_ID_KEY: String = "service_id" + private val JSON_KIOSK_ID_KEY: String = "kiosk_id" + } + } + + class ChannelTab : Tab { + private var channelServiceId: Int = 0 + private var channelUrl: String? = null + private var channelName: String? = null + + constructor() : this(-1, NO_URL, NO_NAME) + constructor(channelServiceId: Int, channelUrl: String?, + channelName: String?) { + this.channelServiceId = channelServiceId + this.channelUrl = channelUrl + this.channelName = channelName + } + + constructor(jsonObject: JsonObject) : super(jsonObject) + + public override fun getTabId(): Int { + return ID + } + + public override fun getTabName(context: Context): String? { + return channelName + } + + @DrawableRes + public override fun getTabIconRes(context: Context): Int { + return R.drawable.ic_tv + } + + public override fun getFragment(context: Context?): ChannelFragment { + return ChannelFragment.Companion.getInstance(channelServiceId, channelUrl, channelName) + } + + override fun writeDataToJson(writerSink: JsonStringWriter) { + writerSink.value(JSON_CHANNEL_SERVICE_ID_KEY, channelServiceId) + .value(JSON_CHANNEL_URL_KEY, channelUrl) + .value(JSON_CHANNEL_NAME_KEY, channelName) + } + + override fun readDataFromJson(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) + } + + public override fun equals(obj: Any?): Boolean { + if (!(obj is ChannelTab)) { + return false + } + val other: ChannelTab = obj + return (super.equals(obj) + && (channelServiceId == other.channelServiceId + ) && (channelUrl == other.channelName) && (channelName == other.channelName)) + } + + public override fun hashCode(): Int { + return Objects.hash(getTabId(), channelServiceId, channelUrl, channelName) + } + + fun getChannelServiceId(): Int { + return channelServiceId + } + + fun getChannelUrl(): String? { + return channelUrl + } + + fun getChannelName(): String? { + return channelName + } + + companion object { + val ID: Int = 6 + private val JSON_CHANNEL_SERVICE_ID_KEY: String = "channel_service_id" + private val JSON_CHANNEL_URL_KEY: String = "channel_url" + private val JSON_CHANNEL_NAME_KEY: String = "channel_name" + } + } + + class DefaultKioskTab() : Tab() { + public override fun getTabId(): Int { + return ID + } + + public override fun getTabName(context: Context): String? { + return KioskTranslator.getTranslatedKioskName(getDefaultKioskId(context), context) + } + + @DrawableRes + public override fun getTabIconRes(context: Context): Int { + return KioskTranslator.getKioskIcon(getDefaultKioskId(context)) + } + + public override fun getFragment(context: Context?): DefaultKioskFragment { + return DefaultKioskFragment() + } + + private fun getDefaultKioskId(context: Context): String { + val kioskServiceId: Int = ServiceHelper.getSelectedServiceId(context) + var kioskId: String = "" + try { + val service: StreamingService = NewPipe.getService(kioskServiceId) + kioskId = service.getKioskList().getDefaultKioskId() + } catch (e: ExtractionException) { + showSnackbar(context, ErrorInfo(e, + UserAction.REQUESTED_KIOSK, "Loading default kiosk for selected service")) + } + return kioskId + } + + companion object { + val ID: Int = 7 + } + } + + class PlaylistTab : Tab { + private var playlistServiceId: Int = 0 + private var playlistUrl: String? = null + private var playlistName: String? = null + private var playlistId: Long = 0 + private var playlistType: LocalItemType? = null + + constructor() : this(-1, NO_NAME) + constructor(playlistId: Long, playlistName: String?) { + this.playlistName = playlistName + this.playlistId = playlistId + playlistType = LocalItemType.PLAYLIST_LOCAL_ITEM + playlistServiceId = -1 + playlistUrl = NO_URL + } + + constructor(playlistServiceId: Int, playlistUrl: String?, + playlistName: String?) { + this.playlistServiceId = playlistServiceId + this.playlistUrl = playlistUrl + this.playlistName = playlistName + playlistType = LocalItemType.PLAYLIST_REMOTE_ITEM + playlistId = -1 + } + + constructor(jsonObject: JsonObject) : super(jsonObject) + + public override fun getTabId(): Int { + return ID + } + + public override fun getTabName(context: Context): String? { + return playlistName + } + + @DrawableRes + public override fun getTabIconRes(context: Context): Int { + return R.drawable.ic_bookmark + } + + public override fun getFragment(context: Context?): Fragment { + if (playlistType == LocalItemType.PLAYLIST_LOCAL_ITEM) { + return LocalPlaylistFragment.Companion.getInstance(playlistId, playlistName) + } else { // playlistType == LocalItemType.PLAYLIST_REMOTE_ITEM + return PlaylistFragment.Companion.getInstance(playlistServiceId, playlistUrl, playlistName) + } + } + + override fun writeDataToJson(writerSink: JsonStringWriter) { + 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 fun readDataFromJson(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).toLong() + playlistType = LocalItemType.valueOf( + jsonObject.getString(JSON_PLAYLIST_TYPE_KEY, + LocalItemType.PLAYLIST_LOCAL_ITEM.toString()) + ) + } + + public override fun equals(obj: Any?): Boolean { + if (!(obj is PlaylistTab)) { + return false + } + val other: PlaylistTab = obj + return (super.equals(obj) + && (playlistServiceId == other.playlistServiceId // Remote + ) && (playlistId == other.playlistId // Local + ) && (playlistUrl == other.playlistUrl) && (playlistName == other.playlistName) && (playlistType == other.playlistType)) + } + + public override fun hashCode(): Int { + return Objects.hash( + getTabId(), + playlistServiceId, + playlistId, + playlistUrl, + playlistName, + playlistType + ) + } + + fun getPlaylistServiceId(): Int { + return playlistServiceId + } + + fun getPlaylistUrl(): String? { + return playlistUrl + } + + fun getPlaylistName(): String? { + return playlistName + } + + fun getPlaylistId(): Long { + return playlistId + } + + fun getPlaylistType(): LocalItemType? { + return playlistType + } + + companion object { + val ID: Int = 8 + private val JSON_PLAYLIST_SERVICE_ID_KEY: String = "playlist_service_id" + private val JSON_PLAYLIST_URL_KEY: String = "playlist_url" + private val JSON_PLAYLIST_NAME_KEY: String = "playlist_name" + private val JSON_PLAYLIST_ID_KEY: String = "playlist_id" + private val JSON_PLAYLIST_TYPE_KEY: String = "playlist_type" + } + } + + companion object { + private val JSON_TAB_ID_KEY: String = "tab_id" + private val NO_NAME: String = "" + private val NO_ID: String = "" + private val NO_URL: String = "" + + /*////////////////////////////////////////////////////////////////////////// + // Tab Handling + ////////////////////////////////////////////////////////////////////////// */ + @JvmStatic + fun from(jsonObject: JsonObject): Tab? { + val tabId: Int = jsonObject.getInt(JSON_TAB_ID_KEY, -1) + if (tabId == -1) { + return null + } + return from(tabId, jsonObject) + } + + fun from(tabId: Int): Tab? { + return from(tabId, null) + } + + fun typeFrom(tabId: Int): Type? { + for (available: Type in Type.entries) { + if (available.getTabId() == tabId) { + return available + } + } + return null + } + + private fun from(tabId: Int, jsonObject: JsonObject?): Tab? { + val type: Type? = typeFrom(tabId) + if (type == null) { + return null + } + if (jsonObject != null) { + when (type) { + Type.KIOSK -> return KioskTab(jsonObject) + Type.CHANNEL -> return ChannelTab(jsonObject) + Type.PLAYLIST -> return PlaylistTab(jsonObject) + } + } + return type.getTab() + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsJsonHelper.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsJsonHelper.java deleted file mode 100644 index 30676477c..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsJsonHelper.java +++ /dev/null @@ -1,121 +0,0 @@ -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.ArrayList; -import java.util.List; - -/** - * 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(); - } - - final List returnTabs = new ArrayList<>(); - - final JsonObject outerJsonObject; - try { - 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); - - for (final Object o : tabsArray) { - if (!(o instanceof JsonObject)) { - continue; - } - - final Tab tab = Tab.from((JsonObject) o); - - if (tab != null) { - returnTabs.add(tab); - } - } - } catch (final JsonParserException e) { - throw new InvalidJsonException(e); - } - - if (returnTabs.isEmpty()) { - return getDefaultTabs(); - } - - return returnTabs; - } - - /** - * 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); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsJsonHelper.kt b/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsJsonHelper.kt new file mode 100644 index 000000000..e53e9ee0f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsJsonHelper.kt @@ -0,0 +1,98 @@ +package org.schabi.newpipe.settings.tabs + +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 + +/** + * Class to get a JSON representation of a list of tabs, and the other way around. + */ +object TabsJsonHelper { + private val JSON_TABS_ARRAY_KEY: String = "tabs" + private val FALLBACK_INITIAL_TABS_LIST: List = java.util.List.of( + Tab.Type.DEFAULT_KIOSK.getTab(), + Tab.Type.FEED.getTab(), + Tab.Type.SUBSCRIPTIONS.getTab(), + Tab.Type.BOOKMARKS.getTab()) + + /** + * 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 + * [fallback list][.getDefaultTabs] will be returned. + * + * + * Tabs with invalid ids (i.e. not in the [Tab.Type] enum) will be ignored. + * + * @param tabsJson a JSON string got from [.getJsonToSave]. + * @return a list of [tabs][Tab]. + * @throws InvalidJsonException if the JSON string is not valid + */ + @JvmStatic + @Throws(InvalidJsonException::class) + fun getTabsFromJson(tabsJson: String?): List { + if (tabsJson == null || tabsJson.isEmpty()) { + return getDefaultTabs() + } + val returnTabs: MutableList = ArrayList() + val outerJsonObject: JsonObject + try { + outerJsonObject = JsonParser.`object`().from(tabsJson) + if (!outerJsonObject.has(JSON_TABS_ARRAY_KEY)) { + throw InvalidJsonException(("JSON doesn't contain \"" + JSON_TABS_ARRAY_KEY + + "\" array")) + } + val tabsArray: JsonArray = outerJsonObject.getArray(JSON_TABS_ARRAY_KEY) + for (o: Any? in tabsArray) { + if (!(o is JsonObject)) { + continue + } + val tab: Tab? = Tab.Companion.from(o) + if (tab != null) { + returnTabs.add(tab) + } + } + } catch (e: JsonParserException) { + throw InvalidJsonException(e) + } + if (returnTabs.isEmpty()) { + return getDefaultTabs() + } + return returnTabs + } + + /** + * Get a JSON representation from a list of tabs. + * + * @param tabList a list of [tabs][Tab]. + * @return a JSON string representing the list of tabs + */ + fun getJsonToSave(tabList: List?): String { + val jsonWriter: JsonStringWriter = JsonWriter.string() + jsonWriter.`object`() + jsonWriter.array(JSON_TABS_ARRAY_KEY) + if (tabList != null) { + for (tab: Tab? in tabList) { + tab!!.writeJsonOn(jsonWriter) + } + } + jsonWriter.end() + jsonWriter.end() + return jsonWriter.done() + } + + @JvmStatic + fun getDefaultTabs(): List { + return FALLBACK_INITIAL_TABS_LIST + } + + class InvalidJsonException : Exception { + private constructor() : super() + constructor(message: String) : super(message) + constructor(cause: Throwable) : super(cause) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsManager.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsManager.java deleted file mode 100644 index 7dcbee56f..000000000 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsManager.java +++ /dev/null @@ -1,85 +0,0 @@ -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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsManager.kt b/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsManager.kt new file mode 100644 index 000000000..febf42b19 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsManager.kt @@ -0,0 +1,82 @@ +package org.schabi.newpipe.settings.tabs + +import android.content.Context +import android.content.SharedPreferences +import android.content.SharedPreferences.OnSharedPreferenceChangeListener +import android.widget.Toast +import androidx.preference.PreferenceManager +import org.schabi.newpipe.R +import org.schabi.newpipe.settings.tabs.TabsJsonHelper.InvalidJsonException + +class TabsManager private constructor(private val context: Context) { + private val sharedPreferences: SharedPreferences + private val savedTabsKey: String + private var savedTabsChangeListener: SavedTabsChangeListener? = null + private var preferenceChangeListener: OnSharedPreferenceChangeListener? = null + + init { + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context) + savedTabsKey = context.getString(R.string.saved_tabs_key) + } + + fun getTabs(): List? { + val savedJson: String? = sharedPreferences.getString(savedTabsKey, null) + try { + return TabsJsonHelper.getTabsFromJson(savedJson) + } catch (e: InvalidJsonException) { + Toast.makeText(context, R.string.saved_tabs_invalid_json, Toast.LENGTH_SHORT).show() + return getDefaultTabs() + } + } + + fun saveTabs(tabList: List?) { + val jsonToSave: String? = TabsJsonHelper.getJsonToSave(tabList) + sharedPreferences.edit().putString(savedTabsKey, jsonToSave).apply() + } + + fun resetTabs() { + sharedPreferences.edit().remove(savedTabsKey).apply() + } + + fun getDefaultTabs(): List? { + return TabsJsonHelper.getDefaultTabs() + } + + /*////////////////////////////////////////////////////////////////////////// + // Listener + ////////////////////////////////////////////////////////////////////////// */ + fun setSavedTabsListener(listener: SavedTabsChangeListener?) { + if (preferenceChangeListener != null) { + sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + } + savedTabsChangeListener = listener + preferenceChangeListener = getPreferenceChangeListener() + sharedPreferences.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + } + + fun unsetSavedTabsListener() { + if (preferenceChangeListener != null) { + sharedPreferences.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + } + preferenceChangeListener = null + savedTabsChangeListener = null + } + + private fun getPreferenceChangeListener(): OnSharedPreferenceChangeListener { + return OnSharedPreferenceChangeListener({ sp: SharedPreferences?, key: String? -> + if ((savedTabsKey == key) && savedTabsChangeListener != null) { + savedTabsChangeListener!!.onTabsChanged() + } + }) + } + + open interface SavedTabsChangeListener { + fun onTabsChanged() + } + + companion object { + fun getManager(context: Context): TabsManager { + return TabsManager(context) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/DataReader.java b/app/src/main/java/org/schabi/newpipe/streams/DataReader.java deleted file mode 100644 index 68225fbab..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/DataReader.java +++ /dev/null @@ -1,267 +0,0 @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/DataReader.kt b/app/src/main/java/org/schabi/newpipe/streams/DataReader.kt new file mode 100644 index 000000000..bbcbc6b77 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/DataReader.kt @@ -0,0 +1,244 @@ +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 kotlin.math.max +import kotlin.math.min + +/** + * @author kapodamy + */ +class DataReader(private val stream: SharpStream) { + private var position: Long = 0 + private var view: InputStream? = null + private var viewSize: Int = 0 + fun position(): Long { + return position + } + + @Throws(IOException::class) + fun read(): Int { + if (fillBuffer()) { + return -1 + } + position++ + readCount-- + return readBuffer.get(readOffset++).toInt() and 0xFF + } + + @Throws(IOException::class) + fun skipBytes(byteAmount: Long): Long { + var amount: Long = byteAmount + if (readCount < 0) { + return 0 + } else if (readCount == 0) { + amount = stream.skip(amount) + } else { + if (readCount > amount) { + readCount -= amount.toInt() + readOffset += amount.toInt() + } else { + amount = readCount + stream.skip(amount - readCount) + readCount = 0 + readOffset = readBuffer.size + } + } + position += amount + return amount + } + + @Throws(IOException::class) + fun readInt(): Int { + primitiveRead(INTEGER_SIZE) + return (primitive.get(0).toInt() shl 24) or (primitive.get(1).toInt() shl 16) or (primitive.get(2).toInt() shl 8) or primitive.get(3).toInt() + } + + @Throws(IOException::class) + fun readUnsignedInt(): Long { + val value: Long = readInt().toLong() + return value and 0xffffffffL + } + + @Throws(IOException::class) + fun readShort(): Short { + primitiveRead(SHORT_SIZE) + return ((primitive.get(0).toInt() shl 8) or primitive.get(1).toInt()).toShort() + } + + @Throws(IOException::class) + fun readLong(): Long { + primitiveRead(LONG_SIZE) + val high: Long = ((primitive.get(0).toInt() shl 24) or (primitive.get(1).toInt() shl 16) or (primitive.get(2).toInt() shl 8) or primitive.get(3).toInt()).toLong() + val low: Long = ((primitive.get(4).toInt() shl 24) or (primitive.get(5).toInt() shl 16) or (primitive.get(6).toInt() shl 8) or primitive.get(7).toInt()).toLong() + return (high shl 32) or low + } + + @JvmOverloads + @Throws(IOException::class) + fun read(buffer: ByteArray, off: Int = 0, c: Int = buffer.length): Int { + var offset: Int = off + var count: Int = c + if (readCount < 0) { + return -1 + } + var total: Int = 0 + if (count >= readBuffer.size) { + if (readCount > 0) { + System.arraycopy(readBuffer, readOffset, buffer, offset, readCount) + readOffset += readCount + offset += readCount + count -= readCount + total = readCount + readCount = 0 + } + (total += max(stream.read(buffer, offset, count).toDouble(), 0.0)).toInt() + } else { + while (count > 0 && !fillBuffer()) { + val read: Int = min(readCount.toDouble(), count.toDouble()).toInt() + System.arraycopy(readBuffer, readOffset, buffer, offset, read) + readOffset += read + readCount -= read + offset += read + count -= read + total += read + } + } + position += total.toLong() + return total + } + + fun available(): Boolean { + return readCount > 0 || stream.available() > 0 + } + + @Throws(IOException::class) + fun rewind() { + stream.rewind() + if ((position - viewSize) > 0) { + viewSize = 0 // drop view + } else { + viewSize += position.toInt() + } + position = 0 + readOffset = readBuffer.size + readCount = 0 + } + + fun canRewind(): Boolean { + return stream.canRewind() + } + + /** + * Wraps this instance of `DataReader` into `InputStream` + * object. Note: Any read in the `DataReader` will not modify + * (decrease) the view size + * + * @param size the size of the view + * @return the view + */ + fun getView(size: Int): InputStream { + if (view == null) { + view = object : InputStream() { + @Throws(IOException::class) + public override fun read(): Int { + if (viewSize < 1) { + return -1 + } + val res: Int = this@DataReader.read() + if (res > 0) { + viewSize-- + } + return res + } + + @Throws(IOException::class) + public override fun read(buffer: ByteArray): Int { + return read(buffer, 0, buffer.size) + } + + @Throws(IOException::class) + public override fun read(buffer: ByteArray, offset: Int, count: Int): Int { + if (viewSize < 1) { + return -1 + } + val res: Int = this@DataReader.read(buffer, offset, min(viewSize.toDouble(), count.toDouble()).toInt()) + viewSize -= res + return res + } + + @Throws(IOException::class) + public override fun skip(amount: Long): Long { + if (viewSize < 1) { + return 0 + } + val res: Int = skipBytes(min(amount.toDouble(), viewSize.toDouble()).toLong()).toInt() + viewSize -= res + return res.toLong() + } + + public override fun available(): Int { + return viewSize + } + + public override fun close() { + viewSize = 0 + } + + public override fun markSupported(): Boolean { + return false + } + } + } + viewSize = size + return view!! + } + + private val primitive: ShortArray = ShortArray(LONG_SIZE) + @Throws(IOException::class) + private fun primitiveRead(amount: Int) { + val buffer: ByteArray = ByteArray(amount) + val read: Int = read(buffer, 0, amount) + if (read != amount) { + throw EOFException(("Truncated stream, missing " + + (amount - read) + " bytes")) + } + for (i in 0 until amount) { + // the "byte" data type in java is signed and is very annoying + primitive.get(i) = (buffer.get(i).toInt() and 0xFF).toShort() + } + } + + private val readBuffer: ByteArray = ByteArray(BUFFER_SIZE) + private var readOffset: Int + private var readCount: Int = 0 + + init { + readOffset = readBuffer.size + } + + @Throws(IOException::class) + private fun fillBuffer(): Boolean { + if (readCount < 0) { + return true + } + if (readOffset >= readBuffer.size) { + readCount = stream.read(readBuffer) + if (readCount < 1) { + readCount = -1 + return true + } + readOffset = 0 + } + return readCount < 1 + } + + companion object { + val SHORT_SIZE: Int = 2 + val LONG_SIZE: Int = 8 + val INTEGER_SIZE: Int = 4 + val FLOAT_SIZE: Int = 4 + private val BUFFER_SIZE: Int = 128 * 1024 // 128 KiB + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java deleted file mode 100644 index de327fba1..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java +++ /dev/null @@ -1,943 +0,0 @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.kt b/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.kt new file mode 100644 index 000000000..338df4b6c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.kt @@ -0,0 +1,820 @@ +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 + +/** + * @author kapodamy + */ +class Mp4DashReader(source: SharpStream) { + private val stream: DataReader + private var tracks: Array? = null + private var brands: IntArray? = null + private var box: Box? = null + private var moof: Moof? = null + private var chunkZero: Boolean = false + private var selectedTrack: Int = -1 + private var backupBox: Box? = null + + enum class TrackKind { + Audio, + Video, + Subtitles, + Other + } + + init { + stream = DataReader(source) + } + + @Throws(IOException::class, NoSuchElementException::class) + fun parse() { + if (selectedTrack > -1) { + return + } + box = readBox(ATOM_FTYP) + brands = parseFtyp(box) + when (brands!!.get(0)) { + BRAND_DASH, BRAND_ISO5 -> {} + else -> throw NoSuchElementException(( + "Not a MPEG-4 DASH container, major brand is not 'dash' or 'iso5' is " + + boxName(brands!!.get(0)) + )) + } + var moov: Moov? = null + var i: Int + while (box!!.type != ATOM_MOOF) { + ensure(box) + box = readBox() + when (box!!.type) { + ATOM_MOOV -> moov = parseMoov(box) + ATOM_SIDX, ATOM_MFRA -> {} + } + } + if (moov == null) { + throw IOException("The provided Mp4 doesn't have the 'moov' box") + } + tracks = arrayOfNulls(moov.trak.size) + i = 0 + while (i < tracks!!.size) { + tracks!!.get(i) = Mp4Track() + tracks!!.get(i)!!.trak = moov.trak.get(i) + if (moov.mvexTrex != null) { + for (mvexTrex: Trex in moov.mvexTrex!!) { + if (tracks!!.get(i)!!.trak!!.tkhd!!.trackId == mvexTrex.trackId) { + tracks!!.get(i)!!.trex = mvexTrex + } + } + } + when (moov.trak.get(i).mdia!!.hdlr!!.subType) { + HANDLER_VIDE -> tracks!!.get(i)!!.kind = TrackKind.Video + HANDLER_SOUN -> tracks!!.get(i)!!.kind = TrackKind.Audio + HANDLER_SUBT -> tracks!!.get(i)!!.kind = TrackKind.Subtitles + else -> tracks!!.get(i)!!.kind = TrackKind.Other + } + i++ + } + backupBox = box + } + + fun selectTrack(index: Int): Mp4Track? { + selectedTrack = index + return tracks!!.get(index) + } + + fun getBrands(): IntArray { + if (brands == null) { + throw IllegalStateException("Not parsed") + } + return brands + } + + @Throws(IOException::class) + fun rewind() { + if (!stream.canRewind()) { + throw IOException("The provided stream doesn't allow seek") + } + if (box == null) { + return + } + box = backupBox + chunkZero = false + stream.rewind() + stream.skipBytes(backupBox!!.offset + (DataReader.Companion.INTEGER_SIZE * 2)) + } + + fun getAvailableTracks(): Array? { + return tracks + } + + @Throws(IOException::class) + fun getNextChunk(infoOnly: Boolean): Mp4DashChunk? { + val track: Mp4Track? = tracks!!.get(selectedTrack) + while (stream.available()) { + if (chunkZero) { + ensure(box) + if (!stream.available()) { + break + } + box = readBox() + } else { + chunkZero = true + } + when (box!!.type) { + ATOM_MOOF -> { + if (moof != null) { + throw 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).toInt() + if (moof!!.traf!!.trun!!.dataOffset < 0) { + throw 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 = (box!!.size - 8).toInt() + } + } + 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) + } + } + } + } + + ATOM_MDAT -> { + if (moof == null) { + throw IOException("mdat found without moof") + } + if (moof!!.traf == null) { + moof = null + continue // find another chunk + } + val chunk: Mp4DashChunk = Mp4DashChunk() + chunk.moof = moof + if (!infoOnly) { + chunk.data = stream.getView(moof!!.traf!!.trun!!.chunkSize) + } + moof = null + stream.skipBytes(chunk.moof!!.traf!!.trun!!.dataOffset.toLong()) + return chunk + } + + else -> {} + } + } + return null + } + + private fun boxName(ref: Box?): String { + return boxName(ref!!.type) + } + + private fun boxName(type: Int): String { + return String(ByteBuffer.allocate(4).putInt(type).array(), StandardCharsets.UTF_8) + } + + @Throws(IOException::class) + private fun readBox(): Box { + val b: Box = Box() + b.offset = stream.position() + b.size = stream.readUnsignedInt() + b.type = stream.readInt() + if (b.size == 1L) { + b.size = stream.readLong() + } + return b + } + + @Throws(IOException::class) + private fun readBox(expected: Int): Box { + val b: Box = readBox() + if (b.type != expected) { + throw NoSuchElementException(("expected " + boxName(expected) + + " found " + boxName(b))) + } + return b + } + + @Throws(IOException::class) + private fun readFullBox(ref: Box): ByteArray { + // full box reading is limited to 2 GiB, and should be enough + val size: Int = ref.size.toInt() + val buffer: ByteBuffer = ByteBuffer.allocate(size) + buffer.putInt(size) + buffer.putInt(ref.type) + val read: Int = size - 8 + if (stream.read(buffer.array(), 8, read) != read) { + throw EOFException(String.format("EOF reached in box: type=%s offset=%s size=%s", + boxName(ref.type), ref.offset, ref.size)) + } + return buffer.array() + } + + @Throws(IOException::class) + private fun ensure(ref: Box?) { + val skip: Long = ref!!.offset + ref.size - stream.position() + if (skip == 0L) { + return + } else if (skip < 0) { + throw 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((skip.toInt()).toLong()) + } + + @Throws(IOException::class) + private fun untilBox(ref: Box?, vararg expected: Int): Box? { + var b: Box + while (stream.position() < (ref!!.offset + ref.size)) { + b = readBox() + for (type: Int in expected) { + if (b.type == type) { + return b + } + } + ensure(b) + } + return null + } + + @Throws(IOException::class) + private fun untilAnyBox(ref: Box): Box? { + if (stream.position() >= (ref.offset + ref.size)) { + return null + } + return readBox() + } + + @Throws(IOException::class) + private fun parseMoof(ref: Box?, trackId: Int): Moof { + val obj: Moof = Moof() + var b: Box? = readBox(ATOM_MFHD) + obj.mfhdSequenceNumber = parseMfhd() + ensure(b) + while ((untilBox(ref, ATOM_TRAF).also({ b = it })) != null) { + obj.traf = parseTraf(b, trackId) + ensure(b) + if (obj.traf != null) { + return obj + } + } + return obj + } + + @Throws(IOException::class) + private fun parseMfhd(): Int { + // version + // flags + stream.skipBytes(4) + return stream.readInt() + } + + @Throws(IOException::class) + private fun parseTraf(ref: Box?, trackId: Int): Traf? { + val traf: Traf = Traf() + var b: Box? = 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 + } + + @Throws(IOException::class) + private fun parseTfhd(trackId: Int): Tfhd? { + val obj: Tfhd = 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 + } + + @Throws(IOException::class) + private fun parseTfdt(): Long { + val version: Int = stream.read() + stream.skipBytes(3) // flags + return if (version == 0) stream.readUnsignedInt() else stream.readLong() + } + + @Throws(IOException::class) + private fun parseTrun(): Trun { + val obj: Trun = 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 = ByteArray(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 (i in 0 until obj.entryCount) { + val entry: TrunEntry = 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 + } + + @Throws(IOException::class) + private fun parseFtyp(ref: Box?): IntArray { + var i: Int = 0 + val list: IntArray = IntArray((((ref!!.offset + ref.size) - stream.position() - 4) / 4).toInt()) + list.get(i++) = stream.readInt() // major brand + stream.skipBytes(4) // minor version + while (i < list.size) { + list.get(i) = stream.readInt() // compatible brands + i++ + } + return list + } + + @Throws(IOException::class) + private fun parseMvhd(): Mvhd { + val version: Int = stream.read() + stream.skipBytes(3) // flags + + // creation entries_time + // modification entries_time + stream.skipBytes((2 * (if (version == 0) 4 else 8)).toLong()) + val obj: Mvhd = Mvhd() + obj.timeScale = stream.readUnsignedInt() + + // chunkDuration + stream.skipBytes((if (version == 0) 4 else 8).toLong()) + + // rate + // volume + // reserved + // matrix array + // predefined + stream.skipBytes(76) + obj.nextTrackId = stream.readUnsignedInt() + return obj + } + + @Throws(IOException::class) + private fun parseTkhd(): Tkhd { + val version: Int = stream.read() + val obj: Tkhd = Tkhd() + + // flags + // creation entries_time + // modification entries_time + stream.skipBytes((3 + (2 * (if (version == 0) 4 else 8))).toLong()) + obj.trackId = stream.readInt() + stream.skipBytes(4) // reserved + obj.duration = if (version == 0) stream.readUnsignedInt() else stream.readLong() + stream.skipBytes((2 * 4).toLong()) // reserved + obj.bLayer = stream.readShort() + obj.bAlternateGroup = stream.readShort() + obj.bVolume = stream.readShort() + stream.skipBytes(2) // reserved + obj.matrix = ByteArray(9 * 4) + stream.read(obj.matrix) + obj.bWidth = stream.readInt() + obj.bHeight = stream.readInt() + return obj + } + + @Throws(IOException::class) + private fun parseTrak(ref: Box): Trak { + val trak: Trak = Trak() + var b: Box = readBox(ATOM_TKHD) + trak.tkhd = parseTkhd() + ensure(b) + while ((untilBox(ref, ATOM_MDIA, ATOM_EDTS).also({ b = (it)!! })) != null) { + when (b.type) { + ATOM_MDIA -> trak.mdia = parseMdia(b) + ATOM_EDTS -> trak.edstElst = parseEdts(b) + } + ensure(b) + } + return trak + } + + @Throws(IOException::class) + private fun parseMdia(ref: Box): Mdia { + val obj: Mdia = Mdia() + var b: Box + while ((untilBox(ref, ATOM_MDHD, ATOM_HDLR, ATOM_MINF).also({ b = (it)!! })) != null) { + when (b.type) { + ATOM_MDHD -> { + obj.mdhd = readFullBox(b) + + // read time scale + val buffer: ByteBuffer = ByteBuffer.wrap(obj.mdhd) + val version: Byte = buffer.get(8) + buffer.position(12 + ((if (version.toInt() == 0) 4 else 8) * 2)) + obj.mdhdTimeScale = buffer.getInt() + } + + ATOM_HDLR -> obj.hdlr = parseHdlr(b) + ATOM_MINF -> obj.minf = parseMinf(b) + } + ensure(b) + } + return obj + } + + @Throws(IOException::class) + private fun parseHdlr(ref: Box): Hdlr { + // version + // flags + stream.skipBytes(4) + val obj: Hdlr = Hdlr() + obj.bReserved = ByteArray(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 + } + + @Throws(IOException::class) + private fun parseMoov(ref: Box?): Moov { + var b: Box = readBox(ATOM_MVHD) + val moov: Moov = Moov() + moov.mvhd = parseMvhd() + ensure(b) + val tmp: ArrayList = ArrayList(moov.mvhd!!.nextTrackId.toInt()) + while ((untilBox(ref, ATOM_TRAK, ATOM_MVEX).also({ b = (it)!! })) != null) { + when (b.type) { + ATOM_TRAK -> tmp.add(parseTrak(b)) + ATOM_MVEX -> moov.mvexTrex = parseMvex(b, moov.mvhd!!.nextTrackId.toInt()) + } + ensure(b) + } + moov.trak = tmp.toTypedArray() + return moov + } + + @Throws(IOException::class) + private fun parseMvex(ref: Box, possibleTrackCount: Int): Array { + val tmp: ArrayList = ArrayList(possibleTrackCount) + var b: Box? + while ((untilBox(ref, ATOM_TREX).also({ b = it })) != null) { + tmp.add(parseTrex()) + ensure(b) + } + return tmp.toTypedArray() + } + + @Throws(IOException::class) + private fun parseTrex(): Trex { + // version + // flags + stream.skipBytes(4) + val obj: Trex = Trex() + obj.trackId = stream.readInt() + obj.defaultSampleDescriptionIndex = stream.readInt() + obj.defaultSampleDuration = stream.readInt() + obj.defaultSampleSize = stream.readInt() + obj.defaultSampleFlags = stream.readInt() + return obj + } + + @Throws(IOException::class) + private fun parseEdts(ref: Box): Elst? { + val b: Box? = untilBox(ref, ATOM_ELST) + if (b == null) { + return null + } + val obj: Elst = Elst() + val v1: Boolean = stream.read() == 1 + stream.skipBytes(3) // flags + val entryCount: Int = stream.readInt() + if (entryCount < 1) { + obj.bMediaRate = 0x00010000 // default media rate (1.0) + return obj + } + if (v1) { + stream.skipBytes(DataReader.Companion.LONG_SIZE.toLong()) // segment duration + obj.mediaTime = stream.readLong() + // ignore all remain entries + stream.skipBytes(((entryCount - 1) * (DataReader.Companion.LONG_SIZE * 2)).toLong()) + } else { + stream.skipBytes(DataReader.Companion.INTEGER_SIZE.toLong()) // segment duration + obj.mediaTime = stream.readInt().toLong() + } + obj.bMediaRate = stream.readInt() + return obj + } + + @Throws(IOException::class) + private fun parseMinf(ref: Box): Minf { + val obj: Minf = Minf() + var b: Box + while ((untilAnyBox(ref).also({ b = (it)!! })) != null) { + when (b.type) { + ATOM_DINF -> obj.dinf = readFullBox(b) + ATOM_STBL -> obj.stblStsd = parseStbl(b) + ATOM_VMHD, ATOM_SMHD -> obj.mhd = readFullBox(b) + } + ensure(b) + } + return obj + } + + /** + * This only reads the "stsd" box inside. + * + * @param ref stbl box + * @return stsd box inside + */ + @Throws(IOException::class) + private fun parseStbl(ref: Box): ByteArray { + val b: Box? = untilBox(ref, ATOM_STSD) + if (b == null) { + return ByteArray(0) // this never should happens (missing codec startup data) + } + return readFullBox(b) + } + + internal class Box() { + var type: Int = 0 + var offset: Long = 0 + var size: Long = 0 + } + + class Moof() { + var mfhdSequenceNumber: Int = 0 + var traf: Traf? = null + } + + class Traf() { + var tfhd: Tfhd? = null + var tfdt: Long = 0 + var trun: Trun? = null + } + + class Tfhd() { + var bFlags: Int = 0 + var trackId: Int = 0 + var defaultSampleDuration: Int = 0 + var defaultSampleSize: Int = 0 + var defaultSampleFlags: Int = 0 + } + + class TrunEntry() { + var sampleDuration: Int = 0 + var sampleSize: Int = 0 + var sampleFlags: Int = 0 + var sampleCompositionTimeOffset: Int = 0 + var hasCompositionTimeOffset: Boolean = false + var isKeyframe: Boolean = false + } + + class Trun() { + var chunkDuration: Int = 0 + var chunkSize: Int = 0 + var bFlags: Int = 0 + var bFirstSampleFlags: Int = 0 + var dataOffset: Int = 0 + var entryCount: Int = 0 + var bEntries: ByteArray + var entriesRowSize: Int = 0 + fun getEntry(i: Int): TrunEntry { + val buffer: ByteBuffer = ByteBuffer.wrap(bEntries, i * entriesRowSize, entriesRowSize) + val entry: TrunEntry = 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 + } + + fun getAbsoluteEntry(i: Int, header: Tfhd?): TrunEntry { + val entry: TrunEntry = 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 + } + } + + class Tkhd() { + var trackId: Int = 0 + var duration: Long = 0 + var bVolume: Short = 0 + var bWidth: Int = 0 + var bHeight: Int = 0 + var matrix: ByteArray + var bLayer: Short = 0 + var bAlternateGroup: Short = 0 + } + + class Trak() { + var tkhd: Tkhd? = null + var edstElst: Elst? = null + var mdia: Mdia? = null + } + + internal class Mvhd() { + var timeScale: Long = 0 + var nextTrackId: Long = 0 + } + + internal class Moov() { + var mvhd: Mvhd? = null + var trak: Array + var mvexTrex: Array? + } + + class Trex() { + val trackId: Int = 0 + var defaultSampleDescriptionIndex: Int = 0 + var defaultSampleDuration: Int = 0 + var defaultSampleSize: Int = 0 + var defaultSampleFlags: Int = 0 + } + + class Elst() { + var mediaTime: Long = 0 + var bMediaRate: Int = 0 + } + + class Mdia() { + var mdhdTimeScale: Int = 0 + var mdhd: ByteArray + var hdlr: Hdlr? = null + var minf: Minf? = null + } + + class Hdlr() { + var type: Int = 0 + var subType: Int = 0 + var bReserved: ByteArray + } + + class Minf() { + var dinf: ByteArray + var stblStsd: ByteArray + var mhd: ByteArray + } + + class Mp4Track() { + var kind: TrackKind? = null + var trak: Trak? = null + var trex: Trex? = null + } + + class Mp4DashChunk() { + var data: InputStream? = null + var moof: Moof? = null + private var i: Int = 0 + fun getNextSampleInfo(): TrunEntry? { + if (i >= moof!!.traf!!.trun!!.entryCount) { + return null + } + return moof!!.traf!!.trun!!.getAbsoluteEntry(i++, moof!!.traf!!.tfhd) + } + + @Throws(IOException::class) + fun getNextSample(): Mp4DashSample? { + if (data == null) { + throw IllegalStateException("This chunk has info only") + } + if (i >= moof!!.traf!!.trun!!.entryCount) { + return null + } + val sample: Mp4DashSample = Mp4DashSample() + sample.info = moof!!.traf!!.trun!!.getAbsoluteEntry(i++, moof!!.traf!!.tfhd) + sample.data = ByteArray(sample.info!!.sampleSize) + if (data!!.read(sample.data) != sample.info!!.sampleSize) { + throw EOFException("EOF reached while reading a sample") + } + return sample + } + } + + class Mp4DashSample() { + var info: TrunEntry? = null + var data: ByteArray + } + + companion object { + private val ATOM_MOOF: Int = 0x6D6F6F66 + private val ATOM_MFHD: Int = 0x6D666864 + private val ATOM_TRAF: Int = 0x74726166 + private val ATOM_TFHD: Int = 0x74666864 + private val ATOM_TFDT: Int = 0x74666474 + private val ATOM_TRUN: Int = 0x7472756E + private val ATOM_MDIA: Int = 0x6D646961 + private val ATOM_FTYP: Int = 0x66747970 + private val ATOM_SIDX: Int = 0x73696478 + private val ATOM_MOOV: Int = 0x6D6F6F76 + private val ATOM_MDAT: Int = 0x6D646174 + private val ATOM_MVHD: Int = 0x6D766864 + private val ATOM_TRAK: Int = 0x7472616B + private val ATOM_MVEX: Int = 0x6D766578 + private val ATOM_TREX: Int = 0x74726578 + private val ATOM_TKHD: Int = 0x746B6864 + private val ATOM_MFRA: Int = 0x6D667261 + private val ATOM_MDHD: Int = 0x6D646864 + private val ATOM_EDTS: Int = 0x65647473 + private val ATOM_ELST: Int = 0x656C7374 + private val ATOM_HDLR: Int = 0x68646C72 + private val ATOM_MINF: Int = 0x6D696E66 + private val ATOM_DINF: Int = 0x64696E66 + private val ATOM_STBL: Int = 0x7374626C + private val ATOM_STSD: Int = 0x73747364 + private val ATOM_VMHD: Int = 0x766D6864 + private val ATOM_SMHD: Int = 0x736D6864 + private val BRAND_DASH: Int = 0x64617368 + private val BRAND_ISO5: Int = 0x69736F35 + private val HANDLER_VIDE: Int = 0x76696465 + private val HANDLER_SOUN: Int = 0x736F756E + private val HANDLER_SUBT: Int = 0x73756274 + fun hasFlag(flags: Int, mask: Int): Boolean { + return (flags and mask) == mask + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java deleted file mode 100644 index 807f190b4..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java +++ /dev/null @@ -1,912 +0,0 @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.kt b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.kt new file mode 100644 index 000000000..17350b863 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.kt @@ -0,0 +1,809 @@ +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.TrunEntry +import org.schabi.newpipe.streams.io.SharpStream +import java.io.IOException +import java.nio.ByteBuffer +import kotlin.math.ceil +import kotlin.math.min + +/** + * @author kapodamy + */ +class Mp4FromDashWriter(vararg sources: SharpStream) { + private val time: Long + private var auxBuffer: ByteBuffer? = null + private var outStream: SharpStream? = null + private var lastWriteOffset: Long = -1 + private var writeOffset: Long = 0 + private var moovSimulation: Boolean = true + private var done: Boolean = false + private var parsed: Boolean = false + private var tracks: Array? + private var sourceTracks: Array? + private var readers: Array? + private var readersChunks: Array? + private var overrideMainBrand: Int = 0x00 + private val compatibleBrands: ArrayList = ArrayList(5) + + init { + for (src: SharpStream in sources) { + if (!src.canRewind() && !src.canRead()) { + throw IOException("All sources must be readable and allow rewind") + } + } + sourceTracks = sources + readers = arrayOfNulls(sourceTracks!!.size) + readersChunks = arrayOfNulls(readers!!.size) + time = (System.currentTimeMillis() / 1000L) + EPOCH_OFFSET + compatibleBrands.add(0x6D703431) // mp41 + compatibleBrands.add(0x69736F6D) // isom + compatibleBrands.add(0x69736F32) // iso2 + } + + @Throws(IllegalStateException::class) + fun getTracksFromSource(sourceIndex: Int): Array? { + if (!parsed) { + throw IllegalStateException("All sources must be parsed first") + } + return readers!!.get(sourceIndex)!!.getAvailableTracks() + } + + @Throws(IOException::class, IllegalStateException::class) + fun parseSources() { + if (done) { + throw IllegalStateException("already done") + } + if (parsed) { + throw IllegalStateException("already parsed") + } + try { + for (i in readers!!.indices) { + readers!!.get(i) = Mp4DashReader(sourceTracks!!.get(i)) + readers!!.get(i)!!.parse() + } + } finally { + parsed = true + } + } + + @Throws(IOException::class) + fun selectTracks(vararg trackIndex: Int) { + if (done) { + throw IOException("already done") + } + if (tracks != null) { + throw IOException("tracks already selected") + } + try { + tracks = arrayOfNulls(readers!!.size) + for (i in readers!!.indices) { + tracks!!.get(i) = readers!!.get(i)!!.selectTrack(trackIndex.get(i)) + } + } finally { + parsed = true + } + } + + fun setMainBrand(brand: Int) { + overrideMainBrand = brand + } + + fun isDone(): Boolean { + return done + } + + fun isParsed(): Boolean { + return parsed + } + + @Throws(IOException::class) + fun close() { + done = true + parsed = true + for (src: SharpStream in sourceTracks!!) { + src.close() + } + tracks = null + sourceTracks = null + readers = null + readersChunks = null + auxBuffer = null + outStream = null + } + + @Throws(IOException::class) + fun build(output: SharpStream?) { + if (done) { + throw RuntimeException("already done") + } + if (!output!!.canWrite()) { + throw 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 + var read: Long = 8 // mdat box header size + var totalSampleSize: Long = 0 + val sampleExtra: IntArray = IntArray(readers!!.size) + val defaultMediaTime: IntArray = IntArray(readers!!.size) + val defaultSampleDuration: IntArray = IntArray(readers!!.size) + val sampleCount: IntArray = IntArray(readers!!.size) + val tablesInfo: Array = arrayOfNulls(tracks!!.size) + for (i in tablesInfo.indices) { + tablesInfo.get(i) = TablesInfo() + } + val singleSampleBuffer: Int + if (tracks!!.size == 1 && tracks!!.get(0)!!.kind == Mp4DashReader.TrackKind.Audio) { + // near 1 second of audio data per chunk, avoid split the audio stream in large chunks + singleSampleBuffer = tracks!!.get(0)!!.trak!!.mdia!!.mdhdTimeScale / 1000 + } else { + singleSampleBuffer = -1 + } + for (i in readers!!.indices) { + var samplesSize: Int = 0 + var sampleSizeChanges: Int = 0 + var compositionOffsetLast: Int = -1 + var chunk: Mp4DashChunk + while ((readers!!.get(i)!!.getNextChunk(true).also({ chunk = (it)!! })) != null) { + if (defaultMediaTime.get(i) < 1 && chunk.moof!!.traf!!.tfhd!!.defaultSampleDuration > 0) { + defaultMediaTime.get(i) = chunk.moof!!.traf!!.tfhd!!.defaultSampleDuration + } + read += chunk.moof!!.traf!!.trun!!.chunkSize.toLong() + sampleExtra.get(i) += chunk.moof!!.traf!!.trun!!.chunkDuration // calculate track duration + var info: TrunEntry + while ((chunk.getNextSampleInfo().also({ info = (it)!! })) != null) { + if (info.isKeyframe) { + tablesInfo.get(i)!!.stss++ + } + if (info.sampleDuration > defaultSampleDuration.get(i)) { + defaultSampleDuration.get(i) = info.sampleDuration + } + tablesInfo.get(i)!!.stsz++ + if (samplesSize != info.sampleSize) { + samplesSize = info.sampleSize + sampleSizeChanges++ + } + if (info.hasCompositionTimeOffset) { + if (info.sampleCompositionTimeOffset != compositionOffsetLast) { + tablesInfo.get(i)!!.ctts++ + compositionOffsetLast = info.sampleCompositionTimeOffset + } + } + totalSampleSize += info.sampleSize.toLong() + } + } + if (defaultMediaTime.get(i) < 1) { + defaultMediaTime.get(i) = defaultSampleDuration.get(i) + } + readers!!.get(i)!!.rewind() + if (singleSampleBuffer > 0) { + initChunkTables(tablesInfo.get(i), singleSampleBuffer, singleSampleBuffer) + } else { + initChunkTables(tablesInfo.get(i), SAMPLES_PER_CHUNK_INIT.toInt(), SAMPLES_PER_CHUNK.toInt()) + } + sampleCount.get(i) = tablesInfo.get(i)!!.stsz + if (sampleSizeChanges == 1) { + tablesInfo.get(i)!!.stsz = 0 + tablesInfo.get(i)!!.stszDefault = samplesSize + } else { + tablesInfo.get(i)!!.stszDefault = 0 + } + if (tablesInfo.get(i)!!.stss == tablesInfo.get(i)!!.stsz) { + tablesInfo.get(i)!!.stss = -1 // for audio tracks (all samples are keyframes) + } + + // ensure track duration + if (tracks!!.get(i)!!.trak!!.tkhd!!.duration < 1) { + tracks!!.get(i)!!.trak!!.tkhd!!.duration = sampleExtra.get(i).toLong() // this never should happen + } + } + val is64: Boolean = read > THRESHOLD_FOR_CO64 + + // calculate the moov size + val auxSize: Int = makeMoov(defaultMediaTime, tablesInfo, is64) + if (auxSize < THRESHOLD_MOOV_LENGTH) { + auxBuffer = ByteBuffer.allocate(auxSize) // cache moov in the memory + } + moovSimulation = false + writeOffset = 0 + val ftypSize: Int = makeFtyp() + + // reserve moov space in the output stream + if (auxSize > 0) { + var length: Int = auxSize + val buffer: ByteArray = ByteArray(64 * 1024) // 64 KiB + while (length > 0) { + val count: Int = min(length.toDouble(), buffer.size.toDouble()).toInt() + outWrite(buffer, count) + length -= count + } + } + if (auxBuffer == null) { + outSeek(ftypSize.toLong()) + } + + // 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 (i in readers!!.indices) { + writeEntryArray(tablesInfo.get(i)!!.stts, 2, sampleCount.get(i), defaultSampleDuration.get(i)) + writeEntryArray(tablesInfo.get(i)!!.stsc, tablesInfo.get(i)!!.stscBEntries!!.size, + *(tablesInfo.get(i)!!.stscBEntries)!!) + tablesInfo.get(i)!!.stscBEntries = null + if (tablesInfo.get(i)!!.ctts > 0) { + sampleCount.get(i) = 1 // the index is not base zero + sampleExtra.get(i) = -1 + } + if (tablesInfo.get(i)!!.sbgp > 0) { + writeEntryArray(tablesInfo.get(i)!!.sbgp, 1, sampleCount.get(i)) + } + } + if (auxBuffer == null) { + outRestore() + } + outWrite(makeMdat(totalSampleSize, is64)) + val sampleIndex: IntArray = IntArray(readers!!.size) + val sizes: IntArray = IntArray(if (singleSampleBuffer > 0) singleSampleBuffer else SAMPLES_PER_CHUNK) + val sync: IntArray = IntArray(if (singleSampleBuffer > 0) singleSampleBuffer else SAMPLES_PER_CHUNK) + var written: Int = readers!!.size + while (written > 0) { + written = 0 + for (i in readers!!.indices) { + if (sampleIndex.get(i) < 0) { + continue // track is done + } + val chunkOffset: Long = writeOffset + var syncCount: Int = 0 + val limit: Int + if (singleSampleBuffer > 0) { + limit = singleSampleBuffer + } else { + limit = (if (sampleIndex.get(i) == 0) SAMPLES_PER_CHUNK_INIT else SAMPLES_PER_CHUNK).toInt() + } + var j: Int = 0 + while (j < limit) { + val sample: Mp4DashSample? = getNextSample(i) + if (sample == null) { + if (tablesInfo.get(i)!!.ctts > 0 && sampleExtra.get(i) >= 0) { + writeEntryArray(tablesInfo.get(i)!!.ctts, 1, sampleCount.get(i), + sampleExtra.get(i)) // flush last entries + outRestore() + } + sampleIndex.get(i) = -1 + break + } + sampleIndex.get(i)++ + if (tablesInfo.get(i)!!.ctts > 0) { + if (sample.info!!.sampleCompositionTimeOffset == sampleExtra.get(i)) { + sampleCount.get(i)++ + } else { + if (sampleExtra.get(i) >= 0) { + tablesInfo.get(i)!!.ctts = writeEntryArray(tablesInfo.get(i)!!.ctts, 2, + sampleCount.get(i), sampleExtra.get(i)) + outRestore() + } + sampleCount.get(i) = 1 + sampleExtra.get(i) = sample.info!!.sampleCompositionTimeOffset + } + } + if (tablesInfo.get(i)!!.stss > 0 && sample.info!!.isKeyframe) { + sync.get(syncCount++) = sampleIndex.get(i) + } + if (tablesInfo.get(i)!!.stsz > 0) { + sizes.get(j) = sample.data.size + } + outWrite(sample.data, sample.data.size) + j++ + } + if (j > 0) { + written++ + if (tablesInfo.get(i)!!.stsz > 0) { + tablesInfo.get(i)!!.stsz = writeEntryArray(tablesInfo.get(i)!!.stsz, j, *sizes) + } + if (syncCount > 0) { + tablesInfo.get(i)!!.stss = writeEntryArray(tablesInfo.get(i)!!.stss, syncCount, *sync) + } + if (tablesInfo.get(i)!!.stco > 0) { + if (is64) { + tablesInfo.get(i)!!.stco = writeEntry64(tablesInfo.get(i)!!.stco, chunkOffset) + } else { + tablesInfo.get(i)!!.stco = writeEntryArray(tablesInfo.get(i)!!.stco, 1, chunkOffset.toInt()) + } + } + outRestore() + } + } + } + if (auxBuffer != null) { + // dump moov + outSeek(ftypSize.toLong()) + outStream!!.write(auxBuffer!!.array(), 0, auxBuffer!!.capacity()) + auxBuffer = null + } + } + + @Throws(IOException::class) + private fun getNextSample(track: Int): Mp4DashSample? { + if (readersChunks!!.get(track) == null) { + readersChunks!!.get(track) = readers!!.get(track)!!.getNextChunk(false) + if (readersChunks!!.get(track) == null) { + return null // EOF reached + } + } + val sample: Mp4DashSample? = readersChunks!!.get(track)!!.getNextSample() + if (sample == null) { + readersChunks!!.get(track) = null + return getNextSample(track) + } else { + return sample + } + } + + @Throws(IOException::class) + private fun writeEntry64(offset: Int, value: Long): Int { + outBackup() + auxSeek(offset) + auxWrite(ByteBuffer.allocate(8).putLong(value).array()) + return offset + 8 + } + + @Throws(IOException::class) + private fun writeEntryArray(offset: Int, count: Int, vararg values: Int): Int { + outBackup() + auxSeek(offset) + val size: Int = count * 4 + val buffer: ByteBuffer = ByteBuffer.allocate(size) + for (i in 0 until count) { + buffer.putInt(values.get(i)) + } + auxWrite(buffer.array()) + return offset + size + } + + private fun outBackup() { + if (auxBuffer == null && lastWriteOffset < 0) { + lastWriteOffset = writeOffset + } + } + + /** + * Restore to the previous position before the first call to writeEntry64() + * or writeEntryArray() methods. + */ + @Throws(IOException::class) + private fun outRestore() { + if (lastWriteOffset > 0) { + outSeek(lastWriteOffset) + lastWriteOffset = -1 + } + } + + private fun initChunkTables(tables: TablesInfo?, firstCount: Int, + successiveCount: Int) { + // tables.stsz holds amount of samples of the track (total) + val totalSamples: Int = (tables!!.stsz - firstCount) + val chunkAmount: Float = totalSamples / successiveCount.toFloat() + val remainChunkOffset: Int = ceil(chunkAmount.toDouble()).toInt() + val remain: Boolean = remainChunkOffset != chunkAmount.toInt() + var index: Int = 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 = IntArray(tables.stsc * 3) + tables.stco = remainChunkOffset + 1 // total entries in chunk offset box + tables.stscBEntries!!.get(index++) = 1 + tables.stscBEntries!!.get(index++) = firstCount + tables.stscBEntries!!.get(index++) = 1 + if (firstCount != successiveCount) { + tables.stscBEntries!!.get(index++) = 2 + tables.stscBEntries!!.get(index++) = successiveCount + tables.stscBEntries!!.get(index++) = 1 + } + if (remain) { + tables.stscBEntries!!.get(index++) = remainChunkOffset + 1 + tables.stscBEntries!!.get(index++) = totalSamples % successiveCount + tables.stscBEntries!!.get(index) = 1 + } + } + + @Throws(IOException::class) + private fun outWrite(buffer: ByteArray?, count: Int = buffer.length) { + writeOffset += count.toLong() + outStream!!.write(buffer, 0, count) + } + + @Throws(IOException::class) + private fun outSeek(offset: Long) { + if (outStream!!.canSeek()) { + outStream!!.seek(offset) + writeOffset = offset + } else if (outStream!!.canRewind()) { + outStream!!.rewind() + writeOffset = 0 + outSkip(offset) + } else { + throw IOException("cannot seek or rewind the output stream") + } + } + + @Throws(IOException::class) + private fun outSkip(amount: Long) { + outStream!!.skip(amount) + writeOffset += amount + } + + @Throws(IOException::class) + private fun lengthFor(offset: Int): Int { + val size: Int = auxOffset() - offset + if (moovSimulation) { + return size + } + auxSeek(offset) + auxWrite(size) + auxSkip(size - 4) + return size + } + + @Throws(IOException::class) + private fun make(type: Int, extra: Int, columns: Int, rows: Int): Int { + val base: Byte = 16 + val size: Int = columns * rows * 4 + var total: Int = size + base + var offset: Int = 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 + } + + @Throws(IOException::class) + private fun auxWrite(value: Int) { + auxWrite(ByteBuffer.allocate(4) + .putInt(value) + .array() + ) + } + + @Throws(IOException::class) + private fun auxWrite(buffer: ByteArray?) { + if (moovSimulation) { + writeOffset += buffer!!.size.toLong() + } else if (auxBuffer == null) { + outWrite(buffer, buffer!!.size) + } else { + auxBuffer!!.put(buffer) + } + } + + @Throws(IOException::class) + private fun auxSeek(offset: Int) { + if (moovSimulation) { + writeOffset = offset.toLong() + } else if (auxBuffer == null) { + outSeek(offset.toLong()) + } else { + auxBuffer!!.position(offset) + } + } + + @Throws(IOException::class) + private fun auxSkip(amount: Int) { + if (moovSimulation) { + writeOffset += amount.toLong() + } else if (auxBuffer == null) { + outSkip(amount.toLong()) + } else { + auxBuffer!!.position(auxBuffer!!.position() + amount) + } + } + + private fun auxOffset(): Int { + return if (auxBuffer == null) writeOffset.toInt() else auxBuffer!!.position() + } + + @Throws(IOException::class) + private fun makeFtyp(): Int { + var size: Int = 16 + (compatibleBrands.size * 4) + if (overrideMainBrand != 0) { + size += 4 + } + val buffer: ByteBuffer = 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 (brand: Int in compatibleBrands) { + buffer.putInt(brand) // compatible brand + } + outWrite(buffer.array()) + return size + } + + private fun makeMdat(refSize: Long, is64: Boolean): ByteArray { + var size: Long = refSize + if (is64) { + size += 16 + } else { + size += 8 + } + val buffer: ByteBuffer = ByteBuffer.allocate(if (is64) 16 else 8) + .putInt(if (is64) 0x01 else size.toInt()) + .putInt(0x6D646174) // mdat + if (is64) { + buffer.putLong(size) + } + return buffer.array() + } + + @Throws(IOException::class) + private fun makeMvhd(longestTrack: Long) { + auxWrite(byteArrayOf( + 0x00, 0x00, 0x00, 0x78, 0x6D, 0x76, 0x68, 0x64, 0x01, 0x00, 0x00, 0x00 + )) + auxWrite(ByteBuffer.allocate(28) + .putLong(time) + .putLong(time) + .putInt(DEFAULT_TIMESCALE.toInt()) + .putLong(longestTrack) + .array() + ) + auxWrite(byteArrayOf( + 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(ByteArray(24)) // predefined + auxWrite(ByteBuffer.allocate(4) + .putInt(tracks!!.size + 1) + .array() + ) + } + + @Throws(RuntimeException::class, IOException::class) + private fun makeMoov(defaultMediaTime: IntArray, tablesInfo: Array, + is64: Boolean): Int { + val start: Int = auxOffset() + auxWrite(byteArrayOf( + 0x00, 0x00, 0x00, 0x00, 0x6D, 0x6F, 0x6F, 0x76 + )) + var longestTrack: Long = 0 + val durations: LongArray = LongArray(tracks!!.size) + for (i in durations.indices) { + durations.get(i) = ceil(( + (tracks!!.get(i)!!.trak!!.tkhd!!.duration.toDouble() / tracks!!.get(i)!!.trak!!.mdia!!.mdhdTimeScale) + * DEFAULT_TIMESCALE)).toLong() + if (durations.get(i) > longestTrack) { + longestTrack = durations.get(i) + } + } + makeMvhd(longestTrack) + for (i in tracks!!.indices) { + if (tracks!!.get(i)!!.trak!!.tkhd!!.matrix.size != 36) { + throw RuntimeException("bad track matrix length (expected 36) in track n°" + i) + } + makeTrak(i, durations.get(i), defaultMediaTime.get(i), tablesInfo.get(i), is64) + } + return lengthFor(start) + } + + @Throws(IOException::class) + private fun makeTrak(index: Int, duration: Long, defaultMediaTime: Int, + tables: TablesInfo?, is64: Boolean) { + val start: Int = auxOffset() + auxWrite(byteArrayOf( // trak header + 0x00, 0x00, 0x00, 0x00, 0x74, 0x72, 0x61, 0x6B, // tkhd header + 0x00, 0x00, 0x00, 0x68, 0x74, 0x6B, 0x68, 0x64, 0x01, 0x00, 0x00, 0x03 + )) + val buffer: ByteBuffer = 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!!.get(index)!!.trak!!.tkhd!!.bLayer) + buffer.putShort(tracks!!.get(index)!!.trak!!.tkhd!!.bAlternateGroup) + buffer.putShort(tracks!!.get(index)!!.trak!!.tkhd!!.bVolume) + auxWrite(buffer.array()) + auxWrite(tracks!!.get(index)!!.trak!!.tkhd!!.matrix) + auxWrite(ByteBuffer.allocate(8) + .putInt(tracks!!.get(index)!!.trak!!.tkhd!!.bWidth) + .putInt(tracks!!.get(index)!!.trak!!.tkhd!!.bHeight) + .array() + ) + auxWrite(byteArrayOf( + 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 + )) + val bMediaRate: Int + val mediaTime: Int + if (tracks!!.get(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 = tracks!!.get(index)!!.trak!!.edstElst!!.mediaTime.toInt() + bMediaRate = tracks!!.get(index)!!.trak!!.edstElst!!.bMediaRate + } + auxWrite(ByteBuffer + .allocate(12) + .putInt(duration.toInt()) + .putInt(mediaTime) + .putInt(bMediaRate) + .array() + ) + makeMdia(tracks!!.get(index)!!.trak!!.mdia, tables, is64, tracks!!.get(index)!!.kind == Mp4DashReader.TrackKind.Audio) + lengthFor(start) + } + + @Throws(IOException::class) + private fun makeMdia(mdia: Mdia?, tablesInfo: TablesInfo?, is64: Boolean, + isAudio: Boolean) { + val startMdia: Int = auxOffset() + auxWrite(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x6D, 0x64, 0x69, 0x61)) // mdia + auxWrite(mdia!!.mdhd) + auxWrite(makeHdlr(mdia.hdlr)) + val startMinf: Int = auxOffset() + auxWrite(byteArrayOf(0x00, 0x00, 0x00, 0x00, 0x6D, 0x69, 0x6E, 0x66)) // minf + auxWrite(mdia.minf!!.mhd) + auxWrite(mdia.minf!!.dinf) + val startStbl: Int = auxOffset() + auxWrite(byteArrayOf(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(if (is64) 0x636F3634 else 0x7374636F, -1, if (is64) 2 else 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(if (is64) 0x636F3634 else 0x7374636F, -1, if (is64) 2 else 1, + tablesInfo.stco) + } + if (isAudio) { + auxWrite(makeSgpd()) + tablesInfo.sbgp = makeSbgp() // during simulation the returned offset is ignored + } + lengthFor(startStbl) + lengthFor(startMinf) + lengthFor(startMdia) + } + + private fun makeHdlr(hdlr: Hdlr?): ByteArray { + val buffer: ByteBuffer = ByteBuffer.wrap(byteArrayOf( + 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() + } + + @Throws(IOException::class) + private fun makeSbgp(): Int { + val offset: Int = auxOffset() + auxWrite(byteArrayOf( + 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 fun makeSgpd(): ByteArray { + /* + * 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) + */ + val buffer: ByteBuffer = ByteBuffer.wrap(byteArrayOf( + 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, 0xFF.toByte(), 0xFF.toByte())) + return buffer.array() + } + + internal class TablesInfo() { + var stts: Int = 0 + var stsc: Int = 0 + var stscBEntries: IntArray? + var ctts: Int = 0 + var stsz: Int = 0 + var stszDefault: Int = 0 + var stss: Int = 0 + var stco: Int = 0 + var sbgp: Int = 0 + } + + companion object { + private val EPOCH_OFFSET: Int = 2082844800 + private val DEFAULT_TIMESCALE: Short = 1000 + private val SAMPLES_PER_CHUNK_INIT: Byte = 2 + + // ffmpeg uses 2, basic uses 1 (with 60fps uses 21 or 22). NewPipe will use 6 + private val SAMPLES_PER_CHUNK: Byte = 6 + + // near 3.999 GiB + private val THRESHOLD_FOR_CO64: Long = 0xFFFEFFFFL + + // 2.2 MiB enough for: 1080p 60fps 00h35m00s + private val THRESHOLD_MOOV_LENGTH: Int = (256 * 1024) + (2048 * 1024) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java deleted file mode 100644 index 266cec24a..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java +++ /dev/null @@ -1,416 +0,0 @@ -package org.schabi.newpipe.streams; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -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; - -/** - * @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]; - - public OggFromWebMWriter(@NonNull final SharpStream source, @NonNull final SharpStream target) { - 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.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 ("A_OPUS".equals(webmTrack.codecId)) { - return new byte[]{ - 0x4F, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73, // "OpusTags" binary string - 0x00, 0x00, 0x00, 0x00, // writing application string size (not present) - 0x00, 0x00, 0x00, 0x00 // additional tags count (zero means no tags) - }; - } 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; - } - - 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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.kt b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.kt new file mode 100644 index 000000000..f778fc9c9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.kt @@ -0,0 +1,364 @@ +package org.schabi.newpipe.streams + +import org.schabi.newpipe.streams.WebMReader.Cluster +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 kotlin.math.ceil +import kotlin.math.min + +/** + * @author kapodamy + */ +class OggFromWebMWriter(source: SharpStream, target: SharpStream) : Closeable { + private var done: Boolean = false + private var parsed: Boolean = false + private val source: SharpStream + private val output: SharpStream + private var sequenceCount: Int = 0 + private val streamId: Int + private var packetFlag: Byte = FLAG_FIRST + private var webm: WebMReader? = null + private var webmTrack: WebMTrack? = null + private var webmSegment: WebMReader.Segment? = null + private var webmCluster: Cluster? = null + private var webmBlock: SimpleBlock? = null + private var webmBlockLastTimecode: Long = 0 + private var webmBlockNearDuration: Long = 0 + private var segmentTableSize: Short = 0 + private val segmentTable: ByteArray = ByteArray(255) + private var segmentTableNextTimestamp: Long = TIME_SCALE_NS.toLong() + private val crc32Table: IntArray = IntArray(256) + + init { + if (!source.canRead() || !source.canRewind()) { + throw IllegalArgumentException("source stream must be readable and allows seeking") + } + if (!target.canWrite() || !target.canRewind()) { + throw IllegalArgumentException("output stream must be writable and allows seeking") + } + this.source = source + output = target + streamId = System.currentTimeMillis().toInt() + populateCrc32Table() + } + + fun isDone(): Boolean { + return done + } + + fun isParsed(): Boolean { + return parsed + } + + @Throws(IllegalStateException::class) + fun getTracksFromSource(): Array? { + if (!parsed) { + throw IllegalStateException("source must be parsed first") + } + return webm!!.getAvailableTracks() + } + + @Throws(IOException::class, IllegalStateException::class) + fun parseSource() { + if (done) { + throw IllegalStateException("already done") + } + if (parsed) { + throw IllegalStateException("already parsed") + } + try { + webm = WebMReader(source) + webm!!.parse() + webmSegment = webm!!.getNextSegment() + } finally { + parsed = true + } + } + + @Throws(IOException::class) + fun selectTrack(trackIndex: Int) { + if (!parsed) { + throw IllegalStateException("source must be parsed first") + } + if (done) { + throw IOException("already done") + } + if (webmTrack != null) { + throw IOException("tracks already selected") + } + when (webm!!.getAvailableTracks()!!.get(trackIndex)!!.kind) { + WebMReader.TrackKind.Audio, WebMReader.TrackKind.Video -> {} + else -> throw UnsupportedOperationException("the track must an audio or video stream") + } + try { + webmTrack = webm!!.selectTrack(trackIndex) + } finally { + parsed = true + } + } + + @Throws(IOException::class) + public override fun close() { + done = true + parsed = true + webmTrack = null + webm = null + if (!output.isClosed()) { + output.flush() + } + source.close() + output.close() + } + + @Throws(IOException::class) + fun build() { + val resolution: Float + var bloq: SimpleBlock? + val header: ByteBuffer = ByteBuffer.allocate(27 + (255 * 255)) + val page: ByteBuffer = ByteBuffer.allocate(64 * 1024) + header.order(ByteOrder.LITTLE_ENDIAN) + when (webmTrack!!.kind) { + WebMReader.TrackKind.Audio -> { + resolution = getSampleFrequencyFromTrack(webmTrack!!.bMetadata) + if (resolution == 0f) { + throw RuntimeException("cannot get the audio sample rate") + } + } + + WebMReader.TrackKind.Video -> { + // WARNING: untested + if (webmTrack!!.defaultDuration == 0L) { + throw RuntimeException("missing default frame time") + } + resolution = 1000f / ((webmTrack!!.defaultDuration.toFloat() / webmSegment!!.info!!.timecodeScale)) + } + + else -> throw RuntimeException("not implemented") + } + + /* step 2: create packet with code init data */if (webmTrack!!.codecPrivate != null) { + addPacketSegment(webmTrack!!.codecPrivate!!.size) + makePacketheader(0x00, header, webmTrack!!.codecPrivate) + write(header) + output.write(webmTrack!!.codecPrivate) + } + + /* step 3: create packet with metadata */ + val buffer: ByteArray? = makeMetadata() + if (buffer != null) { + addPacketSegment(buffer.size) + 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)) { + val pos: Int = page.position() + bloq.data!!.read(page.array(), pos, bloq.dataSize) + page.position(pos + bloq.dataSize) + continue + } + + // calculate the current packet duration using the next block + var elapsedNs: Double = webmTrack!!.codecDelay.toDouble() + if (bloq == null) { + packetFlag = FLAG_LAST // note: if the flag is FLAG_CONTINUED, is changed + elapsedNs += webmBlockLastTimecode.toDouble() + if (webmTrack!!.defaultDuration > 0) { + elapsedNs += webmTrack!!.defaultDuration.toDouble() + } else { + // hardcoded way, guess the sample duration + elapsedNs += webmBlockNearDuration.toDouble() + } + } else { + elapsedNs += bloq.absoluteTimeCodeNs.toDouble() + } + + // get the sample count in the page + elapsedNs = elapsedNs / TIME_SCALE_NS + elapsedNs = ceil(elapsedNs * resolution) + + // create header and calculate page checksum + var checksum: Int = makePacketheader(elapsedNs.toLong(), header, null) + checksum = calcCrc32(checksum, page.array(), page.position()) + header.putInt(HEADER_CHECKSUM_OFFSET.toInt(), checksum) + + // dump data + write(header) + write(page) + webmBlock = bloq + } + } + + private fun makePacketheader(granPos: Long, buffer: ByteBuffer, + immediatePage: ByteArray?): Int { + var length: Short = HEADER_SIZE.toShort() + buffer.putInt(0x5367674f) // "OggS" binary string in little-endian + buffer.put(0x00.toByte()) // 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(segmentTableSize.toByte()) // segment table + buffer.put(segmentTable, 0, segmentTableSize.toInt()) // segment size + length = (length + segmentTableSize).toShort() + clearSegmentTable() // clear segment table for next header + var checksumCrc32: Int = calcCrc32(0x00, buffer.array(), length.toInt()) + if (immediatePage != null) { + checksumCrc32 = calcCrc32(checksumCrc32, immediatePage, immediatePage.size) + buffer.putInt(HEADER_CHECKSUM_OFFSET.toInt(), checksumCrc32) + segmentTableNextTimestamp -= TIME_SCALE_NS.toLong() + } + return checksumCrc32 + } + + private fun makeMetadata(): ByteArray? { + if (("A_OPUS" == webmTrack!!.codecId)) { + return byteArrayOf( + 0x4F, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73, // "OpusTags" binary string + 0x00, 0x00, 0x00, 0x00, // writing application string size (not present) + 0x00, 0x00, 0x00, 0x00 // additional tags count (zero means no tags) + ) + } else if (("A_VORBIS" == webmTrack!!.codecId)) { + return byteArrayOf( + 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 + } + + @Throws(IOException::class) + private fun write(buffer: ByteBuffer) { + output.write(buffer.array(), 0, buffer.position()) + buffer.position(0) + } + + @Throws(IOException::class) + private fun getNextBlock(): SimpleBlock? { + val res: SimpleBlock? + 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 fun getSampleFrequencyFromTrack(bMetadata: ByteArray?): Float { + // hardcoded way + val buffer: ByteBuffer = ByteBuffer.wrap(bMetadata) + while (buffer.remaining() >= 6) { + val id: Int = buffer.getShort().toInt() and 0xFFFF + if (id == 0x0000B584) { + return buffer.getFloat() + } + } + return 0.0f + } + + private fun clearSegmentTable() { + segmentTableNextTimestamp += TIME_SCALE_NS.toLong() + packetFlag = FLAG_UNSET + segmentTableSize = 0 + } + + private fun addPacketSegment(block: SimpleBlock): Boolean { + val timestamp: Long = block.absoluteTimeCodeNs + webmTrack!!.codecDelay + if (timestamp >= segmentTableNextTimestamp) { + return false + } + return addPacketSegment(block.dataSize) + } + + private fun addPacketSegment(size: Int): Boolean { + if (size > 65025) { + throw UnsupportedOperationException("page size cannot be larger than 65025") + } + var available: Int = (segmentTable.size - segmentTableSize) * 255 + val extra: Boolean = (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 + } + var seg: Int = size + while (seg > 0) { + segmentTable.get(segmentTableSize++.toInt()) = min(seg.toDouble(), 255.0).toByte() + seg -= 255 + } + if (extra) { + segmentTable.get(segmentTableSize++.toInt()) = 0x00 + } + return true + } + + private fun populateCrc32Table() { + for (i in 0..0xff) { + var crc: Int = i shl 24 + for (j in 0..7) { + val b: Long = (crc ushr 31).toLong() + crc = crc shl 1 + crc = crc xor ((0x100000000L - b).toInt() and 0x04c11db7) + } + crc32Table.get(i) = crc + } + } + + private fun calcCrc32(initialCrc: Int, buffer: ByteArray, size: Int): Int { + var crc: Int = initialCrc + for (i in 0 until size) { + val reg: Int = (crc ushr 24) and 0xff + crc = (crc shl 8) xor crc32Table.get(reg xor (buffer.get(i).toInt() and 0xff)) + } + return crc + } + + companion object { + private val FLAG_UNSET: Byte = 0x00 + + //private static final byte FLAG_CONTINUED = 0x01; + private val FLAG_FIRST: Byte = 0x02 + private val FLAG_LAST: Byte = 0x04 + private val HEADER_CHECKSUM_OFFSET: Byte = 22 + private val HEADER_SIZE: Byte = 27 + private val TIME_SCALE_NS: Int = 1000000000 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/SrtFromTtmlWriter.java b/app/src/main/java/org/schabi/newpipe/streams/SrtFromTtmlWriter.java deleted file mode 100644 index 7aff655a0..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/SrtFromTtmlWriter.java +++ /dev/null @@ -1,103 +0,0 @@ -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; - -/** - * @author kapodamy - */ -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; - - private int frameIndex = 0; - - 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++)); - 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)); - } - - 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.size() < 1) { - return; - } - - for (final Element paragraph : paragraphList) { - text.setLength(0); - - for (final Node children : paragraph.childNodes()) { - if (children instanceof TextNode) { - text.append(((TextNode) children).text()); - } else if (children instanceof Element - && ((Element) children).tagName().equalsIgnoreCase("br")) { - text.append(NEW_LINE); - } - } - - if (ignoreEmptyFrames && text.length() < 1) { - continue; - } - - final String begin = getTimestamp(paragraph, "begin"); - final String end = getTimestamp(paragraph, "end"); - - writeFrame(begin, end, text); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/SrtFromTtmlWriter.kt b/app/src/main/java/org/schabi/newpipe/streams/SrtFromTtmlWriter.kt new file mode 100644 index 000000000..e340d9b44 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/SrtFromTtmlWriter.kt @@ -0,0 +1,90 @@ +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 + +/** + * @author kapodamy + */ +class SrtFromTtmlWriter(private val out: SharpStream?, private val ignoreEmptyFrames: Boolean) { + private val charset: Charset = StandardCharsets.UTF_8 + private var frameIndex: Int = 0 + @Throws(IOException::class) + private fun writeFrame(begin: String, end: String, text: StringBuilder) { + writeString(frameIndex++.toString()) + writeString(NEW_LINE) + writeString(begin) + writeString(" --> ") + writeString(end) + writeString(NEW_LINE) + writeString(text.toString()) + writeString(NEW_LINE) + writeString(NEW_LINE) + } + + @Throws(IOException::class) + private fun writeString(text: String) { + out!!.write(text.toByteArray(charset)) + } + + @Throws(IOException::class) + fun build(ttml: SharpStream) { + /* + * 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 + val buffer: ByteArray = ByteArray(ttml.available().toInt()) + ttml.read(buffer) + val doc: Document = Jsoup.parse(ByteArrayInputStream(buffer), "UTF-8", "", + Parser.xmlParser()) + val text: StringBuilder = StringBuilder(128) + val paragraphList: Elements = doc.select("body > div > p") + + // check if has frames + if (paragraphList.size < 1) { + return + } + for (paragraph: Element in paragraphList) { + text.setLength(0) + for (children: Node? in paragraph.childNodes()) { + if (children is TextNode) { + text.append(children.text()) + } else if ((children is Element + && children.tagName().equals("br", ignoreCase = true))) { + text.append(NEW_LINE) + } + } + if (ignoreEmptyFrames && text.length < 1) { + continue + } + val begin: String = getTimestamp(paragraph, "begin") + val end: String = getTimestamp(paragraph, "end") + writeFrame(begin, end, text) + } + } + + companion object { + private val NEW_LINE: String = "\r\n" + private fun getTimestamp(frame: Element, attr: String): String { + return frame + .attr(attr) + .replace('.', ',') // SRT subtitles uses comma as decimal separator + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java b/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java deleted file mode 100644 index 678974cce..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java +++ /dev/null @@ -1,537 +0,0 @@ -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; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/WebMReader.kt b/app/src/main/java/org/schabi/newpipe/streams/WebMReader.kt new file mode 100644 index 000000000..783f63d68 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/WebMReader.kt @@ -0,0 +1,457 @@ +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 + +/** + * + * @author kapodamy + */ +class WebMReader(source: SharpStream) { + enum class TrackKind { + Audio /*2*/, + Video /*1*/, + Other + } + + private val stream: DataReader + private var segment: Segment? = null + private var tracks: Array? + private var selectedTrack: Int = 0 + private var done: Boolean = false + private var firstSegment: Boolean = false + + init { + stream = DataReader(source) + } + + @Throws(IOException::class) + fun parse() { + var elem: Element? = readElement(ID_EMBL) + if (!readEbml(elem, 1, 2)) { + throw UnsupportedOperationException("Unsupported EBML data (WebM)") + } + ensure(elem) + elem = untilElement(null, ID_SEGMENT) + if (elem == null) { + throw IOException("Fragment element not found") + } + segment = readSegment(elem, 0, true) + tracks = segment!!.tracks + selectedTrack = -1 + done = false + firstSegment = true + } + + fun getAvailableTracks(): Array? { + return tracks + } + + fun selectTrack(index: Int): WebMTrack? { + selectedTrack = index + return tracks!!.get(index) + } + + @Throws(IOException::class) + fun getNextSegment(): Segment? { + 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 + val elem: Element? = untilElement(null, ID_SEGMENT) + if (elem == null) { + done = true + return null + } + segment = readSegment(elem, 0, false) + return segment + } + + @Throws(IOException::class) + private fun readNumber(parent: Element): Long { + var length: Int = parent.contentSize.toInt() + var value: Long = 0 + while (length-- > 0) { + val read: Int = stream.read() + if (read == -1) { + throw EOFException() + } + value = (value shl 8) or read.toLong() + } + return value + } + + @Throws(IOException::class) + private fun readString(parent: Element): String { + return String(readBlob(parent), StandardCharsets.UTF_8) // or use "utf-8" + } + + @Throws(IOException::class) + private fun readBlob(parent: Element): ByteArray { + val length: Long = parent.contentSize + val buffer: ByteArray = ByteArray(length.toInt()) + val read: Int = stream.read(buffer) + if (read < length) { + throw EOFException() + } + return buffer + } + + @Throws(IOException::class) + private fun readEncodedNumber(): Long { + var value: Int = stream.read() + if (value > 0) { + var size: Byte = 1 + var mask: Int = 0x80 + while (size < 9) { + if ((value and mask) == mask) { + mask = 0xFF + mask = mask shr size.toInt() + var number: Long = (value and mask).toLong() + for (i in 1 until size) { + value = stream.read() + number = number shl 8 + number = number or value.toLong() + } + return number + } + mask = mask shr 1 + size++ + } + } + throw IOException("Invalid encoded length") + } + + @Throws(IOException::class) + private fun readElement(): Element { + val elem: Element = Element() + elem.offset = stream.position() + elem.type = readEncodedNumber().toInt() + elem.contentSize = readEncodedNumber() + elem.size = elem.contentSize + stream.position() - elem.offset + return elem + } + + @Throws(IOException::class) + private fun readElement(expected: Int): Element { + val elem: Element = readElement() + if (expected != 0 && elem.type != expected) { + throw NoSuchElementException(("expected " + elementID(expected.toLong()) + + " found " + elementID(elem.type.toLong()))) + } + return elem + } + + @Throws(IOException::class) + private fun untilElement(ref: Element?, vararg expected: Int): Element? { + var elem: Element + while (if (ref == null) stream.available() else (stream.position() < (ref.offset + ref.size))) { + elem = readElement() + if (expected.size < 1) { + return elem + } + for (type: Int in expected) { + if (elem.type == type) { + return elem + } + } + ensure(elem) + } + return null + } + + private fun elementID(type: Long): String { + return "0x" + java.lang.Long.toHexString(type) + } + + @Throws(IOException::class) + private fun ensure(ref: Element?) { + val skip: Long = (ref!!.offset + ref.size) - stream.position() + if (skip == 0L) { + return + } else if (skip < 0) { + throw EOFException(String.format( + "parser go beyond limits of the Element. type=%s offset=%s size=%s position=%s", + elementID(ref.type.toLong()), ref.offset, ref.size, stream.position() + )) + } + stream.skipBytes(skip) + } + + @Throws(IOException::class) + private fun readEbml(ref: Element?, minReadVersion: Int, + minDocTypeVersion: Int): Boolean { + var elem: Element? = 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) == "webm")) { + return false + } + elem = untilElement(ref, ID_EMBL_DOC_TYPE_READ_VERSION) + return elem != null && readNumber(elem) <= minDocTypeVersion + } + + @Throws(IOException::class) + private fun readInfo(ref: Element): Info { + var elem: Element + val info: Info = Info() + while ((untilElement(ref, ID_TIMECODE_SCALE, ID_DURATION).also({ elem = (it)!! })) != null) { + when (elem.type) { + ID_TIMECODE_SCALE -> info.timecodeScale = readNumber(elem) + ID_DURATION -> info.duration = readNumber(elem) + } + ensure(elem) + } + if (info.timecodeScale == 0L) { + throw NoSuchElementException("Element Timecode not found") + } + return info + } + + @Throws(IOException::class) + private fun readSegment(ref: Element, trackLacingExpected: Int, + metadataExpected: Boolean): Segment { + val obj: Segment = Segment(ref) + var elem: Element + while ((untilElement(ref, ID_INFO, ID_TRACKS, ID_CLUSTER).also({ elem = (it)!! })) != null) { + if (elem.type == ID_CLUSTER) { + obj.currentCluster = elem + break + } + when (elem.type) { + ID_INFO -> obj.info = readInfo(elem) + ID_TRACKS -> obj.tracks = readTracks(elem, trackLacingExpected) + } + ensure(elem) + } + if (metadataExpected && (obj.info == null || obj.tracks == null)) { + throw RuntimeException(( + "Cluster element found without Info and/or Tracks element at position " + + ref.offset)) + } + return obj + } + + @Throws(IOException::class) + private fun readTracks(ref: Element, lacingExpected: Int): Array { + val trackEntries: ArrayList = ArrayList(2) + var elemTrackEntry: Element? + while ((untilElement(ref, ID_TRACK_ENTRY).also({ elemTrackEntry = it })) != null) { + val entry: WebMTrack = WebMTrack() + var drop: Boolean = false + var elem: Element + while ((untilElement(elemTrackEntry).also({ elem = (it)!! })) != null) { + when (elem.type) { + ID_TRACK_NUMBER -> entry.trackNumber = readNumber(elem) + ID_TRACK_TYPE -> entry.trackType = readNumber(elem).toInt() + ID_CODEC_ID -> entry.codecId = readString(elem) + ID_CODEC_PRIVATE -> entry.codecPrivate = readBlob(elem) + ID_AUDIO, ID_VIDEO -> entry.bMetadata = readBlob(elem) + ID_DEFAULT_DURATION -> entry.defaultDuration = readNumber(elem) + ID_FLAG_LACING -> drop = readNumber(elem) != lacingExpected.toLong() + ID_CODEC_DELAY -> entry.codecDelay = readNumber(elem) + ID_SEEK_PRE_ROLL -> entry.seekPreRoll = readNumber(elem) + else -> {} + } + ensure(elem) + } + if (!drop) { + trackEntries.add(entry) + } + ensure(elemTrackEntry) + } + val entries: Array = trackEntries.toTypedArray() + for (entry: WebMTrack? in entries) { + when (entry!!.trackType) { + 1 -> entry.kind = TrackKind.Video + 2 -> entry.kind = TrackKind.Audio + else -> entry.kind = TrackKind.Other + } + } + return entries + } + + @Throws(IOException::class) + private fun readSimpleBlock(ref: Element): SimpleBlock { + val obj: SimpleBlock = SimpleBlock(ref) + obj.trackNumber = readEncodedNumber() + obj.relativeTimeCode = stream.readShort() + obj.flags = stream.read().toByte() + obj.dataSize = ((ref.offset + ref.size) - stream.position()).toInt() + obj.createdFromBlock = ref.type == ID_BLOCK + + // NOTE: lacing is not implemented, and will be mixed with the stream data + if (obj.dataSize < 0) { + throw IOException(String.format( + "Unexpected SimpleBlock element size, missing %s bytes", -obj.dataSize)) + } + return obj + } + + @Throws(IOException::class) + private fun readCluster(ref: Element?): Cluster { + val obj: Cluster = Cluster(ref) + val elem: Element? = untilElement(ref, ID_TIMECODE) + if (elem == null) { + throw NoSuchElementException(("Cluster at " + ref!!.offset + + " without Timecode element")) + } + obj.timecode = readNumber(elem) + return obj + } + + class Element() { + var type: Int = 0 + var offset: Long = 0 + var contentSize: Long = 0 + var size: Long = 0 + } + + class Info() { + var timecodeScale: Long = 0 + var duration: Long = 0 + } + + class WebMTrack() { + var trackNumber: Long = 0 + var trackType: Int = 0 + var codecId: String? = null + var codecPrivate: ByteArray? + var bMetadata: ByteArray + var kind: TrackKind? = null + var defaultDuration: Long = -1 + var codecDelay: Long = -1 + var seekPreRoll: Long = -1 + } + + inner class Segment internal constructor(val ref: Element) { + var info: Info? = null + var tracks: Array? + private var currentCluster: Element? = null + var firstClusterInSegment: Boolean = true + @Throws(IOException::class) + fun getNextCluster(): Cluster? { + if (done) { + return null + } + if (firstClusterInSegment && segment!!.currentCluster != null) { + firstClusterInSegment = false + return readCluster(segment!!.currentCluster) + } + ensure(segment!!.currentCluster) + val elem: Element? = untilElement(segment!!.ref, ID_CLUSTER) + if (elem == null) { + return null + } + segment!!.currentCluster = elem + return readCluster(segment!!.currentCluster) + } + } + + class SimpleBlock internal constructor(val ref: Element) { + var data: InputStream? = null + var createdFromBlock: Boolean = false + var trackNumber: Long = 0 + var relativeTimeCode: Short = 0 + var absoluteTimeCodeNs: Long = 0 + var flags: Byte = 0 + var dataSize: Int = 0 + fun isKeyframe(): Boolean { + return (flags.toInt() and 0x80) == 0x80 + } + } + + inner class Cluster internal constructor(var ref: Element?) { + var currentSimpleBlock: SimpleBlock? = null + var currentBlockGroup: Element? = null + var timecode: Long = 0 + fun insideClusterBounds(): Boolean { + return stream.position() >= (ref!!.offset + ref!!.size) + } + + @Throws(IOException::class) + fun getNextSimpleBlock(): SimpleBlock? { + if (insideClusterBounds()) { + return null + } + if (currentBlockGroup != null) { + ensure(currentBlockGroup) + currentBlockGroup = null + currentSimpleBlock = null + } else if (currentSimpleBlock != null) { + ensure(currentSimpleBlock!!.ref) + } + while (!insideClusterBounds()) { + var elem: Element? = 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!!.get(selectedTrack)!!.trackNumber) { + currentSimpleBlock!!.data = stream.getView(currentSimpleBlock!!.dataSize) + + // calculate the timestamp in nanoseconds + currentSimpleBlock!!.absoluteTimeCodeNs = (currentSimpleBlock!!.relativeTimeCode + + timecode) + currentSimpleBlock!!.absoluteTimeCodeNs *= segment!!.info!!.timecodeScale + return currentSimpleBlock + } + ensure(elem) + } + return null + } + } + + companion object { + private val ID_EMBL: Int = 0x0A45DFA3 + private val ID_EMBL_READ_VERSION: Int = 0x02F7 + private val ID_EMBL_DOC_TYPE: Int = 0x0282 + private val ID_EMBL_DOC_TYPE_READ_VERSION: Int = 0x0285 + private val ID_SEGMENT: Int = 0x08538067 + private val ID_INFO: Int = 0x0549A966 + private val ID_TIMECODE_SCALE: Int = 0x0AD7B1 + private val ID_DURATION: Int = 0x489 + private val ID_TRACKS: Int = 0x0654AE6B + private val ID_TRACK_ENTRY: Int = 0x2E + private val ID_TRACK_NUMBER: Int = 0x57 + private val ID_TRACK_TYPE: Int = 0x03 + private val ID_CODEC_ID: Int = 0x06 + private val ID_CODEC_PRIVATE: Int = 0x23A2 + private val ID_VIDEO: Int = 0x60 + private val ID_AUDIO: Int = 0x61 + private val ID_DEFAULT_DURATION: Int = 0x3E383 + private val ID_FLAG_LACING: Int = 0x1C + private val ID_CODEC_DELAY: Int = 0x16AA + private val ID_SEEK_PRE_ROLL: Int = 0x16BB + private val ID_CLUSTER: Int = 0x0F43B675 + private val ID_TIMECODE: Int = 0x67 + private val ID_SIMPLE_BLOCK: Int = 0x23 + private val ID_BLOCK: Int = 0x21 + private val ID_GROUP_BLOCK: Int = 0x20 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java deleted file mode 100644 index 530959d96..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java +++ /dev/null @@ -1,761 +0,0 @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.kt b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.kt new file mode 100644 index 000000000..a0f5fdbf1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.kt @@ -0,0 +1,652 @@ +package org.schabi.newpipe.streams + +import org.schabi.newpipe.streams.WebMReader.Cluster +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 kotlin.math.ceil +import kotlin.math.min + +/** + * @author kapodamy + */ +class WebMWriter(vararg source: SharpStream) : Closeable { + private var infoTracks: Array? + private var sourceTracks: Array? + private var readers: Array? + private var done: Boolean = false + private var parsed: Boolean = false + private var written: Long = 0 + private var readersSegment: Array? + private var readersCluster: Array? + private var clustersOffsetsSizes: ArrayList? + private var outBuffer: ByteArray? + private var outByteBuffer: ByteBuffer? + + init { + sourceTracks = source + readers = arrayOfNulls(sourceTracks!!.size) + infoTracks = arrayOfNulls(sourceTracks!!.size) + outBuffer = ByteArray(BUFFER_SIZE) + outByteBuffer = ByteBuffer.wrap(outBuffer) + clustersOffsetsSizes = ArrayList(256) + } + + @Throws(IllegalStateException::class) + fun getTracksFromSource(sourceIndex: Int): Array? { + if (done) { + throw IllegalStateException("already done") + } + if (!parsed) { + throw IllegalStateException("All sources must be parsed first") + } + return readers!!.get(sourceIndex)!!.getAvailableTracks() + } + + @Throws(IOException::class, IllegalStateException::class) + fun parseSources() { + if (done) { + throw IllegalStateException("already done") + } + if (parsed) { + throw IllegalStateException("already parsed") + } + try { + for (i in readers!!.indices) { + readers!!.get(i) = WebMReader(sourceTracks!!.get(i)) + readers!!.get(i)!!.parse() + } + } finally { + parsed = true + } + } + + @Throws(IOException::class) + fun selectTracks(vararg trackIndex: Int) { + try { + readersSegment = arrayOfNulls(readers!!.size) + readersCluster = arrayOfNulls(readers!!.size) + for (i in readers!!.indices) { + infoTracks!!.get(i) = readers!!.get(i)!!.selectTrack(trackIndex.get(i)) + readersSegment!!.get(i) = readers!!.get(i)!!.getNextSegment() + } + } finally { + parsed = true + } + } + + fun isDone(): Boolean { + return done + } + + public override fun close() { + done = true + parsed = true + for (src: SharpStream in sourceTracks!!) { + src.close() + } + sourceTracks = null + readers = null + infoTracks = null + readersSegment = null + readersCluster = null + outBuffer = null + outByteBuffer = null + clustersOffsetsSizes = null + } + + @Throws(IOException::class, RuntimeException::class) + fun build(out: SharpStream?) { + if (!out!!.canRewind()) { + throw IOException("The output stream must be allow seek") + } + makeEBML(out) + val offsetSegmentSizeSet: Long = written + 5 + val offsetInfoDurationSet: Long = written + 94 + val offsetClusterSet: Long = written + 58 + val offsetCuesSet: Long = written + 75 + val listBuffer: ArrayList = ArrayList(4) + + /* segment */listBuffer.add(byteArrayOf( + 0x18, 0x53, 0x80.toByte(), 0x67, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 // segment content size + )) + val segmentOffset: Long = written + listBuffer.get(0)!!.size + + /* seek head */listBuffer.add(byteArrayOf( + 0x11, 0x4d, 0x9b.toByte(), 0x74, 0xbe.toByte(), + 0x4d, 0xbb.toByte(), 0x8b.toByte(), + 0x53, 0xab.toByte(), 0x84.toByte(), 0x15, 0x49, 0xa9.toByte(), 0x66, 0x53, 0xac.toByte(), 0x81.toByte(), /*info offset*/ + 0x43, + 0x4d, 0xbb.toByte(), 0x8b.toByte(), 0x53, 0xab.toByte(), 0x84.toByte(), 0x16, 0x54, 0xae.toByte(), 0x6b, 0x53, 0xac.toByte(), 0x81.toByte(), /*tracks offset*/ + 0x56, + 0x4d, 0xbb.toByte(), 0x8e.toByte(), 0x53, 0xab.toByte(), 0x84.toByte(), 0x1f, + 0x43, 0xb6.toByte(), 0x75, 0x53, 0xac.toByte(), 0x84.toByte(), /*cluster offset [2]*/ + 0x00, 0x00, 0x00, 0x00, + 0x4d, 0xbb.toByte(), 0x8e.toByte(), 0x53, 0xab.toByte(), 0x84.toByte(), 0x1c, 0x53, 0xbb.toByte(), 0x6b, 0x53, 0xac.toByte(), 0x84.toByte(), /*cues offset [7]*/ + 0x00, 0x00, 0x00, 0x00 + )) + + /* info */listBuffer.add(byteArrayOf( + 0x15, 0x49, 0xa9.toByte(), 0x66, 0x8e.toByte(), 0x2a, 0xd7.toByte(), 0xb1.toByte())) + // the segment duration MUST NOT exceed 4 bytes + listBuffer.add(encode(DEFAULT_TIMECODE_SCALE.toLong(), true)) + listBuffer.add(byteArrayOf(0x44, 0x89.toByte(), 0x84.toByte(), + 0x00, 0x00, 0x00, 0x00)) + + /* tracks */listBuffer.addAll(makeTracks()) + dump(listBuffer, out) + + // reserve space for Cues element + val cueOffset: Long = written + makeEbmlVoid(out, CUE_RESERVE_SIZE, true) + val defaultSampleDuration: IntArray = IntArray(infoTracks!!.size) + val duration: LongArray = LongArray(infoTracks!!.size) + for (i in infoTracks!!.indices) { + if (infoTracks!!.get(i)!!.defaultDuration < 0) { + defaultSampleDuration.get(i) = -1 // not available + } else { + defaultSampleDuration.get(i) = ceil((infoTracks!!.get(i)!!.defaultDuration + / DEFAULT_TIMECODE_SCALE.toFloat()).toDouble()).toInt() + } + duration.get(i) = -1 + } + + // Select a track for the cue + val cuesForTrackId: Int = selectTrackForCue() + var nextCueTime: Long = (if (infoTracks!!.get(cuesForTrackId)!!.trackType == 1) -1 else 0).toLong() + val keyFrames: ArrayList = ArrayList(32) + var firstClusterOffset: Int = written.toInt() + var currentClusterOffset: Long = makeCluster(out, 0, 0, true) + var baseTimecode: Long = 0 + var limitTimecode: Long = -1 + var limitTimecodeByTrackId: Int = cuesForTrackId + var blockWritten: Int = Int.MAX_VALUE + var newClusterByTrackId: Int = -1 + while (blockWritten > 0) { + blockWritten = 0 + var i: Int = 0 + while (i < readers!!.size) { + val bloq: Block? = 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.toLong() + } + keyFrames.add(KeyFrame(segmentOffset, currentClusterOffset, written, + bloq.absoluteTimecode)) + } + } + writeBlock(out, bloq, baseTimecode) + blockWritten++ + if (defaultSampleDuration.get(i) < 0 && duration.get(i) >= 0) { + // if the sample duration in unknown, + // calculate using current_duration - previous_duration + defaultSampleDuration.get(i) = (bloq.absoluteTimecode - duration.get(i)).toInt() + } + duration.get(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) + val segmentSize: Long = written - offsetSegmentSizeSet - 7 + + /* Segment size */seekTo(out, offsetSegmentSizeSet) + outByteBuffer!!.putLong(0, segmentSize) + out.write(outBuffer, 1, DataReader.Companion.LONG_SIZE - 1) + + /* Segment duration */ + var longestDuration: Long = 0 + for (i in duration.indices) { + if (defaultSampleDuration.get(i) > 0) { + duration.get(i) += defaultSampleDuration.get(i).toLong() + } + if (duration.get(i) > longestDuration) { + longestDuration = duration.get(i) + } + } + seekTo(out, offsetInfoDurationSet) + outByteBuffer!!.putFloat(0, longestDuration.toFloat()) + dump(outBuffer, DataReader.Companion.FLOAT_SIZE, out) + + /* first Cluster offset */firstClusterOffset -= segmentOffset.toInt() + writeInt(out, offsetClusterSet, firstClusterOffset) + seekTo(out, cueOffset) + + /* Cue */ + var cueSize: Short = 0 + dump(byteArrayOf(0x1c, 0x53, 0xbb.toByte(), 0x6b, 0x20, 0x00, 0x00), out) // header size is 7 + for (keyFrame: KeyFrame in keyFrames) { + val size: Int = makeCuePoint(cuesForTrackId, keyFrame, outBuffer) + if ((cueSize + size + 7 + MINIMUM_EBML_VOID_SIZE) > CUE_RESERVE_SIZE) { + break // no space left + } + cueSize = (cueSize + size).toShort() + dump(outBuffer, size, out) + } + makeEbmlVoid(out, CUE_RESERVE_SIZE - cueSize - 7, false) + seekTo(out, cueOffset + 5) + outByteBuffer!!.putShort(0, cueSize) + dump(outBuffer, DataReader.Companion.SHORT_SIZE, out) + + /* seek head, seek for cues element */writeInt(out, offsetCuesSet, (cueOffset - segmentOffset).toInt()) + for (cluster: ClusterInfo in clustersOffsetsSizes!!) { + writeInt(out, cluster.offset, cluster.size or 0x10000000) + } + } + + @Throws(IOException::class) + private fun getNextBlockFrom(internalTrackId: Int): Block? { + if (readersSegment!!.get(internalTrackId) == null) { + readersSegment!!.get(internalTrackId) = readers!!.get(internalTrackId)!!.getNextSegment() + if (readersSegment!!.get(internalTrackId) == null) { + return null // no more blocks in the selected track + } + } + if (readersCluster!!.get(internalTrackId) == null) { + readersCluster!!.get(internalTrackId) = readersSegment!!.get(internalTrackId)!!.getNextCluster() + if (readersCluster!!.get(internalTrackId) == null) { + readersSegment!!.get(internalTrackId) = null + return getNextBlockFrom(internalTrackId) + } + } + val res: SimpleBlock? = readersCluster!!.get(internalTrackId)!!.getNextSimpleBlock() + if (res == null) { + readersCluster!!.get(internalTrackId) = null + return Block() // fake block to indicate the end of the cluster + } + val bloq: Block = 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 + } + + @Throws(IOException::class) + private fun seekTo(stream: SharpStream?, offset: Long) { + if (stream!!.canSeek()) { + stream.seek(offset) + } else { + if (offset > written) { + stream.skip(offset - written) + } else { + stream.rewind() + stream.skip(offset) + } + } + written = offset + } + + @Throws(IOException::class) + private fun writeInt(stream: SharpStream?, offset: Long, number: Int) { + seekTo(stream, offset) + outByteBuffer!!.putInt(0, number) + dump(outBuffer, DataReader.Companion.INTEGER_SIZE, stream) + } + + @Throws(IOException::class) + private fun writeBlock(stream: SharpStream?, bloq: Block, clusterTimecode: Long) { + val relativeTimeCode: Long = bloq.absoluteTimecode - clusterTimecode + if (relativeTimeCode < Short.MIN_VALUE || relativeTimeCode > Short.MAX_VALUE) { + throw IndexOutOfBoundsException("SimpleBlock timecode overflow.") + } + val listBuffer: ArrayList = ArrayList(5) + listBuffer.add(byteArrayOf(0xa3.toByte())) + listBuffer.add(null) // block size + listBuffer.add(encode((bloq.trackNumber + 1).toLong(), false)) + listBuffer.add(ByteBuffer.allocate(DataReader.Companion.SHORT_SIZE).putShort(relativeTimeCode.toShort()) + .array()) + listBuffer.add(byteArrayOf(bloq.flags)) + var blockSize: Int = bloq.dataSize + for (i in 2 until listBuffer.size) { + blockSize += listBuffer.get(i)!!.size + } + listBuffer.set(1, encode(blockSize.toLong(), false)) + dump(listBuffer, stream) + var read: Int + while ((bloq.data!!.read(outBuffer).also({ read = it })) > 0) { + dump(outBuffer, read, stream) + } + } + + @Throws(IOException::class) + private fun makeCluster(stream: SharpStream?, timecode: Long, offsetStart: Long, + create: Boolean): Long { + var cluster: ClusterInfo + var offset: Long = offsetStart + if (offset > 0) { + // save the size of the previous cluster (maximum 256 MiB) + cluster = clustersOffsetsSizes!!.get(clustersOffsetsSizes!!.size - 1) + cluster.size = (written - offset - CLUSTER_HEADER_SIZE).toInt() + } + offset = written + if (create) { + /* cluster */ + dump(byteArrayOf(0x1f, 0x43, 0xb6.toByte(), 0x75), stream) + cluster = ClusterInfo() + cluster.offset = written + clustersOffsetsSizes!!.add(cluster) + dump(byteArrayOf( + 0x10, 0x00, 0x00, 0x00, 0xe7.toByte()), stream) + dump(encode(timecode, true), stream) + } + return offset + } + + @Throws(IOException::class) + private fun makeEBML(stream: SharpStream?) { + // default values + dump(byteArrayOf( + 0x1A, 0x45, 0xDF.toByte(), 0xA3.toByte(), 0x01, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x1F, 0x42, 0x86.toByte(), 0x81.toByte(), 0x01, + 0x42, 0xF7.toByte(), 0x81.toByte(), 0x01, 0x42, 0xF2.toByte(), 0x81.toByte(), 0x04, + 0x42, 0xF3.toByte(), 0x81.toByte(), 0x08, 0x42, 0x82.toByte(), 0x84.toByte(), 0x77, + 0x65, 0x62, 0x6D, 0x42, 0x87.toByte(), 0x81.toByte(), 0x02, + 0x42, 0x85.toByte(), 0x81.toByte(), 0x02 + ), stream) + } + + private fun makeTracks(): ArrayList { + val buffer: ArrayList = ArrayList(1) + buffer.add(byteArrayOf(0x16, 0x54, 0xae.toByte(), 0x6b)) + buffer.add(null) + for (i in infoTracks!!.indices) { + buffer.addAll(makeTrackEntry(i, infoTracks!!.get(i))) + } + return lengthFor(buffer) + } + + private fun makeTrackEntry(internalTrackId: Int, track: WebMTrack?): ArrayList { + val id: ByteArray = encode((internalTrackId + 1).toLong(), true) + val buffer: ArrayList = ArrayList(12) + + /* track */buffer.add(byteArrayOf(0xae.toByte())) + buffer.add(null) + + /* track number */buffer.add(byteArrayOf(0xd7.toByte())) + buffer.add(id) + + /* track uid */buffer.add(byteArrayOf(0x73, 0xc5.toByte())) + buffer.add(id) + + /* flag lacing */buffer.add(byteArrayOf(0x9c.toByte(), 0x81.toByte(), 0x00)) + + /* lang */buffer.add(byteArrayOf(0x22, 0xb5.toByte(), 0x9c.toByte(), 0x83.toByte(), 0x75, 0x6e, 0x64)) + + /* codec id */buffer.add(byteArrayOf(0x86.toByte())) + buffer.addAll(encode(track!!.codecId)) + + /* codec delay*/if (track.codecDelay >= 0) { + buffer.add(byteArrayOf(0x56, 0xAA.toByte())) + buffer.add(encode(track.codecDelay, true)) + } + + /* codec seek pre-roll*/if (track.seekPreRoll >= 0) { + buffer.add(byteArrayOf(0x56, 0xBB.toByte())) + buffer.add(encode(track.seekPreRoll, true)) + } + + /* type */buffer.add(byteArrayOf(0x83.toByte())) + buffer.add(encode(track.trackType.toLong(), true)) + + /* default duration */if (track.defaultDuration >= 0) { + buffer.add(byteArrayOf(0x23, 0xe3.toByte(), 0x83.toByte())) + buffer.add(encode(track.defaultDuration, true)) + } + + /* audio/video */if ((track.trackType == 1 || track.trackType == 2) && valid(track.bMetadata)) { + buffer.add(byteArrayOf((if (track.trackType == 1) 0xe0 else 0xe1).toByte())) + buffer.add(encode(track.bMetadata.size.toLong(), false)) + buffer.add(track.bMetadata) + } + + /* codec private*/if (valid(track.codecPrivate)) { + buffer.add(byteArrayOf(0x63, 0xa2.toByte())) + buffer.add(encode(track.codecPrivate!!.size.toLong(), false)) + buffer.add(track.codecPrivate) + } + return lengthFor(buffer) + } + + private fun makeCuePoint(internalTrackId: Int, keyFrame: KeyFrame, + buffer: ByteArray?): Int { + val cue: ArrayList = ArrayList(5) + + /* CuePoint */cue.add(byteArrayOf(0xbb.toByte())) + cue.add(null) + + /* CueTime */cue.add(byteArrayOf(0xb3.toByte())) + cue.add(encode(keyFrame.duration, true)) + + /* CueTrackPosition */cue.addAll(makeCueTrackPosition(internalTrackId, keyFrame)) + var size: Int = 0 + lengthFor(cue) + for (buff: ByteArray? in cue) { + System.arraycopy(buff, 0, buffer, size, buff!!.size) + size += buff.size + } + return size + } + + private fun makeCueTrackPosition(internalTrackId: Int, + keyFrame: KeyFrame): ArrayList { + val buffer: ArrayList = ArrayList(8) + + /* CueTrackPositions */buffer.add(byteArrayOf(0xb7.toByte())) + buffer.add(null) + + /* CueTrack */buffer.add(byteArrayOf(0xf7.toByte())) + buffer.add(encode((internalTrackId + 1).toLong(), true)) + + /* CueClusterPosition */buffer.add(byteArrayOf(0xf1.toByte())) + buffer.add(encode(keyFrame.clusterPosition, true)) + + /* CueRelativePosition */if (keyFrame.relativePosition > 0) { + buffer.add(byteArrayOf(0xf0.toByte())) + buffer.add(encode(keyFrame.relativePosition.toLong(), true)) + } + return lengthFor(buffer) + } + + @Throws(IOException::class) + private fun makeEbmlVoid(out: SharpStream?, amount: Int, wipe: Boolean) { + var size: Int = amount + + /* ebml void */outByteBuffer!!.putShort(0, 0xec20.toShort()) + outByteBuffer!!.putShort(2, (size - 4).toShort()) + dump(outBuffer, 4, out) + if (wipe) { + size -= 4 + while (size > 0) { + val write: Int = min(size.toDouble(), outBuffer!!.size.toDouble()).toInt() + dump(outBuffer, write, out) + size -= write + } + } + } + + @Throws(IOException::class) + private fun dump(buffer: ByteArray, stream: SharpStream?) { + dump(buffer, buffer.size, stream) + } + + @Throws(IOException::class) + private fun dump(buffer: ByteArray?, count: Int, stream: SharpStream?) { + stream!!.write(buffer, 0, count) + written += count.toLong() + } + + @Throws(IOException::class) + private fun dump(buffers: ArrayList, stream: SharpStream?) { + for (buffer: ByteArray? in buffers) { + stream!!.write(buffer) + written += buffer!!.size.toLong() + } + } + + private fun lengthFor(buffer: ArrayList): ArrayList { + var size: Long = 0 + for (i in 2 until buffer.size) { + size += buffer.get(i)!!.size.toLong() + } + buffer.set(1, encode(size, false)) + return buffer + } + + private fun encode(number: Long, withLength: Boolean): ByteArray { + var length: Int = -1 + for (i in 1..7) { + if (number < 2.pow((7 * i).toDouble())) { + length = i + break + } + } + if (length < 1) { + throw ArithmeticException("Can't encode a number of bigger than 7 bytes") + } + if (number.toDouble() == (2.pow((7 * length).toDouble())) - 1) { + length++ + } + val offset: Int = if (withLength) 1 else 0 + val buffer: ByteArray = ByteArray(offset + length) + val marker: Long = Math.floorDiv(length - 1, 8).toLong() + var shift: Int = 0 + var i: Int = length - 1 + while (i >= 0) { + var b: Long = number ushr shift + if (!withLength && i.toLong() == marker) { + b = b or (0x80 ushr (length - 1)).toLong() + } + buffer.get(offset + i) = b.toByte() + i-- + shift += 8 + } + if (withLength) { + buffer.get(0) = (0x80 or length).toByte() + } + return buffer + } + + private fun encode(value: String?): ArrayList { + val str: ByteArray = value!!.toByteArray(StandardCharsets.UTF_8) // or use "utf-8" + val buffer: ArrayList = ArrayList(2) + buffer.add(encode(str.size.toLong(), false)) + buffer.add(str) + return buffer + } + + private fun valid(buffer: ByteArray?): Boolean { + return buffer != null && buffer.size > 0 + } + + private fun selectTrackForCue(): Int { + var i: Int = 0 + var videoTracks: Int = 0 + var audioTracks: Int = 0 + while (i < infoTracks!!.size) { + when (infoTracks!!.get(i)!!.trackType) { + 1 -> videoTracks++ + 2 -> audioTracks++ + } + i++ + } + val kind: Int + if (audioTracks == infoTracks!!.size) { + kind = 2 + } else if (videoTracks == infoTracks!!.size) { + 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 + i = 0 + while (i < infoTracks!!.size) { + if (kind == infoTracks!!.get(i)!!.trackType) { + return i + } + i++ + } + return 0 + } + + internal class KeyFrame(segment: Long, cluster: Long, block: Long, val duration: Long) { + val clusterPosition: Long + val relativePosition: Int + + init { + clusterPosition = cluster - segment + relativePosition = (block - cluster - CLUSTER_HEADER_SIZE).toInt() + } + } + + internal class Block() { + var data: InputStream? = null + var trackNumber: Int = 0 + var flags: Byte = 0 + var dataSize: Int = 0 + var absoluteTimecode: Long = 0 + fun isKeyframe(): Boolean { + return (flags.toInt() and 0x80) == 0x80 + } + + public override fun toString(): String { + return String.format("trackNumber=%s isKeyFrame=%S absoluteTimecode=%s", trackNumber, + isKeyframe(), absoluteTimecode) + } + } + + internal class ClusterInfo() { + var offset: Long = 0 + var size: Int = 0 + } + + companion object { + private val BUFFER_SIZE: Int = 8 * 1024 + private val DEFAULT_TIMECODE_SCALE: Int = 1000000 + private val INTERV: Int = 100 // 100ms on 1000000us timecode scale + private val DEFAULT_CUES_EACH_MS: Int = 5000 // 5000ms on 1000000us timecode scale + private val CLUSTER_HEADER_SIZE: Byte = 8 + private val CUE_RESERVE_SIZE: Int = 65535 + private val MINIMUM_EBML_VOID_SIZE: Byte = 4 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/NoFileManagerSafeGuard.java b/app/src/main/java/org/schabi/newpipe/streams/io/NoFileManagerSafeGuard.java deleted file mode 100644 index df43c34c1..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/io/NoFileManagerSafeGuard.java +++ /dev/null @@ -1,75 +0,0 @@ -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); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/NoFileManagerSafeGuard.kt b/app/src/main/java/org/schabi/newpipe/streams/io/NoFileManagerSafeGuard.kt new file mode 100644 index 000000000..92a0f5fff --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/io/NoFileManagerSafeGuard.kt @@ -0,0 +1,66 @@ +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. + */ +object NoFileManagerSafeGuard { + /** + * Shows an alert dialog when no file-manager is found. + * @param context Context + */ + private fun showActivityNotFoundAlert(context: Context?) { + if (context == null) { + throw IllegalArgumentException( + "Unable to open no file manager alert dialog: Context is null") + } + val message: String + 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)) + } + 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 [ActivityResultLauncher.launch] + * @param input see [ActivityResultLauncher.launch] + * @param tag Tag used for logging + * @param context Context + * @param see [ActivityResultLauncher.launch] + */ + fun launchSafe( + activityResultLauncher: ActivityResultLauncher, + input: I, + tag: String?, + context: Context? + ) { + try { + activityResultLauncher.launch(input) + } catch (aex: ActivityNotFoundException) { + Log.w(tag, "Unable to launch file/directory picker", aex) + showActivityNotFoundAlert(context) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/SharpInputStream.java b/app/src/main/java/org/schabi/newpipe/streams/io/SharpInputStream.java deleted file mode 100644 index 956e9865c..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/io/SharpInputStream.java +++ /dev/null @@ -1,52 +0,0 @@ -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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/SharpInputStream.kt b/app/src/main/java/org/schabi/newpipe/streams/io/SharpInputStream.kt new file mode 100644 index 000000000..84fefd017 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/io/SharpInputStream.kt @@ -0,0 +1,48 @@ +package org.schabi.newpipe.streams.io + +import java.io.IOException +import java.io.InputStream + +/** + * Simply wraps a readable [SharpStream] allowing it to be used with built-in Java stuff that + * supports [InputStream]. + */ +class SharpInputStream(stream: SharpStream?) : InputStream() { + private val stream: SharpStream? + + init { + if (!stream!!.canRead()) { + throw IOException("SharpStream is not readable") + } + this.stream = stream + } + + @Throws(IOException::class) + public override fun read(): Int { + return stream!!.read() + } + + @Throws(IOException::class) + public override fun read(b: ByteArray): Int { + return stream!!.read(b) + } + + @Throws(IOException::class) + public override fun read(b: ByteArray, off: Int, len: Int): Int { + return stream!!.read(b, off, len) + } + + @Throws(IOException::class) + public override fun skip(n: Long): Long { + return stream!!.skip(n) + } + + public override fun available(): Int { + val res: Long = stream!!.available() + return if (res > Int.MAX_VALUE) Int.MAX_VALUE else res.toInt() + } + + public override fun close() { + stream!!.close() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/SharpOutputStream.java b/app/src/main/java/org/schabi/newpipe/streams/io/SharpOutputStream.java deleted file mode 100644 index 76e394312..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/io/SharpOutputStream.java +++ /dev/null @@ -1,46 +0,0 @@ -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(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/SharpOutputStream.kt b/app/src/main/java/org/schabi/newpipe/streams/io/SharpOutputStream.kt new file mode 100644 index 000000000..8f5e67b1c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/io/SharpOutputStream.kt @@ -0,0 +1,43 @@ +package org.schabi.newpipe.streams.io + +import java.io.IOException +import java.io.OutputStream + +/** + * Simply wraps a writable [SharpStream] allowing it to be used with built-in Java stuff that + * supports [OutputStream]. + */ +class SharpOutputStream(stream: SharpStream?) : OutputStream() { + private val stream: SharpStream? + + init { + if (!stream!!.canWrite()) { + throw IOException("SharpStream is not writable") + } + this.stream = stream + } + + @Throws(IOException::class) + public override fun write(b: Int) { + stream!!.write(b.toByte()) + } + + @Throws(IOException::class) + public override fun write(b: ByteArray) { + stream!!.write(b) + } + + @Throws(IOException::class) + public override fun write(b: ByteArray, off: Int, len: Int) { + stream!!.write(b, off, len) + } + + @Throws(IOException::class) + public override fun flush() { + stream!!.flush() + } + + public override fun close() { + stream!!.close() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java b/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java deleted file mode 100644 index 849c7c051..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java +++ /dev/null @@ -1,70 +0,0 @@ -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"); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.kt b/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.kt new file mode 100644 index 000000000..e6f0c89cc --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.kt @@ -0,0 +1,67 @@ +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 + * ([us.shandian.giga.io.FileStreamSAF]) and non-SAF ([us.shandian.giga.io.FileStream]). + * It has both input and output like in C#, while in Java those are usually different classes. + * [SharpInputStream] and [SharpOutputStream] are simple classes that wrap + * [SharpStream] and extend respectively [java.io.InputStream] and + * [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 [java.io.InputStream] + * or [java.io.OutputStream]. + */ +abstract class SharpStream() : Closeable, Flushable { + @Throws(IOException::class) + abstract fun read(): Int + @Throws(IOException::class) + abstract fun read(buffer: ByteArray): Int + @Throws(IOException::class) + abstract fun read(buffer: ByteArray?, offset: Int, count: Int): Int + @Throws(IOException::class) + abstract fun skip(amount: Long): Long + abstract fun available(): Long + @Throws(IOException::class) + abstract fun rewind() + abstract fun isClosed(): Boolean + abstract override fun close() + abstract fun canRewind(): Boolean + abstract fun canRead(): Boolean + abstract fun canWrite(): Boolean + open fun canSetLength(): Boolean { + return false + } + + open fun canSeek(): Boolean { + return false + } + + @Throws(IOException::class) + abstract fun write(value: Byte) + @Throws(IOException::class) + abstract fun write(buffer: ByteArray?) + @Throws(IOException::class) + abstract fun write(buffer: ByteArray?, offset: Int, count: Int) + @Throws(IOException::class) + public override fun flush() { + // STUB + } + + @Throws(IOException::class) + open fun setLength(length: Long) { + throw IOException("Not implemented") + } + + @Throws(IOException::class) + open fun seek(offset: Long) { + throw IOException("Not implemented") + } + + @Throws(IOException::class) + open fun length(): Long { + throw UnsupportedOperationException("Unsupported operation") + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java b/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java deleted file mode 100644 index 0fe2e0408..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java +++ /dev/null @@ -1,399 +0,0 @@ -package org.schabi.newpipe.streams.io; - -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.database.Cursor; -import android.net.Uri; -import android.os.Build; -import android.os.storage.StorageManager; -import android.os.storage.StorageVolume; -import android.provider.DocumentsContract; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.RequiresApi; -import androidx.documentfile.provider.DocumentFile; - -import org.schabi.newpipe.settings.NewPipeSettings; -import org.schabi.newpipe.util.FilePickerActivityHelper; - -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.UUID; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -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 us.shandian.giga.util.Utility; - -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; - - 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 (root of the directory). - * @return amount of free memory in the volume of current directory (bytes) - */ - @RequiresApi(api = Build.VERSION_CODES.N) // Necessary for `getStorageVolume()` - public long getFreeMemory() { - final Uri uri = getUri(); - final StorageManager storageManager = (StorageManager) context. - getSystemService(Context.STORAGE_SERVICE); - final List volumes = storageManager.getStorageVolumes(); - - final String docId = DocumentsContract.getDocumentId(uri); - final String[] split = docId.split(":"); - if (split.length > 0) { - final String volumeId = split[0]; - - for (final StorageVolume volume : volumes) { - // if the volume is an internal system volume - if (volume.isPrimary() && volumeId.equalsIgnoreCase("primary")) { - return Utility.getSystemFreeMemory(); - } - - // if the volume is a removable volume (normally an SD card) - if (volume.isRemovable() && !volume.isPrimary()) { - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { - try { - final String sdCardUUID = volume.getUuid(); - return storageManager.getAllocatableBytes(UUID.fromString(sdCardUUID)); - } catch (final Exception e) { - // do nothing - } - } - } - } - } - 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); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.kt b/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.kt new file mode 100644 index 000000000..5ee6da976 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.kt @@ -0,0 +1,350 @@ +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.storage.StorageManager +import android.os.storage.StorageVolume +import android.provider.DocumentsContract +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.documentfile.provider.DocumentFile +import com.nononsenseapps.filepicker.AbstractFilePickerActivity +import org.schabi.newpipe.extractor.utils.Utils +import org.schabi.newpipe.settings.NewPipeSettings +import org.schabi.newpipe.util.FilePickerActivityHelper +import us.shandian.giga.util.Utility +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.Collections +import java.util.Locale +import java.util.UUID +import java.util.function.Function +import java.util.function.Predicate +import java.util.stream.Collectors + +class StoredDirectoryHelper(context: Context, path: Uri, + private val tag: String?) { + private var ioTree: Path? = null + private val docTree: DocumentFile? + private val context: Context + + init { + if (ContentResolver.SCHEME_FILE.equals(path.getScheme(), ignoreCase = true)) { + ioTree = Paths.get(URI.create(path.toString())) + return + } + this.context = context + try { + this.context.getContentResolver().takePersistableUriPermission(path, PERMISSION_FLAGS) + } catch (e: Exception) { + throw IOException(e) + } + docTree = DocumentFile.fromTreeUri(context, path) + if (docTree == null) { + throw IOException("Failed to create the tree from Uri") + } + } + + fun createFile(filename: String?, mime: String?): StoredFileHelper? { + return createFile(filename, mime, false) + } + + fun createUniqueFile(name: String, mime: String?): StoredFileHelper? { + val matches: MutableList = ArrayList() + val filename: Array = splitFilename(name) + val lcFileName: String = filename.get(0).lowercase(Locale.getDefault()) + if (docTree == null) { + try { + Files.list(ioTree).use({ stream -> + matches.addAll(stream.map(Function({ path: Path -> path.getFileName().toString().lowercase(Locale.getDefault()) })) + .filter(Predicate({ fileName: String -> fileName.startsWith(lcFileName) })) + .collect(Collectors.toList())) + }) + } catch (e: IOException) { + Log.e(TAG, "Exception while traversing " + ioTree, e) + } + } else { + // warning: SAF file listing is very slow + val docTreeChildren: Uri = DocumentsContract.buildChildDocumentsUriUsingTree( + docTree.getUri(), DocumentsContract.getDocumentId(docTree.getUri())) + val projection: Array = arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val selection: String = "(LOWER(" + DocumentsContract.Document.COLUMN_DISPLAY_NAME + ") LIKE ?%" + val cr: ContentResolver = context.getContentResolver() + cr.query(docTreeChildren, projection, selection, arrayOf(lcFileName), null).use({ cursor -> + 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 + var lcName: String? = name.lowercase(Locale.getDefault()) + for (testName: String in matches) { + if ((testName == lcName)) { + lcName = null + break + } + } + + // create file if filename not in use + if (lcName != null) { + return createFile(name, mime, true) + } + Collections.sort(matches, java.util.Comparator({ obj: String, anotherString: String? -> obj.compareTo((anotherString)!!) })) + for (i in 1..999) { + if (Collections.binarySearch(matches, makeFileName(lcFileName, i, filename.get(1))) < 0) { + return createFile(makeFileName(filename.get(0), i, filename.get(1)), mime, true) + } + } + return createFile(System.currentTimeMillis().toString() + filename.get(1), mime, + false) + } + + private fun createFile(filename: String?, mime: String?, + safe: Boolean): StoredFileHelper? { + val storage: StoredFileHelper + try { + if (docTree == null) { + storage = StoredFileHelper(ioTree, filename, mime) + } else { + storage = StoredFileHelper(context, docTree, filename, mime, safe) + } + } catch (e: IOException) { + return null + } + storage.tag = tag + return storage + } + + fun getUri(): Uri { + return if (docTree == null) Uri.fromFile(ioTree!!.toFile()) else docTree.getUri() + } + + fun exists(): Boolean { + return if (docTree == null) Files.exists(ioTree) else docTree.exists() + } + + /** + * Indicates whether it's using the `java.io` API. + * + * @return `true` for Java I/O API, otherwise, `false` for Storage Access Framework + */ + fun isDirect(): Boolean { + return docTree == null + } + + /** + * Get free memory of the storage partition (root of the directory). + * @return amount of free memory in the volume of current directory (bytes) + */ + @RequiresApi(api = Build.VERSION_CODES.N) // Necessary for `getStorageVolume()` + fun getFreeMemory(): Long { + val uri: Uri = getUri() + val storageManager: StorageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager + val volumes: List = storageManager.getStorageVolumes() + val docId: String = DocumentsContract.getDocumentId(uri) + val split: Array = docId.split(":".toRegex()).dropLastWhile({ it.isEmpty() }).toTypedArray() + if (split.size > 0) { + val volumeId: String = split.get(0) + for (volume: StorageVolume in volumes) { + // if the volume is an internal system volume + if (volume.isPrimary() && volumeId.equals("primary", ignoreCase = true)) { + return Utility.getSystemFreeMemory() + } + + // if the volume is a removable volume (normally an SD card) + if (volume.isRemovable() && !volume.isPrimary()) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + try { + val sdCardUUID: String? = volume.getUuid() + return storageManager.getAllocatableBytes(UUID.fromString(sdCardUUID)) + } catch (e: Exception) { + // do nothing + } + } + } + } + } + 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 + */ + fun mkdirs(): Boolean { + if (docTree == null) { + try { + Files.createDirectories(ioTree) + } catch (e: IOException) { + Log.e(TAG, "Error while creating directories at " + ioTree, e) + } + return Files.exists(ioTree) + } + if (docTree.exists()) { + return true + } + try { + var parent: DocumentFile? + var child: String? = 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 (ignored: Exception) { + // no more parent directories or unsupported by the storage provider + } + return false + } + + fun getTag(): String? { + return tag + } + + fun findFile(filename: String?): Uri? { + if (docTree == null) { + val res: Path = ioTree!!.resolve(filename) + return if (Files.exists(res)) Uri.fromFile(res.toFile()) else null + } + val res: DocumentFile? = findFileSAFHelper(context, docTree, filename) + return if (res == null) null else res.getUri() + } + + fun canWrite(): Boolean { + return if (docTree == null) Files.isWritable(ioTree) else docTree.canWrite() + } + + /** + * @return `false` if the storage is direct, or the SAF storage is valid; `true` if + * SAF access to this SAF storage is denied (e.g. the user clicked on `Android settings -> + * Apps & notifications -> NewPipe -> Storage & cache -> Clear access`); + */ + fun isInvalidSafStorage(): Boolean { + return docTree != null && docTree.getName() == null + } + + public override fun toString(): String { + return (if (docTree == null) Uri.fromFile(ioTree!!.toFile()) else docTree.getUri()).toString() + } + + companion object { + private val TAG: String = StoredDirectoryHelper::class.java.getSimpleName() + val PERMISSION_FLAGS: Int = (Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + + //////////////////// + // Utils + /////////////////// + private fun addIfStartWith(list: MutableList, base: String, + str: String) { + if (Utils.isNullOrEmpty(str)) { + return + } + val lowerStr: String = str.lowercase(Locale.getDefault()) + 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 fun splitFilename(filename: String): Array { + val dotIndex: Int = filename.lastIndexOf('.') + if (dotIndex < 0 || (dotIndex == filename.length - 1)) { + return arrayOf(filename, "") + } + return arrayOf(filename.substring(0, dotIndex), filename.substring(dotIndex)) + } + + private fun makeFileName(name: String, idx: Int, ext: String): String { + 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 [DocumentFile] contain the reference, otherwise, null + */ + fun findFileSAFHelper(context: Context?, tree: DocumentFile?, + filename: String?): DocumentFile? { + if (context == null) { + return tree!!.findFile((filename)!!) // warning: this is very slow + } + if (!tree!!.canRead()) { + return null // missing read permission + } + val name: Int = 0 + val documentId: Int = 1 + + // LOWER() SQL function is not supported + val selection: String = DocumentsContract.Document.COLUMN_DISPLAY_NAME + " = ?" + //final String selection = COLUMN_DISPLAY_NAME + " LIKE ?%"; + val childrenUri: Uri = DocumentsContract.buildChildDocumentsUriUsingTree(tree.getUri(), + DocumentsContract.getDocumentId(tree.getUri())) + val projection: Array = arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME, DocumentsContract.Root.COLUMN_DOCUMENT_ID) + val contentResolver: ContentResolver = context.getContentResolver() + val lowerFilename: String = filename!!.lowercase(Locale.getDefault()) + contentResolver.query(childrenUri, projection, selection, arrayOf(lowerFilename), null).use({ cursor -> + if (cursor == null) { + return null + } + while (cursor.moveToNext()) { + if ((cursor.isNull(name) + || !cursor.getString(name).lowercase(Locale.getDefault()).startsWith(lowerFilename))) { + continue + } + return DocumentFile.fromSingleUri(context, + DocumentsContract.buildDocumentUriUsingTree(tree.getUri(), + cursor.getString(documentId))) + } + }) + return null + } + + fun getPicker(ctx: Context?): Intent { + if (NewPipeSettings.useStorageAccessFramework(ctx)) { + return Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + .putExtra("android.content.extra.SHOW_ADVANCED", true) + .addFlags((Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + or PERMISSION_FLAGS)) + } else { + return Intent(ctx, FilePickerActivityHelper::class.java) + .putExtra(AbstractFilePickerActivity.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(AbstractFilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true) + .putExtra(AbstractFilePickerActivity.EXTRA_MODE, + AbstractFilePickerActivity.MODE_DIR) + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.java b/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.java deleted file mode 100644 index 5404426c4..000000000 --- a/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.java +++ /dev/null @@ -1,577 +0,0 @@ -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()); - } - } - - /** - * 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()); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.kt b/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.kt new file mode 100644 index 000000000..936d92b2a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.kt @@ -0,0 +1,517 @@ +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.documentfile.provider.DocumentFile +import com.nononsenseapps.filepicker.AbstractFilePickerActivity +import com.nononsenseapps.filepicker.Utils +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.settings.NewPipeSettings +import org.schabi.newpipe.streams.io.StoredFileHelper +import org.schabi.newpipe.util.FilePickerActivityHelper +import us.shandian.giga.io.FileStream +import us.shandian.giga.io.FileStreamSAF +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 java.util.Locale + +class StoredFileHelper : Serializable { + @Transient + private var docFile: DocumentFile? = null + + @Transient + private var docTree: DocumentFile? = null + + @Transient + private var ioPath: Path? = null + + @Transient + private var context: Context? = null + protected var source: String? = null + private var sourceTree: String? = null + var tag: String? = null + private var srcName: String? = null + private var srcType: String? + + constructor(context: Context, uri: Uri?, mime: String?) { + if (FilePickerActivityHelper.Companion.isOwnFileUri(context, (uri)!!)) { + val ioFile: File = Utils.getFileForUri((uri)) + ioPath = ioFile.toPath() + source = Uri.fromFile(ioFile).toString() + } else { + docFile = DocumentFile.fromSingleUri(context, (uri)) + source = uri.toString() + } + this.context = context + srcType = mime + } + + constructor(parent: Uri?, filename: String?, mime: String?, + tag: String?) { + source = null // this instance will be "invalid" see invalidate()/isInvalid() methods + srcName = filename + srcType = if (mime == null) DEFAULT_MIME else mime + if (parent != null) { + sourceTree = parent.toString() + } + this.tag = tag + } + + internal constructor(context: Context?, tree: DocumentFile?, + filename: String?, mime: String?, safe: Boolean) { + docTree = tree + this.context = context + val res: DocumentFile? + if (safe) { + // no conflicts (the filename is not in use) + res = docTree!!.createFile((mime)!!, (filename)!!) + if (res == null) { + throw IOException("Cannot create the file") + } + } else { + res = createSAF(context, mime, filename) + } + docFile = res + source = docFile!!.getUri().toString() + sourceTree = docTree!!.getUri().toString() + srcName = docFile!!.getName() + srcType = docFile!!.getType() + } + + internal constructor(location: Path?, filename: String?, mime: String?) { + 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 + } + + constructor(context: Context?, parent: Uri?, + path: Uri, tag: String?) { + this.tag = tag + source = path.toString() + if ((path.getScheme() == null + || path.getScheme().equals(ContentResolver.SCHEME_FILE, ignoreCase = true))) { + ioPath = Paths.get(URI.create(source)) + } else { + val file: DocumentFile? = DocumentFile.fromSingleUri((context)!!, path) + if (file == null) { + throw IOException("SAF not available") + } + this.context = context + if (file.getName() == null) { + source = null + return + } else { + docFile = file + takePermissionSAF() + } + } + if (parent != null) { + if (!(ContentResolver.SCHEME_FILE == parent.getScheme())) { + docTree = DocumentFile.fromTreeUri((context)!!, parent) + } + sourceTree = parent.toString() + } + srcName = getName() + srcType = getType() + } + + @Throws(IOException::class) + fun getStream(): SharpStream { + assertValid() + if (docFile == null) { + return FileStream(ioPath!!.toFile()) + } else { + return FileStreamSAF(context!!.getContentResolver(), docFile!!.getUri()) + } + } + + /** + * Indicates whether it's using the `java.io` API. + * + * @return `true` for Java I/O API, otherwise, `false` for Storage Access Framework + */ + fun isDirect(): Boolean { + assertValid() + return docFile == null + } + + fun isInvalid(): Boolean { + return source == null + } + + fun getUri(): Uri { + assertValid() + return if (docFile == null) Uri.fromFile(ioPath!!.toFile()) else docFile!!.getUri() + } + + fun getParentUri(): Uri? { + assertValid() + return if (sourceTree == null) null else Uri.parse(sourceTree) + } + + @Throws(IOException::class) + fun truncate() { + assertValid() + getStream().use({ fs -> fs.setLength(0) }) + } + + fun delete(): Boolean { + if (source == null) { + return true + } + if (docFile == null) { + try { + return Files.deleteIfExists(ioPath) + } catch (e: IOException) { + Log.e(TAG, "Exception while deleting " + ioPath, e) + return false + } + } + val res: Boolean = docFile!!.delete() + try { + val flags: Int = (Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) + context!!.getContentResolver().releasePersistableUriPermission(docFile!!.getUri(), flags) + } catch (ex: Exception) { + // nothing to do + } + return res + } + + fun length(): Long { + assertValid() + if (docFile == null) { + try { + return Files.size(ioPath) + } catch (e: IOException) { + Log.e(TAG, "Exception while getting the size of " + ioPath, e) + return 0 + } + } else { + return docFile!!.length() + } + } + + fun canWrite(): Boolean { + if (source == null) { + return false + } + return if (docFile == null) Files.isWritable(ioPath) else docFile!!.canWrite() + } + + fun getName(): String? { + if (source == null) { + return srcName + } else if (docFile == null) { + return ioPath!!.getFileName().toString() + } + val name: String? = docFile!!.getName() + return if (name == null) srcName else name + } + + fun getType(): String? { + if (source == null || docFile == null) { + return srcType + } + val type: String? = docFile!!.getType() + return if (type == null) srcType else type + } + + fun getTag(): String? { + return tag + } + + fun existsAsFile(): Boolean { + if (source == null || (docFile == null && ioPath == null)) { + if (DEBUG) { + Log.d(TAG, ("existsAsFile called but something is null: source = [" + + (if (source == null) "null => storage is invalid" else source) + + "], docFile = [" + docFile + "], ioPath = [" + ioPath + "]")) + } + return false + } + + // WARNING: DocumentFile.exists() and DocumentFile.isFile() methods are slow + // docFile.isVirtual() means it is non-physical? + return if (docFile == null) Files.isRegularFile(ioPath) else (docFile!!.exists() && docFile!!.isFile()) + } + + fun create(): Boolean { + assertValid() + val result: Boolean + if (docFile == null) { + try { + Files.createFile(ioPath) + result = true + } catch (e: IOException) { + 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 (e: IOException) { + return false + } + } + if (result) { + source = (if (docFile == null) Uri.fromFile(ioPath!!.toFile()) else docFile!!.getUri()) + .toString() + srcName = getName() + srcType = getType() + } + return result + } + + fun invalidate() { + if (source == null) { + return + } + srcName = getName() + srcType = getType() + source = null + docTree = null + docFile = null + ioPath = null + context = null + } + + fun equals(storage: StoredFileHelper): Boolean { + 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(sourceTree), getLowerCase(sourceTree))) { + return false + } + if (isInvalid() || storage.isInvalid()) { + if ((srcName == null) || (storage.srcName == null) || (srcType == null + ) || (storage.srcType == null)) { + return false + } + return (srcName.equals(storage.srcName, ignoreCase = true) + && srcType.equals(storage.srcType, ignoreCase = true)) + } + if (isDirect() != storage.isDirect()) { + return false + } + if (isDirect()) { + return (ioPath == storage.ioPath) + } + return DocumentsContract.getDocumentId(docFile!!.getUri()) + .equals(DocumentsContract.getDocumentId(storage.docFile!!.getUri()), ignoreCase = true) + } + + public override fun toString(): String { + if (source == null) { + return "[Invalid state] name=" + srcName + " type=" + srcType + " tag=" + tag + } else { + return ("sourceFile=" + source + " treeSource=" + (if (sourceTree == null) "" else sourceTree) + + " tag=" + tag) + } + } + + private fun assertValid() { + if (source == null) { + throw IllegalStateException("In invalid state") + } + } + + @Throws(IOException::class) + private fun takePermissionSAF() { + try { + context!!.getContentResolver().takePersistableUriPermission(docFile!!.getUri(), + StoredDirectoryHelper.Companion.PERMISSION_FLAGS) + } catch (e: Exception) { + if (docFile!!.getName() == null) { + throw IOException(e) + } + } + } + + @Throws(IOException::class) + private fun createSAF(ctx: Context?, mime: String?, + filename: String?): DocumentFile { + var res: DocumentFile = (StoredDirectoryHelper.Companion.findFileSAFHelper(ctx, docTree, filename))!! + if ((res != null) && res.exists() && res.isDirectory()) { + if (!res.delete()) { + throw IOException("Directory with the same name found but cannot delete") + } + res = null + } + if (res == null) { + res = (docTree!!.createFile((if (srcType == null) DEFAULT_MIME else mime)!!, (filename)!!))!! + if (res == null) { + throw IOException("Cannot create the file") + } + } + return res + } + + private fun getLowerCase(str: String?): String? { + return if (str == null) null else str.lowercase(Locale.getDefault()) + } + + private fun stringMismatch(str1: String?, str2: String?): Boolean { + if (str1 == null && str2 == null) { + return false + } + if ((str1 == null) != (str2 == null)) { + return true + } + return !(str1 == str2) + } + + companion object { + private val DEBUG: Boolean = MainActivity.Companion.DEBUG + private val TAG: String = StoredFileHelper::class.java.getSimpleName() + private val serialVersionUID: Long = 0L + val DEFAULT_MIME: String = "application/octet-stream" + @Throws(IOException::class) + fun deserialize(storage: StoredFileHelper, + context: Context?): StoredFileHelper { + val treeUri: Uri? = if (storage.sourceTree == null) null else Uri.parse(storage.sourceTree) + if (storage.isInvalid()) { + return StoredFileHelper(treeUri, storage.srcName, storage.srcType, storage.tag) + } + val instance: StoredFileHelper = 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 + } + + fun getPicker(ctx: Context, + mimeType: String): Intent { + if (NewPipeSettings.useStorageAccessFramework(ctx)) { + return 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 + or StoredDirectoryHelper.Companion.PERMISSION_FLAGS)) + } else { + return Intent(ctx, FilePickerActivityHelper::class.java) + .putExtra(AbstractFilePickerActivity.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(AbstractFilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true) + .putExtra(AbstractFilePickerActivity.EXTRA_SINGLE_CLICK, true) + .putExtra(AbstractFilePickerActivity.EXTRA_MODE, + AbstractFilePickerActivity.MODE_FILE) + } + } + + fun getPicker(ctx: Context, + mimeType: String, + initialPath: Uri?): Intent { + return applyInitialPathToPickerIntent(ctx, getPicker(ctx, mimeType), initialPath, null) + } + + fun getNewPicker(ctx: Context, + filename: String?, + mimeType: String, + initialPath: Uri?): Intent { + val i: Intent + if (NewPipeSettings.useStorageAccessFramework(ctx)) { + i = 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 + or StoredDirectoryHelper.Companion.PERMISSION_FLAGS)) + if (filename != null) { + i.putExtra(Intent.EXTRA_TITLE, filename) + } + } else { + i = Intent(ctx, FilePickerActivityHelper::class.java) + .putExtra(AbstractFilePickerActivity.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(AbstractFilePickerActivity.EXTRA_ALLOW_CREATE_DIR, true) + .putExtra(AbstractFilePickerActivity.EXTRA_ALLOW_EXISTING_FILE, true) + .putExtra(AbstractFilePickerActivity.EXTRA_MODE, + AbstractFilePickerActivity.MODE_NEW_FILE) + } + return applyInitialPathToPickerIntent(ctx, i, initialPath, filename) + } + + private fun applyInitialPathToPickerIntent(ctx: Context, + intent: Intent, + initialPath: Uri?, + filename: String?): Intent { + 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 + } + var 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 (ignored: Throwable) { + // getFileForUri() can't decode paths to 'storage', fallback to this + file = 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 = File(file, filename) + } + return intent + .putExtra(AbstractFilePickerActivity.EXTRA_START_PATH, file!!.getAbsolutePath()) + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java b/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java deleted file mode 100644 index 90689052e..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java +++ /dev/null @@ -1,94 +0,0 @@ -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(); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.kt b/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.kt new file mode 100644 index 000000000..016211452 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.kt @@ -0,0 +1,69 @@ +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 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.function.Function +import java.util.stream.Collectors + +/** + * A list adapter for groups of [AudioStream]s (audio tracks). + */ +class AudioTrackAdapter(private val tracksWrapper: AudioTracksWrapper?) : BaseAdapter() { + public override fun getCount(): Int { + return tracksWrapper!!.size() + } + + public override fun getItem(position: Int): List { + return tracksWrapper!!.tracksList.get(position).getStreamsList() + } + + public override fun getItemId(position: Int): Long { + return position.toLong() + } + + public override fun getView(position: Int, convertView: View, parent: ViewGroup): View { + val context: Context = parent.getContext() + val view: View + if (convertView == null) { + view = LayoutInflater.from(context).inflate( + R.layout.stream_quality_item, parent, false) + } else { + view = convertView + } + val woSoundIconView: ImageView = view.findViewById(R.id.wo_sound_icon) + val formatNameView: TextView = view.findViewById(R.id.stream_format_name) + val qualityView: TextView = view.findViewById(R.id.stream_quality) + val sizeView: TextView = view.findViewById(R.id.stream_size) + val streams: List = getItem(position) + val stream: AudioStream? = 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 + } + + class AudioTracksWrapper(groupedAudioStreams: List?>, + context: Context?) : Serializable { + val tracksList: List> + + init { + tracksList = groupedAudioStreams.stream().map>(Function({ streams: List -> StreamInfoWrapper(streams, context) })).collect(Collectors.toList()) + } + + fun size(): Int { + return tracksList.size + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java b/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java deleted file mode 100644 index 8e8d38490..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java +++ /dev/null @@ -1,151 +0,0 @@ -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.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; - 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; - 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; - 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)); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.kt b/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.kt new file mode 100644 index 000000000..28218b5d8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.kt @@ -0,0 +1,115 @@ +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 + +object ChannelTabHelper { + /** + * @param tab the channel tab to check + * @return whether the tab should contain (playable) streams or not + */ + fun isStreamsTab(tab: String?): Boolean { + when (tab) { + ChannelTabs.VIDEOS, ChannelTabs.TRACKS, ChannelTabs.SHORTS, ChannelTabs.LIVESTREAMS -> return true + else -> return false + } + } + + /** + * @param tab the channel tab link handler to check + * @return whether the tab should contain (playable) streams or not + */ + fun isStreamsTab(tab: ListLinkHandler?): Boolean { + val contentFilters: List = 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 fun getShowTabKey(tab: String): Int { + when (tab) { + ChannelTabs.VIDEOS -> return R.string.show_channel_tabs_videos + ChannelTabs.TRACKS -> return R.string.show_channel_tabs_tracks + ChannelTabs.SHORTS -> return R.string.show_channel_tabs_shorts + ChannelTabs.LIVESTREAMS -> return R.string.show_channel_tabs_livestreams + ChannelTabs.CHANNELS -> return R.string.show_channel_tabs_channels + ChannelTabs.PLAYLISTS -> return R.string.show_channel_tabs_playlists + ChannelTabs.ALBUMS -> return R.string.show_channel_tabs_albums + else -> return -1 + } + } + + @StringRes + private fun getFetchFeedTabKey(tab: String): Int { + when (tab) { + ChannelTabs.VIDEOS -> return R.string.fetch_channel_tabs_videos + ChannelTabs.TRACKS -> return R.string.fetch_channel_tabs_tracks + ChannelTabs.SHORTS -> return R.string.fetch_channel_tabs_shorts + ChannelTabs.LIVESTREAMS -> return R.string.fetch_channel_tabs_livestreams + else -> return -1 + } + } + + @StringRes + fun getTranslationKey(tab: String?): Int { + when (tab) { + ChannelTabs.VIDEOS -> return R.string.channel_tab_videos + ChannelTabs.TRACKS -> return R.string.channel_tab_tracks + ChannelTabs.SHORTS -> return R.string.channel_tab_shorts + ChannelTabs.LIVESTREAMS -> return R.string.channel_tab_livestreams + ChannelTabs.CHANNELS -> return R.string.channel_tab_channels + ChannelTabs.PLAYLISTS -> return R.string.channel_tab_playlists + ChannelTabs.ALBUMS -> return R.string.channel_tab_albums + else -> return R.string.unknown_content + } + } + + fun showChannelTab(context: Context, + sharedPreferences: SharedPreferences, + @StringRes key: Int): Boolean { + val enabledTabs: Set? = 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)) + } + } + + fun showChannelTab(context: Context, + sharedPreferences: SharedPreferences, + tab: String): Boolean { + val key: Int = getShowTabKey(tab) + if (key == -1) { + return false + } + return showChannelTab(context, sharedPreferences, key) + } + + fun fetchFeedChannelTab(context: Context, + sharedPreferences: SharedPreferences, + tab: ListLinkHandler): Boolean { + val contentFilters: List = tab.getContentFilters() + if (contentFilters.isEmpty()) { + return false // this should never happen, but check just to be sure + } + val key: Int = getFetchFeedTabKey(contentFilters.get(0)) + if (key == -1) { + return false + } + val enabledTabs: Set? = 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)) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/DependentPreferenceHelper.java b/app/src/main/java/org/schabi/newpipe/util/DependentPreferenceHelper.java deleted file mode 100644 index 9591beddb..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/DependentPreferenceHelper.java +++ /dev/null @@ -1,51 +0,0 @@ -package org.schabi.newpipe.util; - -import android.content.Context; -import android.content.SharedPreferences; - -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. - */ -public final class DependentPreferenceHelper { - - private DependentPreferenceHelper() { - // no instance - } - - /** - * 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 - */ - public static boolean getResumePlaybackEnabled(final Context context) { - final SharedPreferences 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 - */ - public static boolean getPositionsInListsEnabled(final Context context) { - final SharedPreferences 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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/DependentPreferenceHelper.kt b/app/src/main/java/org/schabi/newpipe/util/DependentPreferenceHelper.kt new file mode 100644 index 000000000..9319584d0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/DependentPreferenceHelper.kt @@ -0,0 +1,42 @@ +package org.schabi.newpipe.util + +import android.content.Context +import android.content.SharedPreferences +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 + */ + fun getResumePlaybackEnabled(context: Context): Boolean { + val prefs: SharedPreferences = 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 + */ + fun getPositionsInListsEnabled(context: Context): Boolean { + val prefs: SharedPreferences = 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)) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java b/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java deleted file mode 100644 index e9678c2b0..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java +++ /dev/null @@ -1,338 +0,0 @@ -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 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.getApp().getPackageManager().hasSystemFeature(AMAZON_FEATURE_FIRE_TV); - return isFireTV; - } - - public static boolean isTv(final Context context) { - if (isTV != null) { - return isTV; - } - - final PackageManager pm = App.getApp().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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.kt b/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.kt new file mode 100644 index 000000000..8028ab27b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.kt @@ -0,0 +1,334 @@ +package org.schabi.newpipe.util + +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 androidx.annotation.Dimension +import androidx.appcompat.app.AppCompatActivity +import androidx.core.content.ContextCompat +import androidx.preference.PreferenceManager +import org.schabi.newpipe.App +import org.schabi.newpipe.R + +object DeviceUtils { + private const val AMAZON_FEATURE_FIRE_TV = "amazon.hardware.fire_tv" + private val SAMSUNG = Build.MANUFACTURER == "samsung" + private var isTV: Boolean? = null + private var isFireTV: Boolean? = 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 + */ + const val 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 val HI3798MV200 = Build.VERSION.SDK_INT == 24 && Build.DEVICE == "Hi3798MV200" + + /** + * + * Zephir TS43UHD-2. + * + * Blacklist reason: black screen + */ + private val CVT_MT5886_EU_1G = Build.VERSION.SDK_INT == 24 && Build.DEVICE == "cvt_mt5886_eu_1g" + + /** + * Hilife TV. + * + * Blacklist reason: black screen + */ + private val REALTEKATV = Build.VERSION.SDK_INT == 25 && Build.DEVICE == "RealtekATV" + + /** + * + * Phillips 4K (O)LED TV. + * Supports custom ROMs with different API levels + */ + private val PH7M_EU_5596 = Build.VERSION.SDK_INT >= 26 && Build.DEVICE == "PH7M_EU_5596" + + /** + * + * Philips QM16XE. + * + * Blacklist reason: black screen + */ + private val QM16XE_U = Build.VERSION.SDK_INT == 23 && Build.DEVICE == "QM16XE_U" + + /** + * + * Sony Bravia VH1. + * + * Processor: MT5895 + * + * Blacklist reason: fullscreen crash / stuttering + */ + private val BRAVIA_VH1 = Build.VERSION.SDK_INT == 29 && Build.DEVICE == "BRAVIA_VH1" + + /** + * + * Sony Bravia VH2. + * + * Blacklist reason: fullscreen crash; this includes model A90J as reported in + * [ + * #9023](https://github.com/TeamNewPipe/NewPipe/issues/9023#issuecomment-1387106242) + */ + private val BRAVIA_VH2 = Build.VERSION.SDK_INT == 29 && Build.DEVICE == "BRAVIA_VH2" + + /** + * + * Sony Bravia Android TV platform 2. + * Uses a MediaTek MT5891 (MT5596) SoC. + * @see [ + * https://github.com/CiNcH83/bravia_atv2](https://github.com/CiNcH83/bravia_atv2) + */ + private val BRAVIA_ATV2 = Build.DEVICE == "BRAVIA_ATV2" + + /** + * + * Sony Bravia Android TV platform 3 4K. + * + * Uses ARM MT5891 and a [.BRAVIA_ATV2] motherboard. + * + * @see [ + * https://browser.geekbench.com/v4/cpu/9101105](https://browser.geekbench.com/v4/cpu/9101105) + */ + private val BRAVIA_ATV3_4K = Build.DEVICE == "BRAVIA_ATV3_4K" + + /** + * + * Panasonic 4KTV-JUP. + * + * Blacklist reason: fullscreen crash + */ + private val TX_50JXW834 = Build.DEVICE == "TX_50JXW834" + + /** + * + * Bouygtel4K / Bouygues Telecom Bbox 4K. + * + * Blacklist reason: black screen; reported at + * [ + * #10122](https://github.com/TeamNewPipe/NewPipe/pull/10122#issuecomment-1638475769) + */ + private val HMB9213NW = Build.DEVICE == "HMB9213NW" + val isFireTv: Boolean + get() { + if (isFireTV != null) { + return isFireTV!! + } + isFireTV = App.Companion.getApp().getPackageManager().hasSystemFeature(AMAZON_FEATURE_FIRE_TV) + return isFireTV!! + } + + fun isTv(context: Context?): Boolean { + if (isTV != null) { + return isTV!! + } + val pm: PackageManager = App.Companion.getApp().getPackageManager() + + // from doc: https://developer.android.com/training/tv/start/hardware.html#runtime-check + var isTv = (ContextCompat.getSystemService(context!!, UiModeManager::class.java) + .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) { + val isBatteryAbsent = context.getSystemService(BatteryManager::class.java) + .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)) + } + isTV = isTv + return 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. + */ + fun isDesktopMode(context: Context): Boolean { + // Adapted from https://stackoverflow.com/a/64615568 + // to check for all input devices that have an active cursor + val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager + for (id in im.inputDeviceIds) { + val 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 + } + } + val uiModeManager = ContextCompat.getSystemService(context, UiModeManager::class.java) + 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 { + val config = context.resources.configuration + val configClass: Class<*> = config.javaClass + val semDesktopModeEnabledConst = configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass) + val currentMode = configClass.getField("semDesktopModeEnabled").getInt(config) + if (semDesktopModeEnabledConst == currentMode) { + return true + } + } catch (ignored: NoSuchFieldException) { + // Device doesn't seem to support DeX + } catch (ignored: IllegalAccessException) { + } + @SuppressLint("WrongConstant") val desktopModeManager = context + .applicationContext + .getSystemService("desktopmode") + if (desktopModeManager != null) { + try { + val getDesktopModeStateMethod = desktopModeManager.javaClass + .getDeclaredMethod("getDesktopModeState") + val desktopModeState = getDesktopModeStateMethod + .invoke(desktopModeManager) + val desktopModeStateClass: Class<*> = desktopModeState.javaClass + val getEnabledMethod = desktopModeStateClass + .getDeclaredMethod("getEnabled") + val enabledStatus = getEnabledMethod.invoke(desktopModeState) as Int + if (enabledStatus == desktopModeStateClass + .getDeclaredField("ENABLED").getInt(desktopModeStateClass)) { + return true + } + } catch (ignored: Exception) { + // Device does not support DeX 3.0 or something went wrong when trying to determine + // if it supports this feature + } + } + return false + } + + fun isTablet(context: Context): Boolean { + val tabletModeSetting = PreferenceManager.getDefaultSharedPreferences(context) + .getString(context.getString(R.string.tablet_mode_key), "") + if (tabletModeSetting == context.getString(R.string.tablet_mode_on_key)) { + return true + } else if (tabletModeSetting == context.getString(R.string.tablet_mode_off_key)) { + return false + } + + // else automatically determine whether we are in a tablet or not + return (context.resources.configuration.screenLayout + and Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE + } + + fun isConfirmKey(keyCode: Int): Boolean { + return when (keyCode) { + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER -> true + else -> false + } + } + + fun dpToPx(@Dimension(unit = DP) dp: Int, + context: Context): Int { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + dp.toFloat(), + context.resources.displayMetrics).toInt() + } + + fun spToPx(@Dimension(unit = SP) sp: Int, + context: Context): Int { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + sp.toFloat(), + context.resources.displayMetrics).toInt() + } + + fun isLandscape(context: Context?): Boolean { + return context!!.resources.displayMetrics.heightPixels < context.resources + .displayMetrics.widthPixels + } + + fun isInMultiWindow(activity: AppCompatActivity): Boolean { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity.isInMultiWindowMode + } + + fun hasAnimationsAnimatorDurationEnabled(context: Context): Boolean { + return Settings.System.getFloat( + context.contentResolver, + Settings.Global.ANIMATOR_DURATION_SCALE, + 1f) != 0f + } + + fun getWindowHeight(windowManager: WindowManager): Int { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val windowMetrics = windowManager.currentWindowMetrics + val windowInsets = windowMetrics.getWindowInsets() + val insets = windowInsets.getInsetsIgnoringVisibility( + WindowInsets.Type.navigationBars() or WindowInsets.Type.displayCutout()) + windowMetrics.bounds.height() - (insets.top + insets.bottom) + } else { + val point = Point() + windowManager.defaultDisplay.getSize(point) + 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](https://github.com/TeamNewPipe/NewPipe/issues/5911) and + * [#9023](https://github.com/TeamNewPipe/NewPipe/issues/9023) for more info. + * @Note Update [.MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION] + * when adding a new device to the method. + * @return `false` if affected device; `true` otherwise + */ + fun shouldSupportMediaTunneling(): Boolean { + // 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) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java deleted file mode 100644 index 066d5f570..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ /dev/null @@ -1,358 +0,0 @@ -/* - * 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(); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.kt b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.kt new file mode 100644 index 000000000..6afabb8e6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.kt @@ -0,0 +1,324 @@ +/* + * 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 android.content.Context +import android.util.Log +import android.view.View +import android.widget.TextView +import androidx.core.text.HtmlCompat +import androidx.preference.PreferenceManager +import io.reactivex.rxjava3.core.Maybe +import io.reactivex.rxjava3.core.MaybeSource +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.functions.Consumer +import io.reactivex.rxjava3.functions.Supplier +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.extractor.utils.Utils +import org.schabi.newpipe.util.text.TextLinkifier +import java.util.Locale +import java.util.concurrent.Callable + +object ExtractorHelper { + private val TAG: String = ExtractorHelper::class.java.getSimpleName() + private val CACHE: InfoCache = InfoCache.Companion.getInstance() + private fun checkServiceId(serviceId: Int) { + if (serviceId == NO_SERVICE_ID) { + throw IllegalArgumentException("serviceId is NO_SERVICE_ID") + } + } + + fun searchFor(serviceId: Int, searchString: String?, + contentFilter: List?, + sortFilter: String?): Single { + checkServiceId(serviceId) + return Single.fromCallable(Callable({ + SearchInfo.getInfo(NewPipe.getService(serviceId), + NewPipe.getService(serviceId) + .getSearchQHFactory() + .fromQuery(searchString, contentFilter, sortFilter)) + })) + } + + fun getMoreSearchItems( + serviceId: Int, + searchString: String?, + contentFilter: List?, + sortFilter: String?, + page: Page?): Single> { + checkServiceId(serviceId) + return Single.fromCallable(Callable({ + SearchInfo.getMoreItems(NewPipe.getService(serviceId), + NewPipe.getService(serviceId) + .getSearchQHFactory() + .fromQuery(searchString, contentFilter, sortFilter), page) + })) + } + + fun suggestionsFor(serviceId: Int, query: String?): Single> { + checkServiceId(serviceId) + return Single.fromCallable(Callable({ + val extractor: SuggestionExtractor? = NewPipe.getService(serviceId) + .getSuggestionExtractor() + if (extractor != null) extractor.suggestionList(query) else emptyList() + })) + } + + fun getStreamInfo(serviceId: Int, url: String?, + forceLoad: Boolean): Single { + checkServiceId(serviceId) + return checkCache(forceLoad, serviceId, (url)!!, InfoCache.Type.STREAM, + Single.fromCallable(Callable({ StreamInfo.getInfo(NewPipe.getService(serviceId), url) }))) + } + + fun getChannelInfo(serviceId: Int, url: String?, + forceLoad: Boolean): Single { + checkServiceId(serviceId) + return checkCache(forceLoad, serviceId, (url)!!, InfoCache.Type.CHANNEL, + Single.fromCallable(Callable({ ChannelInfo.getInfo(NewPipe.getService(serviceId), url) }))) + } + + fun getChannelTab(serviceId: Int, + listLinkHandler: ListLinkHandler?, + forceLoad: Boolean): Single { + checkServiceId(serviceId) + return checkCache(forceLoad, serviceId, + listLinkHandler!!.getUrl(), InfoCache.Type.CHANNEL_TAB, + Single.fromCallable(Callable({ ChannelTabInfo.getInfo(NewPipe.getService(serviceId), (listLinkHandler)) }))) + } + + fun getMoreChannelTabItems( + serviceId: Int, + listLinkHandler: ListLinkHandler?, + nextPage: Page?): Single> { + checkServiceId(serviceId) + return Single.fromCallable(Callable({ + ChannelTabInfo.getMoreItems(NewPipe.getService(serviceId), + (listLinkHandler)!!, (nextPage)!!) + })) + } + + fun getCommentsInfo(serviceId: Int, + url: String, + forceLoad: Boolean): Single { + checkServiceId(serviceId) + return checkCache(forceLoad, serviceId, url, InfoCache.Type.COMMENTS, + Single.fromCallable(Callable({ CommentsInfo.getInfo(NewPipe.getService(serviceId), url) }))) + } + + fun getMoreCommentItems( + serviceId: Int, + info: CommentsInfo?, + nextPage: Page?): Single> { + checkServiceId(serviceId) + return Single.fromCallable(Callable({ CommentsInfo.getMoreItems(NewPipe.getService(serviceId), info, nextPage) })) + } + + fun getMoreCommentItems( + serviceId: Int, + url: String?, + nextPage: Page?): Single> { + checkServiceId(serviceId) + return Single.fromCallable(Callable({ CommentsInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage) })) + } + + fun getPlaylistInfo(serviceId: Int, + url: String?, + forceLoad: Boolean): Single { + checkServiceId(serviceId) + return checkCache(forceLoad, serviceId, (url)!!, InfoCache.Type.PLAYLIST, + Single.fromCallable(Callable({ PlaylistInfo.getInfo(NewPipe.getService(serviceId), url) }))) + } + + fun getMorePlaylistItems(serviceId: Int, + url: String?, + nextPage: Page?): Single> { + checkServiceId(serviceId) + return Single.fromCallable(Callable({ PlaylistInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage) })) + } + + fun getKioskInfo(serviceId: Int, + url: String, + forceLoad: Boolean): Single { + return checkCache(forceLoad, serviceId, url, InfoCache.Type.KIOSK, + Single.fromCallable(Callable({ KioskInfo.getInfo(NewPipe.getService(serviceId), url) }))) + } + + fun getMoreKioskItems(serviceId: Int, + url: String?, + nextPage: Page?): Single> { + return Single.fromCallable(Callable({ 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 [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 [InfoCache.Type] of the item + * @param loadFromNetwork the [Single] to load the item from the network + * @return a [Single] that loads the item + */ + private fun checkCache(forceLoad: Boolean, + serviceId: Int, + url: String, + cacheType: InfoCache.Type, + loadFromNetwork: Single): Single { + checkServiceId(serviceId) + val actualLoadFromNetwork: Single = loadFromNetwork + .doOnSuccess(Consumer({ info: I -> CACHE.putInfo(serviceId, url, info, cacheType) })) + val load: Single + if (forceLoad) { + CACHE.removeInfo(serviceId, url, cacheType) + load = actualLoadFromNetwork + } else { + load = Maybe.concat(loadFromCache(serviceId, url, cacheType), + actualLoadFromNetwork.toMaybe()) + .firstElement() // Take the first valid + .toSingle() + } + return load + } + + /** + * Default implementation uses the [InfoCache] to get cached results. + * + * @param the item type's class that extends [Info] + * @param serviceId the service to load from + * @param url the URL to load + * @param cacheType the [InfoCache.Type] of the item + * @return a [Single] that loads the item + */ + private fun loadFromCache( + serviceId: Int, + url: String, + cacheType: InfoCache.Type): Maybe { + checkServiceId(serviceId) + return Maybe.defer(Supplier>({ + val info: I? = CACHE.getFromKey(serviceId, url, cacheType) as I? + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "loadFromCache() called, info > " + info) + } + + // Only return info if it's not null (it is cached) + if (info != null) { + return@defer Maybe.just(info) + } + Maybe.empty() + })) + } + + fun isCached(serviceId: Int, + url: String, + cacheType: InfoCache.Type): Boolean { + 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 + */ + fun showMetaInfoInTextView(metaInfos: List?, + metaInfoTextView: TextView, + metaInfoSeparator: View, + disposables: CompositeDisposable) { + val 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 { + val stringBuilder: StringBuilder = StringBuilder() + for (metaInfo: MetaInfo in metaInfos) { + if (!Utils.isNullOrEmpty(metaInfo.getTitle())) { + stringBuilder.append("").append(metaInfo.getTitle()).append("") + .append(Localization.DOT_SEPARATOR) + } + var content: String = metaInfo.getContent().getContent().trim({ it <= ' ' }) + if (content.endsWith(".")) { + content = content.substring(0, content.length - 1) // remove . at end + } + stringBuilder.append(content) + for (i in metaInfo.getUrls().indices) { + if (i == 0) { + stringBuilder.append(Localization.DOT_SEPARATOR) + } else { + stringBuilder.append("

") + } + stringBuilder + .append("") + .append(capitalizeIfAllUppercase(metaInfo.getUrlTexts().get(i).trim({ it <= ' ' }))) + .append("") + } + } + metaInfoSeparator.setVisibility(View.VISIBLE) + TextLinkifier.fromHtml(metaInfoTextView, stringBuilder.toString(), + HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, null, disposables, + TextLinkifier.SET_LINK_MOVEMENT_METHOD) + } + } + + private fun capitalizeIfAllUppercase(text: String): String { + for (i in 0 until text.length) { + if (Character.isLowerCase(text.get(i))) { + return text // there is at least a lowercase letter -> not all uppercase + } + } + if (text.isEmpty()) { + return text + } else { + return text.substring(0, 1).uppercase(Locale.getDefault()) + text.substring(1).lowercase(Locale.getDefault()) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/FallbackViewHolder.java b/app/src/main/java/org/schabi/newpipe/util/FallbackViewHolder.java deleted file mode 100644 index 967a54f0a..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/FallbackViewHolder.java +++ /dev/null @@ -1,11 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/FallbackViewHolder.kt b/app/src/main/java/org/schabi/newpipe/util/FallbackViewHolder.kt new file mode 100644 index 000000000..424da7f49 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/FallbackViewHolder.kt @@ -0,0 +1,6 @@ +package org.schabi.newpipe.util + +import android.view.View +import androidx.recyclerview.widget.RecyclerView + +class FallbackViewHolder(itemView: View?) : RecyclerView.ViewHolder((itemView)!!) diff --git a/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java b/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java deleted file mode 100644 index d7fb39651..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java +++ /dev/null @@ -1,147 +0,0 @@ -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); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.kt b/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.kt new file mode 100644 index 000000000..cdcbc3f8f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.kt @@ -0,0 +1,128 @@ +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.loader.content.Loader +import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.SortedList +import com.nononsenseapps.filepicker.AbstractFilePickerFragment +import com.nononsenseapps.filepicker.FilePickerActivity +import com.nononsenseapps.filepicker.FilePickerFragment +import org.schabi.newpipe.R +import java.io.File + +class FilePickerActivityHelper() : FilePickerActivity() { + private var currentFragment: CustomFilePickerFragment? = null + public override fun onCreate(savedInstanceState: Bundle?) { + if (ThemeHelper.isLightThemeSelected(this)) { + this.setTheme(R.style.FilePickerThemeLight) + } else { + this.setTheme(R.style.FilePickerThemeDark) + } + super.onCreate(savedInstanceState) + } + + public override fun onBackPressed() { + // If at top most level, normal behaviour + if (currentFragment!!.isBackTop()) { + super.onBackPressed() + } else { + // Else go up + currentFragment!!.goUp() + } + } + + override fun getFragment(startPath: String?, + mode: Int, + allowMultiple: Boolean, + allowCreateDir: Boolean, + allowExistingFile: Boolean, + singleClick: Boolean): AbstractFilePickerFragment { + val fragment: CustomFilePickerFragment = CustomFilePickerFragment() + fragment.setArgs(if (startPath != null) startPath else Environment.getExternalStorageDirectory().getPath(), + mode, allowMultiple, allowCreateDir, allowExistingFile, singleClick) + currentFragment = fragment + return currentFragment!! + } + + /*////////////////////////////////////////////////////////////////////////// + // Internal + ////////////////////////////////////////////////////////////////////////// */ + class CustomFilePickerFragment() : FilePickerFragment() { + public override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle?): View? { + return super.onCreateView(inflater, container, savedInstanceState) + } + + public override fun onCreateViewHolder(parent: ViewGroup, + viewType: Int): RecyclerView.ViewHolder { + val viewHolder: RecyclerView.ViewHolder = super.onCreateViewHolder(parent, viewType) + val view: View = viewHolder.itemView.findViewById(android.R.id.text1) + if (view is TextView) { + view.setTextSize(TypedValue.COMPLEX_UNIT_PX, + getResources().getDimension(R.dimen.file_picker_items_text_size)) + } + return viewHolder + } + + public override fun onClickOk(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 fun isItemVisible(file: File): Boolean { + if (file.isDirectory() && file.isHidden()) { + return true + } + return super.isItemVisible(file) + } + + val backTop: File + get() { + if (getArguments() == null) { + return Environment.getExternalStorageDirectory() + } + val path: String = getArguments()!!.getString(KEY_START_PATH, "/") + if (path.contains(Environment.getExternalStorageDirectory().getPath())) { + return Environment.getExternalStorageDirectory() + } + return getPath(path) + } + + fun isBackTop(): Boolean { + return compareFiles(mCurrentPath, + backTop) == 0 || compareFiles(mCurrentPath, File("/")) == 0 + } + + public override fun onLoadFinished(loader: Loader>, + data: SortedList) { + super.onLoadFinished(loader, data) + layoutManager.scrollToPosition(0) + } + } + + companion object { + fun isOwnFileUri(context: Context, uri: Uri): Boolean { + if (uri.getAuthority() == null) { + return false + } + return uri.getAuthority()!!.startsWith(context.getPackageName()) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.java b/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.java deleted file mode 100644 index bc15f3f02..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.java +++ /dev/null @@ -1,70 +0,0 @@ -package org.schabi.newpipe.util; - -import android.content.Context; -import android.content.SharedPreferences; - -import androidx.preference.PreferenceManager; - -import org.schabi.newpipe.R; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public final class FilenameUtils { - private static final String CHARSET_MOST_SPECIAL = "[\\n\\r|?*<\":\\\\>/']+"; - private static final String CHARSET_ONLY_LETTERS_AND_DIGITS = "[^\\w\\d]+"; - - private FilenameUtils() { } - - /** - * #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 - */ - public static String createFilename(final Context context, final String title) { - final SharedPreferences sharedPreferences = PreferenceManager - .getDefaultSharedPreferences(context); - - final String charsetLd = context.getString(R.string.charset_letters_and_digits_value); - final String charsetMs = context.getString(R.string.charset_most_special_value); - final String defaultCharset = context.getString(R.string.default_file_charset_value); - - final String replacementChar = sharedPreferences.getString( - context.getString(R.string.settings_file_replacement_character_key), "_"); - String selectedCharset = sharedPreferences.getString( - context.getString(R.string.settings_file_charset_key), null); - - final String charset; - - if (selectedCharset == null || selectedCharset.isEmpty()) { - selectedCharset = defaultCharset; - } - - if (selectedCharset.equals(charsetLd)) { - charset = CHARSET_ONLY_LETTERS_AND_DIGITS; - } else if (selectedCharset.equals(charsetMs)) { - charset = CHARSET_MOST_SPECIAL; - } else { - charset = selectedCharset; // Is the user using a custom charset? - } - - final Pattern pattern = Pattern.compile(charset); - - return createFilename(title, pattern, 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 static String createFilename(final String title, final Pattern invalidCharacters, - final String replacementChar) { - return title.replaceAll(invalidCharacters.pattern(), replacementChar); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.kt b/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.kt new file mode 100644 index 000000000..211e06a4e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.kt @@ -0,0 +1,58 @@ +package org.schabi.newpipe.util + +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.PreferenceManager +import org.schabi.newpipe.R +import java.util.regex.Matcher +import java.util.regex.Pattern + +object FilenameUtils { + private val CHARSET_MOST_SPECIAL: String = "[\\n\\r|?*<\":\\\\>/']+" + private val CHARSET_ONLY_LETTERS_AND_DIGITS: String = "[^\\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 + */ + fun createFilename(context: Context?, title: String): String { + val sharedPreferences: SharedPreferences = PreferenceManager + .getDefaultSharedPreferences((context)!!) + val charsetLd: String = context!!.getString(R.string.charset_letters_and_digits_value) + val charsetMs: String = context.getString(R.string.charset_most_special_value) + val defaultCharset: String = context.getString(R.string.default_file_charset_value) + val replacementChar: String? = sharedPreferences.getString( + context.getString(R.string.settings_file_replacement_character_key), "_") + var selectedCharset: String? = sharedPreferences.getString( + context.getString(R.string.settings_file_charset_key), null) + val charset: String? + if (selectedCharset == null || selectedCharset.isEmpty()) { + selectedCharset = defaultCharset + } + if ((selectedCharset == charsetLd)) { + charset = CHARSET_ONLY_LETTERS_AND_DIGITS + } else if ((selectedCharset == charsetMs)) { + charset = CHARSET_MOST_SPECIAL + } else { + charset = selectedCharset // Is the user using a custom charset? + } + val pattern: Pattern = Pattern.compile(charset) + return createFilename(title, pattern, 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: Pattern, + replacementChar: String): String { + return title.replace(invalidCharacters.pattern().toRegex(), replacementChar) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java deleted file mode 100644 index b9c91f8a5..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java +++ /dev/null @@ -1,175 +0,0 @@ -/* - * 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; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/InfoCache.kt b/app/src/main/java/org/schabi/newpipe/util/InfoCache.kt new file mode 100644 index 000000000..1734a2f55 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/InfoCache.kt @@ -0,0 +1,141 @@ +/* + * 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.collection.LruCache +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.extractor.Info + +class InfoCache private constructor() { + private val TAG = javaClass.getSimpleName() + + /** + * Identifies the type of [Info] to put into the cache. + */ + enum class Type { + STREAM, + CHANNEL, + CHANNEL_TAB, + COMMENTS, + PLAYLIST, + KIOSK + } + + fun getFromKey(serviceId: Int, + url: String, + cacheType: Type): Info? { + if (DEBUG) { + Log.d(TAG, "getFromKey() called with: " + + "serviceId = [" + serviceId + "], url = [" + url + "]") + } + synchronized(LRU_CACHE) { return getInfo(keyOf(serviceId, url, cacheType)) } + } + + fun putInfo(serviceId: Int, + url: String, + info: Info, + cacheType: Type) { + if (DEBUG) { + Log.d(TAG, "putInfo() called with: info = [$info]") + } + val expirationMillis = ServiceHelper.getCacheExpirationMillis(info.serviceId) + synchronized(LRU_CACHE) { + val data = CacheData(info, expirationMillis) + LRU_CACHE.put(keyOf(serviceId, url, cacheType), data) + } + } + + fun removeInfo(serviceId: Int, + url: String, + cacheType: Type) { + if (DEBUG) { + Log.d(TAG, "removeInfo() called with: " + + "serviceId = [" + serviceId + "], url = [" + url + "]") + } + synchronized(LRU_CACHE) { LRU_CACHE.remove(keyOf(serviceId, url, cacheType)) } + } + + fun clearCache() { + if (DEBUG) { + Log.d(TAG, "clearCache() called") + } + synchronized(LRU_CACHE) { LRU_CACHE.evictAll() } + } + + fun trimCache() { + if (DEBUG) { + Log.d(TAG, "trimCache() called") + } + synchronized(LRU_CACHE) { + removeStaleCache() + LRU_CACHE.trimToSize(TRIM_CACHE_TO) + } + } + + val size: Long + get() { + synchronized(LRU_CACHE) { return LRU_CACHE.size().toLong() } + } + + private class CacheData(val info: Info, timeoutMillis: Long) { + private val expireTimestamp: Long + + init { + expireTimestamp = System.currentTimeMillis() + timeoutMillis + } + + val isExpired: Boolean + private get() = System.currentTimeMillis() > expireTimestamp + } + + companion object { + private val DEBUG: Boolean = MainActivity.Companion.DEBUG + val instance = InfoCache() + private const val MAX_ITEMS_ON_CACHE = 60 + + /** + * Trim the cache to this size. + */ + private const val TRIM_CACHE_TO = 30 + private val LRU_CACHE = LruCache(MAX_ITEMS_ON_CACHE) + private fun keyOf(serviceId: Int, + url: String, + cacheType: Type): String { + return serviceId.toString() + ":" + cacheType.ordinal + ":" + url + } + + private fun removeStaleCache() { + for ((key, data) in LRU_CACHE.snapshot()) { + if (data != null && data.isExpired()) { + LRU_CACHE.remove(key) + } + } + } + + private fun getInfo(key: String): Info? { + val data = LRU_CACHE[key] ?: return null + if (data.isExpired()) { + LRU_CACHE.remove(key) + return null + } + return data.info + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java b/app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java deleted file mode 100644 index a709dc32e..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java +++ /dev/null @@ -1,55 +0,0 @@ -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.RESULT_UNCHANGED_SHOWN); - - editText.clearFocus(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.kt b/app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.kt new file mode 100644 index 000000000..7f132482f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.kt @@ -0,0 +1,49 @@ +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](https://stackoverflow.com/q/1109022) + * + */ +object KeyboardUtil { + fun showKeyboard(activity: Activity?, editText: EditText?) { + if (activity == null || editText == null) { + return + } + if (editText.requestFocus()) { + val imm = ContextCompat.getSystemService(activity, + InputMethodManager::class.java) + 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) + } + } + } + + fun hideKeyboard(activity: Activity?, editText: EditText?) { + if (activity == null || editText == null) { + return + } + val imm = ContextCompat.getSystemService(activity, + InputMethodManager::class.java) + imm!!.hideSoftInputFromWindow(editText.windowToken, + InputMethodManager.RESULT_UNCHANGED_SHOWN) + editText.clearFocus() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java b/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java deleted file mode 100644 index b8c2ff236..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java +++ /dev/null @@ -1,84 +0,0 @@ -package org.schabi.newpipe.util; - -import android.content.Context; - -import org.schabi.newpipe.R; - -/** - * Created by Christian Schabesberger on 28.09.17. - * KioskTranslator.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 KioskTranslator { - private KioskTranslator() { } - - public static String getTranslatedKioskName(final String kioskId, final Context c) { - switch (kioskId) { - case "Trending": - return c.getString(R.string.trending); - case "Top 50": - return c.getString(R.string.top_50); - case "New & hot": - return c.getString(R.string.new_and_hot); - case "Local": - return c.getString(R.string.local); - case "Recently added": - return c.getString(R.string.recently_added); - case "Most liked": - return c.getString(R.string.most_liked); - case "conferences": - return c.getString(R.string.conferences); - case "recent": - return c.getString(R.string.recent); - case "live": - return c.getString(R.string.duration_live); - case "Featured": - return c.getString(R.string.featured); - case "Radio": - return c.getString(R.string.radio); - default: - return kioskId; - } - } - - public static int getKioskIcon(final String kioskId) { - switch (kioskId) { - case "Trending": - case "Top 50": - case "New & hot": - case "conferences": - return R.drawable.ic_whatshot; - case "Local": - return R.drawable.ic_home; - case "Recently added": - case "recent": - return R.drawable.ic_add_circle_outline; - case "Most liked": - return R.drawable.ic_thumb_up; - case "live": - return R.drawable.ic_live_tv; - case "Featured": - return R.drawable.ic_stars; - case "Radio": - return R.drawable.ic_radio; - default: - return 0; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.kt b/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.kt new file mode 100644 index 000000000..7ee10d1a8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.kt @@ -0,0 +1,59 @@ +package org.schabi.newpipe.util + +import android.content.Context +import org.schabi.newpipe.R + +/** + * Created by Christian Schabesberger on 28.09.17. + * KioskTranslator.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 //www.gnu.org/licenses/>. + * + */ +object KioskTranslator { + fun getTranslatedKioskName(kioskId: String?, c: Context?): String? { + when (kioskId) { + "Trending" -> return c!!.getString(R.string.trending) + "Top 50" -> return c!!.getString(R.string.top_50) + "New & hot" -> return c!!.getString(R.string.new_and_hot) + "Local" -> return c!!.getString(R.string.local) + "Recently added" -> return c!!.getString(R.string.recently_added) + "Most liked" -> return c!!.getString(R.string.most_liked) + "conferences" -> return c!!.getString(R.string.conferences) + "recent" -> return c!!.getString(R.string.recent) + "live" -> return c!!.getString(R.string.duration_live) + "Featured" -> return c!!.getString(R.string.featured) + "Radio" -> return c!!.getString(R.string.radio) + else -> return kioskId + } + } + + fun getKioskIcon(kioskId: String?): Int { + when (kioskId) { + "Trending", "Top 50", "New & hot", "conferences" -> return R.drawable.ic_whatshot + "Local" -> return R.drawable.ic_home + "Recently added", "recent" -> return R.drawable.ic_add_circle_outline + "Most liked" -> return R.drawable.ic_thumb_up + "live" -> return R.drawable.ic_live_tv + "Featured" -> return R.drawable.ic_stars + "Radio" -> return R.drawable.ic_radio + else -> return 0 + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java deleted file mode 100644 index f1904565d..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java +++ /dev/null @@ -1,880 +0,0 @@ -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.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.ORIGINAL, AudioTrackType.DUBBED, 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(context)) - .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(context); - 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), - false); - 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. - * - * @param context app context for localization - * @return Comparator - */ - private static Comparator getAudioTrackNameComparator( - @NonNull final Context context) { - final Locale appLoc = Localization.getAppLocale(context); - - return Comparator.comparing(AudioStream::getAudioLocale, Comparator.nullsLast( - Comparator.comparing(locale -> locale.getDisplayName(appLoc)))) - .thenComparing(AudioStream::getAudioTrackType, Comparator.nullsLast( - Comparator.naturalOrder())); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ListHelper.kt b/app/src/main/java/org/schabi/newpipe/util/ListHelper.kt new file mode 100644 index 000000000..bb9fd938f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/ListHelper.kt @@ -0,0 +1,805 @@ +package org.schabi.newpipe.util + +import android.content.Context +import android.content.res.Resources +import android.net.ConnectivityManager +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.ServiceList +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.Arrays +import java.util.Collections +import java.util.Locale +import java.util.Objects +import java.util.function.Predicate +import java.util.stream.Collectors + +object ListHelper { + // Video format in order of quality. 0=lowest quality, n=highest quality + private val VIDEO_FORMAT_QUALITY_RANKING = java.util.List.of(MediaFormat.v3GPP, MediaFormat.WEBM, MediaFormat.MPEG_4) + + // Audio format in order of quality. 0=lowest quality, n=highest quality + private val AUDIO_FORMAT_QUALITY_RANKING = java.util.List.of(MediaFormat.MP3, MediaFormat.WEBMA, MediaFormat.M4A) + + // Audio format in order of efficiency. 0=least efficient, n=most efficient + private val AUDIO_FORMAT_EFFICIENCY_RANKING = java.util.List.of(MediaFormat.MP3, MediaFormat.M4A, MediaFormat.WEBMA) + + // Use a Set for better performance + private val HIGH_RESOLUTION_LIST = setOf("1440p", "2160p") + + // Audio track types in order of priority. 0=lowest, n=highest + private val AUDIO_TRACK_TYPE_RANKING = java.util.List.of(AudioTrackType.DESCRIPTIVE, AudioTrackType.DUBBED, AudioTrackType.ORIGINAL) + + // Audio track types in order of priority when descriptive audio is preferred. + private val AUDIO_TRACK_TYPE_RANKING_DESCRIPTIVE = java.util.List.of(AudioTrackType.ORIGINAL, AudioTrackType.DUBBED, 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 val SUPPORTED_ITAG_IDS = listOf( + 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 + ) + + /** + * @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 + */ + fun getDefaultResolutionIndex(context: Context, + videoStreams: List): Int { + val 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 + */ + fun getResolutionIndex(context: Context, + videoStreams: List, + defaultResolution: String?): Int { + 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 + */ + fun getPopupDefaultResolutionIndex(context: Context, + videoStreams: List): Int { + val 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 + */ + fun getPopupResolutionIndex(context: Context, + videoStreams: List, + defaultResolution: String?): Int { + return getDefaultResolutionWithDefaultFormat(context, defaultResolution, videoStreams) + } + + fun getDefaultAudioFormat(context: Context?, + audioStreams: List?): Int { + return getAudioIndexByHighestRank(audioStreams, + getAudioTrackComparator(context!!).thenComparing(getAudioFormatComparator(context))) + } + + fun getDefaultAudioTrackGroup(context: Context, + groupedAudioStreams: List?>?): Int { + if (groupedAudioStreams == null || groupedAudioStreams.isEmpty()) { + return -1 + } + val cmp = getAudioTrackComparator(context) + val highestRanked = groupedAudioStreams.stream() + .max { o1: List?, o2: List? -> cmp.compare(o1!![0], o2!![0]) } + .orElse(null) + return groupedAudioStreams.indexOf(highestRanked) + } + + fun getAudioFormatIndex(context: Context?, + audioStreams: List?, + trackId: String?): Int { + if (trackId != null) { + for (i in audioStreams!!.indices) { + val s = audioStreams[i] + if (s!!.audioTrackId != null && s.audioTrackId == trackId) { + return i + } + } + } + return getDefaultAudioFormat(context, audioStreams) + } + + /** + * Return a [Stream] list which uses the given delivery method from a [Stream] + * list. + * + * @param streamList the original [stream][Stream] list + * @param deliveryMethod the [delivery method][DeliveryMethod] + * @param the item type's class that extends [Stream] + * @return a [stream][Stream] list which uses the given delivery method + */ + fun getStreamsOfSpecifiedDelivery( + streamList: List?, + deliveryMethod: DeliveryMethod): List { + return getFilteredStreamList(streamList + ) { stream: S -> stream!!.deliveryMethod == deliveryMethod } + } + + /** + * Return a [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 [Stream] + * @return a stream list which only contains URL streams and non-torrent streams + */ + fun getUrlAndNonTorrentStreams( + streamList: List?): List { + return getFilteredStreamList(streamList + ) { stream: S -> stream!!.isUrl && stream.deliveryMethod != DeliveryMethod.TORRENT } + } + + /** + * Return a [Stream] list which only contains streams which can be played by the player. + * + * + * + * Some formats are not supported, see [.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 [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 + */ + fun getPlayableStreams( + streamList: List?, serviceId: Int): List { + val youtubeServiceId = ServiceList.YouTube.serviceId + return getFilteredStreamList(streamList + ) { stream: S -> + (stream!!.deliveryMethod != DeliveryMethod.TORRENT && (stream.deliveryMethod != DeliveryMethod.HLS + || stream.format != MediaFormat.OPUS) + && (serviceId != youtubeServiceId || stream.itagItem == null || SUPPORTED_ITAG_IDS.contains(stream.itagItem!!.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 + */ + fun getSortedStreamVideosList( + context: Context, + videoStreams: List?, + videoOnlyStreams: List?, + ascendingOrder: Boolean, + preferVideoOnlyStreams: Boolean): List { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + val showHigherResolutions = preferences.getBoolean( + context.getString(R.string.show_higher_resolutions_key), false) + val 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 + */ + fun getSortedResolutionList( + resources: Resources, + defaultResolutionKey: Int, + additionalResolutionKey: Int, + showHigherResolutions: Boolean): List { + val resolutions: MutableList = ArrayList(Arrays.asList( + *resources.getStringArray(defaultResolutionKey))) + if (!showHigherResolutions) { + return resolutions + } + val additionalResolutions = Arrays.asList( + *resources.getStringArray(additionalResolutionKey)) + // keep "best resolution" at the top + resolutions.addAll(1, additionalResolutions) + return resolutions + } + + fun isHighResolutionSelected(selectedResolution: String, + additionalResolutionKey: Int, + resources: Resources): Boolean { + 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 + */ + fun getFilteredAudioStreams( + context: Context, + audioStreams: List?): List { + if (audioStreams == null) { + return emptyList() + } + val collectedStreams = HashMap() + val cmp = getAudioFormatComparator(context) + for (stream in audioStreams) { + if (stream!!.deliveryMethod == DeliveryMethod.TORRENT + || (stream.deliveryMethod == DeliveryMethod.HLS + && stream.format == MediaFormat.OPUS)) { + continue + } + val trackId = Objects.toString(stream.audioTrackId, "") + val presentStream = collectedStreams[trackId] + if (presentStream == null || cmp.compare(stream, presentStream) > 0) { + collectedStreams[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(context)) + .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 + */ + fun getGroupedAudioStreams( + context: Context, + audioStreams: List?): List> { + if (audioStreams == null) { + return emptyList() + } + val collectedStreams = HashMap>() + for (stream in audioStreams) { + val trackId = Objects.toString(stream!!.audioTrackId, "") + if (collectedStreams.containsKey(trackId)) { + collectedStreams[trackId]!!.add(stream) + } else { + val list: MutableList = ArrayList() + list.add(stream) + collectedStreams[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 + val nameCmp = getAudioTrackNameComparator(context) + val formatCmp = getAudioFormatComparator(context) + return collectedStreams.values.stream() + .sorted { o1: List, o2: List -> nameCmp.compare(o1[0], o2[0]) } + .map { streams: List -> 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 [Stream] + * @return a new stream list filtered using the given predicate + */ + private fun getFilteredStreamList( + streamList: List?, + streamListPredicate: Predicate): List { + return if (streamList == null) { + emptyList() + } else streamList.stream() + .filter(streamListPredicate) + .collect(Collectors.toList()) + } + + private fun computeDefaultResolution(context: Context, key: Int, + value: Int): String? { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + + // Load the preferred resolution otherwise the best available + var resolution = if (preferences != null) preferences.getString(context.getString(key), context.getString(value)) else context.getString(R.string.best_resolution_key) + val maxResolution = getResolutionLimit(context) + if (maxResolution != null + && (resolution == 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 + */ + fun getDefaultResolutionIndex(defaultResolution: String, + bestResolutionKey: String, + defaultFormat: MediaFormat?, + videoStreams: List?): Int { + if (videoStreams == null || videoStreams.isEmpty()) { + return -1 + } + sortStreamList(videoStreams, false) + if (defaultResolution == bestResolutionKey) { + return 0 + } + val defaultStreamIndex = getVideoStreamIndex(defaultResolution, defaultFormat, videoStreams) + + // this is actually an error, + // but maybe there is really no stream fitting to the default value. + return if (defaultStreamIndex == -1) { + 0 + } else 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 + */ + fun getSortedStreamVideosList( + defaultFormat: MediaFormat?, + showHigherResolutions: Boolean, + videoStreams: List?, + videoOnlyStreams: List?, + ascendingOrder: Boolean, + preferVideoOnlyStreams: Boolean + ): List { + // Determine order of streams + // The last added list is preferred + val videoStreamsOrdered = if (preferVideoOnlyStreams) Arrays.asList(videoStreams, videoOnlyStreams) else Arrays.asList(videoOnlyStreams, videoStreams) + val allInitialStreams = videoStreamsOrdered.stream() // Ignore lists that are null + .filter { obj: List? -> Objects.nonNull(obj) } + .flatMap { obj: List? -> obj!!.stream() } // Filter out higher resolutions (or not if high resolutions should always be shown) + .filter { stream: VideoStream? -> + (showHigherResolutions + || !HIGH_RESOLUTION_LIST.contains(stream!!.getResolution() // Replace any frame rate with nothing + .replace("p\\d+$".toRegex(), "p"))) + } + .collect(Collectors.toList()) + val hashMap = HashMap() + // Add all to the hashmap + for (videoStream in allInitialStreams) { + hashMap[videoStream!!.getResolution()] = videoStream + } + + // Override the values when the key == resolution, with the defaultFormat + for (videoStream in allInitialStreams) { + if (videoStream!!.format == defaultFormat) { + hashMap[videoStream.getResolution()] = videoStream + } + } + + // Return the sorted list + return sortStreamList(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 fun sortStreamList(videoStreams: List, + ascendingOrder: Boolean): List { + // Compares the quality of two video streams. + val comparator = Comparator.nullsLast(Comparator + .comparing({ obj: VideoStream? -> obj!!.getResolution() }) { obj: String?, r1: String? -> compareVideoStreamResolution(r1!!) } + .thenComparingInt { s: VideoStream? -> VIDEO_FORMAT_QUALITY_RANKING.indexOf(s!!.format) }) + Collections.sort(videoStreams, if (ascendingOrder) comparator else 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 + */ + fun getAudioIndexByHighestRank(audioStreams: List?, + comparator: Comparator?): Int { + if (audioStreams == null || audioStreams.isEmpty()) { + return -1 + } + val 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 + * 1. Find a format and resolution match and ignore the refresh + * 1. Find a resolution match + * 1. Find a resolution match and ignore the refresh + * 1. Find a resolution just below the requested resolution and ignore the refresh + * 1. Give up + * + * + * @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 + */ + fun getVideoStreamIndex(targetResolution: String, + targetFormat: MediaFormat?, + videoStreams: List): Int { + var fullMatchIndex = -1 + var fullMatchNoRefreshIndex = -1 + var resMatchOnlyIndex = -1 + var resMatchOnlyNoRefreshIndex = -1 + var lowerResMatchNoRefreshIndex = -1 + val targetResolutionNoRefresh = targetResolution.replace("p\\d+$".toRegex(), "p") + for (idx in videoStreams.indices) { + val format = if (targetFormat == null) null else videoStreams[idx]!!.format + val resolution = videoStreams[idx]!!.getResolution() + val resolutionNoRefresh = resolution.replace("p\\d+$".toRegex(), "p") + if (format == targetFormat && resolution == targetResolution) { + fullMatchIndex = idx + } + if (format == targetFormat && resolutionNoRefresh == targetResolutionNoRefresh) { + fullMatchNoRefreshIndex = idx + } + if (resMatchOnlyIndex == -1 && resolution == targetResolution) { + resMatchOnlyIndex = idx + } + if (resMatchOnlyNoRefreshIndex == -1 && resolutionNoRefresh == 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 + } + return if (resMatchOnlyNoRefreshIndex != -1) { + resMatchOnlyNoRefreshIndex + } else 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 fun getDefaultResolutionWithDefaultFormat(context: Context, + defaultResolution: String?, + videoStreams: List): Int { + val 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) + } + + private fun getDefaultFormat(context: Context, + @StringRes defaultFormatKey: Int, + @StringRes defaultFormatValueKey: Int): MediaFormat? { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + val defaultFormat = context.getString(defaultFormatValueKey) + val defaultFormatString = preferences.getString( + context.getString(defaultFormatKey), + defaultFormat + ) + return getMediaFormatFromKey(context, defaultFormatString!!) + } + + private fun getMediaFormatFromKey(context: Context, + formatKey: String): MediaFormat? { + var format: MediaFormat? = null + if (formatKey == context.getString(R.string.video_webm_key)) { + format = MediaFormat.WEBM + } else if (formatKey == context.getString(R.string.video_mp4_key)) { + format = MediaFormat.MPEG_4 + } else if (formatKey == context.getString(R.string.video_3gp_key)) { + format = MediaFormat.v3GPP + } else if (formatKey == context.getString(R.string.audio_webm_key)) { + format = MediaFormat.WEBMA + } else if (formatKey == context.getString(R.string.audio_m4a_key)) { + format = MediaFormat.M4A + } + return format + } + + private fun compareVideoStreamResolution(r1: String, + r2: String): Int { + return try { + val res1 = r1.replace("0p\\d+$".toRegex(), "1") + .replace("[^\\d.]".toRegex(), "").toInt() + val res2 = r2.replace("0p\\d+$".toRegex(), "1") + .replace("[^\\d.]".toRegex(), "").toInt() + res1 - res2 + } catch (e: NumberFormatException) { + // 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) + 1 + } + } + + fun isLimitingDataUsage(context: Context): Boolean { + return getResolutionLimit(context) != null + } + + /** + * The maximum resolution allowed. + * + * @param context App context + * @return maximum resolution allowed or null if there is no maximum + */ + private fun getResolutionLimit(context: Context): String? { + var resolutionLimit: String? = null + if (isMeteredNetwork(context)) { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + val defValue = context.getString(R.string.limit_data_usage_none_key) + val value = preferences.getString( + context.getString(R.string.limit_mobile_data_usage_key), defValue) + resolutionLimit = if (defValue == value) null else value + } + return resolutionLimit + } + + /** + * The current network is metered (like mobile data)? + * + * @param context App context + * @return `true` if connected to a metered network + */ + fun isMeteredNetwork(context: Context): Boolean { + val manager = ContextCompat.getSystemService(context, ConnectivityManager::class.java) + return if (manager == null || manager.activeNetworkInfo == null) { + false + } else manager.isActiveNetworkMetered + } + + /** + * Get a [Comparator] to compare [AudioStream]s by their format and bitrate. + * + * + * The preferred stream will be ordered last. + * + * @param context app context + * @return Comparator + */ + private fun getAudioFormatComparator( + context: Context): Comparator { + val defaultFormat = getDefaultFormat(context, + R.string.default_audio_format_key, R.string.default_audio_format_value) + return getAudioFormatComparator(defaultFormat, isLimitingDataUsage(context)) + } + + /** + * Get a [Comparator] to compare [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 + */ + @JvmStatic + fun getAudioFormatComparator( + defaultFormat: MediaFormat?, limitDataUsage: Boolean): Comparator { + val formatRanking = if (limitDataUsage) AUDIO_FORMAT_EFFICIENCY_RANKING else AUDIO_FORMAT_QUALITY_RANKING + var bitrateComparator = Comparator.comparingInt { obj: AudioStream? -> obj!!.averageBitrate } + if (limitDataUsage) { + bitrateComparator = bitrateComparator.reversed() + } + return Comparator.comparing({ obj: AudioStream? -> obj!!.format }) { o1: MediaFormat?, o2: MediaFormat? -> + if (defaultFormat != null) { + return@comparing java.lang.Boolean.compare(o1 == defaultFormat, o2 == defaultFormat) + } + 0 + }.thenComparing(bitrateComparator).thenComparingInt { stream: AudioStream? -> formatRanking.indexOf(stream!!.format) } + } + + /** + * Get a [Comparator] to compare [AudioStream]s by their tracks. + * + * + * Tracks will be compared this order: + * + * 1. If `preferOriginalAudio`: use original audio + * 1. Language matches `preferredLanguage` + * 1. + * Track type ranks highest in this order: + * *Original* > *Dubbed* > *Descriptive* + * + * If `preferDescriptiveAudio`: + * *Descriptive* > *Dubbed* > *Original* + * + * 1. Language is English + * + * + * + * The preferred track will be ordered last. + * + * @param context App context + * @return Comparator + */ + private fun getAudioTrackComparator( + context: Context): Comparator { + val preferences = PreferenceManager.getDefaultSharedPreferences(context) + val preferredLanguage = Localization.getPreferredLocale(context) + val preferOriginalAudio = preferences.getBoolean(context.getString(R.string.prefer_original_audio_key), + false) + val preferDescriptiveAudio = preferences.getBoolean(context.getString(R.string.prefer_descriptive_audio_key), + false) + return getAudioTrackComparator(preferredLanguage, preferOriginalAudio, + preferDescriptiveAudio) + } + + /** + * Get a [Comparator] to compare [AudioStream]s by their tracks. + * + * + * Tracks will be compared this order: + * + * 1. If `preferOriginalAudio`: use original audio + * 1. Language matches `preferredLanguage` + * 1. + * Track type ranks highest in this order: + * *Original* > *Dubbed* > *Descriptive* + * + * If `preferDescriptiveAudio`: + * *Descriptive* > *Dubbed* > *Original* + * + * 1. Language is English + * + * + * + * 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 + */ + @JvmStatic + fun getAudioTrackComparator( + preferredLanguage: Locale?, + preferOriginalAudio: Boolean, + preferDescriptiveAudio: Boolean): Comparator { + val langCode = preferredLanguage!!.getISO3Language() + val trackTypeRanking = if (preferDescriptiveAudio) AUDIO_TRACK_TYPE_RANKING_DESCRIPTIVE else AUDIO_TRACK_TYPE_RANKING + return Comparator.comparing({ obj: AudioStream? -> obj!!.audioTrackType }) { o1: AudioTrackType?, o2: AudioTrackType? -> + if (preferOriginalAudio) { + return@comparing java.lang.Boolean.compare( + o1 == AudioTrackType.ORIGINAL, o2 == AudioTrackType.ORIGINAL) + } + 0 + }.thenComparing({ obj: AudioStream? -> obj!!.audioLocale }, + Comparator.nullsFirst(Comparator.comparing { locale: Locale? -> locale!!.getISO3Language() == langCode })) + .thenComparing({ obj: AudioStream? -> obj!!.audioTrackType }, + Comparator.nullsFirst(Comparator.comparingInt { o: AudioTrackType? -> trackTypeRanking.indexOf(o) })) + .thenComparing({ obj: AudioStream? -> obj!!.audioLocale }, + Comparator.nullsFirst(Comparator.comparing { locale: Locale? -> + locale!!.getISO3Language() == + Locale.ENGLISH.getISO3Language() + })) + } + + /** + * Get a [Comparator] to compare [AudioStream]s by their languages and track types + * for alphabetical sorting. + * + * @param context app context for localization + * @return Comparator + */ + private fun getAudioTrackNameComparator( + context: Context): Comparator { + val appLoc = Localization.getAppLocale(context) + return Comparator.comparing({ obj: AudioStream? -> obj!!.audioLocale }, Comparator.nullsLast( + Comparator.comparing { locale: Locale? -> locale!!.getDisplayName(appLoc) })) + .thenComparing({ obj: AudioStream? -> obj!!.audioTrackType }, Comparator.nullsLast( + Comparator.naturalOrder())) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/Localization.java b/app/src/main/java/org/schabi/newpipe/util/Localization.java deleted file mode 100644 index 5d73d21f0..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/Localization.java +++ /dev/null @@ -1,447 +0,0 @@ -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.Configuration; -import android.content.res.Resources; -import android.icu.text.CompactDecimalFormat; -import android.os.Build; -import android.text.TextUtils; -import android.util.DisplayMetrics; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.PluralsRes; -import androidx.annotation.StringRes; -import androidx.core.math.MathUtils; -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 { - 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)); - } - - 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(@NonNull final Context context) { - return getLocaleFromPrefs(context, R.string.app_language_key); - } - - public static String localizeNumber(@NonNull final Context context, final long number) { - return localizeNumber(context, (double) number); - } - - public static String localizeNumber(@NonNull final Context context, final double number) { - final NumberFormat nf = NumberFormat.getInstance(getAppLocale(context)); - return nf.format(number); - } - - public static String formatDate(@NonNull final Context context, - @NonNull final OffsetDateTime offsetDateTime) { - return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) - .withLocale(getAppLocale(context)).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(context, offsetDateTime)); - } - - public static String localizeViewCount(@NonNull final Context context, final long viewCount) { - return getQuantity(context, R.plurals.views, R.string.no_views, viewCount, - localizeNumber(context, 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.getResources().getString(R.string.infinite_videos); - case (int) ListExtractor.ITEM_COUNT_MORE_THAN_100: - return context.getResources().getString(R.string.more_than_100_videos); - default: - return getQuantity(context, R.plurals.videos, R.string.no_videos, streamCount, - localizeNumber(context, 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.getResources().getString(R.string.infinite_videos_mini); - case (int) ListExtractor.ITEM_COUNT_MORE_THAN_100: - return context.getResources().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(context, 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(context), - CompactDecimalFormat.CompactStyle.SHORT).format(count); - } - - final double value = (double) count; - if (count >= 1000000000) { - return localizeNumber(context, round(value / 1000000000)) - + context.getString(R.string.short_billion); - } else if (count >= 1000000) { - return localizeNumber(context, round(value / 1000000)) - + context.getString(R.string.short_million); - } else if (count >= 1000) { - return localizeNumber(context, round(value / 1000)) - + context.getString(R.string.short_thousand); - } else { - return localizeNumber(context, 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 days:hours:minutes:seconds}. - * Prepended zeros are removed. - * @param duration the duration in seconds - * @return a formatted duration String or {@code 0:00} if the duration is zero. - */ - public static String getDurationString(final long duration) { - return getDurationString(duration, true); - } - - /** - * Get a readable text for a duration in the format {@code days:hours:minutes:seconds+}. - * Prepended zeros are removed. 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 - * @return a formatted duration String or {@code 0:00} if the duration is zero. - */ - public static String getDurationString(final long duration, final boolean isDurationComplete) { - final String output; - - final long days = duration / (24 * 60 * 60L); /* greater than a day */ - final long hours = duration % (24 * 60 * 60L) / (60 * 60L); /* greater than an hour */ - final long minutes = duration % (24 * 60 * 60L) % (60 * 60L) / 60L; - final long seconds = duration % 60L; - - if (duration < 0) { - output = "0:00"; - } else if (days > 0) { - //handle days - output = String.format(Locale.US, "%d:%02d:%02d:%02d", days, hours, minutes, seconds); - } else if (hours > 0) { - output = String.format(Locale.US, "%d:%02d:%02d", hours, minutes, seconds); - } else { - output = String.format(Locale.US, "%d:%02d", minutes, seconds); - } - final String durationPostfix = isDurationComplete ? "" : "+"; - return 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 (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().getDisplayLanguage(getAppLocale(context)); - } 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()); - if (trackType != null) { - return context.getString(R.string.audio_track_name, name, trackType); - } - } - return name; - } - - @Nullable - private static String audioTrackType(@NonNull final Context context, - final AudioTrackType trackType) { - switch (trackType) { - case ORIGINAL: - return context.getString(R.string.audio_track_type_original); - case DUBBED: - return context.getString(R.string.audio_track_type_dubbed); - case DESCRIPTIVE: - return context.getString(R.string.audio_track_type_descriptive); - } - return null; - } - - /*////////////////////////////////////////////////////////////////////////// - // 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(@NonNull final Context context) { - return new PrettyTime(getAppLocale(context)); - } - - 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. - */ - public static String relativeTimeOrTextual(@Nullable final Context context, - @Nullable final DateWrapper parsed, - 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()); - } - } - - public static void assureCorrectAppLanguage(final Context c) { - final Resources res = c.getResources(); - final DisplayMetrics dm = res.getDisplayMetrics(); - final Configuration conf = res.getConfiguration(); - conf.setLocale(getAppLocale(c)); - res.updateConfiguration(conf, dm); - } - - 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) { - return new BigDecimal(value).setScale(1, RoundingMode.HALF_UP).doubleValue(); - } - - 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) { - 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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/Localization.kt b/app/src/main/java/org/schabi/newpipe/util/Localization.kt new file mode 100644 index 000000000..06d741998 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/Localization.kt @@ -0,0 +1,407 @@ +package org.schabi.newpipe.util + +import android.annotation.SuppressLint +import android.content.Context +import android.icu.text.CompactDecimalFormat +import android.os.Build +import android.text.TextUtils +import androidx.annotation.PluralsRes +import androidx.annotation.StringRes +import androidx.core.math.MathUtils +import androidx.preference.PreferenceManager +import org.ocpsoft.prettytime.PrettyTime +import org.ocpsoft.prettytime.units.Decade +import org.schabi.newpipe.MainActivity +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.localization.Localization +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.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 . + */ +object Localization { + const val DOT_SEPARATOR = " • " + private var prettyTime: PrettyTime? = null + fun concatenateStrings(vararg strings: String?): String { + return concatenateStrings(DOT_SEPARATOR, Arrays.asList(*strings)) + } + + fun concatenateStrings(delimiter: String?, strings: List): String { + return strings.stream() + .filter({ string: String? -> !TextUtils.isEmpty(string) }) + .collect(Collectors.joining(delimiter)) + } + + fun getPreferredLocalization( + context: Context): Localization { + return Localization + .fromLocale(getPreferredLocale(context)) + } + + fun getPreferredContentCountry(context: Context): ContentCountry { + val contentCountry = PreferenceManager.getDefaultSharedPreferences(context) + .getString(context.getString(R.string.content_country_key), + context.getString(R.string.default_localization_key)) + return if ((contentCountry == context.getString(R.string.default_localization_key))) { + ContentCountry(Locale.getDefault().country) + } else ContentCountry((contentCountry)!!) + } + + fun getPreferredLocale(context: Context): Locale { + return getLocaleFromPrefs(context, R.string.content_language_key) + } + + fun getAppLocale(context: Context): Locale { + return getLocaleFromPrefs(context, R.string.app_language_key) + } + + fun localizeNumber(context: Context, number: Long): String { + return localizeNumber(context, number.toDouble()) + } + + fun localizeNumber(context: Context, number: Double): String { + val nf = NumberFormat.getInstance(getAppLocale(context)) + return nf.format(number) + } + + fun formatDate(context: Context, + offsetDateTime: OffsetDateTime): String { + return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) + .withLocale(getAppLocale(context)).format(offsetDateTime + .atZoneSameInstant(ZoneId.systemDefault())) + } + + @SuppressLint("StringFormatInvalid") + fun localizeUploadDate(context: Context, + offsetDateTime: OffsetDateTime): String { + return context.getString(R.string.upload_date_text, formatDate(context, offsetDateTime)) + } + + fun localizeViewCount(context: Context, viewCount: Long): String { + return getQuantity(context, R.plurals.views, R.string.no_views, viewCount, + localizeNumber(context, viewCount)) + } + + fun localizeStreamCount(context: Context, + streamCount: Long): String { + return when (streamCount.toInt()) { + ListExtractor.ITEM_COUNT_UNKNOWN.toInt() -> "" + ListExtractor.ITEM_COUNT_INFINITE.toInt() -> context.resources.getString(R.string.infinite_videos) + ListExtractor.ITEM_COUNT_MORE_THAN_100.toInt() -> context.resources.getString(R.string.more_than_100_videos) + else -> getQuantity(context, R.plurals.videos, R.string.no_videos, streamCount, + localizeNumber(context, streamCount)) + } + } + + fun localizeStreamCountMini(context: Context, + streamCount: Long): String { + return when (streamCount.toInt()) { + ListExtractor.ITEM_COUNT_UNKNOWN.toInt() -> "" + ListExtractor.ITEM_COUNT_INFINITE.toInt() -> context.resources.getString(R.string.infinite_videos_mini) + ListExtractor.ITEM_COUNT_MORE_THAN_100.toInt() -> context.resources.getString(R.string.more_than_100_videos_mini) + else -> streamCount.toString() + } + } + + fun localizeWatchingCount(context: Context, + watchingCount: Long): String { + return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount, + localizeNumber(context, watchingCount)) + } + + fun shortCount(context: Context, count: Long): String { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + return CompactDecimalFormat.getInstance(getAppLocale(context), + CompactDecimalFormat.CompactStyle.SHORT).format(count) + } + val value = count.toDouble() + return if (count >= 1000000000) { + (localizeNumber(context, round(value / 1000000000)) + + context.getString(R.string.short_billion)) + } else if (count >= 1000000) { + (localizeNumber(context, round(value / 1000000)) + + context.getString(R.string.short_million)) + } else if (count >= 1000) { + (localizeNumber(context, round(value / 1000)) + + context.getString(R.string.short_thousand)) + } else { + localizeNumber(context, value) + } + } + + fun listeningCount(context: Context, listeningCount: Long): String { + return getQuantity(context, R.plurals.listening, R.string.no_one_listening, listeningCount, + shortCount(context, listeningCount)) + } + + fun shortWatchingCount(context: Context, + watchingCount: Long): String { + return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount, + shortCount(context, watchingCount)) + } + + fun shortViewCount(context: Context, viewCount: Long): String { + return getQuantity(context, R.plurals.views, R.string.no_views, viewCount, + shortCount(context, viewCount)) + } + + fun shortSubscriberCount(context: Context, + subscriberCount: Long): String { + return getQuantity(context, R.plurals.subscribers, R.string.no_subscribers, subscriberCount, + shortCount(context, subscriberCount)) + } + + fun downloadCount(context: Context, downloadCount: Int): String { + return getQuantity(context, R.plurals.download_finished_notification, 0, + downloadCount.toLong(), shortCount(context, downloadCount.toLong())) + } + + fun deletedDownloadCount(context: Context, + deletedCount: Int): String { + return getQuantity(context, R.plurals.deleted_downloads_toast, 0, + deletedCount.toLong(), shortCount(context, deletedCount.toLong())) + } + + fun replyCount(context: Context, replyCount: Int): String { + return getQuantity(context, R.plurals.replies, 0, replyCount.toLong(), replyCount.toString()) + } + + /** + * @param context the Android context + * @param likeCount the like count, possibly negative if unknown + * @return if `likeCount` is smaller than `0`, the string `"-"`, otherwise + * the result of calling [.shortCount] on the like count + */ + fun likeCount(context: Context, likeCount: Int): String { + return if (likeCount < 0) { + "-" + } else { + shortCount(context, likeCount.toLong()) + } + } + + /** + * Get a readable text for a duration in the format `days:hours:minutes:seconds`. + * Prepended zeros are removed. + * @param duration the duration in seconds + * @return a formatted duration String or `0:00` if the duration is zero. + */ + fun getDurationString(duration: Long): String { + return getDurationString(duration, true) + } + + /** + * Get a readable text for a duration in the format `days:hours:minutes:seconds+`. + * Prepended zeros are removed. 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 + * @return a formatted duration String or `0:00` if the duration is zero. + */ + fun getDurationString(duration: Long, isDurationComplete: Boolean): String { + val output: String + val days = duration / (24 * 60 * 60L) /* greater than a day */ + val hours = duration % (24 * 60 * 60L) / (60 * 60L) /* greater than an hour */ + val minutes = duration % (24 * 60 * 60L) % (60 * 60L) / 60L + val seconds = duration % 60L + output = if (duration < 0) { + "0:00" + } else if (days > 0) { + //handle days + String.format(Locale.US, "%d:%02d:%02d:%02d", days, hours, minutes, seconds) + } else if (hours > 0) { + String.format(Locale.US, "%d:%02d:%02d", hours, minutes, seconds) + } else { + String.format(Locale.US, "%d:%02d", minutes, seconds) + } + val durationPostfix = if (isDurationComplete) "" else "+" + return 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. + */ + fun localizeDuration(context: Context, + durationInSecs: Int): String { + require(!(durationInSecs < 0)) { "duration can not be negative" } + val days = (durationInSecs / (24 * 60 * 60L)).toInt() + val hours = (durationInSecs % (24 * 60 * 60L) / (60 * 60L)).toInt() + val minutes = (durationInSecs % (24 * 60 * 60L) % (60 * 60L) / 60L).toInt() + val seconds = (durationInSecs % (24 * 60 * 60L) % (60 * 60L) % 60L).toInt() + val resources = context.resources + return if (days > 0) { + resources.getQuantityString(R.plurals.days, days, days) + } else if (hours > 0) { + resources.getQuantityString(R.plurals.hours, hours, hours) + } else if (minutes > 0) { + resources.getQuantityString(R.plurals.minutes, minutes, minutes) + } else { + 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 (dubbed) + * + * + * @param context the context used to get the app language + * @param track an [AudioStream] of the track + * @return the localized name of the audio track + */ + fun audioTrackName(context: Context, track: AudioStream?): String? { + val name: String? + name = if (track!!.audioLocale != null) { + track.audioLocale!!.getDisplayLanguage(getAppLocale(context)) + } else if (track.audioTrackName != null) { + track.audioTrackName + } else { + context.getString(R.string.unknown_audio_track) + } + if (track.audioTrackType != null) { + val trackType = audioTrackType(context, track.audioTrackType) + if (trackType != null) { + return context.getString(R.string.audio_track_name, name, trackType) + } + } + return name + } + + private fun audioTrackType(context: Context, + trackType: AudioTrackType?): String? { + when (trackType) { + AudioTrackType.ORIGINAL -> return context.getString(R.string.audio_track_type_original) + AudioTrackType.DUBBED -> return context.getString(R.string.audio_track_type_dubbed) + AudioTrackType.DESCRIPTIVE -> return context.getString(R.string.audio_track_type_descriptive) + } + return null + } + + /*////////////////////////////////////////////////////////////////////////// + // Pretty Time + ////////////////////////////////////////////////////////////////////////// */ + fun initPrettyTime(time: PrettyTime) { + prettyTime = time + // Do not use decades as YouTube doesn't either. + prettyTime!!.removeUnit(Decade::class.java) + } + + fun resolvePrettyTime(context: Context): PrettyTime { + return PrettyTime(getAppLocale(context)) + } + + fun relativeTime(offsetDateTime: OffsetDateTime): String { + return prettyTime!!.formatUnrounded(offsetDateTime) + } + + /** + * @param context the Android context; if `null` then even if in debug mode and the + * setting is enabled, `textual` will not be shown next to `parsed` + * @param parsed the textual date or time ago parsed by NewPipeExtractor, or `null` if + * the extractor could not parse it + * @param textual the original textual date or time ago string as provided by services + * @return [.relativeTime] is used if `parsed != null`, otherwise + * `textual` is returned. If in debug mode, `context != null`, + * `parsed != null` and the relevant setting is enabled, `textual` will + * be appended to the returned string for debugging purposes. + */ + fun relativeTimeOrTextual(context: Context?, + parsed: DateWrapper?, + textual: String?): String? { + return if (parsed == null) { + textual + } else if (MainActivity.Companion.DEBUG && (context != null) && PreferenceManager + .getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.show_original_time_ago_key), false)) { + relativeTime(parsed.offsetDateTime()) + " (" + textual + ")" + } else { + relativeTime(parsed.offsetDateTime()) + } + } + + fun assureCorrectAppLanguage(c: Context?) { + val res = c!!.resources + val dm = res.displayMetrics + val conf = res.configuration + conf.setLocale(getAppLocale(c)) + res.updateConfiguration(conf, dm) + } + + private fun getLocaleFromPrefs(context: Context, + @StringRes prefKey: Int): Locale { + val sp = PreferenceManager.getDefaultSharedPreferences(context) + val defaultKey = context.getString(R.string.default_localization_key) + val languageCode = sp.getString(context.getString(prefKey), defaultKey) + return if ((languageCode == defaultKey)) { + Locale.getDefault() + } else { + Locale.forLanguageTag(languageCode) + } + } + + private fun round(value: Double): Double { + return BigDecimal(value).setScale(1, RoundingMode.HALF_UP).toDouble() + } + + private fun getQuantity(context: Context, + @PluralsRes pluralId: Int, + @StringRes zeroCaseStringId: Int, + count: Long, + formattedCount: String): String { + if (count == 0L) { + 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) + val safeCount: Int = MathUtils.clamp(count, Int.MIN_VALUE, Int.MAX_VALUE).toInt() + return context.resources.getQuantityString(pluralId, safeCount, formattedCount) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java deleted file mode 100644 index 5dee32371..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ /dev/null @@ -1,734 +0,0 @@ -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.PlayerService; -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.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; - -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, - final boolean resumePlayback) { - final Intent intent = new Intent(context, targetClazz); - - if (playQueue != null) { - final String cacheKey = SerializedCache.getInstance().put(playQueue, PlayQueue.class); - if (cacheKey != null) { - intent.putExtra(Player.PLAY_QUEUE_KEY, cacheKey); - } - } - intent.putExtra(Player.PLAYER_TYPE, PlayerType.MAIN.valueForIntent()); - intent.putExtra(Player.RESUME_PLAYBACK, resumePlayback); - - return intent; - } - - @NonNull - public static Intent getPlayerIntent(@NonNull final Context context, - @NonNull final Class targetClazz, - @Nullable final PlayQueue playQueue, - final boolean resumePlayback, - final boolean playWhenReady) { - return getPlayerIntent(context, targetClazz, playQueue, resumePlayback) - .putExtra(Player.PLAY_WHEN_READY, playWhenReady); - } - - @NonNull - public static Intent getPlayerEnqueueIntent(@NonNull final Context context, - @NonNull final Class targetClazz, - @Nullable final PlayQueue playQueue) { - // 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 - return getPlayerIntent(context, targetClazz, playQueue, false) - .putExtra(Player.ENQUEUE, true); - } - - @NonNull - public static Intent getPlayerEnqueueNextIntent(@NonNull final Context context, - @NonNull final Class targetClazz, - @Nullable final PlayQueue playQueue) { - // see comment in `getPlayerEnqueueIntent` as to why `resumePlayback` is false - return getPlayerIntent(context, targetClazz, playQueue, false) - .putExtra(Player.ENQUEUE_NEXT, true); - } - - /* 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 Intent intent = getPlayerIntent(context, PlayerService.class, queue, resumePlayback); - intent.putExtra(Player.PLAYER_TYPE, PlayerType.POPUP.valueForIntent()); - 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, resumePlayback); - intent.putExtra(Player.PLAYER_TYPE, PlayerType.AUDIO.valueForIntent()); - 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(); - final Intent intent = getPlayerEnqueueIntent(context, PlayerService.class, queue); - - intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent()); - 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); - - intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent()); - 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 { - final VideoDetailFragment instance = VideoDetailFragment - .getInstance(serviceId, url, 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) { - defaultTransaction(activity.getSupportFragmentManager()) - .replace(R.id.fragment_holder, new CommentRepliesFragment(comment), - CommentRepliesFragment.TAG) - .addToBackStack(CommentRepliesFragment.TAG) - .commit(); - } - - 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()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.kt b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.kt new file mode 100644 index 000000000..c314d111e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.kt @@ -0,0 +1,672 @@ +package org.schabi.newpipe.util + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.text.TextUtils +import android.util.Log +import android.widget.Toast +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.Companion.showUiErrorSnackbar +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.StreamingService +import org.schabi.newpipe.extractor.StreamingService.LinkType +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.Companion.newInstance +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.PlayerService +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.PlayQueue +import org.schabi.newpipe.player.playqueue.PlayQueueItem +import org.schabi.newpipe.settings.SettingsActivity +import org.schabi.newpipe.util.external_communication.ShareUtils + +object NavigationHelper { + val MAIN_FRAGMENT_TAG: String = "main_fragment_tag" + val SEARCH_FRAGMENT_TAG: String = "search_fragment_tag" + private val TAG: String = NavigationHelper::class.java.getSimpleName() + + /*////////////////////////////////////////////////////////////////////////// + // Players + ////////////////////////////////////////////////////////////////////////// */ + /* INTENT */ + fun getPlayerIntent(context: Context, + targetClazz: Class, + playQueue: PlayQueue?, + resumePlayback: Boolean): Intent { + val intent: Intent = Intent(context, targetClazz) + if (playQueue != null) { + val cacheKey: String? = SerializedCache.Companion.getInstance().put(playQueue, PlayQueue::class.java) + if (cacheKey != null) { + intent.putExtra(Player.Companion.PLAY_QUEUE_KEY, cacheKey) + } + } + intent.putExtra(Player.Companion.PLAYER_TYPE, PlayerType.MAIN.valueForIntent()) + intent.putExtra(Player.Companion.RESUME_PLAYBACK, resumePlayback) + return intent + } + + fun getPlayerIntent(context: Context, + targetClazz: Class, + playQueue: PlayQueue?, + resumePlayback: Boolean, + playWhenReady: Boolean): Intent { + return getPlayerIntent(context, targetClazz, playQueue, resumePlayback) + .putExtra(Player.Companion.PLAY_WHEN_READY, playWhenReady) + } + + fun getPlayerEnqueueIntent(context: Context, + targetClazz: Class, + playQueue: PlayQueue?): Intent { + // 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 + return getPlayerIntent(context, targetClazz, playQueue, false) + .putExtra(Player.Companion.ENQUEUE, true) + } + + fun getPlayerEnqueueNextIntent(context: Context, + targetClazz: Class, + playQueue: PlayQueue?): Intent { + // see comment in `getPlayerEnqueueIntent` as to why `resumePlayback` is false + return getPlayerIntent(context, targetClazz, playQueue, false) + .putExtra(Player.Companion.ENQUEUE_NEXT, true) + } + + /* PLAY */ + fun playOnMainPlayer(activity: AppCompatActivity, + playQueue: PlayQueue) { + val item: PlayQueueItem? = playQueue.getItem() + if (item != null) { + openVideoDetailFragment(activity, activity.getSupportFragmentManager(), + item.getServiceId(), item.getUrl(), item.getTitle(), playQueue, + false) + } + } + + fun playOnMainPlayer(context: Context, + playQueue: PlayQueue, + switchingPlayers: Boolean) { + val item: PlayQueueItem? = playQueue.getItem() + if (item != null) { + openVideoDetail(context, + item.getServiceId(), item.getUrl(), item.getTitle(), playQueue, + switchingPlayers) + } + } + + fun playOnPopupPlayer(context: Context?, + queue: PlayQueue?, + resumePlayback: Boolean) { + if (!PermissionHelper.isPopupEnabledElseAsk(context)) { + return + } + Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show() + val intent: Intent = getPlayerIntent((context)!!, PlayerService::class.java, queue, resumePlayback) + intent.putExtra(Player.Companion.PLAYER_TYPE, PlayerType.POPUP.valueForIntent()) + ContextCompat.startForegroundService((context), intent) + } + + fun playOnBackgroundPlayer(context: Context?, + queue: PlayQueue?, + resumePlayback: Boolean) { + Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT) + .show() + val intent: Intent = getPlayerIntent((context)!!, PlayerService::class.java, queue, resumePlayback) + intent.putExtra(Player.Companion.PLAYER_TYPE, PlayerType.AUDIO.valueForIntent()) + ContextCompat.startForegroundService((context), intent) + } + + /* ENQUEUE */ + fun enqueueOnPlayer(context: Context, + queue: PlayQueue?, + playerType: PlayerType) { + if (playerType == PlayerType.POPUP && !PermissionHelper.isPopupEnabledElseAsk(context)) { + return + } + Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show() + val intent: Intent = getPlayerEnqueueIntent(context, PlayerService::class.java, queue) + intent.putExtra(Player.Companion.PLAYER_TYPE, playerType.valueForIntent()) + ContextCompat.startForegroundService(context, intent) + } + + fun enqueueOnPlayer(context: Context?, queue: PlayQueue?) { + var playerType: PlayerType? = PlayerHolder.Companion.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 */ + fun enqueueNextOnPlayer(context: Context?, queue: PlayQueue?) { + var playerType: PlayerType? = PlayerHolder.Companion.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() + val intent: Intent = getPlayerEnqueueNextIntent((context)!!, PlayerService::class.java, queue) + intent.putExtra(Player.Companion.PLAYER_TYPE, playerType.valueForIntent()) + ContextCompat.startForegroundService((context), intent) + } + + /*////////////////////////////////////////////////////////////////////////// + // External Players + ////////////////////////////////////////////////////////////////////////// */ + fun playOnExternalAudioPlayer(context: Context, + info: StreamInfo) { + val audioStreams: List? = info.getAudioStreams() + if (audioStreams == null || audioStreams.isEmpty()) { + Toast.makeText(context, R.string.audio_streams_empty, Toast.LENGTH_SHORT).show() + return + } + val audioStreamsForExternalPlayers: List = ListHelper.getUrlAndNonTorrentStreams(audioStreams) + if (audioStreamsForExternalPlayers.isEmpty()) { + Toast.makeText(context, R.string.no_audio_streams_available_for_external_players, + Toast.LENGTH_SHORT).show() + return + } + val index: Int = ListHelper.getDefaultAudioFormat(context, audioStreamsForExternalPlayers) + val audioStream: AudioStream? = audioStreamsForExternalPlayers.get(index) + playOnExternalPlayer(context, info.getName(), info.getUploaderName(), (audioStream)!!) + } + + fun playOnExternalVideoPlayer(context: Context, + info: StreamInfo) { + val videoStreams: List? = info.getVideoStreams() + if (videoStreams == null || videoStreams.isEmpty()) { + Toast.makeText(context, R.string.video_streams_empty, Toast.LENGTH_SHORT).show() + return + } + val videoStreamsForExternalPlayers: List = ListHelper.getSortedStreamVideosList(context, + ListHelper.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 + } + val index: Int = ListHelper.getDefaultResolutionIndex(context, + videoStreamsForExternalPlayers) + val videoStream: VideoStream? = videoStreamsForExternalPlayers.get(index) + playOnExternalPlayer(context, info.getName(), info.getUploaderName(), (videoStream)!!) + } + + fun playOnExternalPlayer(context: Context, + name: String?, + artist: String?, + stream: Stream) { + val deliveryMethod: DeliveryMethod = stream.getDeliveryMethod() + val mimeType: String + if (!stream.isUrl() || deliveryMethod == DeliveryMethod.TORRENT) { + Toast.makeText(context, R.string.selected_stream_external_player_not_supported, + Toast.LENGTH_SHORT).show() + return + } + when (deliveryMethod) { + DeliveryMethod.PROGRESSIVE_HTTP -> if (stream.getFormat() == null) { + if (stream is AudioStream) { + mimeType = "audio/*" + } else if (stream is VideoStream) { + mimeType = "video/*" + } else { + // This should never be reached, because subtitles are not opened in + // external players + return + } + } else { + mimeType = stream.getFormat()!!.getMimeType() + } + + DeliveryMethod.HLS -> mimeType = "application/x-mpegURL" + DeliveryMethod.DASH -> mimeType = "application/dash+xml" + DeliveryMethod.SS -> mimeType = "application/vnd.ms-sstr+xml" + else -> // Torrent streams are not exposed to external players + mimeType = "" + } + val intent: Intent = 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) + } + + fun resolveActivityOrAskToInstall(context: Context, + intent: Intent) { + if (!ShareUtils.tryOpenIntentInApp(context, intent)) { + if (context is Activity) { + AlertDialog.Builder(context) + .setMessage(R.string.no_player_found) + .setPositiveButton(R.string.install, DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> + ShareUtils.installApp(context, + context.getString(R.string.vlc_package)) + })) + .setNegativeButton(R.string.cancel, DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> 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 fun defaultTransaction(fragmentManager: FragmentManager?): FragmentTransaction { + return fragmentManager!!.beginTransaction() + .setCustomAnimations(R.animator.custom_fade_in, R.animator.custom_fade_out, + R.animator.custom_fade_in, R.animator.custom_fade_out) + } + + fun gotoMainFragment(fragmentManager: FragmentManager?) { + val popped: Boolean = fragmentManager!!.popBackStackImmediate(MAIN_FRAGMENT_TAG, 0) + if (!popped) { + openMainFragment(fragmentManager) + } + } + + fun openMainFragment(fragmentManager: FragmentManager?) { + InfoCache.Companion.getInstance().trimCache() + fragmentManager.popBackStackImmediate(null, FragmentManager.POP_BACK_STACK_INCLUSIVE) + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, MainFragment()) + .addToBackStack(MAIN_FRAGMENT_TAG) + .commit() + } + + fun tryGotoSearchFragment(fragmentManager: FragmentManager): Boolean { + if (MainActivity.Companion.DEBUG) { + for (i in 0 until fragmentManager.getBackStackEntryCount()) { + Log.d("NavigationHelper", ("tryGoToSearchFragment() [" + i + "]" + + " = [" + fragmentManager.getBackStackEntryAt(i) + "]")) + } + } + return fragmentManager.popBackStackImmediate(SEARCH_FRAGMENT_TAG, 0) + } + + fun openSearchFragment(fragmentManager: FragmentManager?, + serviceId: Int, searchString: String?) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, SearchFragment.Companion.getInstance(serviceId, searchString)) + .addToBackStack(SEARCH_FRAGMENT_TAG) + .commit() + } + + fun expandMainPlayer(context: Context) { + context.sendBroadcast(Intent(VideoDetailFragment.Companion.ACTION_SHOW_MAIN_PLAYER)) + } + + fun sendPlayerStartedEvent(context: Context) { + context.sendBroadcast(Intent(VideoDetailFragment.Companion.ACTION_PLAYER_STARTED)) + } + + fun showMiniPlayer(fragmentManager: FragmentManager?) { + val instance: VideoDetailFragment = VideoDetailFragment.Companion.getInstanceInCollapsedState() + defaultTransaction(fragmentManager) + .replace(R.id.fragment_player_holder, instance) + .runOnCommit(Runnable({ sendPlayerStartedEvent(instance.requireActivity()) })) + .commitAllowingStateLoss() + } + + fun openVideoDetailFragment(context: Context, + fragmentManager: FragmentManager, + serviceId: Int, + url: String?, + title: String, + playQueue: PlayQueue?, + switchingPlayers: Boolean) { + val autoPlay: Boolean + val playerType: PlayerType? = PlayerHolder.Companion.getInstance().getType() + if (playerType == null) { + // no player open + autoPlay = PlayerHelper.isAutoplayAllowedByUser(context) + } else if (switchingPlayers) { + // switching player to main player + autoPlay = PlayerHolder.Companion.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 + } + val onVideoDetailFragmentReady: RunnableWithVideoDetailFragment = RunnableWithVideoDetailFragment({ detailFragment: VideoDetailFragment -> + 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() + }) + val fragment: Fragment? = fragmentManager.findFragmentById(R.id.fragment_player_holder) + if (fragment is VideoDetailFragment && fragment.isVisible()) { + onVideoDetailFragmentReady.run(fragment as VideoDetailFragment?) + } else { + val instance: VideoDetailFragment = VideoDetailFragment.Companion.getInstance(serviceId, url, title, playQueue) + instance.setAutoPlay(autoPlay) + defaultTransaction(fragmentManager) + .replace(R.id.fragment_player_holder, instance) + .runOnCommit(Runnable({ onVideoDetailFragmentReady.run(instance) })) + .commit() + } + } + + fun openChannelFragment(fragmentManager: FragmentManager?, + serviceId: Int, url: String?, + name: String) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, ChannelFragment.Companion.getInstance(serviceId, url, name)) + .addToBackStack(null) + .commit() + } + + fun openChannelFragment(fragment: Fragment, + item: StreamInfoItem, + uploaderUrl: String?) { + // 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 [CommentsInfoItem.getUploaderUrl] + * of `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 + */ + fun openCommentAuthorIfPresent(activity: FragmentActivity, + comment: CommentsInfoItem) { + if (TextUtils.isEmpty(comment.getUploaderUrl())) { + return + } + try { + openChannelFragment(activity.getSupportFragmentManager(), comment.getServiceId(), + comment.getUploaderUrl(), comment.getUploaderName()) + } catch (e: Exception) { + showUiErrorSnackbar(activity, "Opening channel fragment", e) + } + } + + fun openCommentRepliesFragment(activity: FragmentActivity, + comment: CommentsInfoItem) { + defaultTransaction(activity.getSupportFragmentManager()) + .replace(R.id.fragment_holder, CommentRepliesFragment(comment), + CommentRepliesFragment.Companion.TAG) + .addToBackStack(CommentRepliesFragment.Companion.TAG) + .commit() + } + + fun openPlaylistFragment(fragmentManager: FragmentManager?, + serviceId: Int, url: String?, + name: String) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, PlaylistFragment.Companion.getInstance(serviceId, url, name)) + .addToBackStack(null) + .commit() + } + + @JvmOverloads + fun openFeedFragment(fragmentManager: FragmentManager?, groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + groupName: String? = null) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, newInstance(groupId, groupName)) + .addToBackStack(null) + .commit() + } + + fun openBookmarksFragment(fragmentManager: FragmentManager?) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, BookmarkFragment()) + .addToBackStack(null) + .commit() + } + + fun openSubscriptionFragment(fragmentManager: FragmentManager?) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, SubscriptionFragment()) + .addToBackStack(null) + .commit() + } + + @Throws(ExtractionException::class) + fun openKioskFragment(fragmentManager: FragmentManager?, serviceId: Int, + kioskId: String?) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, KioskFragment.Companion.getInstance(serviceId, kioskId)) + .addToBackStack(null) + .commit() + } + + fun openLocalPlaylistFragment(fragmentManager: FragmentManager?, + playlistId: Long, name: String?) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, LocalPlaylistFragment.Companion.getInstance(playlistId, + if (name == null) "" else name)) + .addToBackStack(null) + .commit() + } + + fun openStatisticFragment(fragmentManager: FragmentManager?) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, StatisticsPlaylistFragment()) + .addToBackStack(null) + .commit() + } + + fun openSubscriptionsImportFragment(fragmentManager: FragmentManager?, + serviceId: Int) { + defaultTransaction(fragmentManager) + .replace(R.id.fragment_holder, SubscriptionsImportFragment.Companion.getInstance(serviceId)) + .addToBackStack(null) + .commit() + } + + /*////////////////////////////////////////////////////////////////////////// + // Through Intents + ////////////////////////////////////////////////////////////////////////// */ + fun openSearch(context: Context, serviceId: Int, + searchString: String?) { + val mIntent: Intent = Intent(context, MainActivity::class.java) + mIntent.putExtra(KEY_SERVICE_ID, serviceId) + mIntent.putExtra(KEY_SEARCH_STRING, searchString) + mIntent.putExtra(KEY_OPEN_SEARCH, true) + context.startActivity(mIntent) + } + + fun openVideoDetail(context: Context, + serviceId: Int, + url: String?, + title: String, + playQueue: PlayQueue?, + switchingPlayers: Boolean) { + val intent: Intent = getStreamIntent(context, serviceId, url, title) + .putExtra(VideoDetailFragment.Companion.KEY_SWITCHING_PLAYERS, switchingPlayers) + if (playQueue != null) { + val cacheKey: String? = SerializedCache.Companion.getInstance().put(playQueue, PlayQueue::class.java) + if (cacheKey != null) { + intent.putExtra(Player.Companion.PLAY_QUEUE_KEY, cacheKey) + } + } + context.startActivity(intent) + } + + /** + * Opens [ChannelFragment]. + * Use this instead of [.openChannelFragment] + * when no fragments are used / no FragmentManager is available. + * @param context + * @param serviceId + * @param url + * @param title + */ + fun openChannelFragmentUsingIntent(context: Context, + serviceId: Int, + url: String?, + title: String) { + val intent: Intent = getOpenIntent(context, url, serviceId, + LinkType.CHANNEL) + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.putExtra(KEY_TITLE, title) + context.startActivity(intent) + } + + fun openMainActivity(context: Context) { + val mIntent: Intent = Intent(context, MainActivity::class.java) + mIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + mIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) + context.startActivity(mIntent) + } + + fun openRouterActivity(context: Context, url: String?) { + val mIntent: Intent = Intent(context, RouterActivity::class.java) + mIntent.setData(Uri.parse(url)) + context.startActivity(mIntent) + } + + fun openAbout(context: Context) { + val intent: Intent = Intent(context, AboutActivity::class.java) + context.startActivity(intent) + } + + fun openSettings(context: Context) { + val intent: Intent = Intent(context, SettingsActivity::class.java) + context.startActivity(intent) + } + + fun openDownloads(activity: Activity) { + if (PermissionHelper.checkStoragePermissions( + activity, PermissionHelper.DOWNLOADS_REQUEST_CODE)) { + val intent: Intent = Intent(activity, DownloadActivity::class.java) + activity.startActivity(intent) + } + } + + fun getPlayQueueActivityIntent(context: Context?): Intent { + val intent: Intent = Intent(context, PlayQueueActivity::class.java) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + return intent + } + + fun openPlayQueue(context: Context) { + val intent: Intent = Intent(context, PlayQueueActivity::class.java) + context.startActivity(intent) + } + + /*////////////////////////////////////////////////////////////////////////// + // Link handling + ////////////////////////////////////////////////////////////////////////// */ + private fun getOpenIntent(context: Context, url: String?, + serviceId: Int, type: LinkType): Intent { + val mIntent: Intent = Intent(context, MainActivity::class.java) + mIntent.putExtra(KEY_SERVICE_ID, serviceId) + mIntent.putExtra(KEY_URL, url) + mIntent.putExtra(KEY_LINK_TYPE, type) + return mIntent + } + + @Throws(ExtractionException::class) + fun getIntentByLink(context: Context, url: String?): Intent { + return getIntentByLink(context, NewPipe.getServiceByUrl(url), url) + } + + @Throws(ExtractionException::class) + fun getIntentByLink(context: Context, + service: StreamingService, + url: String?): Intent { + val linkType: LinkType = service.getLinkTypeByUrl(url) + if (linkType == LinkType.NONE) { + throw ExtractionException(("Url not known to service. service=" + service + + " url=" + url)) + } + return getOpenIntent(context, url, service.getServiceId(), linkType) + } + + fun getChannelIntent(context: Context, + serviceId: Int, + url: String?): Intent { + return getOpenIntent(context, url, serviceId, LinkType.CHANNEL) + } + + fun getStreamIntent(context: Context, + serviceId: Int, + url: String?, + title: String?): Intent { + return getOpenIntent(context, url, serviceId, LinkType.STREAM) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(KEY_TITLE, title) + } + + /** + * Finish this `Activity` as well as all `Activities` running below it + * and then start `MainActivity`. + * + * @param activity the activity to finish + */ + fun restartApp(activity: Activity?) { + NewPipeDatabase.close() + ProcessPhoenix.triggerRebirth(activity!!.getApplicationContext()) + } + + private open interface RunnableWithVideoDetailFragment { + fun run(detailFragment: VideoDetailFragment?) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/NewPipeTextViewHelper.java b/app/src/main/java/org/schabi/newpipe/util/NewPipeTextViewHelper.java deleted file mode 100644 index cf1a9a03a..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/NewPipeTextViewHelper.java +++ /dev/null @@ -1,61 +0,0 @@ -package org.schabi.newpipe.util; - -import android.content.Context; -import android.text.Selection; -import android.text.Spannable; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.views.NewPipeEditText; -import org.schabi.newpipe.views.NewPipeTextView; - -public final class NewPipeTextViewHelper { - private NewPipeTextViewHelper() { - } - - /** - * Share the selected text of {@link NewPipeTextView NewPipeTextViews} and - * {@link NewPipeEditText NewPipeEditTexts} with - * {@link ShareUtils#shareText(Context, String, String)}. - * - *

- * This allows EMUI users to get the Android share sheet instead of the EMUI share sheet when - * using the {@code Share} command of the popup menu which appears when selecting text. - *

- * - * @param textView the {@link TextView} on which sharing the selected text. It should be a - * {@link NewPipeTextView} or a {@link NewPipeEditText} (even if - * {@link TextView standard TextViews} are supported). - */ - public static void shareSelectedTextWithShareUtils(@NonNull final TextView textView) { - final CharSequence textViewText = textView.getText(); - shareSelectedTextIfNotNullAndNotEmpty(textView, getSelectedText(textView, textViewText)); - if (textViewText instanceof Spannable) { - Selection.setSelection((Spannable) textViewText, textView.getSelectionEnd()); - } - } - - @Nullable - private static CharSequence getSelectedText(@NonNull final TextView textView, - @Nullable final CharSequence text) { - if (!textView.hasSelection() || text == null) { - return null; - } - - final int start = textView.getSelectionStart(); - final int end = textView.getSelectionEnd(); - return String.valueOf(start > end ? text.subSequence(end, start) - : text.subSequence(start, end)); - } - - private static void shareSelectedTextIfNotNullAndNotEmpty( - @NonNull final TextView textView, - @Nullable final CharSequence selectedText) { - if (selectedText != null && selectedText.length() != 0) { - ShareUtils.shareText(textView.getContext(), "", selectedText.toString()); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/NewPipeTextViewHelper.kt b/app/src/main/java/org/schabi/newpipe/util/NewPipeTextViewHelper.kt new file mode 100644 index 000000000..bda014b8e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/NewPipeTextViewHelper.kt @@ -0,0 +1,49 @@ +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][NewPipeTextView] and + * [NewPipeEditTexts][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 + * [NewPipeTextView] or a [NewPipeEditText] (even if + * [standard TextViews][TextView] are supported). + */ + fun shareSelectedTextWithShareUtils(textView: TextView) { + val textViewText: CharSequence = textView.getText() + shareSelectedTextIfNotNullAndNotEmpty(textView, getSelectedText(textView, textViewText)) + if (textViewText is Spannable) { + Selection.setSelection(textViewText as Spannable?, textView.getSelectionEnd()) + } + } + + private fun getSelectedText(textView: TextView, + text: CharSequence?): CharSequence? { + if (!textView.hasSelection() || text == null) { + return null + } + val start: Int = textView.getSelectionStart() + val end: Int = textView.getSelectionEnd() + return (if (start > end) text.subSequence(end, start) else text.subSequence(start, end)).toString() + } + + private fun shareSelectedTextIfNotNullAndNotEmpty( + textView: TextView, + selectedText: CharSequence?) { + if (selectedText != null && selectedText.length != 0) { + shareText(textView.getContext(), "", selectedText.toString()) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/OnClickGesture.java b/app/src/main/java/org/schabi/newpipe/util/OnClickGesture.java deleted file mode 100644 index ae8d86af1..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/OnClickGesture.java +++ /dev/null @@ -1,15 +0,0 @@ -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 - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/OnClickGesture.kt b/app/src/main/java/org/schabi/newpipe/util/OnClickGesture.kt new file mode 100644 index 000000000..f45715157 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/OnClickGesture.kt @@ -0,0 +1,14 @@ +package org.schabi.newpipe.util + +import androidx.recyclerview.widget.RecyclerView + +open interface OnClickGesture { + fun selected(selectedItem: T) + fun held(selectedItem: T) { + // Optional gesture + } + + fun drag(selectedItem: T, viewHolder: RecyclerView.ViewHolder?) { + // Optional gesture + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/PeertubeHelper.java b/app/src/main/java/org/schabi/newpipe/util/PeertubeHelper.java deleted file mode 100644 index 34f99d262..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/PeertubeHelper.java +++ /dev/null @@ -1,69 +0,0 @@ -package org.schabi.newpipe.util; - -import android.content.Context; -import android.content.SharedPreferences; - -import androidx.preference.PreferenceManager; - -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 org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.ServiceList; -import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; - -import java.util.ArrayList; -import java.util.List; - -public final class PeertubeHelper { - private PeertubeHelper() { } - - public static List getInstanceList(final Context context) { - final SharedPreferences sharedPreferences = PreferenceManager - .getDefaultSharedPreferences(context); - final String savedInstanceListKey = context.getString(R.string.peertube_instance_list_key); - final String savedJson = sharedPreferences.getString(savedInstanceListKey, null); - if (null == savedJson) { - return List.of(getCurrentInstance()); - } - - try { - final JsonArray array = JsonParser.object().from(savedJson).getArray("instances"); - final List result = new ArrayList<>(); - for (final Object o : array) { - if (o instanceof JsonObject) { - final JsonObject instance = (JsonObject) o; - final String name = instance.getString("name"); - final String url = instance.getString("url"); - result.add(new PeertubeInstance(url, name)); - } - } - return result; - } catch (final JsonParserException e) { - return List.of(getCurrentInstance()); - } - } - - public static PeertubeInstance selectInstance(final PeertubeInstance instance, - final Context context) { - final SharedPreferences sharedPreferences = PreferenceManager - .getDefaultSharedPreferences(context); - final String selectedInstanceKey = - context.getString(R.string.peertube_selected_instance_key); - final JsonStringWriter jsonWriter = JsonWriter.string().object(); - jsonWriter.value("name", instance.getName()); - jsonWriter.value("url", instance.getUrl()); - final String jsonToSave = jsonWriter.end().done(); - sharedPreferences.edit().putString(selectedInstanceKey, jsonToSave).apply(); - ServiceList.PeerTube.setInstance(instance); - return instance; - } - - public static PeertubeInstance getCurrentInstance() { - return ServiceList.PeerTube.getInstance(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/PeertubeHelper.kt b/app/src/main/java/org/schabi/newpipe/util/PeertubeHelper.kt new file mode 100644 index 000000000..cd37b8c8e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/PeertubeHelper.kt @@ -0,0 +1,60 @@ +package org.schabi.newpipe.util + +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.PreferenceManager +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 org.schabi.newpipe.R +import org.schabi.newpipe.extractor.ServiceList +import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance + +object PeertubeHelper { + fun getInstanceList(context: Context): List { + val sharedPreferences: SharedPreferences = PreferenceManager + .getDefaultSharedPreferences(context) + val savedInstanceListKey: String = context.getString(R.string.peertube_instance_list_key) + val savedJson: String? = sharedPreferences.getString(savedInstanceListKey, null) + if (null == savedJson) { + return java.util.List.of(currentInstance) + } + try { + val array: JsonArray = JsonParser.`object`().from(savedJson).getArray("instances") + val result: MutableList = ArrayList() + for (o: Any? in array) { + if (o is JsonObject) { + val instance: JsonObject = o + val name: String = instance.getString("name") + val url: String = instance.getString("url") + result.add(PeertubeInstance(url, name)) + } + } + return result + } catch (e: JsonParserException) { + return java.util.List.of(currentInstance) + } + } + + fun selectInstance(instance: PeertubeInstance?, + context: Context): PeertubeInstance? { + val sharedPreferences: SharedPreferences = PreferenceManager + .getDefaultSharedPreferences(context) + val selectedInstanceKey: String = context.getString(R.string.peertube_selected_instance_key) + val jsonWriter: JsonStringWriter = JsonWriter.string().`object`() + jsonWriter.value("name", instance!!.getName()) + jsonWriter.value("url", instance.getUrl()) + val jsonToSave: String = jsonWriter.end().done() + sharedPreferences.edit().putString(selectedInstanceKey, jsonToSave).apply() + ServiceList.PeerTube.setInstance(instance) + return instance + } + + val currentInstance: PeertubeInstance + get() { + return ServiceList.PeerTube.getInstance() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java b/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java deleted file mode 100644 index 55193599e..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java +++ /dev/null @@ -1,146 +0,0 @@ -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.widget.Toast; - -import androidx.annotation.RequiresApi; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; - -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) { - ActivityCompat.requestPermissions(activity, - new String[] {Manifest.permission.POST_NOTIFICATIONS}, requestCode); - 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)) { - 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; - } 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; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.kt b/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.kt new file mode 100644 index 000000000..ac4f38650 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.kt @@ -0,0 +1,136 @@ +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.widget.Toast +import androidx.annotation.RequiresApi +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import org.schabi.newpipe.R +import org.schabi.newpipe.settings.NewPipeSettings + +object PermissionHelper { + val POST_NOTIFICATIONS_REQUEST_CODE: Int = 779 + val DOWNLOAD_DIALOG_REQUEST_CODE: Int = 778 + val DOWNLOADS_REQUEST_CODE: Int = 777 + fun checkStoragePermissions(activity: Activity?, requestCode: Int): Boolean { + if (NewPipeSettings.useStorageAccessFramework(activity)) { + return true // Storage permissions are not needed for SAF + } + if (!checkReadStoragePermissions(activity, requestCode)) { + return false + } + return checkWriteStoragePermissions(activity, requestCode) + } + + fun checkReadStoragePermissions(activity: Activity?, + requestCode: Int): Boolean { + if ((ContextCompat.checkSelfPermission((activity)!!, Manifest.permission.READ_EXTERNAL_STORAGE) + != PackageManager.PERMISSION_GRANTED)) { + ActivityCompat.requestPermissions((activity), arrayOf( + Manifest.permission.READ_EXTERNAL_STORAGE, + Manifest.permission.WRITE_EXTERNAL_STORAGE), + requestCode) + return false + } + return true + } + + fun checkWriteStoragePermissions(activity: Activity?, + requestCode: Int): Boolean { + // 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), arrayOf(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 + } + + fun checkPostNotificationsPermission(activity: Activity?, + requestCode: Int): Boolean { + if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + && ContextCompat.checkSelfPermission((activity)!!, + Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED)) { + ActivityCompat.requestPermissions((activity), arrayOf(Manifest.permission.POST_NOTIFICATIONS), requestCode) + 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 [Context] + * @return [Settings.canDrawOverlays] + */ + @RequiresApi(api = Build.VERSION_CODES.M) + fun checkSystemAlertWindowPermission(context: Context?): Boolean { + if (!Settings.canDrawOverlays(context)) { + val i: Intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:" + context!!.getPackageName())) + i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + context.startActivity(i) + } catch (ignored: ActivityNotFoundException) { + } + 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 [.checkSystemAlertWindowPermission] and shows a + * toast to the user explaining why the permission is needed. + * + * @param context the Android context + * @return whether the popup is enabled + */ + fun isPopupEnabledElseAsk(context: Context?): Boolean { + 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 + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.java b/app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.java deleted file mode 100644 index 9727c8083..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.java +++ /dev/null @@ -1,90 +0,0 @@ -package org.schabi.newpipe.util; - -import android.content.Context; -import android.widget.Toast; - -import androidx.annotation.NonNull; -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. - */ -public final class PlayButtonHelper { - - private PlayButtonHelper() { - // utility class - } - - /** - * Initialize {@link android.view.View.OnClickListener OnClickListener} - * and {@link android.view.View.OnLongClickListener OnLongClickListener} for playlist control - * buttons defined in {@link R.layout#playlist_control}. - * - * @param activity The activity to use for the {@link android.widget.Toast Toast}. - * @param playlistControlBinding The binding of the - * {@link R.layout#playlist_control playlist control layout}. - * @param fragment The fragment to get the play queue from. - */ - public static void initPlaylistControlClickListener( - @NonNull final AppCompatActivity activity, - @NonNull final PlaylistControlBinding playlistControlBinding, - @NonNull final PlaylistControlViewHolder fragment) { - // click listener - playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view -> { - NavigationHelper.playOnMainPlayer(activity, fragment.getPlayQueue()); - showHoldToAppendToastIfNeeded(activity); - }); - playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view -> { - NavigationHelper.playOnPopupPlayer(activity, fragment.getPlayQueue(), false); - showHoldToAppendToastIfNeeded(activity); - }); - playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view -> { - NavigationHelper.playOnBackgroundPlayer(activity, fragment.getPlayQueue(), false); - showHoldToAppendToastIfNeeded(activity); - }); - - // long click listener - playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> { - NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.POPUP); - return true; - }); - playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> { - NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.AUDIO); - return true; - }); - } - - /** - * Show the "hold to append" toast if the corresponding preference is enabled. - * - * @param context The context to show the toast. - */ - private static void showHoldToAppendToastIfNeeded(@NonNull final 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 {@code true} if the tip should be shown, {@code false} otherwise. - */ - public static boolean shouldShowHoldToAppendTip(@NonNull final Context context) { - return PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean(context.getString(R.string.show_hold_to_append_key), true); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.kt b/app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.kt new file mode 100644 index 000000000..d60cd78ae --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.kt @@ -0,0 +1,84 @@ +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][android.view.View.OnClickListener] + * and [OnLongClickListener][android.view.View.OnLongClickListener] for playlist control + * buttons defined in [R.layout.playlist_control]. + * + * @param activity The activity to use for the [Toast][android.widget.Toast]. + * @param playlistControlBinding The binding of the + * [playlist control layout][R.layout.playlist_control]. + * @param fragment The fragment to get the play queue from. + */ + fun initPlaylistControlClickListener( + activity: AppCompatActivity, + playlistControlBinding: PlaylistControlBinding, + fragment: PlaylistControlViewHolder) { + // click listener + playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(View.OnClickListener({ view: View? -> + NavigationHelper.playOnMainPlayer(activity, fragment.getPlayQueue()) + showHoldToAppendToastIfNeeded(activity) + })) + playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(View.OnClickListener({ view: View? -> + NavigationHelper.playOnPopupPlayer(activity, fragment.getPlayQueue(), false) + showHoldToAppendToastIfNeeded(activity) + })) + playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(View.OnClickListener({ view: View? -> + NavigationHelper.playOnBackgroundPlayer(activity, fragment.getPlayQueue(), false) + showHoldToAppendToastIfNeeded(activity) + })) + + // long click listener + playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(OnLongClickListener({ view: View? -> + NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.POPUP) + true + })) + playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(OnLongClickListener({ view: View? -> + 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. + */ + fun shouldShowHoldToAppendTip(context: Context): Boolean { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.show_hold_to_append_key), true) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java deleted file mode 100644 index 69dc697fe..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java +++ /dev/null @@ -1,75 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.kt b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.kt new file mode 100644 index 000000000..206aec730 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.kt @@ -0,0 +1,69 @@ +package org.schabi.newpipe.util + +import android.content.Context +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.function.Predicate + +class SecondaryStreamHelper(private val streams: StreamInfoWrapper, + selectedStream: T) { + private val position: Int + + init { + position = streams.getStreamsList().indexOf(selectedStream) + if (position < 0) { + throw RuntimeException("selected stream not found") + } + } + + val stream: T? + get() { + return streams.getStreamsList().get(position) + } + val sizeInBytes: Long + get() { + return streams.getSizeInBytes(position) + } + + companion object { + /** + * 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 + * [ListHelper.isLimitingDataUsage]. + * + * @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 + */ + fun getAudioStreamFor(context: Context, + audioStreams: List, + videoStream: VideoStream): AudioStream? { + val mediaFormat: MediaFormat? = videoStream.getFormat() + if (mediaFormat == MediaFormat.WEBM) { + return audioStreams + .stream() + .filter(Predicate({ audioStream: 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(Predicate({ audioStream: AudioStream? -> audioStream!!.getFormat() == MediaFormat.M4A })) + .max(ListHelper.getAudioFormatComparator(MediaFormat.M4A, + ListHelper.isLimitingDataUsage(context))) + .orElse(null) + } else { + return null + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/SerializedCache.java b/app/src/main/java/org/schabi/newpipe/util/SerializedCache.java deleted file mode 100644 index b4c196ce4..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/SerializedCache.java +++ /dev/null @@ -1,120 +0,0 @@ -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; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/SerializedCache.kt b/app/src/main/java/org/schabi/newpipe/util/SerializedCache.kt new file mode 100644 index 000000000..baf83d157 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/SerializedCache.kt @@ -0,0 +1,89 @@ +package org.schabi.newpipe.util + +import android.util.Log +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 + +class SerializedCache private constructor() { + fun take(key: String, type: Class): T? { + if (DEBUG) { + Log.d(TAG, "take() called with: key = [" + key + "]") + } + synchronized(LRU_CACHE, { return if (LRU_CACHE.get(key) != null) getItem(LRU_CACHE.remove(key)!!, type) else null }) + } + + operator fun get(key: String, type: Class): T? { + if (DEBUG) { + Log.d(TAG, "get() called with: key = [" + key + "]") + } + synchronized(LRU_CACHE, { + val data: CacheData<*>? = LRU_CACHE.get(key) + return if (data != null) getItem(data, type) else null + }) + } + + fun put(item: T, + type: Class): String? { + val key: String = UUID.randomUUID().toString() + return if (put(key, item, type)) key else null + } + + fun put(key: String, item: T, + type: Class): Boolean { + if (DEBUG) { + Log.d(TAG, "put() called with: key = [" + key + "], item = [" + item + "]") + } + synchronized(LRU_CACHE, { + try { + LRU_CACHE.put(key, CacheData(clone(item, type), type)) + return true + } catch (error: Exception) { + Log.e(TAG, "Serialization failed for: ", error) + } + }) + return false + } + + fun clear() { + if (DEBUG) { + Log.d(TAG, "clear() called") + } + synchronized(LRU_CACHE, { LRU_CACHE.evictAll() }) + } + + fun size(): Long { + synchronized(LRU_CACHE, { return LRU_CACHE.size().toLong() }) + } + + private fun getItem(data: CacheData<*>, type: Class): T? { + return if (type.isAssignableFrom(data.type)) type.cast(data.item) else null + } + + @Throws(Exception::class) + private fun clone(item: T, + type: Class): T { + val bytesOutput: ByteArrayOutputStream = ByteArrayOutputStream() + ObjectOutputStream(bytesOutput).use({ objectOutput -> + objectOutput.writeObject(item) + objectOutput.flush() + }) + val clone: Any = ObjectInputStream( + ByteArrayInputStream(bytesOutput.toByteArray())).readObject() + return type.cast(clone) + } + + private class CacheData(val item: T, val type: Class) + companion object { + private val DEBUG: Boolean = MainActivity.Companion.DEBUG + val instance: SerializedCache = SerializedCache() + private val MAX_ITEMS_ON_CACHE: Int = 5 + private val LRU_CACHE: LruCache> = LruCache(MAX_ITEMS_ON_CACHE) + private val TAG: String = "SerializedCache" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java deleted file mode 100644 index c712157b3..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java +++ /dev/null @@ -1,213 +0,0 @@ -package org.schabi.newpipe.util; - -import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; - -import android.content.Context; -import android.content.SharedPreferences; - -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.preference.PreferenceManager; - -import com.grack.nanojson.JsonObject; -import com.grack.nanojson.JsonParser; -import com.grack.nanojson.JsonParserException; - -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.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; - -import java.util.Optional; -import java.util.concurrent.TimeUnit; - -public final class ServiceHelper { - private static final StreamingService DEFAULT_FALLBACK_SERVICE = ServiceList.YouTube; - - private ServiceHelper() { } - - @DrawableRes - public static int getIcon(final int serviceId) { - switch (serviceId) { - case 0: - return R.drawable.ic_smart_display; - case 1: - return R.drawable.ic_cloud; - case 2: - return R.drawable.ic_placeholder_media_ccc; - case 3: - return R.drawable.ic_placeholder_peertube; - case 4: - return R.drawable.ic_placeholder_bandcamp; - default: - return R.drawable.ic_circle; - } - } - - public static String getTranslatedFilterString(final String filter, final Context c) { - switch (filter) { - case "all": - return c.getString(R.string.all); - case "videos": - case "sepia_videos": - case "music_videos": - return c.getString(R.string.videos_string); - case "channels": - return c.getString(R.string.channels); - case "playlists": - case "music_playlists": - return c.getString(R.string.playlists); - case "tracks": - return c.getString(R.string.tracks); - case "users": - return c.getString(R.string.users); - case "conferences": - return c.getString(R.string.conferences); - case "events": - return c.getString(R.string.events); - case "music_songs": - return c.getString(R.string.songs); - case "music_albums": - return c.getString(R.string.albums); - case "music_artists": - return c.getString(R.string.artists); - default: - return 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 - */ - @StringRes - public static int getImportInstructions(final int serviceId) { - switch (serviceId) { - case 0: - return R.string.import_youtube_instructions; - case 1: - return R.string.import_soundcloud_instructions; - default: - return -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 - */ - @StringRes - public static int getImportInstructionsHint(final int serviceId) { - switch (serviceId) { - case 1: - return R.string.import_soundcloud_instructions_hint; - default: - return -1; - } - } - - public static int getSelectedServiceId(final Context context) { - return Optional.ofNullable(getSelectedService(context)) - .orElse(DEFAULT_FALLBACK_SERVICE) - .getServiceId(); - } - - @Nullable - public static StreamingService getSelectedService(final Context context) { - final String serviceName = PreferenceManager.getDefaultSharedPreferences(context) - .getString(context.getString(R.string.current_service_key), - context.getString(R.string.default_service_value)); - - try { - return NewPipe.getService(serviceName); - } catch (final ExtractionException e) { - return null; - } - } - - @NonNull - public static String getNameOfServiceById(final int serviceId) { - return ServiceList.all().stream() - .filter(s -> s.getServiceId() == 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 - */ - @NonNull - public static StreamingService getServiceById(final int serviceId) { - return ServiceList.all().stream() - .filter(s -> s.getServiceId() == serviceId) - .findFirst() - .orElseThrow(); - } - - public static void setSelectedServiceId(final Context context, final int serviceId) { - String serviceName; - try { - serviceName = NewPipe.getService(serviceId).getServiceInfo().getName(); - } catch (final ExtractionException e) { - serviceName = DEFAULT_FALLBACK_SERVICE.getServiceInfo().getName(); - } - - setSelectedServicePreferences(context, serviceName); - } - - private static void setSelectedServicePreferences(final Context context, - final String serviceName) { - PreferenceManager.getDefaultSharedPreferences(context).edit(). - putString(context.getString(R.string.current_service_key), serviceName).apply(); - } - - public static long getCacheExpirationMillis(final int serviceId) { - if (serviceId == SoundCloud.getServiceId()) { - return TimeUnit.MILLISECONDS.convert(5, TimeUnit.MINUTES); - } else { - return TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS); - } - } - - public static void initService(final Context context, final int serviceId) { - if (serviceId == ServiceList.PeerTube.getServiceId()) { - final SharedPreferences sharedPreferences = PreferenceManager - .getDefaultSharedPreferences(context); - final String json = sharedPreferences.getString(context.getString( - R.string.peertube_selected_instance_key), null); - if (null == json) { - return; - } - - final JsonObject jsonObject; - try { - jsonObject = JsonParser.object().from(json); - } catch (final JsonParserException e) { - return; - } - final String name = jsonObject.getString("name"); - final String url = jsonObject.getString("url"); - final PeertubeInstance instance = new PeertubeInstance(url, name); - ServiceList.PeerTube.setInstance(instance); - } - } - - public static void initServices(final Context context) { - for (final StreamingService s : ServiceList.all()) { - initService(context, s.getServiceId()); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.kt b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.kt new file mode 100644 index 000000000..e580cac63 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.kt @@ -0,0 +1,171 @@ +package org.schabi.newpipe.util + +import android.content.Context +import android.content.SharedPreferences +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.preference.PreferenceManager +import com.grack.nanojson.JsonObject +import com.grack.nanojson.JsonParser +import com.grack.nanojson.JsonParserException +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.exceptions.ExtractionException +import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance +import java.util.Optional +import java.util.concurrent.TimeUnit +import java.util.function.Function +import java.util.function.Predicate + +object ServiceHelper { + private val DEFAULT_FALLBACK_SERVICE: StreamingService = ServiceList.YouTube + @DrawableRes + fun getIcon(serviceId: Int): Int { + when (serviceId) { + 0 -> return R.drawable.ic_smart_display + 1 -> return R.drawable.ic_cloud + 2 -> return R.drawable.ic_placeholder_media_ccc + 3 -> return R.drawable.ic_placeholder_peertube + 4 -> return R.drawable.ic_placeholder_bandcamp + else -> return R.drawable.ic_circle + } + } + + fun getTranslatedFilterString(filter: String, c: Context?): String { + when (filter) { + "all" -> return c!!.getString(R.string.all) + "videos", "sepia_videos", "music_videos" -> return c!!.getString(R.string.videos_string) + "channels" -> return c!!.getString(R.string.channels) + "playlists", "music_playlists" -> return c!!.getString(R.string.playlists) + "tracks" -> return c!!.getString(R.string.tracks) + "users" -> return c!!.getString(R.string.users) + "conferences" -> return c!!.getString(R.string.conferences) + "events" -> return c!!.getString(R.string.events) + "music_songs" -> return c!!.getString(R.string.songs) + "music_albums" -> return c!!.getString(R.string.albums) + "music_artists" -> return c!!.getString(R.string.artists) + else -> return 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 + */ + @StringRes + fun getImportInstructions(serviceId: Int): Int { + when (serviceId) { + 0 -> return R.string.import_youtube_instructions + 1 -> return R.string.import_soundcloud_instructions + else -> return -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 + */ + @StringRes + fun getImportInstructionsHint(serviceId: Int): Int { + when (serviceId) { + 1 -> return R.string.import_soundcloud_instructions_hint + else -> return -1 + } + } + + fun getSelectedServiceId(context: Context): Int { + return Optional.ofNullable(getSelectedService(context)) + .orElse(DEFAULT_FALLBACK_SERVICE) + .getServiceId() + } + + fun getSelectedService(context: Context): StreamingService? { + val serviceName: String? = PreferenceManager.getDefaultSharedPreferences(context) + .getString(context.getString(R.string.current_service_key), + context.getString(R.string.default_service_value)) + try { + return NewPipe.getService(serviceName) + } catch (e: ExtractionException) { + return null + } + } + + fun getNameOfServiceById(serviceId: Int): String { + return ServiceList.all().stream() + .filter(Predicate({ s: StreamingService -> s.getServiceId() == serviceId })) + .findFirst() + .map(Function({ obj: StreamingService -> obj.getServiceInfo() })) + .map(Function({ 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 + */ + fun getServiceById(serviceId: Int): StreamingService { + return ServiceList.all().stream() + .filter(Predicate({ s: StreamingService -> s.getServiceId() == serviceId })) + .findFirst() + .orElseThrow() + } + + fun setSelectedServiceId(context: Context, serviceId: Int) { + var serviceName: String + try { + serviceName = NewPipe.getService(serviceId).getServiceInfo().getName() + } catch (e: ExtractionException) { + serviceName = DEFAULT_FALLBACK_SERVICE.getServiceInfo().getName() + } + setSelectedServicePreferences(context, serviceName) + } + + private fun setSelectedServicePreferences(context: Context, + serviceName: String) { + PreferenceManager.getDefaultSharedPreferences(context).edit().putString(context.getString(R.string.current_service_key), serviceName).apply() + } + + fun getCacheExpirationMillis(serviceId: Int): Long { + if (serviceId == ServiceList.SoundCloud.getServiceId()) { + return TimeUnit.MILLISECONDS.convert(5, TimeUnit.MINUTES) + } else { + return TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS) + } + } + + fun initService(context: Context, serviceId: Int) { + if (serviceId == ServiceList.PeerTube.getServiceId()) { + val sharedPreferences: SharedPreferences = PreferenceManager + .getDefaultSharedPreferences(context) + val json: String? = sharedPreferences.getString(context.getString( + R.string.peertube_selected_instance_key), null) + if (null == json) { + return + } + val jsonObject: JsonObject + try { + jsonObject = JsonParser.`object`().from(json) + } catch (e: JsonParserException) { + return + } + val name: String = jsonObject.getString("name") + val url: String = jsonObject.getString("url") + val instance: PeertubeInstance = PeertubeInstance(url, name) + ServiceList.PeerTube.setInstance(instance) + } + } + + fun initServices(context: Context) { + for (s: StreamingService in ServiceList.all()) { + initService(context, s.getServiceId()) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/SliderStrategy.java b/app/src/main/java/org/schabi/newpipe/util/SliderStrategy.java deleted file mode 100644 index c6191fcc2..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/SliderStrategy.java +++ /dev/null @@ -1,79 +0,0 @@ -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; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/SliderStrategy.kt b/app/src/main/java/org/schabi/newpipe/util/SliderStrategy.kt new file mode 100644 index 000000000..3dcd1a619 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/SliderStrategy.kt @@ -0,0 +1,73 @@ +package org.schabi.newpipe.util + +import kotlin.math.abs +import kotlin.math.sqrt + +open 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 + */ + fun progressOf(value: Double): Int + + /** + * 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 + */ + fun valueOf(progress: Int): Double + + // TODO: also implement linear strategy when needed + class Quadratic(minimum: Double, maximum: Double, center: Double, + maxProgress: Int) : SliderStrategy { + private val leftGap: Double + private val rightGap: Double + private val center: Double + private val centerProgress: Int + + /** + * 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. + */ + init { + if (center < minimum || center > maximum) { + throw IllegalArgumentException("Center must be in between minimum and maximum") + } + leftGap = minimum - center + rightGap = maximum - center + this.center = center + centerProgress = maxProgress / 2 + } + + public override fun progressOf(value: Double): Int { + val difference: Double = value - center + val root: Double = if (difference >= 0) sqrt(difference / rightGap) else -sqrt(abs(difference / leftGap)) + val offset: Double = Math.round(root * centerProgress).toDouble() + return (centerProgress + offset).toInt() + } + + public override fun valueOf(progress: Int): Double { + val offset: Int = progress - centerProgress + val square: Double = ((offset.toDouble()) / (centerProgress.toDouble())).pow(2.0) + val difference: Double = square * (if (offset >= 0) rightGap else leftGap) + return difference + center + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java b/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java deleted file mode 100644 index 6e9ea7a47..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java +++ /dev/null @@ -1,127 +0,0 @@ -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) - )); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.kt b/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.kt new file mode 100644 index 000000000..f02a80421 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.kt @@ -0,0 +1,118 @@ +package org.schabi.newpipe.util + +import android.content.Context +import android.widget.Toast +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Completable +import io.reactivex.rxjava3.functions.Action +import io.reactivex.rxjava3.schedulers.Schedulers +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.Companion.createNotification +import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.extractor.utils.Utils +import org.schabi.newpipe.player.playqueue.SinglePlayQueue + +/** + * Utility class for fetching additional data for stream items when needed. + */ +object 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 [ ]. FeedExtractors provide a fast and + * lightweight method to fetch info, but the info might be incomplete (see + * [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 [ ] + */ + fun fetchItemInfoIfSparse(context: Context, + item: StreamInfoItem, + callback: java.util.function.Consumer) { + if (((StreamTypeUtil.isLiveStream(item.getStreamType()) || item.getDuration() >= 0) + && !Utils.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(SinglePlayQueue(item)) + return + } + + // either the duration or the uploader url are not available, so fetch more info + fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(), + java.util.function.Consumer({ streamInfo: StreamInfo? -> callback.accept(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 [ ]). 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 [ ] corresponding to the item + */ + fun fetchUploaderUrlIfSparse(context: Context, + serviceId: Int, + url: String, + uploaderUrl: String?, + callback: java.util.function.Consumer) { + if (!Utils.isNullOrEmpty(uploaderUrl)) { + callback.accept(uploaderUrl) + return + } + fetchStreamInfoAndSaveToDatabase(context, serviceId, url, + java.util.function.Consumer({ streamInfo: 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 + */ + fun fetchStreamInfoAndSaveToDatabase(context: Context, + serviceId: Int, + url: String, + callback: java.util.function.Consumer) { + Toast.makeText(context, R.string.loading_stream_details, Toast.LENGTH_SHORT).show() + ExtractorHelper.getStreamInfo(serviceId, url, false) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(io.reactivex.rxjava3.functions.Consumer({ result: StreamInfo -> + // save to database in the background (not on main thread) + Completable.fromAction(Action({ + NewPipeDatabase.getInstance(context) + .streamDAO().upsert(StreamEntity(result)) + })) + .subscribeOn(Schedulers.io()) + .observeOn(Schedulers.io()) + .doOnError(io.reactivex.rxjava3.functions.Consumer({ throwable: Throwable? -> + createNotification(context, + ErrorInfo((throwable)!!, UserAction.REQUESTED_STREAM, + "Saving stream info to database", result)) + })) + .subscribe() + + // call callback on main thread with the obtained result + callback.accept(result) + }), io.reactivex.rxjava3.functions.Consumer({ throwable: Throwable? -> + createNotification(context, + ErrorInfo((throwable)!!, UserAction.REQUESTED_STREAM, + "Loading stream info: " + url, serviceId) + ) + })) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/StateSaver.java b/app/src/main/java/org/schabi/newpipe/util/StateSaver.java deleted file mode 100644 index 61fdb602f..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/StateSaver.java +++ /dev/null @@ -1,341 +0,0 @@ -/* - * 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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/StateSaver.kt b/app/src/main/java/org/schabi/newpipe/util/StateSaver.kt new file mode 100644 index 000000000..497746a9b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/StateSaver.kt @@ -0,0 +1,296 @@ +/* + * 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.util.Log +import androidx.core.os.BundleCompat +import org.schabi.newpipe.BuildConfig +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). + */ +object StateSaver { + const val KEY_SAVED_STATE = "key_saved_state" + private val STATE_OBJECTS_HOLDER = ConcurrentHashMap>() + private const val TAG = "StateSaver" + private const val CACHE_DIR_NAME = "state_cache" + private var cacheDirPath: String? = null + + /** + * Initialize the StateSaver, usually you want to call this in the Application class. + * + * @param context used to get the available cache dir + */ + fun init(context: Context) { + val externalCacheDir = context.externalCacheDir + if (externalCacheDir != null) { + cacheDirPath = externalCacheDir.absolutePath + } + if (TextUtils.isEmpty(cacheDirPath)) { + cacheDirPath = context.cacheDir.absolutePath + } + } + + /** + * @param outState + * @param writeRead + * @return the saved state + * @see .tryToRestore + */ + fun tryToRestore(outState: Bundle?, writeRead: WriteRead?): SavedState? { + if (outState == null || writeRead == null) { + return null + } + val savedState = BundleCompat.getParcelable( + outState, KEY_SAVED_STATE, SavedState::class.java) + ?: return null + return tryToRestore(savedState, writeRead) + } + + /** + * Try to restore the state from memory and disk, + * using the [StateSaver.WriteRead.readFrom] from the writeRead. + * + * @param savedState + * @param writeRead + * @return the saved state + */ + private fun tryToRestore(savedState: SavedState, + writeRead: WriteRead): SavedState? { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "tryToRestore() called with: savedState = [" + savedState + "], " + + "writeRead = [" + writeRead + "]") + } + try { + var savedObjects = STATE_OBJECTS_HOLDER.remove(savedState.prefixFileSaved)!! + if (savedObjects != null) { + writeRead.readFrom(savedObjects) + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "tryToSave: reading objects from holder > " + savedObjects + + ", stateObjectsHolder > " + STATE_OBJECTS_HOLDER) + } + return savedState + } + val file = File(savedState.pathFileSaved) + if (!file.exists()) { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "Cache file doesn't exist: " + file.absolutePath) + } + return null + } + FileInputStream(file).use { fileInputStream -> ObjectInputStream(fileInputStream).use { inputStream -> savedObjects = inputStream.readObject() as Queue } } + if (savedObjects != null) { + writeRead.readFrom(savedObjects) + } + return savedState + } catch (e: Exception) { + Log.e(TAG, "Failed to restore state", e) + } + return null + } + + /** + * @param isChangingConfig + * @param savedState + * @param outState + * @param writeRead + * @return the saved state or `null` + * @see .tryToSave + */ + fun tryToSave(isChangingConfig: Boolean, + savedState: SavedState?, outState: Bundle, + writeRead: WriteRead): SavedState? { + val currentSavedPrefix: String + currentSavedPrefix = if (savedState == null || TextUtils.isEmpty(savedState.prefixFileSaved)) { + // Generate unique prefix + (System.nanoTime() - writeRead.hashCode()).toString() + "" + } else { + // Reuse prefix + savedState.prefixFileSaved + } + val newSavedState = tryToSave(isChangingConfig, currentSavedPrefix, + writeRead.generateSuffix(), writeRead) + if (newSavedState != null) { + outState.putParcelable(KEY_SAVED_STATE, newSavedState) + return newSavedState + } + return null + } + + /** + * If it's not changing configuration (i.e. rotating screen), + * try to write the state from [StateSaver.WriteRead.writeTo] + * to the file with the name of prefixFileName + suffixFileName, + * in a cache folder got from the [.init]. + * + * + * 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 `null` + */ + private fun tryToSave(isChangingConfig: Boolean, prefixFileName: String, + suffixFileName: String?, writeRead: WriteRead): SavedState? { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "tryToSave() called with: " + + "isChangingConfig = [" + isChangingConfig + "], " + + "prefixFileName = [" + prefixFileName + "], " + + "suffixFileName = [" + suffixFileName + "], " + + "writeRead = [" + writeRead + "]") + } + val savedObjects = LinkedList() + writeRead.writeTo(savedObjects) + if (isChangingConfig) { + return if (savedObjects.size > 0) { + STATE_OBJECTS_HOLDER[prefixFileName] = savedObjects + SavedState(prefixFileName, "") + } else { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "Nothing to save") + } + null + } + } + try { + var cacheDir = File(cacheDirPath) + if (!cacheDir.exists()) { + throw RuntimeException("Cache dir does not exist > " + cacheDirPath) + } + cacheDir = File(cacheDir, CACHE_DIR_NAME) + if (!cacheDir.exists()) { + if (!cacheDir.mkdir()) { + if (BuildConfig.DEBUG) { + Log.e(TAG, + "Failed to create cache directory " + cacheDir.absolutePath) + } + return null + } + } + val file = File(cacheDir, prefixFileName + + if (TextUtils.isEmpty(suffixFileName)) ".cache" else suffixFileName) + if (file.exists() && file.length() > 0) { + // If the file already exists, just return it + return SavedState(prefixFileName, file.absolutePath) + } else { + // Delete any file that contains the prefix + val files = cacheDir.listFiles { dir: File?, name: String -> name.contains(prefixFileName) } + for (fileToDelete in files) { + fileToDelete.delete() + } + } + FileOutputStream(file).use { fileOutputStream -> ObjectOutputStream(fileOutputStream).use { outputStream -> outputStream.writeObject(savedObjects) } } + return SavedState(prefixFileName, file.absolutePath) + } catch (e: Exception) { + 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 + */ + fun onDestroy(savedState: SavedState?) { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "onDestroy() called with: savedState = [$savedState]") + } + if (savedState != null && !savedState.pathFileSaved.isEmpty()) { + STATE_OBJECTS_HOLDER.remove(savedState.prefixFileSaved) + try { + File(savedState.pathFileSaved).delete() + } catch (ignored: Exception) { + } + } + } + + /** + * Clear all the files in cache (in memory and disk). + */ + fun clearStateFiles() { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "clearStateFiles() called") + } + STATE_OBJECTS_HOLDER.clear() + var cacheDir = File(cacheDirPath) + if (!cacheDir.exists()) { + return + } + cacheDir = File(cacheDir, CACHE_DIR_NAME) + if (cacheDir.exists()) { + val list = cacheDir.listFiles() + if (list != null) { + for (file in list) { + file.delete() + } + } + } + } + + /** + * Used for describing how to save/read the objects. + * + * + * Queue was chosen by its FIFO property. + */ + 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 + */ + fun generateSuffix(): String? + + /** + * Add to this queue objects that you want to save. + * + * @param objectsToSave the objects to save + */ + fun writeTo(objectsToSave: Queue) + + /** + * Poll saved objects from the queue in the order they were written. + * + * @param savedObjects queue of objects returned by [.writeTo] + */ + @Throws(Exception::class) + fun readFrom(savedObjects: Queue) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java deleted file mode 100644 index 2eeb14b1b..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java +++ /dev/null @@ -1,470 +0,0 @@ -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; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.kt b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.kt new file mode 100644 index 000000000..ea6bc0212 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.kt @@ -0,0 +1,423 @@ +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.Spinner +import android.widget.TextView +import androidx.annotation.VisibleForTesting +import androidx.collection.SparseArrayCompat +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Single +import io.reactivex.rxjava3.schedulers.Schedulers +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 us.shandian.giga.util.Utility +import java.io.Serializable +import java.util.Arrays +import java.util.concurrent.Callable +import java.util.function.Predicate +import java.util.stream.Collectors + +/** + * A list adapter for a list of [streams][Stream]. + * It currently supports [VideoStream], [AudioStream] and [SubtitlesStream]. + * + * @param the primary stream type's class extending [Stream] + * @param the secondary stream type's class extending [Stream] + */ +class StreamItemAdapter( + private val streamsWrapper: StreamInfoWrapper, + val allSecondary: SparseArrayCompat?> +) : BaseAdapter() { + + /** + * Indicates that at least one of the primary streams is an instance of [VideoStream], + * has no audio ([VideoStream.isVideoOnly] returns true) and has no secondary stream + * associated with it. + */ + private val hasAnyVideoOnlyStreamWithNoSecondaryStream: Boolean + + init { + hasAnyVideoOnlyStreamWithNoSecondaryStream = checkHasAnyVideoOnlyStreamWithNoSecondaryStream() + } + + constructor(streamsWrapper: StreamInfoWrapper?) : this(streamsWrapper, SparseArrayCompat?>(0)) + + val all: List + get() { + return streamsWrapper.streamsList + } + + public override fun getCount(): Int { + return streamsWrapper.streamsList.size + } + + public override fun getItem(position: Int): T { + return streamsWrapper.streamsList.get(position) + } + + public override fun getItemId(position: Int): Long { + return position.toLong() + } + + public override fun getDropDownView(position: Int, + convertView: View, + parent: ViewGroup): View { + return getCustomView(position, convertView, parent, true) + } + + public override fun getView(position: Int, convertView: View, parent: ViewGroup): View { + return getCustomView((parent as Spinner).getSelectedItemPosition(), + convertView, parent, false) + } + + private fun getCustomView(position: Int, + view: View, + parent: ViewGroup, + isDropdownItem: Boolean): View { + val context: Context = parent.getContext() + var convertView: View = view + if (convertView == null) { + convertView = LayoutInflater.from(context).inflate( + R.layout.stream_quality_item, parent, false) + } + val woSoundIconView: ImageView = convertView.findViewById(R.id.wo_sound_icon) + val formatNameView: TextView = convertView.findViewById(R.id.stream_format_name) + val qualityView: TextView = convertView.findViewById(R.id.stream_quality) + val sizeView: TextView = convertView.findViewById(R.id.stream_size) + val stream: T = getItem(position) + val mediaFormat: MediaFormat? = streamsWrapper.getFormat(position) + var woSoundIconVisibility: Int = View.GONE + var qualityString: String? + if (stream is VideoStream) { + val videoStream: VideoStream = stream + qualityString = videoStream.getResolution() + if (hasAnyVideoOnlyStreamWithNoSecondaryStream) { + if (videoStream.isVideoOnly()) { + woSoundIconVisibility = if (allSecondary.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. + ) (if (isDropdownItem) View.INVISIBLE else View.GONE) // It doesn't have a secondary stream, icon is visible no matter what. + else View.VISIBLE + } else if (isDropdownItem) { + woSoundIconVisibility = View.INVISIBLE + } + } + } else if (stream is AudioStream) { + val audioStream: AudioStream = stream + if (audioStream.getAverageBitrate() > 0) { + qualityString = audioStream.getAverageBitrate().toString() + "kbps" + } else { + qualityString = context.getString(R.string.unknown_quality) + } + } else if (stream is SubtitlesStream) { + qualityString = (stream as SubtitlesStream).getDisplayLanguageName() + if ((stream as SubtitlesStream).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) { + val secondary: SecondaryStreamHelper? = allSecondary.get(position) + if (secondary != null) { + val size: Long = (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 is SubtitlesStream) { + formatNameView.setText((stream as SubtitlesStream).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 fun checkHasAnyVideoOnlyStreamWithNoSecondaryStream(): Boolean { + for (i in streamsWrapper.streamsList.indices) { + val stream: T = streamsWrapper.streamsList.get(i) + if (stream is VideoStream) { + val videoOnly: Boolean = (stream as VideoStream).isVideoOnly() + if (videoOnly && allSecondary.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 [Stream] + */ + class StreamInfoWrapper(streamList: List, + context: Context?) : Serializable { + val streamsList: List + private val streamSizes: LongArray + private val streamFormats: Array + private val unknownSize: String + + init { + streamsList = streamList + streamSizes = LongArray(streamsList.size) + unknownSize = if (context == null) "--.-" else context.getString(R.string.unknown_content) + streamFormats = arrayOfNulls(streamsList.size) + resetInfo() + } + + fun resetInfo() { + Arrays.fill(streamSizes, SIZE_UNSET.toLong()) + for (i in streamsList.indices) { + streamFormats.get(i) = if (streamsList.get(i) == null // test for invalid streams + ) null else streamsList.get(i)!!.getFormat() + } + } + + fun getSizeInBytes(streamIndex: Int): Long { + return streamSizes.get(streamIndex) + } + + fun getSizeInBytes(stream: T): Long { + return streamSizes.get(streamsList.indexOf(stream)) + } + + fun getFormattedSize(streamIndex: Int): String? { + return formatSize(getSizeInBytes(streamIndex)) + } + + private fun formatSize(size: Long): String? { + if (size > -1) { + return Utility.formatBytes(size) + } + return unknownSize + } + + fun setSize(stream: T, sizeInBytes: Long) { + streamSizes.get(streamsList.indexOf(stream)) = sizeInBytes + } + + fun getFormat(streamIndex: Int): MediaFormat? { + return streamFormats.get(streamIndex) + } + + fun setFormat(stream: T, format: MediaFormat?) { + streamFormats.get(streamsList.indexOf(stream)) = format + } + + companion object { + private val EMPTY: StreamInfoWrapper = StreamInfoWrapper(emptyList(), null) + private val SIZE_UNSET: Int = -2 + + /** + * Helper method to fetch the sizes and missing media formats + * of all the streams in a wrapper. + * + * @param the stream type's class extending [Stream] + * @param streamsWrapper the wrapper + * @return a [Single] that returns a boolean indicating if any elements were changed + */ + fun fetchMoreInfoForWrapper( + streamsWrapper: StreamInfoWrapper?): Single { + val fetchAndSet: Callable = Callable({ + var hasChanged: Boolean = false + for (stream: X in streamsWrapper!!.streamsList) { + val changeSize: Boolean = streamsWrapper.getSizeInBytes(stream) <= SIZE_UNSET + val changeFormat: Boolean = stream!!.getFormat() == null + if (!changeSize && !changeFormat) { + continue + } + val response: Response = DownloaderImpl.Companion.getInstance() + .head(stream.getContent()) + if (changeSize) { + val contentLength: String? = response.getHeader("Content-Length") + if (!Utils.isNullOrEmpty(contentLength)) { + streamsWrapper.setSize(stream, contentLength!!.toLong()) + hasChanged = true + } + } + if (changeFormat) { + hasChanged = (retrieveMediaFormat(stream, streamsWrapper, response) + || hasChanged) + } + } + hasChanged + }) + return Single.fromCallable(fetchAndSet) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .onErrorReturnItem(true) + } + + /** + * Try to retrieve the [MediaFormat] for a stream from the request headers. + * + * @param the stream type to get the [MediaFormat] for + * @param stream the stream to find the [MediaFormat] for + * @param streamsWrapper the wrapper to store the found [MediaFormat] in + * @param response the response of the head request for the given stream + * @return `true` if the media format could be retrieved; `false` otherwise + */ + @VisibleForTesting + fun retrieveMediaFormat( + stream: X, + streamsWrapper: StreamInfoWrapper, + response: Response): Boolean { + return (retrieveMediaFormatFromFileTypeHeaders(stream, streamsWrapper, response) + || retrieveMediaFormatFromContentDispositionHeader( + stream, streamsWrapper, response) + || retrieveMediaFormatFromContentTypeHeader(stream, streamsWrapper, response)) + } + + @VisibleForTesting + fun retrieveMediaFormatFromFileTypeHeaders( + stream: X, + streamsWrapper: StreamInfoWrapper, + response: Response): Boolean { + // try to use additional headers from CDNs or servers, + // e.g. x-amz-meta-file-type (e.g. for SoundCloud) + val keys: List = response.responseHeaders().keys.stream() + .filter(Predicate({ k: String -> k.endsWith("file-type") })).collect(Collectors.toList()) + if (!keys.isEmpty()) { + for (key: String? in keys) { + val suffix: String? = response.getHeader(key) + val format: MediaFormat? = MediaFormat.getFromSuffix(suffix) + if (format != null) { + streamsWrapper.setFormat(stream, format) + return true + } + } + } + return false + } + + /** + * + * Retrieve a [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](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition) + * + * @param stream the stream to get the [MediaFormat] for + * @param streamsWrapper the wrapper to store the [MediaFormat] in + * @param response the response to get the Content-Disposition header from + * @return `true` if the [MediaFormat] could be retrieved from the response; + * otherwise `false` + * @param + */ + @VisibleForTesting + fun retrieveMediaFormatFromContentDispositionHeader( + stream: X, + streamsWrapper: StreamInfoWrapper, + response: Response): Boolean { + // parse the Content-Disposition header, + // see + // there can be two filename directives + var contentDisposition: String? = response.getHeader("Content-Disposition") + if (contentDisposition == null) { + return false + } + try { + contentDisposition = Utils.decodeUrlUtf8(contentDisposition) + val parts: Array = contentDisposition.split(";".toRegex()).dropLastWhile({ it.isEmpty() }).toTypedArray() + for (part: String in parts) { + val fileName: String + part = part.trim({ it <= ' ' }) + + // 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 + val p: Array = fileName.split("\\.".toRegex()).dropLastWhile({ it.isEmpty() }).toTypedArray() + var suffix: String = p.get(p.size - 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 + val format: MediaFormat? = MediaFormat.getFromSuffix(suffix) + if (format != null) { + streamsWrapper.setFormat(stream, format) + return true + } + } + } catch (ignored: Exception) { + // fail silently + } + return false + } + + @VisibleForTesting + fun retrieveMediaFormatFromContentTypeHeader( + stream: X, + streamsWrapper: StreamInfoWrapper, + response: Response): Boolean { + // try to get the format by content type + // some mime types are not unique for every format, those are omitted + val contentTypeHeader: String? = response.getHeader("Content-Type") + if (contentTypeHeader == null) { + return false + } + var foundFormat: MediaFormat? = null + for (format: MediaFormat in 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 + } + + fun empty(): StreamInfoWrapper { + return EMPTY as StreamInfoWrapper + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.java b/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.java deleted file mode 100644 index 0cc0ecf1f..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.schabi.newpipe.util; - -import org.schabi.newpipe.extractor.stream.StreamType; - -/** - * Utility class for {@link StreamType}. - */ -public final class StreamTypeUtil { - private StreamTypeUtil() { - // No impl pls - } - - /** - * Check if the {@link StreamType} of a stream is a livestream. - * - * @param streamType the stream type of the stream - * @return whether the stream type is {@link StreamType#AUDIO_STREAM}, - * {@link StreamType#AUDIO_LIVE_STREAM} or {@link StreamType#POST_LIVE_AUDIO_STREAM} - */ - public static boolean isAudio(final StreamType streamType) { - return streamType == StreamType.AUDIO_STREAM - || streamType == StreamType.AUDIO_LIVE_STREAM - || streamType == StreamType.POST_LIVE_AUDIO_STREAM; - } - - /** - * Check if the {@link StreamType} of a stream is a livestream. - * - * @param streamType the stream type of the stream - * @return whether the stream type is {@link StreamType#VIDEO_STREAM}, - * {@link StreamType#LIVE_STREAM} or {@link StreamType#POST_LIVE_STREAM} - */ - public static boolean isVideo(final StreamType streamType) { - return streamType == StreamType.VIDEO_STREAM - || streamType == StreamType.LIVE_STREAM - || streamType == StreamType.POST_LIVE_STREAM; - } - - /** - * Check if the {@link StreamType} of a stream is a livestream. - * - * @param streamType the stream type of the stream - * @return whether the stream type is {@link StreamType#LIVE_STREAM} or - * {@link StreamType#AUDIO_LIVE_STREAM} - */ - public static boolean isLiveStream(final StreamType streamType) { - return streamType == StreamType.LIVE_STREAM - || streamType == StreamType.AUDIO_LIVE_STREAM; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.kt b/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.kt new file mode 100644 index 000000000..fbad9c9c6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.kt @@ -0,0 +1,46 @@ +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] + */ + 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] + */ + 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] + */ + fun isLiveStream(streamType: StreamType): Boolean { + return (streamType == StreamType.LIVE_STREAM + || streamType == StreamType.AUDIO_LIVE_STREAM) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java deleted file mode 100644 index ab74e0305..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java +++ /dev/null @@ -1,415 +0,0 @@ -/* - * 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.getResources().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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.kt b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.kt new file mode 100644 index 000000000..6c54f8737 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.kt @@ -0,0 +1,379 @@ +/* + * 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.graphics.drawable.Drawable +import android.util.TypedValue +import androidx.annotation.AttrRes +import androidx.annotation.StyleRes +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 +import kotlin.math.max + +object ThemeHelper { + /** + * Apply the selected theme (on NewPipe settings) in the context + * with the default style (see [.setTheme]). + * + * ThemeHelper.setDayNightMode should be called before + * the applying theme for the first time in session + * + * @param context context that the theme will be applied + */ + fun setTheme(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 + */ + fun setTheme(context: Context, serviceId: Int) { + 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 + */ + fun isLightThemeSelected(context: Context?): Boolean { + val selectedThemeKey = getSelectedThemeKey(context) + val res = context!!.resources + return selectedThemeKey == res.getString(R.string.light_theme_key) || selectedThemeKey == 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 + fun getDialogTheme(context: Context?): Int { + return if (isLightThemeSelected(context)) R.style.LightDialogTheme else 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 + fun getMinWidthDialogTheme(context: Context?): Int { + return if (isLightThemeSelected(context)) R.style.LightDialogMinWidthTheme else 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 + fun getThemeForService(context: Context, serviceId: Int): Int { + val res = context.resources + val lightThemeKey = res.getString(R.string.light_theme_key) + val blackThemeKey = res.getString(R.string.black_theme_key) + val automaticDeviceThemeKey = res.getString(R.string.auto_device_theme_key) + val selectedThemeKey = getSelectedThemeKey(context) + var baseTheme = R.style.DarkTheme // default to dark theme + if (selectedThemeKey == lightThemeKey) { + baseTheme = R.style.LightTheme + } else if (selectedThemeKey == blackThemeKey) { + baseTheme = R.style.BlackTheme + } else if (selectedThemeKey == automaticDeviceThemeKey) { + baseTheme = if (isDeviceDarkThemeEnabled(context)) { + // use the dark theme variant preferred by the user + val selectedNightThemeKey = getSelectedNightThemeKey(context) + if (selectedNightThemeKey == blackThemeKey) { + R.style.BlackTheme + } else { + R.style.DarkTheme + } + } else { + // there is only one day theme + R.style.LightTheme + } + } + if (serviceId <= -1) { + return baseTheme + } + val service: StreamingService + service = try { + NewPipe.getService(serviceId) + } catch (ignored: ExtractionException) { + return baseTheme + } + var themeName = "DarkTheme" // default + if (baseTheme == R.style.LightTheme) { + themeName = "LightTheme" + } else if (baseTheme == R.style.BlackTheme) { + themeName = "BlackTheme" + } + themeName += "." + service.serviceInfo.name + val resourceId = context.resources + .getIdentifier(themeName, "style", context.packageName) + return if (resourceId > 0) { + resourceId + } else baseTheme + } + + @StyleRes + fun getSettingsThemeStyle(context: Context): Int { + val res = context.resources + val lightTheme = res.getString(R.string.light_theme_key) + val blackTheme = res.getString(R.string.black_theme_key) + val automaticDeviceTheme = res.getString(R.string.auto_device_theme_key) + val selectedTheme = getSelectedThemeKey(context) + return if (selectedTheme == lightTheme) { + R.style.LightSettingsTheme + } else if (selectedTheme == blackTheme) { + R.style.BlackSettingsTheme + } else if (selectedTheme == automaticDeviceTheme) { + if (isDeviceDarkThemeEnabled(context)) { + // use the dark theme variant preferred by the user + val selectedNightTheme = getSelectedNightThemeKey(context) + if (selectedNightTheme == blackTheme) { + R.style.BlackSettingsTheme + } else { + R.style.DarkSettingsTheme + } + } else { + // there is only one day theme + R.style.LightSettingsTheme + } + } else { + // default to dark theme + 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 + */ + fun resolveColorFromAttr(context: Context, @AttrRes attrColor: Int): Int { + val value = TypedValue() + context.theme.resolveAttribute(attrColor, value, true) + return if (value.resourceId != 0) { + ContextCompat.getColor(context, value.resourceId) + } else value.data + } + + /** + * Resolves a [Drawable] by it's id. + * + * @param context Context + * @param attrResId Resource id + * @return the [Drawable] + */ + fun resolveDrawable(context: Context, + @AttrRes attrResId: Int): Drawable? { + val typedValue = TypedValue() + context.theme.resolveAttribute(attrResId, typedValue, true) + return AppCompatResources.getDrawable(context, typedValue.resourceId) + } + + /** + * Gets a runtime dimen from the `android` package. Should be used for dimens for which + * normal accessing with `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 + */ + fun getAndroidDimenPx(context: Context, name: String?): Int { + val resId = context.resources.getIdentifier(name, "dimen", "android") + return if (resId <= 0) { + 0 + } else context.resources.getDimensionPixelSize(resId) + } + + private fun getSelectedThemeKey(context: Context?): String? { + val themeKey = context!!.getString(R.string.theme_key) + val defaultTheme = context.resources.getString(R.string.default_theme_value) + return PreferenceManager.getDefaultSharedPreferences(context) + .getString(themeKey, defaultTheme) + } + + private fun getSelectedNightThemeKey(context: Context): String? { + val nightThemeKey = context.getString(R.string.night_theme_key) + val defaultNightTheme = context.resources + .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 [AppCompatActivity] and has an + * action bar. + * + * @param activity the activity to set the title of + * @param title the title to set to the activity + */ + fun setTitleToAppCompatActivity(activity: Activity?, + title: CharSequence?) { + if (activity is AppCompatActivity) { + val actionBar = activity.supportActionBar + 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 + */ + fun isDeviceDarkThemeEnabled(context: Context?): Boolean { + val deviceTheme = (context!!.resources.configuration.uiMode + and Configuration.UI_MODE_NIGHT_MASK) + return when (deviceTheme) { + Configuration.UI_MODE_NIGHT_YES -> true + Configuration.UI_MODE_NIGHT_UNDEFINED, Configuration.UI_MODE_NIGHT_NO -> false + else -> false + } + } + + fun setDayNightMode(context: Context) { + setDayNightMode(context, getSelectedThemeKey(context)) + } + + fun setDayNightMode(context: Context, selectedThemeKey: String?) { + val res = context.resources + if (selectedThemeKey == res.getString(R.string.light_theme_key)) { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + } else if (selectedThemeKey == res.getString(R.string.dark_theme_key) || selectedThemeKey == 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 + */ + fun shouldUseGridLayout(context: Context): Boolean { + val 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 + */ + fun getGridSpanCountChannels(context: Context): Int { + return getGridSpanCount(context, + context.resources.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 + */ + fun getItemViewMode(context: Context): ItemViewMode { + val listMode = PreferenceManager.getDefaultSharedPreferences(context) + .getString(context.getString(R.string.list_view_mode_key), + context.getString(R.string.list_view_mode_value)) + val result: ItemViewMode + result = if (listMode == context.getString(R.string.list_view_mode_list_key)) { + ItemViewMode.LIST + } else if (listMode == context.getString(R.string.list_view_mode_grid_key)) { + ItemViewMode.GRID + } else if (listMode == context.getString(R.string.list_view_mode_card_key)) { + ItemViewMode.CARD + } else { + // Auto mode - evaluate whether to use Grid based on screen real estate. + val configuration = context.resources.configuration + val useGrid = (configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + && configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE)) + if (useGrid) { + ItemViewMode.GRID + } else { + 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 + */ + fun getGridSpanCountStreams(context: Context): Int { + val res = context.resources + 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 + */ + fun getGridSpanCount(context: Context, minWidth: Int): Int { + return max(1.0, (context.resources.displayMetrics.widthPixels / minWidth).toDouble()).toInt() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java b/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java deleted file mode 100644 index bc08e6197..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java +++ /dev/null @@ -1,115 +0,0 @@ -package org.schabi.newpipe.util; - -import org.schabi.newpipe.streams.io.SharpInputStream; - -import java.io.BufferedInputStream; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.util.zip.ZipEntry; -import java.util.zip.ZipInputStream; -import java.util.zip.ZipOutputStream; - -import org.schabi.newpipe.streams.io.StoredFileHelper; - -/** - * 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 { - private ZipHelper() { } - - private static final int BUFFER_SIZE = 2048; - - /** - * This function helps to create zip files. - * Caution this will override the original file. - * - * @param outZip The ZipOutputStream where the data should be stored in - * @param file The path of the file that should be added to zip. - * @param name The path of the file inside the zip. - * @throws Exception - */ - public static void addFileToZip(final ZipOutputStream outZip, final String file, - final String name) throws Exception { - final byte[] data = new byte[BUFFER_SIZE]; - try (FileInputStream fi = new FileInputStream(file); - BufferedInputStream inputStream = new BufferedInputStream(fi, BUFFER_SIZE)) { - final ZipEntry entry = new ZipEntry(name); - outZip.putNextEntry(entry); - int count; - while ((count = inputStream.read(data, 0, BUFFER_SIZE)) != -1) { - outZip.write(data, 0, count); - } - } - } - - /** - * This will extract data from ZipInputStream. - * Caution this will override the original file. - * - * @param zipFile The zip file - * @param file The path of the file on the disk where the data should be extracted to. - * @param name The path of the file inside the zip. - * @return will return true if the file was found within the zip file - * @throws Exception - */ - public static boolean extractFileFromZip(final StoredFileHelper zipFile, final String file, - final String name) throws Exception { - try (ZipInputStream inZip = new ZipInputStream(new BufferedInputStream( - new SharpInputStream(zipFile.getStream())))) { - final byte[] data = new byte[BUFFER_SIZE]; - boolean found = false; - ZipEntry ze; - - while ((ze = inZip.getNextEntry()) != null) { - if (ze.getName().equals(name)) { - found = true; - // delete old file first - final File oldFile = new File(file); - if (oldFile.exists()) { - if (!oldFile.delete()) { - throw new Exception("Could not delete " + file); - } - } - - try (FileOutputStream outFile = new FileOutputStream(file)) { - int count = 0; - while ((count = inZip.read(data)) != -1) { - outFile.write(data, 0, count); - } - } - - inZip.closeEntry(); - } - } - return found; - } - } - - 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; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.kt b/app/src/main/java/org/schabi/newpipe/util/ZipHelper.kt new file mode 100644 index 000000000..b6e92dfa3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/ZipHelper.kt @@ -0,0 +1,113 @@ +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.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +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 @mailbox.org> + * 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 //www.gnu.org/licenses/>. + */ +object ZipHelper { + private const val BUFFER_SIZE = 2048 + + /** + * This function helps to create zip files. + * Caution this will override the original file. + * + * @param outZip The ZipOutputStream where the data should be stored in + * @param file The path of the file that should be added to zip. + * @param name The path of the file inside the zip. + * @throws Exception + */ + @Throws(Exception::class) + fun addFileToZip(outZip: ZipOutputStream, file: String?, + name: String?) { + val data = ByteArray(BUFFER_SIZE) + FileInputStream(file).use { fi -> + BufferedInputStream(fi, BUFFER_SIZE).use { inputStream -> + val entry = ZipEntry(name) + outZip.putNextEntry(entry) + var count: Int + while (inputStream.read(data, 0, BUFFER_SIZE).also { count = it } != -1) { + outZip.write(data, 0, count) + } + } + } + } + + /** + * This will extract data from ZipInputStream. + * Caution this will override the original file. + * + * @param zipFile The zip file + * @param file The path of the file on the disk where the data should be extracted to. + * @param name The path of the file inside the zip. + * @return will return true if the file was found within the zip file + * @throws Exception + */ + @Throws(Exception::class) + fun extractFileFromZip(zipFile: StoredFileHelper, file: String, + name: String): Boolean { + ZipInputStream(BufferedInputStream( + SharpInputStream(zipFile.getStream()))).use { inZip -> + val data = ByteArray(BUFFER_SIZE) + var found = false + var ze: ZipEntry + while (inZip.getNextEntry().also { ze = it } != null) { + if (ze.name == name) { + found = true + // delete old file first + val oldFile = File(file) + if (oldFile.exists()) { + if (!oldFile.delete()) { + throw Exception("Could not delete $file") + } + } + FileOutputStream(file).use { outFile -> + var count = 0 + while (inZip.read(data).also { count = it } != -1) { + outFile.write(data, 0, count) + } + } + inZip.closeEntry() + } + } + return found + } + } + + fun isValidZipFile(file: StoredFileHelper): Boolean { + try { + ZipInputStream(BufferedInputStream( + SharpInputStream(file.getStream()))).use { ignored -> return true } + } catch (ioe: IOException) { + return false + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.java b/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.java deleted file mode 100644 index acc515dd6..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.java +++ /dev/null @@ -1,15 +0,0 @@ -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); -} diff --git a/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.kt b/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.kt new file mode 100644 index 000000000..a285bb331 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSavable.kt @@ -0,0 +1,13 @@ +package org.schabi.newpipe.util.debounce + +import org.schabi.newpipe.error.ErrorInfo + +interface DebounceSavable { + /** + * Execute operations to save the data.

+ * Must set [DebounceSaver.setIsModified] false in this method manually + * after the data has been saved. + */ + fun saveImmediate() + fun showError(errorInfo: ErrorInfo?) +} diff --git a/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.java b/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.java deleted file mode 100644 index 5bd5cdd55..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.java +++ /dev/null @@ -1,81 +0,0 @@ -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()); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.kt b/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.kt new file mode 100644 index 000000000..c69314c3f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/debounce/DebounceSaver.kt @@ -0,0 +1,66 @@ +package org.schabi.newpipe.util.debounce + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.subjects.PublishSubject +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.UserAction +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +class DebounceSaver(private val saveDebounceMillis: Long, private val debounceSavable: DebounceSavable) { + val debouncedSaveSignal: PublishSubject? + + // Has the object been modified + private val isModified: AtomicBoolean? + + /** + * Creates a new `DebounceSaver`. + * + * @param saveDebounceMillis Save the object milliseconds later after the last change + * occurred. + * @param debounceSavable The object containing data to be saved. + */ + init { + debouncedSaveSignal = PublishSubject.create() + isModified = AtomicBoolean() + } + + /** + * Creates a new `DebounceSaver`. Save the object 10 seconds later after the last change + * occurred. + * + * @param debounceSavable The object containing data to be saved. + */ + constructor(debounceSavable: DebounceSavable) : this(DEFAULT_SAVE_DEBOUNCE_MILLIS, debounceSavable) + + fun getIsModified(): Boolean { + return isModified!!.get() + } + + fun setNoChangesToSave() { + isModified!!.set(false) + } + + val debouncedSaver: Disposable + get() = debouncedSaveSignal + .debounce(saveDebounceMillis, TimeUnit.MILLISECONDS) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ ignored: Long? -> debounceSavable.saveImmediate() }) { throwable: Throwable? -> + debounceSavable.showError(ErrorInfo(throwable!!, + UserAction.SOMETHING_ELSE, "Debounced saver")) + } + + fun setHasChangesToSave() { + if (isModified == null || debouncedSaveSignal == null) { + return + } + isModified.set(true) + debouncedSaveSignal.onNext(System.currentTimeMillis()) + } + + companion object { + // Default 10 seconds + private const val DEFAULT_SAVE_DEBOUNCE_MILLIS: Long = 10000 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java deleted file mode 100644 index 6a605e982..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java +++ /dev/null @@ -1,72 +0,0 @@ -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(); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.kt b/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.kt new file mode 100644 index 000000000..cd7195056 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.kt @@ -0,0 +1,64 @@ +package org.schabi.newpipe.util.external_communication + +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +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](https://kodi.tv/) + */ +object KoreUtils { + fun isServiceSupportedByKore(serviceId: Int): Boolean { + return ((serviceId == ServiceList.YouTube.getServiceId() + || serviceId == ServiceList.SoundCloud.getServiceId())) + } + + fun shouldShowPlayWithKodi(context: Context, + serviceId: Int): Boolean { + 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 + */ + fun installKore(context: Context) { + ShareUtils.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 +](https://github.com/xbmc/Kore/blob/master/app/src/main/AndroidManifest.xml) * . + * + * @param context the context to use + * @param streamUrl the url to the stream to play + */ + fun playWithKore(context: Context, streamUrl: Uri?) { + val intent: Intent = Intent(Intent.ACTION_VIEW) + .setPackage(context.getString(R.string.kore_package)) + .setData(streamUrl) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (!ShareUtils.tryOpenIntentInApp(context, intent)) { + AlertDialog.Builder(context) + .setMessage(R.string.kore_not_found) + .setPositiveButton(R.string.install, DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> installKore(context) })) + .setNegativeButton(R.string.cancel, null) + .show() + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java deleted file mode 100644 index 7524e5413..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java +++ /dev/null @@ -1,413 +0,0 @@ -package org.schabi.newpipe.util.external_communication; - -import static org.schabi.newpipe.MainActivity.DEBUG; - -import android.content.ActivityNotFoundException; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.graphics.Bitmap; -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.extractor.Image; -import org.schabi.newpipe.util.image.ImageStrategy; -import org.schabi.newpipe.util.image.PicassoHelper; - -import java.io.File; -import java.io.FileOutputStream; -import java.util.List; - -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 set as default, falls back to - * {@link #openAppChooser(Context, Intent, boolean)}. - *

- * 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) { - // Resolve using 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 ResolveInfo defaultBrowserInfo; - final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("http://")); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - defaultBrowserInfo = context.getPackageManager().resolveActivity(browserIntent, - PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY)); - } else { - defaultBrowserInfo = context.getPackageManager().resolveActivity(browserIntent, - PackageManager.MATCH_DEFAULT_ONLY); - } - - final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - - if (defaultBrowserInfo == null) { - // No app installed to open a web URL, but it may be handled by other apps so try - // opening a system chooser for the link in this case (it could be bypassed by the - // system if there is only one app which can open the link or a default app associated - // with the link domain on Android 12 and higher) - openAppChooser(context, intent, true); - return; - } - - final String defaultBrowserPackage = defaultBrowserInfo.activityInfo.packageName; - - if (defaultBrowserPackage.equals("android")) { - // No browser set as default (doesn't work on some devices) - openAppChooser(context, intent, true); - } else { - try { - intent.setPackage(defaultBrowserPackage); - context.startActivity(intent); - } catch (final ActivityNotFoundException e) { - // Not a browser but an app chooser because of OEMs changes - intent.setPackage(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)); - } - - // 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 Picasso'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 com.squareup.picasso.LruCache LruCache} - * used by the Picasso library inside {@link PicassoHelper} 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. - *

- * - *

- * This method will call {@link PicassoHelper#getImageFromCacheIfPresent(String)} to get the - * thumbnail of the content in the {@link com.squareup.picasso.LruCache LruCache} used by - * the Picasso library inside {@link PicassoHelper}. - *

- * - *

- * 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 { - final Bitmap bitmap = PicassoHelper.getImageFromCacheIfPresent(thumbnailUrl); - if (bitmap == null) { - return null; - } - - // 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 String appFolder = applicationContext.getCacheDir().getAbsolutePath(); - final File thumbnailPreviewFile = new File(appFolder - + "/android_share_sheet_image_preview.jpg"); - - // Any existing file will be overwritten with FileOutputStream - final FileOutputStream fileOutputStream = new FileOutputStream(thumbnailPreviewFile); - bitmap.compress(Bitmap.CompressFormat.JPEG, 90, fileOutputStream); - fileOutputStream.close(); - - final ClipData clipData = ClipData.newUri(applicationContext.getContentResolver(), "", - FileProvider.getUriForFile(applicationContext, - BuildConfig.APPLICATION_ID + ".provider", - thumbnailPreviewFile)); - - 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; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.kt b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.kt new file mode 100644 index 000000000..813c51f72 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.kt @@ -0,0 +1,382 @@ +package org.schabi.newpipe.util.external_communication + +import android.content.ClipboardManager +import android.content.Context +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.util.Log +import org.schabi.newpipe.BuildConfig +import org.schabi.newpipe.extractor.Image +import java.io.File +import java.io.FileOutputStream + +object ShareUtils { + private val TAG: String = ShareUtils::class.java.getSimpleName() + + /** + * 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 [.openIntentInApp] to open market scheme and [ ][.openUrlInBrowser] to open Google Play Store web URL. + * + * @param context the context to use + * @param packageId the package id of the app to be installed + */ + fun installApp(context: Context, packageId: String) { + // Try market scheme + val marketSchemeIntent: Intent = 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 set as default, falls back to + * [.openAppChooser]. + * + * + * This function selects the package to open based on which apps respond to the `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 [.openUrlInApp]**, 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 + */ + fun openUrlInBrowser(context: Context, url: String?) { + // Resolve using 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. + val defaultBrowserInfo: ResolveInfo? + val browserIntent: Intent = Intent(Intent.ACTION_VIEW, Uri.parse("http://")) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + defaultBrowserInfo = context.getPackageManager().resolveActivity(browserIntent, + PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY.toLong())) + } else { + defaultBrowserInfo = context.getPackageManager().resolveActivity(browserIntent, + PackageManager.MATCH_DEFAULT_ONLY) + } + val intent: Intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + if (defaultBrowserInfo == null) { + // No app installed to open a web URL, but it may be handled by other apps so try + // opening a system chooser for the link in this case (it could be bypassed by the + // system if there is only one app which can open the link or a default app associated + // with the link domain on Android 12 and higher) + openAppChooser(context, intent, true) + return + } + val defaultBrowserPackage: String = defaultBrowserInfo.activityInfo.packageName + if ((defaultBrowserPackage == "android")) { + // No browser set as default (doesn't work on some devices) + openAppChooser(context, intent, true) + } else { + try { + intent.setPackage(defaultBrowserPackage) + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + // Not a browser but an app chooser because of OEMs changes + intent.setPackage(null) + openAppChooser(context, intent, true) + } + } + } + + /** + * Open a url with the system default app using [Intent.ACTION_VIEW], showing a toast in + * case of failure. + * + * @param context the context to use + * @param url the url to open + */ + fun openUrlInApp(context: Context, url: String?) { + openIntentInApp(context, Intent(Intent.ACTION_VIEW, Uri.parse(url)) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + + /** + * Open an intent with the system default app. + * + * + * Use [.openIntentInApp] 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 + */ + fun tryOpenIntentInApp(context: Context, + intent: Intent): Boolean { + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + return false + } + return true + } + + /** + * Open an intent with the system default app, showing a toast in case of failure. + * + * + * Use [.tryOpenIntentInApp] if you don't want the toast. Use [ ][.openUrlInApp] as a shorthand for [Intent.ACTION_VIEW] with urls. + * + * @param context the context to use + * @param intent the intent to + */ + fun openIntentInApp(context: Context, + 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 [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 [android.content.Intent.ACTION_SEND] system + * choosers must be set on this intent, not on the + * [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 fun openAppChooser(context: Context, + intent: Intent, + setTitleChooser: Boolean) { + val chooserIntent: Intent = 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)) + } + + // Migrate any clip data and flags from the original intent. + val permFlags: Int = intent.getFlags() and ((Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION)) + if (permFlags != 0) { + var targetClipData: ClipData? = intent.getClipData() + if (targetClipData == null && intent.getData() != null) { + val item: ClipData.Item = ClipData.Item(intent.getData()) + val mimeTypes: Array + if (intent.getType() != null) { + mimeTypes = arrayOf(intent.getType()) + } else { + mimeTypes = arrayOf() + } + targetClipData = ClipData(null, mimeTypes, item) + } + if (targetClipData != null) { + chooserIntent.setClipData(targetClipData) + chooserIntent.addFlags(permFlags) + } + } + try { + context.startActivity(chooserIntent) + } catch (e: ActivityNotFoundException) { + 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 + */ + /** + * Open the android share sheet to share a content. + * + * + * + * This calls [.shareText] with an empty string for the + * `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 + */ + @JvmOverloads + fun shareText(context: Context, + title: String, + content: String?, + imagePreviewUrl: String = "") { + val shareIntent: Intent = 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())) { + val 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 [ ][ImageStrategy.choosePreferredImage] 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 [Image]s of the subject, among which to choose with + * [ImageStrategy.choosePreferredImage] since that's likely to + * provide an image that is in Picasso's cache + */ + fun shareText(context: Context, + title: String, + content: String?, + images: List?) { + shareText(context, title, content, ImageStrategy.choosePreferredImage(images)) + } + + /** + * 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 + */ + fun copyToClipboard(context: Context, text: String?) { + val clipboardManager: ClipboardManager? = ContextCompat.getSystemService(context, ClipboardManager::class.java) + 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 (e: Exception) { + 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 [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 [LruCache][com.squareup.picasso.LruCache] + * used by the Picasso library inside [PicassoHelper] are used as preview images. If the + * thumbnail image is not in the cache, no [ClipData] will be generated and `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 `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 `90` compression level. + * + * + * + * + * Note that if an exception occurs when generating the [ClipData], `null` is + * returned. + * + * + * + * + * This method will call [PicassoHelper.getImageFromCacheIfPresent] to get the + * thumbnail of the content in the [LruCache][com.squareup.picasso.LruCache] used by + * the Picasso library inside [PicassoHelper]. + * + * + * + * + * 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 [ClipData] of the content thumbnail, or `null` + */ + private fun generateClipDataForImagePreview( + context: Context, + thumbnailUrl: String): ClipData? { + try { + val bitmap: Bitmap? = PicassoHelper.getImageFromCacheIfPresent(thumbnailUrl) + if (bitmap == null) { + return null + } + + // 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 + val applicationContext: Context = context.getApplicationContext() + val appFolder: String = applicationContext.getCacheDir().getAbsolutePath() + val thumbnailPreviewFile: File = File((appFolder + + "/android_share_sheet_image_preview.jpg")) + + // Any existing file will be overwritten with FileOutputStream + val fileOutputStream: FileOutputStream = FileOutputStream(thumbnailPreviewFile) + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, fileOutputStream) + fileOutputStream.close() + val clipData: ClipData = ClipData.newUri(applicationContext.getContentResolver(), "", + FileProvider.getUriForFile(applicationContext, + BuildConfig.APPLICATION_ID + ".provider", + thumbnailPreviewFile)) + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "ClipData successfully generated for Android share sheet: " + clipData) + } + return clipData + } catch (e: Exception) { + Log.w(TAG, "Error when setting preview image for share sheet", e) + return null + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.java b/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.java deleted file mode 100644 index da97179b6..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.java +++ /dev/null @@ -1,195 +0,0 @@ -package org.schabi.newpipe.util.image; - -import static org.schabi.newpipe.extractor.Image.HEIGHT_UNKNOWN; -import static org.schabi.newpipe.extractor.Image.WIDTH_UNKNOWN; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.extractor.Image; - -import java.util.Comparator; -import java.util.List; - -public final class 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 static final int BEST_LOW_H = 75; - private static final int BEST_MEDIUM_H = 250; - - private static PreferredImageQuality preferredImageQuality = PreferredImageQuality.MEDIUM; - - private ImageStrategy() { - } - - public static void setPreferredImageQuality(final PreferredImageQuality preferredImageQuality) { - ImageStrategy.preferredImageQuality = preferredImageQuality; - } - - public static boolean shouldLoadImages() { - return preferredImageQuality != PreferredImageQuality.NONE; - } - - - static double estimatePixelCount(final Image image, final double widthOverHeight) { - if (image.getHeight() == HEIGHT_UNKNOWN) { - if (image.getWidth() == 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; - } else { - return image.getWidth() * image.getWidth() / widthOverHeight; - } - } else if (image.getWidth() == WIDTH_UNKNOWN) { - return image.getHeight() * image.getHeight() * widthOverHeight; - } else { - return image.getHeight() * image.getWidth(); - } - } - - /** - * {@link #choosePreferredImage(List)} contains the description for this function's logic. - * - * @param images the images from which to choose - * @param nonNoneQuality the preferred quality (must NOT be {@link PreferredImageQuality#NONE}) - * @return the chosen preferred image, or {@link null} if the list is empty - * @see #choosePreferredImage(List) - */ - @Nullable - static String choosePreferredImage(@NonNull final List images, - final PreferredImageQuality nonNoneQuality) { - // this will be used to estimate the pixel count for images where only one of height or - // width are known - final double widthOverHeight = images.stream() - .filter(image -> image.getHeight() != HEIGHT_UNKNOWN - && image.getWidth() != WIDTH_UNKNOWN) - .mapToDouble(image -> ((double) image.getWidth()) / image.getHeight()) - .findFirst() - .orElse(1.0); - - final Image.ResolutionLevel preferredLevel = nonNoneQuality.toResolutionLevel(); - final Comparator initialComparator = Comparator - // the first step splits the images into groups of resolution levels - .comparingInt(i -> { - if (i.getEstimatedResolutionLevel() == Image.ResolutionLevel.UNKNOWN) { - return 3; // avoid unknowns as much as possible - } else if (i.getEstimatedResolutionLevel() == preferredLevel) { - return 0; // prefer a matching resolution level - } else if (i.getEstimatedResolutionLevel() == Image.ResolutionLevel.MEDIUM) { - return 1; // the preferredLevel is only 1 "step" away (either HIGH or LOW) - } else { - return 2; // the preferredLevel is the furthest away possible (2 "steps") - } - }) - // 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.getHeight() == HEIGHT_UNKNOWN && image.getWidth() == 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. - final Comparator finalComparator = switch (nonNoneQuality) { - case NONE -> initialComparator; // unreachable - case LOW -> initialComparator.thenComparingDouble(image -> { - final double pixelCount = estimatePixelCount(image, widthOverHeight); - return Math.abs(pixelCount - BEST_LOW_H * BEST_LOW_H * widthOverHeight); - }); - case MEDIUM -> initialComparator.thenComparingDouble(image -> { - final double pixelCount = estimatePixelCount(image, widthOverHeight); - return Math.abs(pixelCount - BEST_MEDIUM_H * BEST_MEDIUM_H * widthOverHeight); - }); - case HIGH -> initialComparator.thenComparingDouble( - // this is reversed with a - so that the highest resolution is chosen - i -> -estimatePixelCount(i, 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 - * {@link #setPreferredImageQuality(PreferredImageQuality)}. {@code 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 {@link Image#getEstimatedResolutionLevel()} is not unknown and is close - * to {@link #preferredImageQuality}
  2. - *
  3. At least one of the image's width or height are known
  4. - *
  5. The highest resolution image is finally chosen if the user's preference is {@link - * PreferredImageQuality#HIGH}, otherwise the chosen image is the one that has the height - * closest to {@link #BEST_LOW_H} or {@link #BEST_MEDIUM_H}
  6. - *
- *
- * Use {@link #imageListToDbUrl(List)} 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 {@link null} if the list is empty or the user disabled - * images - * @see #imageListToDbUrl(List) - */ - @Nullable - public static String choosePreferredImage(@NonNull final List images) { - if (preferredImageQuality == PreferredImageQuality.NONE) { - return null; // do not load images - } - - return choosePreferredImage(images, preferredImageQuality); - } - - /** - * Like {@link #choosePreferredImage(List)}, except that if {@link #preferredImageQuality} is - * {@link PreferredImageQuality#NONE} an image will be chosen anyway (with preferred quality - * {@link 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 {@link #dbUrlToImageList(String)}. - * - * @param images the images from which to choose - * @return the chosen preferred image, or {@link null} if the list is empty - * @see #choosePreferredImage(List) - * @see #dbUrlToImageList(String) - */ - @Nullable - public static String imageListToDbUrl(@NonNull final List images) { - final PreferredImageQuality quality; - if (preferredImageQuality == PreferredImageQuality.NONE) { - quality = PreferredImageQuality.MEDIUM; - } else { - quality = preferredImageQuality; - } - - return choosePreferredImage(images, quality); - } - - /** - * Wraps the URL (coming from the database) in a {@code 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 {@link - * #imageListToDbUrl(List)}. - * - * @param url the URL to wrap coming from the database, or {@code null} to get an empty list - * @return a list containing just one {@link Image} wrapping the provided URL, with unknown - * image size fields, or an empty list if the URL is {@code null} - * @see #imageListToDbUrl(List) - */ - @NonNull - public static List dbUrlToImageList(@Nullable final String url) { - if (url == null) { - return List.of(); - } else { - return List.of(new Image(url, -1, -1, Image.ResolutionLevel.UNKNOWN)); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt b/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt new file mode 100644 index 000000000..a302fe703 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.kt @@ -0,0 +1,170 @@ +package org.schabi.newpipe.util.image + +import org.schabi.newpipe.extractor.Image +import org.schabi.newpipe.extractor.Image.ResolutionLevel +import kotlin.math.abs + +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 + fun setPreferredImageQuality(preferredImageQuality: PreferredImageQuality) { + ImageStrategy.preferredImageQuality = preferredImageQuality + } + + fun shouldLoadImages(): Boolean { + return preferredImageQuality != PreferredImageQuality.NONE + } + + @JvmStatic + fun estimatePixelCount(image: Image, widthOverHeight: Double): Double { + return 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 + 0 + } else { + image.width * image.width / widthOverHeight + } + } else if (image.width == Image.WIDTH_UNKNOWN) { + image.height * image.height * widthOverHeight + } else { + (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 + */ + 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.stream() + .filter { image: Image? -> + (image!!.height != Image.HEIGHT_UNKNOWN + && image.width != Image.WIDTH_UNKNOWN) + } + .mapToDouble { image: Image? -> image!!.width.toDouble() / image.height } + .findFirst() + .orElse(1.0) + val preferredLevel = nonNoneQuality.toResolutionLevel() + val initialComparator = Comparator // the first step splits the images into groups of resolution levels + .comparingInt { i: Image -> + if (i.estimatedResolutionLevel == ResolutionLevel.UNKNOWN) { + return@comparingInt 3 // avoid unknowns as much as possible + } else if (i.estimatedResolutionLevel == preferredLevel) { + return@comparingInt 0 // prefer a matching resolution level + } else if (i.estimatedResolutionLevel == ResolutionLevel.MEDIUM) { + return@comparingInt 1 // the preferredLevel is only 1 "step" away (either HIGH or LOW) + } else { + return@comparingInt 2 // the preferredLevel is the furthest away possible (2 "steps") + } + } // 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 -> 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: Image -> + val pixelCount = estimatePixelCount(image, widthOverHeight) + abs(pixelCount - BEST_LOW_H * BEST_LOW_H * widthOverHeight) + } + + PreferredImageQuality.MEDIUM -> initialComparator.thenComparingDouble { image: Image -> + val pixelCount = estimatePixelCount(image, widthOverHeight) + abs(pixelCount - BEST_MEDIUM_H * BEST_MEDIUM_H * widthOverHeight) + } + + PreferredImageQuality.HIGH -> initialComparator.thenComparingDouble // this is reversed with a - so that the highest resolution is chosen + { i: Image -> -estimatePixelCount(i, 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 { obj: Image? -> obj!!.url } + .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.getEstimatedResolutionLevel] is not unknown and is close + * to [.preferredImageQuality] + * 1. At least one of the image's width or height are known + * 1. 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 + */ + fun choosePreferredImage(images: List): String? { + return if (preferredImageQuality == PreferredImageQuality.NONE) { + null // do not load images + } else 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 + */ + fun imageListToDbUrl(images: List): String? { + val quality: PreferredImageQuality + quality = if (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 + */ + fun dbUrlToImageList(url: String?): List { + return if (url == null) { + listOf() + } else { + java.util.List.of(Image(url, -1, -1, ResolutionLevel.UNKNOWN)) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.java b/app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.java deleted file mode 100644 index 4b116bdf9..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.java +++ /dev/null @@ -1,224 +0,0 @@ -package org.schabi.newpipe.util.image; - -import static org.schabi.newpipe.MainActivity.DEBUG; -import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; -import static org.schabi.newpipe.util.image.ImageStrategy.choosePreferredImage; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.graphics.Bitmap; -import android.util.Log; - -import androidx.annotation.DrawableRes; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.graphics.BitmapCompat; - -import com.squareup.picasso.Cache; -import com.squareup.picasso.LruCache; -import com.squareup.picasso.OkHttp3Downloader; -import com.squareup.picasso.Picasso; -import com.squareup.picasso.RequestCreator; -import com.squareup.picasso.Transformation; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.Image; - -import java.io.File; -import java.io.IOException; -import java.util.List; -import java.util.concurrent.TimeUnit; - -import okhttp3.OkHttpClient; - -public final class PicassoHelper { - private static final String TAG = PicassoHelper.class.getSimpleName(); - private static final String PLAYER_THUMBNAIL_TRANSFORMATION_KEY = - "PICASSO_PLAYER_THUMBNAIL_TRANSFORMATION_KEY"; - - private PicassoHelper() { - } - - private static Cache picassoCache; - private static OkHttpClient picassoDownloaderClient; - - // suppress because terminate() is called in App.onTerminate(), preventing leaks - @SuppressLint("StaticFieldLeak") - private static Picasso picassoInstance; - - - public static void init(final Context context) { - picassoCache = new LruCache(10 * 1024 * 1024); - picassoDownloaderClient = new OkHttpClient.Builder() - .cache(new okhttp3.Cache(new File(context.getExternalCacheDir(), "picasso"), - 50L * 1024L * 1024L)) - // this should already be the default timeout in OkHttp3, but just to be sure... - .callTimeout(15, TimeUnit.SECONDS) - .build(); - - picassoInstance = new Picasso.Builder(context) - .memoryCache(picassoCache) // memory cache - .downloader(new OkHttp3Downloader(picassoDownloaderClient)) // disk cache - .defaultBitmapConfig(Bitmap.Config.RGB_565) - .build(); - } - - public static void terminate() { - picassoCache = null; - picassoDownloaderClient = null; - - if (picassoInstance != null) { - picassoInstance.shutdown(); - picassoInstance = null; - } - } - - public static void clearCache(final Context context) throws IOException { - picassoInstance.shutdown(); - picassoCache.clear(); // clear memory cache - final okhttp3.Cache diskCache = picassoDownloaderClient.cache(); - if (diskCache != null) { - diskCache.delete(); // clear disk cache - } - init(context); - } - - public static void cancelTag(final Object tag) { - picassoInstance.cancelTag(tag); - } - - public static void setIndicatorsEnabled(final boolean enabled) { - picassoInstance.setIndicatorsEnabled(enabled); // useful for debugging - } - - - public static RequestCreator loadAvatar(@NonNull final List images) { - return loadImageDefault(images, R.drawable.placeholder_person); - } - - public static RequestCreator loadAvatar(@Nullable final String url) { - return loadImageDefault(url, R.drawable.placeholder_person); - } - - public static RequestCreator loadThumbnail(@NonNull final List images) { - return loadImageDefault(images, R.drawable.placeholder_thumbnail_video); - } - - public static RequestCreator loadThumbnail(@Nullable final String url) { - return loadImageDefault(url, R.drawable.placeholder_thumbnail_video); - } - - public static RequestCreator loadDetailsThumbnail(@NonNull final List images) { - return loadImageDefault(choosePreferredImage(images), - R.drawable.placeholder_thumbnail_video, false); - } - - public static RequestCreator loadBanner(@NonNull final List images) { - return loadImageDefault(images, R.drawable.placeholder_channel_banner); - } - - public static RequestCreator loadPlaylistThumbnail(@NonNull final List images) { - return loadImageDefault(images, R.drawable.placeholder_thumbnail_playlist); - } - - public static RequestCreator loadPlaylistThumbnail(@Nullable final String url) { - return loadImageDefault(url, R.drawable.placeholder_thumbnail_playlist); - } - - public static RequestCreator loadSeekbarThumbnailPreview(@Nullable final String url) { - return picassoInstance.load(url); - } - - public static RequestCreator loadNotificationIcon(@Nullable final String url) { - return loadImageDefault(url, R.drawable.ic_newpipe_triangle_white); - } - - - public static RequestCreator loadScaledDownThumbnail(final Context context, - @NonNull final List images) { - // scale down the notification thumbnail for performance - return PicassoHelper.loadThumbnail(images) - .transform(new Transformation() { - @Override - public Bitmap transform(final Bitmap source) { - if (DEBUG) { - Log.d(TAG, "Thumbnail - transform() called"); - } - - final float notificationThumbnailWidth = Math.min( - context.getResources() - .getDimension(R.dimen.player_notification_thumbnail_width), - source.getWidth()); - - final Bitmap result = BitmapCompat.createScaledBitmap( - source, - (int) notificationThumbnailWidth, - (int) (source.getHeight() - / (source.getWidth() / notificationThumbnailWidth)), - null, - true); - - if (result == source || !result.isMutable()) { - // create a new mutable bitmap to prevent strange crashes on some - // devices (see #4638) - final Bitmap copied = BitmapCompat.createScaledBitmap( - source, - (int) notificationThumbnailWidth - 1, - (int) (source.getHeight() / (source.getWidth() - / (notificationThumbnailWidth - 1))), - null, - true); - source.recycle(); - return copied; - } else { - source.recycle(); - return result; - } - } - - @Override - public String key() { - return PLAYER_THUMBNAIL_TRANSFORMATION_KEY; - } - }); - } - - @Nullable - public static Bitmap getImageFromCacheIfPresent(@NonNull final String imageUrl) { - // URLs in the internal cache finish with \n so we need to add \n to image URLs - return picassoCache.get(imageUrl + "\n"); - } - - - private static RequestCreator loadImageDefault(@NonNull final List images, - @DrawableRes final int placeholderResId) { - return loadImageDefault(choosePreferredImage(images), placeholderResId); - } - - private static RequestCreator loadImageDefault(@Nullable final String url, - @DrawableRes final int placeholderResId) { - return loadImageDefault(url, placeholderResId, true); - } - - private static RequestCreator loadImageDefault(@Nullable final String url, - @DrawableRes final int placeholderResId, - final boolean showPlaceholderWhileLoading) { - // 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) - if (isNullOrEmpty(url) || !ImageStrategy.shouldLoadImages()) { - return picassoInstance - .load((String) null) - .placeholder(placeholderResId) // show placeholder when no image should load - .error(placeholderResId); - } else { - final RequestCreator requestCreator = picassoInstance - .load(url) - .error(placeholderResId); - if (showPlaceholderWhileLoading) { - requestCreator.placeholder(placeholderResId); - } - return requestCreator; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.kt b/app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.kt new file mode 100644 index 000000000..d27fa246a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.kt @@ -0,0 +1,193 @@ +package org.schabi.newpipe.util.image + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.util.Log +import androidx.annotation.DrawableRes +import androidx.core.graphics.BitmapCompat +import androidx.room.RoomDatabase.Builder.build +import com.squareup.picasso.Cache +import com.squareup.picasso.LruCache +import com.squareup.picasso.OkHttp3Downloader +import com.squareup.picasso.Picasso +import com.squareup.picasso.RequestCreator +import com.squareup.picasso.Transformation +import okhttp3.OkHttpClient +import okhttp3.OkHttpClient.Builder.build +import okhttp3.OkHttpClient.Builder.cache +import okhttp3.OkHttpClient.Builder.callTimeout +import okhttp3.OkHttpClient.cache +import okhttp3.Request.Builder.build +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.R +import org.schabi.newpipe.extractor.Image +import org.schabi.newpipe.extractor.utils.Utils +import java.io.File +import java.io.IOException +import java.util.concurrent.TimeUnit +import kotlin.math.min + +object PicassoHelper { + private val TAG = PicassoHelper::class.java.getSimpleName() + private const val PLAYER_THUMBNAIL_TRANSFORMATION_KEY = "PICASSO_PLAYER_THUMBNAIL_TRANSFORMATION_KEY" + private var picassoCache: Cache? = null + private var picassoDownloaderClient: OkHttpClient? = null + + // suppress because terminate() is called in App.onTerminate(), preventing leaks + @SuppressLint("StaticFieldLeak") + private var picassoInstance: Picasso? = null + fun init(context: Context) { + picassoCache = LruCache(10 * 1024 * 1024) + picassoDownloaderClient = Builder() + .cache(okhttp3.Cache(File(context.externalCacheDir, "picasso"), + 50L * 1024L * 1024L)) // this should already be the default timeout in OkHttp3, but just to be sure... + .callTimeout(15, TimeUnit.SECONDS) + .build() + picassoInstance = Picasso.Builder(context) + .memoryCache(picassoCache) // memory cache + .downloader(OkHttp3Downloader(picassoDownloaderClient)) // disk cache + .defaultBitmapConfig(Bitmap.Config.RGB_565) + .build() + } + + fun terminate() { + picassoCache = null + picassoDownloaderClient = null + if (picassoInstance != null) { + picassoInstance!!.shutdown() + picassoInstance = null + } + } + + @Throws(IOException::class) + fun clearCache(context: Context) { + picassoInstance!!.shutdown() + picassoCache!!.clear() // clear memory cache + val diskCache = picassoDownloaderClient!!.cache + diskCache?.delete() + init(context) + } + + fun cancelTag(tag: Any?) { + picassoInstance!!.cancelTag(tag!!) + } + + fun setIndicatorsEnabled(enabled: Boolean) { + picassoInstance!!.setIndicatorsEnabled(enabled) // useful for debugging + } + + fun loadAvatar(images: List): RequestCreator { + return loadImageDefault(images, R.drawable.placeholder_person) + } + + fun loadAvatar(url: String?): RequestCreator { + return loadImageDefault(url, R.drawable.placeholder_person) + } + + fun loadThumbnail(images: List): RequestCreator { + return loadImageDefault(images, R.drawable.placeholder_thumbnail_video) + } + + fun loadThumbnail(url: String?): RequestCreator { + return loadImageDefault(url, R.drawable.placeholder_thumbnail_video) + } + + fun loadDetailsThumbnail(images: List): RequestCreator { + return loadImageDefault(ImageStrategy.choosePreferredImage(images), + R.drawable.placeholder_thumbnail_video, false) + } + + fun loadBanner(images: List): RequestCreator { + return loadImageDefault(images, R.drawable.placeholder_channel_banner) + } + + fun loadPlaylistThumbnail(images: List): RequestCreator { + return loadImageDefault(images, R.drawable.placeholder_thumbnail_playlist) + } + + fun loadPlaylistThumbnail(url: String?): RequestCreator { + return loadImageDefault(url, R.drawable.placeholder_thumbnail_playlist) + } + + fun loadSeekbarThumbnailPreview(url: String?): RequestCreator { + return picassoInstance!!.load(url) + } + + fun loadNotificationIcon(url: String?): RequestCreator { + return loadImageDefault(url, R.drawable.ic_newpipe_triangle_white) + } + + fun loadScaledDownThumbnail(context: Context, + images: List): RequestCreator { + // scale down the notification thumbnail for performance + return loadThumbnail(images) + .transform(object : Transformation { + override fun transform(source: Bitmap): Bitmap { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, "Thumbnail - transform() called") + } + val notificationThumbnailWidth = min( + context.resources + .getDimension(R.dimen.player_notification_thumbnail_width).toDouble(), + source.getWidth().toDouble()).toFloat() + val result = BitmapCompat.createScaledBitmap( + source, notificationThumbnailWidth.toInt(), (source.getHeight() + / (source.getWidth() / notificationThumbnailWidth)).toInt(), + null, + true) + return if (result == source || !result.isMutable) { + // create a new mutable bitmap to prevent strange crashes on some + // devices (see #4638) + val copied = BitmapCompat.createScaledBitmap( + source, + notificationThumbnailWidth.toInt() - 1, (source.getHeight() / (source.getWidth() + / (notificationThumbnailWidth - 1))).toInt(), + null, + true) + source.recycle() + copied + } else { + source.recycle() + result + } + } + + override fun key(): String { + return PLAYER_THUMBNAIL_TRANSFORMATION_KEY + } + }) + } + + fun getImageFromCacheIfPresent(imageUrl: String): Bitmap? { + // URLs in the internal cache finish with \n so we need to add \n to image URLs + return picassoCache!![imageUrl + "\n"] + } + + private fun loadImageDefault(images: List, + @DrawableRes placeholderResId: Int): RequestCreator { + return loadImageDefault(ImageStrategy.choosePreferredImage(images), placeholderResId) + } + + private fun loadImageDefault(url: String?, + @DrawableRes placeholderResId: Int, + showPlaceholderWhileLoading: Boolean = true): RequestCreator { + // 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) + return if (Utils.isNullOrEmpty(url) || !ImageStrategy.shouldLoadImages()) { + picassoInstance + .load(null as String?) + .placeholder(placeholderResId) // show placeholder when no image should load + .error(placeholderResId) + } else { + val requestCreator = picassoInstance + .load(url) + .error(placeholderResId) + if (showPlaceholderWhileLoading) { + requestCreator.placeholder(placeholderResId) + } + requestCreator + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.java b/app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.java deleted file mode 100644 index 7106359b3..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.schabi.newpipe.util.image; - -import android.content.Context; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.Image; - -public enum PreferredImageQuality { - NONE, - LOW, - MEDIUM, - HIGH; - - public static PreferredImageQuality fromPreferenceKey(final Context context, final String key) { - if (context.getString(R.string.image_quality_none_key).equals(key)) { - return NONE; - } else if (context.getString(R.string.image_quality_low_key).equals(key)) { - return LOW; - } else if (context.getString(R.string.image_quality_high_key).equals(key)) { - return HIGH; - } else { - return MEDIUM; // default to medium - } - } - - public Image.ResolutionLevel toResolutionLevel() { - switch (this) { - case LOW: - return Image.ResolutionLevel.LOW; - case MEDIUM: - return Image.ResolutionLevel.MEDIUM; - case HIGH: - return Image.ResolutionLevel.HIGH; - default: - case NONE: - return Image.ResolutionLevel.UNKNOWN; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.kt b/app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.kt new file mode 100644 index 000000000..600145c78 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.kt @@ -0,0 +1,36 @@ +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 + else -> ResolutionLevel.UNKNOWN + } + } + + companion object { + fun fromPreferenceKey(context: Context, key: String?): PreferredImageQuality { + return if (context.getString(R.string.image_quality_none_key) == key) { + NONE + } else if (context.getString(R.string.image_quality_low_key) == key) { + LOW + } else if (context.getString(R.string.image_quality_high_key) == key) { + HIGH + } else { + MEDIUM // default to medium + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/CommentTextOnTouchListener.java b/app/src/main/java/org/schabi/newpipe/util/text/CommentTextOnTouchListener.java deleted file mode 100644 index 5018a6120..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/CommentTextOnTouchListener.java +++ /dev/null @@ -1,42 +0,0 @@ -package org.schabi.newpipe.util.text; - -import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine; - -import android.annotation.SuppressLint; -import android.text.Spanned; -import android.text.style.ClickableSpan; -import android.view.MotionEvent; -import android.view.View; -import android.widget.TextView; - -public class CommentTextOnTouchListener implements View.OnTouchListener { - public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener(); - - @SuppressLint("ClickableViewAccessibility") - @Override - public boolean onTouch(final View v, final MotionEvent event) { - if (!(v instanceof TextView)) { - return false; - } - final TextView widget = (TextView) v; - final CharSequence text = widget.getText(); - if (text instanceof Spanned) { - final Spanned buffer = (Spanned) text; - final int action = event.getAction(); - - if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { - final int offset = getOffsetForHorizontalLine(widget, event); - final ClickableSpan[] links = buffer.getSpans(offset, offset, ClickableSpan.class); - - if (links.length != 0) { - if (action == MotionEvent.ACTION_UP) { - links[0].onClick(widget); - } - // we handle events that intersect links, so return true - return true; - } - } - } - return false; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/CommentTextOnTouchListener.kt b/app/src/main/java/org/schabi/newpipe/util/text/CommentTextOnTouchListener.kt new file mode 100644 index 000000000..028f454e4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/CommentTextOnTouchListener.kt @@ -0,0 +1,39 @@ +package org.schabi.newpipe.util.text + +import android.annotation.SuppressLint +import android.text.Spanned +import android.text.style.ClickableSpan +import android.view.MotionEvent +import android.view.View +import android.view.View.OnTouchListener +import android.widget.TextView + +class CommentTextOnTouchListener : OnTouchListener { + @SuppressLint("ClickableViewAccessibility") + override fun onTouch(v: View, event: MotionEvent): Boolean { + if (v !is TextView) { + return false + } + val widget = v + val text = widget.getText() + if (text is Spanned) { + val action = event.action + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { + val offset = TouchUtils.getOffsetForHorizontalLine(widget, event) + val links = text.getSpans(offset, offset, ClickableSpan::class.java) + if (links.size != 0) { + if (action == MotionEvent.ACTION_UP) { + links[0].onClick(widget) + } + // we handle events that intersect links, so return true + return true + } + } + } + return false + } + + companion object { + val INSTANCE = CommentTextOnTouchListener() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.java deleted file mode 100644 index 8a0363ecb..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.java +++ /dev/null @@ -1,36 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.kt b/app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.kt new file mode 100644 index 000000000..1e562c9dd --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.kt @@ -0,0 +1,18 @@ +package org.schabi.newpipe.util.text + +import android.content.Context +import android.view.View +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.external_communication.ShareUtils + +internal class HashtagLongPressClickableSpan(private val context: Context, + private val parsedHashtag: String, + private val relatedInfoServiceId: Int) : LongPressClickableSpan() { + override fun onClick(view: View) { + NavigationHelper.openSearch(context, relatedInfoServiceId, parsedHashtag) + } + + override fun onLongClick(view: View) { + ShareUtils.copyToClipboard(context, parsedHashtag) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java b/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java deleted file mode 100644 index 066515d6b..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java +++ /dev/null @@ -1,177 +0,0 @@ -package org.schabi.newpipe.util.text; - -import android.content.Context; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AlertDialog; - -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorPanelHelper; -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.extractor.stream.StreamInfo; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.SinglePlayQueue; -import org.schabi.newpipe.util.ExtractorHelper; -import org.schabi.newpipe.util.NavigationHelper; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -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 InternalUrlsHandler { - private static final String TAG = InternalUrlsHandler.class.getSimpleName(); - private static final boolean DEBUG = MainActivity.DEBUG; - - private static final Pattern AMPERSAND_TIMESTAMP_PATTERN = Pattern.compile("(.*)&t=(\\d+)"); - private static final Pattern HASHTAG_TIMESTAMP_PATTERN = - Pattern.compile("(.*)#timestamp=(\\d+)"); - - private InternalUrlsHandler() { - } - - /** - * Handle a YouTube timestamp comment URL in NewPipe. - *

- * This method will check if the provided url is a YouTube comment description URL ({@code - * https://www.youtube.com/watch?v=}video_id{@code #timestamp=}time_in_seconds). If yes, the - * popup player will be opened when the user will click on the timestamp in the comment, - * at the time and for the video indicated in the timestamp. - * - * @param disposables a field of the Activity/Fragment class that calls this method - * @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 handleUrlCommentsTimestamp(@NonNull final CompositeDisposable - disposables, - final Context context, - @NonNull final String url) { - return handleUrl(context, url, HASHTAG_TIMESTAMP_PATTERN, disposables); - } - - /** - * 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 disposables a field of the Activity/Fragment class that calls this method - * @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(@NonNull final CompositeDisposable - disposables, - final Context context, - @NonNull final String url) { - return handleUrl(context, url, AMPERSAND_TIMESTAMP_PATTERN, disposables); - } - - /** - * Handle an URL in NewPipe. - *

- * This method will check if the provided url can be handled in NewPipe or not. If this is a - * service URL with a timestamp, the popup player will be opened and true will be returned; - * else, false will be returned. - * - * @param context the context to use - * @param url the URL to check if it can be handled - * @param pattern the pattern to use - * @param disposables a field of the Activity/Fragment class that calls this method - * @return true if the URL can be handled by NewPipe, false if it cannot - */ - private static boolean handleUrl(final Context context, - @NonNull final String url, - @NonNull final Pattern pattern, - @NonNull final CompositeDisposable disposables) { - final Matcher matcher = 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, disposables); - } 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 - * @param disposables disposables created by the method are added here and their lifecycle - * should be handled by the calling class - * @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, - @NonNull final CompositeDisposable disposables) { - final LinkHandlerFactory factory = service.getStreamLHFactory(); - final String cleanUrl; - - try { - cleanUrl = factory.getUrl(factory.getId(url)); - } catch (final ParsingException e) { - return false; - } - - final Single single = - ExtractorHelper.getStreamInfo(service.getServiceId(), cleanUrl, false); - disposables.add(single.subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(info -> { - final PlayQueue playQueue = - new SinglePlayQueue(info, seconds * 1000L); - NavigationHelper.playOnPopupPlayer(context, playQueue, false); - }, throwable -> { - if (DEBUG) { - Log.e(TAG, "Could not play on popup: " + url, throwable); - } - new AlertDialog.Builder(context) - .setTitle(R.string.player_stream_failure) - .setMessage( - ErrorPanelHelper.Companion.getExceptionDescription(throwable)) - .setPositiveButton(R.string.ok, null) - .show(); - })); - return true; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.kt b/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.kt new file mode 100644 index 000000000..e07e70ee1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.kt @@ -0,0 +1,159 @@ +package org.schabi.newpipe.util.text + +import android.content.Context +import android.util.Log +import androidx.appcompat.app.AlertDialog +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 org.schabi.newpipe.MainActivity +import org.schabi.newpipe.R +import org.schabi.newpipe.error.ErrorPanelHelper.Companion.getExceptionDescription +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.StreamingService +import org.schabi.newpipe.extractor.StreamingService.LinkType +import org.schabi.newpipe.extractor.exceptions.ExtractionException +import org.schabi.newpipe.extractor.exceptions.ParsingException +import org.schabi.newpipe.extractor.stream.StreamInfo +import org.schabi.newpipe.player.playqueue.PlayQueue +import org.schabi.newpipe.player.playqueue.SinglePlayQueue +import org.schabi.newpipe.util.ExtractorHelper +import org.schabi.newpipe.util.NavigationHelper +import java.util.regex.Pattern + +object InternalUrlsHandler { + private val TAG = InternalUrlsHandler::class.java.getSimpleName() + private val DEBUG: Boolean = MainActivity.Companion.DEBUG + private val AMPERSAND_TIMESTAMP_PATTERN = Pattern.compile("(.*)&t=(\\d+)") + private val HASHTAG_TIMESTAMP_PATTERN = Pattern.compile("(.*)#timestamp=(\\d+)") + + /** + * Handle a YouTube timestamp comment URL in NewPipe. + * + * + * This method will check if the provided url is a YouTube comment description URL (`https://www.youtube.com/watch?v=`video_id`#timestamp=`time_in_seconds). If yes, the + * popup player will be opened when the user will click on the timestamp in the comment, + * at the time and for the video indicated in the timestamp. + * + * @param disposables a field of the Activity/Fragment class that calls this method + * @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 + */ + fun handleUrlCommentsTimestamp(disposables: CompositeDisposable, + context: Context, + url: String): Boolean { + return handleUrl(context, url, HASHTAG_TIMESTAMP_PATTERN, disposables) + } + + /** + * Handle a YouTube timestamp description URL in NewPipe. + * + * + * This method will check if the provided url is a YouTube timestamp description URL (`https://www.youtube.com/watch?v=`video_id`&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 disposables a field of the Activity/Fragment class that calls this method + * @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 + */ + fun handleUrlDescriptionTimestamp(disposables: CompositeDisposable, + context: Context, + url: String): Boolean { + return handleUrl(context, url, AMPERSAND_TIMESTAMP_PATTERN, disposables) + } + + /** + * Handle an URL in NewPipe. + * + * + * This method will check if the provided url can be handled in NewPipe or not. If this is a + * service URL with a timestamp, the popup player will be opened and true will be returned; + * else, false will be returned. + * + * @param context the context to use + * @param url the URL to check if it can be handled + * @param pattern the pattern to use + * @param disposables a field of the Activity/Fragment class that calls this method + * @return true if the URL can be handled by NewPipe, false if it cannot + */ + private fun handleUrl(context: Context, + url: String, + pattern: Pattern, + disposables: CompositeDisposable): Boolean { + val matcher = pattern.matcher(url) + if (!matcher.matches()) { + return false + } + val matchedUrl = matcher.group(1) + val seconds: Int + seconds = if (matcher.group(2) == null) { + -1 + } else { + matcher.group(2).toInt() + } + val service: StreamingService + val linkType: LinkType + try { + service = NewPipe.getServiceByUrl(matchedUrl) + linkType = service.getLinkTypeByUrl(matchedUrl) + if (linkType == LinkType.NONE) { + return false + } + } catch (e: ExtractionException) { + return false + } + return if (linkType == LinkType.STREAM && seconds != -1) { + playOnPopup(context, matchedUrl, service, seconds, disposables) + } else { + NavigationHelper.openRouterActivity(context, matchedUrl) + 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 + * @param disposables disposables created by the method are added here and their lifecycle + * should be handled by the calling class + * @return true if the playback of the content has successfully started or false if not + */ + fun playOnPopup(context: Context?, + url: String, + service: StreamingService, + seconds: Int, + disposables: CompositeDisposable): Boolean { + val factory = service.streamLHFactory + val cleanUrl: String + cleanUrl = try { + factory.getUrl(factory.getId(url)) + } catch (e: ParsingException) { + return false + } + val single: Single? = ExtractorHelper.getStreamInfo(service.serviceId, cleanUrl, false) + disposables.add(single!!.subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ info: StreamInfo? -> + val playQueue: PlayQueue = SinglePlayQueue(info!!, seconds * 1000L) + NavigationHelper.playOnPopupPlayer(context, playQueue, false) + }) { throwable: Throwable? -> + if (DEBUG) { + Log.e(TAG, "Could not play on popup: $url", throwable) + } + AlertDialog.Builder(context!!) + .setTitle(R.string.player_stream_failure) + .setMessage( + getExceptionDescription(throwable)) + .setPositiveButton(R.string.ok, null) + .show() + }) + return true + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/LongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/LongPressClickableSpan.java deleted file mode 100644 index 5c94a5850..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/LongPressClickableSpan.java +++ /dev/null @@ -1,12 +0,0 @@ -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); - -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/LongPressClickableSpan.kt b/app/src/main/java/org/schabi/newpipe/util/text/LongPressClickableSpan.kt new file mode 100644 index 000000000..f108cae42 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/LongPressClickableSpan.kt @@ -0,0 +1,8 @@ +package org.schabi.newpipe.util.text + +import android.text.style.ClickableSpan +import android.view.View + +abstract class LongPressClickableSpan : ClickableSpan() { + abstract fun onLongClick(view: View) +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/LongPressLinkMovementMethod.java b/app/src/main/java/org/schabi/newpipe/util/text/LongPressLinkMovementMethod.java deleted file mode 100644 index bd57621cb..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/LongPressLinkMovementMethod.java +++ /dev/null @@ -1,77 +0,0 @@ -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; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/LongPressLinkMovementMethod.kt b/app/src/main/java/org/schabi/newpipe/util/text/LongPressLinkMovementMethod.kt new file mode 100644 index 000000000..dd173d282 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/LongPressLinkMovementMethod.kt @@ -0,0 +1,64 @@ +package org.schabi.newpipe.util.text + +import android.os.Handler +import android.os.Looper +import android.text.Selection +import android.text.Spannable +import android.text.method.LinkMovementMethod +import android.view.MotionEvent +import android.view.ViewConfiguration +import android.widget.TextView + +// Class adapted from https://stackoverflow.com/a/31786969 +class LongPressLinkMovementMethod : LinkMovementMethod() { + private var longClickHandler: Handler? = null + private var isLongPressed = false + override fun onTouchEvent(widget: TextView, + buffer: Spannable, + event: MotionEvent): Boolean { + val action = event.action + if (action == MotionEvent.ACTION_CANCEL && longClickHandler != null) { + longClickHandler!!.removeCallbacksAndMessages(null) + } + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { + val offset = TouchUtils.getOffsetForHorizontalLine(widget, event) + val link = buffer.getSpans(offset, offset, + LongPressClickableSpan::class.java) + if (link.size != 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.toLong()) + } + } + return true + } + } + return super.onTouchEvent(widget, buffer, event) + } + + companion object { + private val LONG_PRESS_TIME = ViewConfiguration.getLongPressTimeout() + var instance: LongPressLinkMovementMethod? = null + get() { + if (field == null) { + field = LongPressLinkMovementMethod() + field!!.longClickHandler = Handler(Looper.myLooper()!!) + } + return field + } + private set + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java b/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java deleted file mode 100644 index 184b73304..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java +++ /dev/null @@ -1,193 +0,0 @@ -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); - } - } - -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.kt b/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.kt new file mode 100644 index 000000000..5f6935280 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.kt @@ -0,0 +1,174 @@ +package org.schabi.newpipe.util.text + +import android.graphics.Paint +import android.view.View +import android.widget.TextView +import androidx.core.text.HtmlCompat +import io.reactivex.rxjava3.disposables.CompositeDisposable +import org.schabi.newpipe.extractor.StreamingService +import org.schabi.newpipe.extractor.stream.Description +import java.util.function.Consumer + +/** + * + * Class to ellipsize text inside a [TextView]. + * This class provides all utils to automatically ellipsize and expand a text + */ +class TextEllipsizer(private val view: TextView, + private val maxLines: Int, + private var streamingService: StreamingService?) { + private val disposable = CompositeDisposable() + private var content: Description + private var streamUrl: String? = null + private var isEllipsized = false + private var canBeEllipsized: Boolean? = null + private val paintAtContentSize = Paint() + private val ellipsisWidthPx: Float + private var stateChangeListener: Consumer? = null + private var onContentChanged: Consumer? = null + + init { + content = Description.EMPTY_DESCRIPTION + paintAtContentSize.textSize = view.textSize + ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS) + } + + fun setOnContentChanged(onContentChanged: Consumer?) { + this.onContentChanged = onContentChanged + } + + fun setContent(content: Description) { + this.content = content + canBeEllipsized = null + linkifyContentView { v: View? -> + val currentMaxLines = view.maxLines + view.setMaxLines(EXPANDED_LINES) + canBeEllipsized = view.lineCount > maxLines + view.setMaxLines(currentMaxLines) + if (onContentChanged != null) { + onContentChanged!!.accept(canBeEllipsized!!) + } + } + } + + fun setStreamUrl(streamUrl: String?) { + this.streamUrl = streamUrl + } + + fun setStreamingService(streamingService: StreamingService) { + this.streamingService = streamingService + } + + /** + * Expand the [TextEllipsizer.content] to its full length. + */ + fun expand() { + view.setMaxLines(EXPANDED_LINES) + linkifyContentView { v: View? -> isEllipsized = false } + } + + /** + * Shorten the [TextEllipsizer.content] to the given number of + * [maximum lines][maxLines] and add trailing '`…`' + * if the text was shorted. + */ + fun ellipsize() { + // expand text to see whether it is necessary to ellipsize the text + view.setMaxLines(EXPANDED_LINES) + linkifyContentView { v: View? -> + val charSeqText = view.getText() + if (charSeqText != null && view.lineCount > 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. + val text = charSeqText.toString() + val layout = view.layout + val lineWidth = layout.getLineWidth(maxLines - 1) + val layoutWidth = layout.width.toFloat() + val lineStart = layout.getLineStart(maxLines - 1) + val 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) + var end = lineEnd + var 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[end - 1])) { + end -= 1 + } + val newVal = text.substring(0, end) + ELLIPSIS + view.text = newVal + isEllipsized = true + } else { + isEllipsized = false + } + view.setMaxLines(maxLines) + } + } + + /** + * Toggle the view between the ellipsized and expanded state. + */ + fun toggle() { + if (isEllipsized) { + expand() + } else { + ellipsize() + } + } + + /** + * Whether the [.view] can be ellipsized. + * This is only the case when the [.content] has more lines + * than allowed via [.maxLines]. + * @return `true` if the [.content] has more lines than allowed via + * [.maxLines] and thus can be shortened, `false` if the `content` fits into + * the [.view] without being shortened and `null` if the initialization is not + * completed yet. + */ + fun canBeEllipsized(): Boolean? { + return canBeEllipsized + } + + private fun linkifyContentView(consumer: Consumer) { + val oldState = isEllipsized + disposable.clear() + TextLinkifier.fromDescription(view, content, + HtmlCompat.FROM_HTML_MODE_LEGACY, streamingService, streamUrl, disposable + ) { v: TextView? -> + 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 `null` to remove it. + * The Boolean parameter is the new state. + * *Ellipsized* content is represented as `true`, + * normal or *full* content by `false`. + */ + fun setStateChangeListener(listener: Consumer?) { + stateChangeListener = listener + } + + private fun notifyStateChangeListener(oldState: Boolean) { + if (oldState != isEllipsized && stateChangeListener != null) { + stateChangeListener!!.accept(isEllipsized) + } + } + + companion object { + private const val EXPANDED_LINES = Int.MAX_VALUE + private const val ELLIPSIS = "…" + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java b/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java deleted file mode 100644 index 1419ac85a..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java +++ /dev/null @@ -1,369 +0,0 @@ -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, boolean)}. - *

- * - *

- * 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, disposables, 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); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.kt b/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.kt new file mode 100644 index 000000000..7c34657c7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.kt @@ -0,0 +1,340 @@ +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 android.widget.TextView.BufferType +import androidx.core.text.HtmlCompat +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 +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.Pattern + +object TextLinkifier { + val TAG = TextLinkifier::class.java.getSimpleName() + + // Looks for hashtags with characters from any language (\p{L}), numbers, or underscores + private val HASHTAGS_PATTERN = Pattern.compile("(#[\\p{L}0-9_]+)") + val SET_LINK_MOVEMENT_METHOD = Consumer { v: TextView -> v.movementMethod = LongPressLinkMovementMethod.Companion.getInstance() } + + /** + * Create links for contents with an [Description] in the various possible formats. + * + * + * This will call one of these three functions based on the format: [.fromHtml], + * [.fromMarkdown] or [.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 [HtmlCompat.fromHtml] + * 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 `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 [ ][.SET_LINK_MOVEMENT_METHOD] to make links clickable and focusable + */ + fun fromDescription(textView: TextView, + description: Description, + htmlCompatFlag: Int, + relatedInfoService: StreamingService?, + relatedStreamUrl: String?, + disposables: CompositeDisposable, + onCompletion: Consumer?) { + when (description.type) { + Description.HTML -> fromHtml(textView, description.content, htmlCompatFlag, + relatedInfoService, relatedStreamUrl, disposables, onCompletion) + + Description.MARKDOWN -> fromMarkdown(textView, description.content, + relatedInfoService, relatedStreamUrl, disposables, onCompletion) + + Description.PLAIN_TEXT -> fromPlainText(textView, description.content, + relatedInfoService, relatedStreamUrl, disposables, onCompletion) + + else -> fromPlainText(textView, description.content, + relatedInfoService, relatedStreamUrl, disposables, onCompletion) + } + } + + /** + * Create links for contents with an HTML description. + * + * + * + * This method will call [.changeLinkIntents] after having linked the URLs with + * [HtmlCompat.fromHtml]. + * + * + * @param textView the [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 [HtmlCompat.fromHtml] will be called + * @param relatedInfoService if given, handle hashtags to search for the term in the correct + * service + * @param relatedStreamUrl if given, used alongside `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 [ ][.SET_LINK_MOVEMENT_METHOD] to make links clickable and focusable + */ + fun fromHtml(textView: TextView, + htmlBlock: String, + htmlCompatFlag: Int, + relatedInfoService: StreamingService?, + relatedStreamUrl: String?, + disposables: CompositeDisposable, + onCompletion: Consumer?) { + changeLinkIntents( + textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfoService, + relatedStreamUrl, disposables, onCompletion) + } + + /** + * Create links for contents with a plain text description. + * + * + * + * This method will call [.changeLinkIntents] after having linked the URLs with + * [TextView.setAutoLinkMask] and + * [TextView.setText]. + * + * + * @param textView the [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 `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 [ ][.SET_LINK_MOVEMENT_METHOD] to make links clickable and focusable + */ + fun fromPlainText(textView: TextView, + plainTextBlock: String, + relatedInfoService: StreamingService?, + relatedStreamUrl: String?, + disposables: CompositeDisposable, + onCompletion: Consumer?) { + textView.autoLinkMask = Linkify.WEB_URLS + textView.setText(plainTextBlock, BufferType.SPANNABLE) + changeLinkIntents(textView, textView.getText(), relatedInfoService, + relatedStreamUrl, disposables, onCompletion) + } + + /** + * Create links for contents with a markdown description. + * + * + * + * This method will call [.changeLinkIntents] after creating a [Markwon] object and using + * [Markwon.setMarkdown]. + * + * + * @param textView the [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 `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 [ ][.SET_LINK_MOVEMENT_METHOD] to make links clickable and focusable + */ + fun fromMarkdown(textView: TextView, + markdownBlock: String, + relatedInfoService: StreamingService?, + relatedStreamUrl: String?, + disposables: CompositeDisposable, + onCompletion: Consumer?) { + val markwon = Markwon.builder(textView.context) + .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 [android.content.Intent.ACTION_VIEW] intent in the description of + * a content, this method will parse the [CharSequence] and replace all current web links + * with [ShareUtils.openUrlInBrowser]. + * + * + * + * + * 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 + * [TextLinkifier.addClickListenersOnTimestamps] method and click listeners on hashtags, by + * using [TextLinkifier.addClickListenersOnHashtags], 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 [TextView] to which the converted [CharSequence] + * will be applied + * @param chars the [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 `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 [ ][.SET_LINK_MOVEMENT_METHOD] to make links clickable and focusable + */ + private fun changeLinkIntents(textView: TextView, + chars: CharSequence, + relatedInfoService: StreamingService?, + relatedStreamUrl: String?, + disposables: CompositeDisposable, + onCompletion: Consumer?) { + disposables.add(Single.fromCallable { + val context = textView.context + + // add custom click actions on web links + val textBlockLinked = SpannableStringBuilder(chars) + val urls = textBlockLinked.getSpans(0, chars.length, + URLSpan::class.java) + for (span in urls) { + val url = span.url + val longPressClickableSpan: LongPressClickableSpan = UrlLongPressClickableSpan(context, disposables, 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) + } + textBlockLinked + }.subscribeOn(Schedulers.computation()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { textBlockLinked: SpannableStringBuilder? -> setTextViewCharSequence(textView, textBlockLinked, onCompletion) } + ) { throwable: 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 [SpannableStringBuilder] of the description + * using a regular expression, adds for each a [LongPressClickableSpan] which opens + * [NavigationHelper.openSearch] 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 `addLongClickCopyListener`). + * + * + * @param context the [Context] to use + * @param spannableDescription the [SpannableStringBuilder] with the text of the + * content description + * @param relatedInfoService used to search for the term in the correct service + */ + private fun addClickListenersOnHashtags( + context: Context, + spannableDescription: SpannableStringBuilder, + relatedInfoService: StreamingService) { + val descriptionText = spannableDescription.toString() + val hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText) + while (hashtagsMatches.find()) { + val hashtagStart = hashtagsMatches.start(1) + val hashtagEnd = hashtagsMatches.end(1) + val 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.java).size == 0) { + val serviceId = relatedInfoService.serviceId + spannableDescription.setSpan( + 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 [SpannableStringBuilder] of the description + * using a regular expression, adds for each a [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 [Context] to use + * @param spannableDescription the [SpannableStringBuilder] with the text of the + * content description + * @param relatedInfoService the service of the `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 fun addClickListenersOnTimestamps( + context: Context, + spannableDescription: SpannableStringBuilder, + relatedInfoService: StreamingService, + relatedStreamUrl: String, + disposables: CompositeDisposable) { + val descriptionText = spannableDescription.toString() + val timestampsMatches = TimestampExtractor.TIMESTAMPS_PATTERN.matcher( + descriptionText) + while (timestampsMatches.find()) { + val timestampMatchDTO = TimestampExtractor.getTimestampFromMatcher(timestampsMatches, descriptionText) + ?: continue + spannableDescription.setSpan( + TimestampLongPressClickableSpan(context, descriptionText, disposables, + relatedInfoService, relatedStreamUrl, timestampMatchDTO), + timestampMatchDTO.timestampStart(), + timestampMatchDTO.timestampEnd(), + 0) + } + } + + private fun setTextViewCharSequence(textView: TextView, + charSequence: CharSequence?, + onCompletion: Consumer?) { + textView.text = charSequence + textView.visibility = View.VISIBLE + onCompletion?.accept(textView) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.java b/app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.java deleted file mode 100644 index be603f41a..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.java +++ /dev/null @@ -1,83 +0,0 @@ -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 static class TimestampMatchDTO { - private final int timestampStart; - private final int timestampEnd; - private final int seconds; - - public TimestampMatchDTO( - final int timestampStart, - final int timestampEnd, - final int seconds) { - this.timestampStart = timestampStart; - this.timestampEnd = timestampEnd; - this.seconds = seconds; - } - - public int timestampStart() { - return timestampStart; - } - - public int timestampEnd() { - return timestampEnd; - } - - public int seconds() { - return seconds; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.kt b/app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.kt new file mode 100644 index 000000000..8a314e070 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.kt @@ -0,0 +1,64 @@ +package org.schabi.newpipe.util.text + +import java.util.regex.Matcher +import java.util.regex.Pattern + +/** + * Extracts timestamps. + */ +object TimestampExtractor { + @JvmField + val TIMESTAMPS_PATTERN = Pattern.compile( + "(?:^|(?!:)\\W)(?:([0-5]?[0-9]):)?([0-5]?[0-9]):([0-5][0-9])(?=$|(?!:)\\W)") + + /** + * Gets a single timestamp from a matcher. + * + * @param timestampMatches the matcher which was created using [.TIMESTAMPS_PATTERN] + * @param baseText the text where the pattern was applied to / where the matcher is + * based upon + * @return if a match occurred, a [TimestampMatchDTO] filled with information, otherwise + * `null`. + */ + @JvmStatic + fun getTimestampFromMatcher( + timestampMatches: Matcher, + baseText: String): TimestampMatchDTO? { + var timestampStart = timestampMatches.start(1) + if (timestampStart == -1) { + timestampStart = timestampMatches.start(2) + } + val timestampEnd = timestampMatches.end(3) + val parsedTimestamp = baseText.substring(timestampStart, timestampEnd) + val timestampParts = parsedTimestamp.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() + val seconds: Int + if (timestampParts.size == 3) { // timestamp format: XX:XX:XX + seconds = timestampParts[0].toInt() * 3600 // hours + +(timestampParts[1].toInt() * 60 // minutes + ) + timestampParts[2].toInt() // seconds + } else if (timestampParts.size == 2) { // timestamp format: XX:XX + seconds = (timestampParts[0].toInt() * 60 // minutes + + timestampParts[1].toInt()) // seconds + } else { + return null + } + return TimestampMatchDTO(timestampStart, timestampEnd, seconds) + } + + class TimestampMatchDTO( + private val timestampStart: Int, + private val timestampEnd: Int, + private val seconds: Int) { + fun timestampStart(): Int { + return timestampStart + } + + fun timestampEnd(): Int { + return timestampEnd + } + + fun seconds(): Int { + return seconds + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java deleted file mode 100644 index f5864794a..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java +++ /dev/null @@ -1,78 +0,0 @@ -package org.schabi.newpipe.util.text; - -import static org.schabi.newpipe.util.text.InternalUrlsHandler.playOnPopup; - -import android.content.Context; -import android.view.View; - -import androidx.annotation.NonNull; - -import org.schabi.newpipe.extractor.ServiceList; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.util.external_communication.ShareUtils; - -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -final class TimestampLongPressClickableSpan extends LongPressClickableSpan { - - @NonNull - private final Context context; - @NonNull - private final String descriptionText; - @NonNull - private final CompositeDisposable disposables; - @NonNull - private final StreamingService relatedInfoService; - @NonNull - private final String relatedStreamUrl; - @NonNull - private final TimestampExtractor.TimestampMatchDTO timestampMatchDTO; - - TimestampLongPressClickableSpan( - @NonNull final Context context, - @NonNull final String descriptionText, - @NonNull final CompositeDisposable disposables, - @NonNull final StreamingService relatedInfoService, - @NonNull final String relatedStreamUrl, - @NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) { - this.context = context; - this.descriptionText = descriptionText; - this.disposables = disposables; - this.relatedInfoService = relatedInfoService; - this.relatedStreamUrl = relatedStreamUrl; - this.timestampMatchDTO = timestampMatchDTO; - } - - @Override - public void onClick(@NonNull final View view) { - playOnPopup(context, relatedStreamUrl, relatedInfoService, - timestampMatchDTO.seconds(), disposables); - } - - @Override - public void onLongClick(@NonNull final View view) { - ShareUtils.copyToClipboard(context, getTimestampTextToCopy( - relatedInfoService, relatedStreamUrl, descriptionText, timestampMatchDTO)); - } - - @NonNull - private static String getTimestampTextToCopy( - @NonNull final StreamingService relatedInfoService, - @NonNull final String relatedStreamUrl, - @NonNull final String descriptionText, - @NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) { - // TODO: use extractor methods to get timestamps when this feature will be implemented in it - if (relatedInfoService == ServiceList.YouTube) { - return relatedStreamUrl + "&t=" + timestampMatchDTO.seconds(); - } else if (relatedInfoService == ServiceList.SoundCloud - || relatedInfoService == ServiceList.MediaCCC) { - return relatedStreamUrl + "#t=" + timestampMatchDTO.seconds(); - } else if (relatedInfoService == ServiceList.PeerTube) { - return relatedStreamUrl + "?start=" + timestampMatchDTO.seconds(); - } - - // Return timestamp text for other services - return descriptionText.subSequence(timestampMatchDTO.timestampStart(), - timestampMatchDTO.timestampEnd()).toString(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.kt b/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.kt new file mode 100644 index 000000000..9391016db --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.kt @@ -0,0 +1,49 @@ +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 + +internal 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(), disposables) + } + + 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 + if (relatedInfoService === ServiceList.YouTube) { + return relatedStreamUrl + "&t=" + timestampMatchDTO.seconds() + } else if (relatedInfoService === ServiceList.SoundCloud + || relatedInfoService === ServiceList.MediaCCC) { + return relatedStreamUrl + "#t=" + timestampMatchDTO.seconds() + } else if (relatedInfoService === ServiceList.PeerTube) { + return relatedStreamUrl + "?start=" + timestampMatchDTO.seconds() + } + + // Return timestamp text for other services + return descriptionText.subSequence(timestampMatchDTO.timestampStart(), + timestampMatchDTO.timestampEnd()).toString() + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TouchUtils.java b/app/src/main/java/org/schabi/newpipe/util/text/TouchUtils.java deleted file mode 100644 index 5c0db20a3..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/TouchUtils.java +++ /dev/null @@ -1,38 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TouchUtils.kt b/app/src/main/java/org/schabi/newpipe/util/text/TouchUtils.kt new file mode 100644 index 000000000..fa34d5971 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/TouchUtils.kt @@ -0,0 +1,27 @@ +package org.schabi.newpipe.util.text + +import android.view.MotionEvent +import android.widget.TextView + +object TouchUtils { + /** + * Get the character offset on the closest line to the position pressed by the user of a + * [TextView] from a [MotionEvent] which was fired on this [TextView]. + * + * @param textView the [TextView] on which the [MotionEvent] was fired + * @param event the [MotionEvent] which was fired + * @return the character offset on the closest line to the position pressed by the user + */ + fun getOffsetForHorizontalLine(textView: TextView, + event: MotionEvent): Int { + var x = event.x.toInt() + var y = event.y.toInt() + x -= textView.totalPaddingLeft + y -= textView.totalPaddingTop + x += textView.scrollX + y += textView.scrollY + val layout = textView.layout + val line = layout.getLineForVertical(y) + return layout.getOffsetForHorizontal(line, x.toFloat()) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java deleted file mode 100644 index 61c1a546d..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java +++ /dev/null @@ -1,41 +0,0 @@ -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; - -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -final class UrlLongPressClickableSpan extends LongPressClickableSpan { - - @NonNull - private final Context context; - @NonNull - private final CompositeDisposable disposables; - @NonNull - private final String url; - - UrlLongPressClickableSpan(@NonNull final Context context, - @NonNull final CompositeDisposable disposables, - @NonNull final String url) { - this.context = context; - this.disposables = disposables; - this.url = url; - } - - @Override - public void onClick(@NonNull final View view) { - if (!InternalUrlsHandler.handleUrlDescriptionTimestamp( - disposables, context, url)) { - ShareUtils.openUrlInApp(context, url); - } - } - - @Override - public void onLongClick(@NonNull final View view) { - ShareUtils.copyToClipboard(context, url); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.kt b/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.kt new file mode 100644 index 000000000..1a6c070bd --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.kt @@ -0,0 +1,21 @@ +package org.schabi.newpipe.util.text + +import android.content.Context +import android.view.View +import io.reactivex.rxjava3.disposables.CompositeDisposable +import org.schabi.newpipe.util.external_communication.ShareUtils + +internal class UrlLongPressClickableSpan(private val context: Context, + private val disposables: CompositeDisposable, + private val url: String) : LongPressClickableSpan() { + override fun onClick(view: View) { + if (!InternalUrlsHandler.handleUrlDescriptionTimestamp( + disposables, context, url)) { + ShareUtils.openUrlInApp(context, url) + } + } + + override fun onLongClick(view: View) { + ShareUtils.copyToClipboard(context, url) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/urlfinder/PatternsCompat.java b/app/src/main/java/org/schabi/newpipe/util/urlfinder/PatternsCompat.kt similarity index 71% rename from app/src/main/java/org/schabi/newpipe/util/urlfinder/PatternsCompat.java rename to app/src/main/java/org/schabi/newpipe/util/urlfinder/PatternsCompat.kt index 49be86ae0..32d583b9c 100644 --- a/app/src/main/java/org/schabi/newpipe/util/urlfinder/PatternsCompat.java +++ b/app/src/main/java/org/schabi/newpipe/util/urlfinder/PatternsCompat.kt @@ -1,6 +1,4 @@ -/* THIS FILE WAS MODIFIED, CHANGES ARE DOCUMENTED. */ - -/* +/* 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"); @@ -15,29 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +package org.schabi.newpipe.util.urlfinder -package org.schabi.newpipe.util.urlfinder; - -import java.util.regex.Pattern; +import java.util.regex.Pattern /** * Commonly used regular expression patterns. */ -public final class PatternsCompat { +object PatternsCompat { //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! // CHANGED: Removed unused code // //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - - public static final Pattern IP_ADDRESS = Pattern.compile( + val 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]))"); + + "|[1-9][0-9]|[0-9]))") /** * Valid UCS characters defined in RFC 3987. Excludes space characters. */ - private static final String UCS_CHAR = "[" + private const val UCS_CHAR = ("[" + "\u00A0-\uD7FF" + "\uF900-\uFDCF" + "\uFDF0-\uFFEF" @@ -55,43 +51,37 @@ public final class PatternsCompat { + "\uDAC0\uDC00-\uDAFF\uDFFD" + "\uDB00\uDC00-\uDB3F\uDFFD" + "\uDB44\uDC00-\uDB7F\uDFFD" - + "&&[^\u00A0[\u2000-\u200A]\u2028\u2029\u202F\u3000]]"; + + "&&[^\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; + private const val 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}"; + private const val 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)://"; + private const val 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\\$\\-\\_\\.\\+\\!\\*\\'\\(\\)" + private const val WORD_BOUNDARY = "(?:\\b|$|^)" + private const val 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}))*"; + + "\\.\\+\\!\\*\\'\\(\\)\\,\\;\\?\\&\\=]|(?:\\%[a-fA-F0-9]{2})){1,25})?\\@") + private const val PORT_NUMBER = "\\:\\d{1,5}" + private const val 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 + ")"; - + private val RELAXED_DOMAIN_NAME = "(?:" + "(?:" + IRI_LABEL + "(?:\\.(?=\\S))" + "?)+" + "|" + IP_ADDRESS + ")" //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! // CHANGED: Field visibility was modified // //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! @@ -99,7 +89,8 @@ public final class PatternsCompat { * 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 = "(" + /*package*/ + val WEB_URL_WITH_PROTOCOL = ("(" + WORD_BOUNDARY + "(?:" + "(?:" + PROTOCOL + "(?:" + USER_INFO + ")?" + ")" @@ -108,10 +99,5 @@ public final class PatternsCompat { + ")" + "(?:" + PATH_AND_QUERY + ")?" + WORD_BOUNDARY - + ")"; - - /** - * Do not create this static utility class. - */ - private PatternsCompat() { } + + ")") } diff --git a/app/src/main/java/org/schabi/newpipe/views/AnimatedProgressBar.java b/app/src/main/java/org/schabi/newpipe/views/AnimatedProgressBar.java deleted file mode 100644 index b1fabe715..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/AnimatedProgressBar.java +++ /dev/null @@ -1,66 +0,0 @@ -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); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/AnimatedProgressBar.kt b/app/src/main/java/org/schabi/newpipe/views/AnimatedProgressBar.kt new file mode 100644 index 000000000..686d89f1b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/AnimatedProgressBar.kt @@ -0,0 +1,46 @@ +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 + +class AnimatedProgressBar : ProgressBar { + private var animation: ProgressBarAnimation? = null + + constructor(context: Context?) : super(context) + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context?, attrs: AttributeSet?, + defStyleAttr: Int) : super(context, attrs, defStyleAttr) + + @Synchronized + fun setProgressAnimated(progress: Int) { + cancelAnimation() + animation = ProgressBarAnimation(this, getProgress().toFloat(), progress.toFloat()) + startAnimation(animation) + } + + private fun cancelAnimation() { + if (animation != null) { + animation!!.cancel() + animation = null + } + clearAnimation() + } + + private class ProgressBarAnimation internal constructor(private val progressBar: AnimatedProgressBar, private val from: Float, + private val to: Float) : Animation() { + init { + setDuration(500) + setInterpolator(AccelerateDecelerateInterpolator()) + } + + override fun applyTransformation(interpolatedTime: Float, t: Transformation) { + super.applyTransformation(interpolatedTime, t) + val value: Float = from + (to - from) * interpolatedTime + progressBar.setProgress(value.toInt()) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java b/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java deleted file mode 100644 index f79e1e3a3..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java +++ /dev/null @@ -1,249 +0,0 @@ -/* - * Copyright 2018 Mauricio Colli - * CollapsibleView.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.animation.ValueAnimator; -import android.content.Context; -import android.os.Parcelable; -import android.util.AttributeSet; -import android.util.Log; -import android.widget.LinearLayout; - -import androidx.annotation.IntDef; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.ktx.ViewUtils; - -import java.lang.annotation.Retention; -import java.util.ArrayList; -import java.util.List; - -import icepick.Icepick; -import icepick.State; - -import static java.lang.annotation.RetentionPolicy.SOURCE; -import static org.schabi.newpipe.MainActivity.DEBUG; - -/** - * A view that can be fully collapsed and expanded. - */ -public class CollapsibleView extends LinearLayout { - private static final String TAG = CollapsibleView.class.getSimpleName(); - - private static final int ANIMATION_DURATION = 420; - - public static final int COLLAPSED = 0; - public static final int EXPANDED = 1; - - @State - @ViewMode - int currentState = COLLAPSED; - private boolean readyToChangeState; - - private int targetHeight = -1; - private ValueAnimator currentAnimator; - private final List listeners = new ArrayList<>(); - - public CollapsibleView(final Context context) { - super(context); - } - - public CollapsibleView(final Context context, @Nullable final AttributeSet attrs) { - super(context, attrs); - } - - public CollapsibleView(final Context context, @Nullable final AttributeSet attrs, - final int defStyleAttr) { - super(context, attrs, defStyleAttr); - } - - public CollapsibleView(final Context context, final AttributeSet attrs, final int defStyleAttr, - final int defStyleRes) { - super(context, attrs, defStyleAttr, defStyleRes); - } - - /*////////////////////////////////////////////////////////////////////////// - // Collapse/expand logic - //////////////////////////////////////////////////////////////////////////*/ - - /** - * This method recalculates the height of this view so it must be called when - * some child changes (e.g. add new views, change text). - */ - public void ready() { - if (DEBUG) { - Log.d(TAG, getDebugLogString("ready() called")); - } - - measure(MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.AT_MOST), - MeasureSpec.UNSPECIFIED); - targetHeight = getMeasuredHeight(); - - getLayoutParams().height = currentState == COLLAPSED ? 0 : targetHeight; - requestLayout(); - broadcastState(); - - readyToChangeState = true; - - if (DEBUG) { - Log.d(TAG, getDebugLogString("ready() *after* measuring")); - } - } - - public void collapse() { - if (DEBUG) { - Log.d(TAG, getDebugLogString("collapse() called")); - } - - if (!readyToChangeState) { - return; - } - - final int height = getHeight(); - if (height == 0) { - setCurrentState(COLLAPSED); - return; - } - - if (currentAnimator != null && currentAnimator.isRunning()) { - currentAnimator.cancel(); - } - currentAnimator = ViewUtils.animateHeight(this, ANIMATION_DURATION, 0); - - setCurrentState(COLLAPSED); - } - - public void expand() { - if (DEBUG) { - Log.d(TAG, getDebugLogString("expand() called")); - } - - if (!readyToChangeState) { - return; - } - - final int height = getHeight(); - if (height == this.targetHeight) { - setCurrentState(EXPANDED); - return; - } - - if (currentAnimator != null && currentAnimator.isRunning()) { - currentAnimator.cancel(); - } - currentAnimator = ViewUtils.animateHeight(this, ANIMATION_DURATION, this.targetHeight); - setCurrentState(EXPANDED); - } - - public void switchState() { - if (!readyToChangeState) { - return; - } - - if (currentState == COLLAPSED) { - expand(); - } else { - collapse(); - } - } - - @ViewMode - public int getCurrentState() { - return currentState; - } - - public void setCurrentState(@ViewMode final int currentState) { - this.currentState = currentState; - broadcastState(); - } - - public void broadcastState() { - for (final StateListener listener : listeners) { - listener.onStateChanged(currentState); - } - } - - /** - * Add a listener which will be listening for changes in this view (i.e. collapsed or expanded). - * @param listener {@link StateListener} to be added - */ - public void addListener(final StateListener listener) { - if (listeners.contains(listener)) { - throw new IllegalStateException("Trying to add the same listener multiple times"); - } - - listeners.add(listener); - } - - /** - * Remove a listener so it doesn't receive more state changes. - * @param listener {@link StateListener} to be removed - */ - public void removeListener(final StateListener listener) { - listeners.remove(listener); - } - - /*////////////////////////////////////////////////////////////////////////// - // State Saving - //////////////////////////////////////////////////////////////////////////*/ - - @Nullable - @Override - public Parcelable onSaveInstanceState() { - return Icepick.saveInstanceState(this, super.onSaveInstanceState()); - } - - @Override - public void onRestoreInstanceState(final Parcelable state) { - super.onRestoreInstanceState(Icepick.restoreInstanceState(this, state)); - - ready(); - } - - /*////////////////////////////////////////////////////////////////////////// - // Internal - //////////////////////////////////////////////////////////////////////////*/ - - public String getDebugLogString(final String description) { - return String.format("%-100s → %s", - description, "readyToChangeState = [" + readyToChangeState + "], " - + "currentState = [" + currentState + "], " - + "targetHeight = [" + targetHeight + "], " - + "mW x mH = [" + getMeasuredWidth() + "x" + getMeasuredHeight() + "], " - + "W x H = [" + getWidth() + "x" + getHeight() + "]"); - } - - @Retention(SOURCE) - @IntDef({COLLAPSED, EXPANDED}) - public @interface ViewMode { } - - /** - * Simple interface used for listening state changes of the {@link CollapsibleView}. - */ - public interface StateListener { - /** - * Called when the state changes. - * - * @param newState the state that the {@link CollapsibleView} transitioned to,
- * it's an integer being either {@link #COLLAPSED} or {@link #EXPANDED} - */ - void onStateChanged(@ViewMode int newState); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.kt b/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.kt new file mode 100644 index 000000000..95ce68839 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.kt @@ -0,0 +1,207 @@ +/* + * Copyright 2018 Mauricio Colli + * CollapsibleView.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.animation.ValueAnimator +import android.content.Context +import android.os.Parcelable +import android.util.AttributeSet +import android.util.Log +import android.widget.LinearLayout +import androidx.annotation.IntDef +import icepick.Icepick +import icepick.State +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.ktx.animateHeight +import org.schabi.newpipe.views.CollapsibleView + +/** + * A view that can be fully collapsed and expanded. + */ +class CollapsibleView : LinearLayout { + @State + @ViewMode + var currentState: Int = COLLAPSED + private var readyToChangeState: Boolean = false + private var targetHeight: Int = -1 + private var currentAnimator: ValueAnimator? = null + private val listeners: MutableList = ArrayList() + + constructor(context: Context?) : super(context) + constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context?, attrs: AttributeSet?, + defStyleAttr: Int) : super(context, attrs, defStyleAttr) + + constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, + defStyleRes: Int) : super(context, attrs, defStyleAttr, defStyleRes) + /*////////////////////////////////////////////////////////////////////////// + // Collapse/expand logic + ////////////////////////////////////////////////////////////////////////// */ + /** + * This method recalculates the height of this view so it **must** be called when + * some child changes (e.g. add new views, change text). + */ + fun ready() { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, getDebugLogString("ready() called")) + } + measure(MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.AT_MOST), + MeasureSpec.UNSPECIFIED) + targetHeight = getMeasuredHeight() + getLayoutParams().height = if (currentState == COLLAPSED) 0 else targetHeight + requestLayout() + broadcastState() + readyToChangeState = true + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, getDebugLogString("ready() *after* measuring")) + } + } + + fun collapse() { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, getDebugLogString("collapse() called")) + } + if (!readyToChangeState) { + return + } + val height: Int = getHeight() + if (height == 0) { + setCurrentState(COLLAPSED) + return + } + if (currentAnimator != null && currentAnimator!!.isRunning()) { + currentAnimator!!.cancel() + } + currentAnimator = this.animateHeight(ANIMATION_DURATION.toLong(), 0) + setCurrentState(COLLAPSED) + } + + fun expand() { + if (MainActivity.Companion.DEBUG) { + Log.d(TAG, getDebugLogString("expand() called")) + } + if (!readyToChangeState) { + return + } + val height: Int = getHeight() + if (height == targetHeight) { + setCurrentState(EXPANDED) + return + } + if (currentAnimator != null && currentAnimator!!.isRunning()) { + currentAnimator!!.cancel() + } + currentAnimator = this.animateHeight(ANIMATION_DURATION.toLong(), targetHeight) + setCurrentState(EXPANDED) + } + + fun switchState() { + if (!readyToChangeState) { + return + } + if (currentState == COLLAPSED) { + expand() + } else { + collapse() + } + } + + @ViewMode + fun getCurrentState(): Int { + return currentState + } + + fun setCurrentState(@ViewMode currentState: Int) { + this.currentState = currentState + broadcastState() + } + + fun broadcastState() { + for (listener: StateListener in listeners) { + listener.onStateChanged(currentState) + } + } + + /** + * Add a listener which will be listening for changes in this view (i.e. collapsed or expanded). + * @param listener [StateListener] to be added + */ + fun addListener(listener: StateListener) { + if (listeners.contains(listener)) { + throw IllegalStateException("Trying to add the same listener multiple times") + } + listeners.add(listener) + } + + /** + * Remove a listener so it doesn't receive more state changes. + * @param listener [StateListener] to be removed + */ + fun removeListener(listener: StateListener) { + listeners.remove(listener) + } + + /*////////////////////////////////////////////////////////////////////////// + // State Saving + ////////////////////////////////////////////////////////////////////////// */ + public override fun onSaveInstanceState(): Parcelable? { + return Icepick.saveInstanceState(this, super.onSaveInstanceState()) + } + + public override fun onRestoreInstanceState(state: Parcelable) { + super.onRestoreInstanceState(Icepick.restoreInstanceState(this, state)) + ready() + } + + /*////////////////////////////////////////////////////////////////////////// + // Internal + ////////////////////////////////////////////////////////////////////////// */ + fun getDebugLogString(description: String?): String { + return String.format("%-100s → %s", + description, ("readyToChangeState = [" + readyToChangeState + "], " + + "currentState = [" + currentState + "], " + + "targetHeight = [" + targetHeight + "], " + + "mW x mH = [" + getMeasuredWidth() + "x" + getMeasuredHeight() + "], " + + "W x H = [" + getWidth() + "x" + getHeight() + "]")) + } + + @Retention(AnnotationRetention.SOURCE) + @IntDef([COLLAPSED, EXPANDED]) + annotation class ViewMode() + + /** + * Simple interface used for listening state changes of the [CollapsibleView]. + */ + open interface StateListener { + /** + * Called when the state changes. + * + * @param newState the state that the [CollapsibleView] transitioned to,

+ * it's an integer being either [.COLLAPSED] or [.EXPANDED] + */ + fun onStateChanged(@ViewMode newState: Int) + } + + companion object { + private val TAG: String = CollapsibleView::class.java.getSimpleName() + private val ANIMATION_DURATION: Int = 420 + val COLLAPSED: Int = 0 + val EXPANDED: Int = 1 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/CustomCollapsingToolbarLayout.java b/app/src/main/java/org/schabi/newpipe/views/CustomCollapsingToolbarLayout.java deleted file mode 100644 index dc667b22a..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/CustomCollapsingToolbarLayout.java +++ /dev/null @@ -1,41 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/CustomCollapsingToolbarLayout.kt b/app/src/main/java/org/schabi/newpipe/views/CustomCollapsingToolbarLayout.kt new file mode 100644 index 000000000..c805221ab --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/CustomCollapsingToolbarLayout.kt @@ -0,0 +1,35 @@ +package org.schabi.newpipe.views + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import com.google.android.material.appbar.CollapsingToolbarLayout + +class CustomCollapsingToolbarLayout : CollapsingToolbarLayout { + constructor(context: Context) : super(context) { + overrideListener() + } + + constructor(context: Context, + attrs: AttributeSet?) : super(context, attrs) { + overrideListener() + } + + constructor(context: Context, + attrs: AttributeSet?, + defStyleAttr: Int) : super(context, attrs, defStyleAttr) { + overrideListener() + } + + /** + * CollapsingToolbarLayout sets it's own setOnApplyInsetsListener which consumes + * system insets [CollapsingToolbarLayout.onWindowInsetChanged] + * so we will not receive them in subviews with fitsSystemWindows = true. + * Override Google's behavior + */ + fun overrideListener() { + ViewCompat.setOnApplyWindowInsetsListener(this, androidx.core.view.OnApplyWindowInsetsListener({ v: View?, insets: WindowInsetsCompat? -> (insets)!! })) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java b/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java deleted file mode 100644 index 175c81e46..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.java +++ /dev/null @@ -1,110 +0,0 @@ -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 (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) { - return; - } - - videoAspectRatio = aspectRatio; - requestLayout(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.kt b/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.kt new file mode 100644 index 000000000..3e889d91a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/ExpandableSurfaceView.kt @@ -0,0 +1,92 @@ +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 com.google.android.exoplayer2.ui.AspectRatioFrameLayout.ResizeMode + +class ExpandableSurfaceView(context: Context?, attrs: AttributeSet?) : SurfaceView(context, attrs) { + private var resizeMode: Int = AspectRatioFrameLayout.RESIZE_MODE_FIT + private var baseHeight: Int = 0 + private var maxHeight: Int = 0 + private var videoAspectRatio: Float = 0.0f + private var scaleX: Float = 1.0f + private var scaleY: Float = 1.0f + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + if (videoAspectRatio == 0.0f) { + return + } + var width: Int = MeasureSpec.getSize(widthMeasureSpec) + val verticalVideo: Boolean = videoAspectRatio < 1 + // Use maxHeight only on non-fit resize mode and in vertical videos + var height: Int = if ((maxHeight != 0 + ) && (resizeMode != AspectRatioFrameLayout.RESIZE_MODE_FIT + ) && verticalVideo) maxHeight else baseHeight + if (height == 0) { + return + } + val viewAspectRatio: Float = width / (height.toFloat()) + val aspectDeformation: Float = videoAspectRatio / viewAspectRatio - 1 + scaleX = 1.0f + scaleY = 1.0f + if (resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FIT) { + if (aspectDeformation > 0) { + height = (width / videoAspectRatio).toInt() + } else { + width = (height * videoAspectRatio).toInt() + } + } else if (resizeMode == AspectRatioFrameLayout.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 [.onLayout] to make transition for ZOOM mode as smooth as possible. + */ + override fun onLayout(changed: Boolean, + left: Int, top: Int, right: Int, bottom: Int) { + 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 + */ + fun setHeights(base: Int, max: Int) { + if (baseHeight == base && maxHeight == max) { + return + } + baseHeight = base + maxHeight = max + requestLayout() + } + + fun setResizeMode(newResizeMode: @ResizeMode Int) { + if (resizeMode == newResizeMode) { + return + } + resizeMode = newResizeMode + requestLayout() + } + + fun getResizeMode(): @ResizeMode Int { + return resizeMode + } + + fun setAspectRatio(aspectRatio: Float) { + if (videoAspectRatio == aspectRatio) { + return + } + videoAspectRatio = aspectRatio + requestLayout() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.java b/app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.java deleted file mode 100644 index d4fafc31a..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.java +++ /dev/null @@ -1,105 +0,0 @@ -/* - * 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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.kt b/app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.kt new file mode 100644 index 000000000..5910563fa --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.kt @@ -0,0 +1,87 @@ +/* + * 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.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat +import org.schabi.newpipe.R + +class FocusAwareCoordinator : CoordinatorLayout { + private val childFocus: Rect = Rect() + + constructor(context: Context) : super(context) + constructor(context: Context, + attrs: AttributeSet?) : super(context, attrs) + + constructor(context: Context, + attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) + + public override fun requestChildFocus(child: View, focused: View) { + super.requestChildFocus(child, focused) + if (!isInTouchMode()) { + if (focused.getHeight() >= getHeight()) { + focused.getFocusedRect(childFocus) + (child as ViewGroup).offsetDescendantRectToMyCoords(focused, childFocus) + } else { + focused.getHitRect(childFocus) + (child as ViewGroup).offsetDescendantRectToMyCoords(focused.getParent() as View?, + 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 + */ + public override fun dispatchApplyWindowInsets(insets: WindowInsets): WindowInsets { + var consumed: Boolean = false + for (i in 0 until getChildCount()) { + val child: View = getChildAt(i) + val res: WindowInsets = child.dispatchApplyWindowInsets(insets) + if (res.isConsumed()) { + consumed = true + } + } + return if (consumed) (WindowInsetsCompat.CONSUMED.toWindowInsets())!! else 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 + */ + public override fun onApplyWindowInsets(windowInsets: WindowInsets): WindowInsets { + val windowInsetsCompat: WindowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(windowInsets, this) + val insets: Insets = windowInsetsCompat.getInsets(WindowInsetsCompat.Type.systemBars()) + val controls: ViewGroup? = findViewById(R.id.playbackControlRoot) + if (controls != null) { + controls.setPadding(insets.left, insets.top, insets.right, insets.bottom) + } + return super.onApplyWindowInsets(windowInsets) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareDrawerLayout.java b/app/src/main/java/org/schabi/newpipe/views/FocusAwareDrawerLayout.java deleted file mode 100644 index 5c694c3a9..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/FocusAwareDrawerLayout.java +++ /dev/null @@ -1,115 +0,0 @@ -/* - * 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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareDrawerLayout.kt b/app/src/main/java/org/schabi/newpipe/views/FocusAwareDrawerLayout.kt new file mode 100644 index 000000000..ae83d1e19 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/FocusAwareDrawerLayout.kt @@ -0,0 +1,86 @@ +/* + * 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.drawerlayout.widget.DrawerLayout + +class FocusAwareDrawerLayout : DrawerLayout { + constructor(context: Context) : super(context) + constructor(context: Context, + attrs: AttributeSet?) : super(context, attrs) + + constructor(context: Context, + attrs: AttributeSet?, + defStyle: Int) : super(context, attrs, defStyle) + + override fun onRequestFocusInDescendants(direction: Int, + previouslyFocusedRect: Rect): Boolean { + // 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 + var hasOpenPanels: Boolean = false + for (i in 0 until getChildCount()) { + val child: View = getChildAt(i) + val lp: LayoutParams = child.getLayoutParams() as LayoutParams + if (lp.gravity != 0 && isDrawerVisible(child)) { + hasOpenPanels = true + if (child.requestFocus(direction, previouslyFocusedRect)) { + return true + } + } + } + if (hasOpenPanels) { + return false + } + return super.onRequestFocusInDescendants(direction, previouslyFocusedRect) + } + + public override fun addFocusables(views: ArrayList, direction: Int, + focusableMode: Int) { + var hasOpenPanels: Boolean = false + var content: View? = null + for (i in 0 until getChildCount()) { + val child: View = getChildAt(i) + val lp: LayoutParams = child.getLayoutParams() as LayoutParams + 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) + @SuppressLint("RtlHardcoded") + public override fun openDrawer(drawerView: View, animate: Boolean) { + super.openDrawer(drawerView, animate) + drawerView.requestFocus(FOCUS_FORWARD) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java b/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java deleted file mode 100644 index 8176a9aef..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.java +++ /dev/null @@ -1,147 +0,0 @@ -/* - * 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); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.kt b/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.kt new file mode 100644 index 000000000..a280214b0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/FocusAwareSeekBar.kt @@ -0,0 +1,112 @@ +/* + * 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.view.ViewTreeObserver.OnTouchModeChangeListener +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. + */ +class FocusAwareSeekBar : AppCompatSeekBar { + private var listener: NestedListener? = null + private var treeObserver: ViewTreeObserver? = null + + constructor(context: Context?) : super((context)!!) + constructor(context: Context?, attrs: AttributeSet?) : super((context)!!, attrs) + constructor(context: Context?, attrs: AttributeSet?, + defStyleAttr: Int) : super((context)!!, attrs, defStyleAttr) + + public override fun setOnSeekBarChangeListener(l: OnSeekBarChangeListener) { + listener = if (l == null) null else NestedListener(l) + super.setOnSeekBarChangeListener(listener) + } + + public override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean { + if (!isInTouchMode() && DeviceUtils.isConfirmKey(keyCode)) { + releaseTrack() + } + return super.onKeyDown(keyCode, event) + } + + override fun onFocusChanged(gainFocus: Boolean, direction: Int, + previouslyFocusedRect: Rect?) { + super.onFocusChanged(gainFocus, direction, previouslyFocusedRect) + if (!isInTouchMode() && !gainFocus) { + releaseTrack() + } + } + + private val touchModeListener: OnTouchModeChangeListener = OnTouchModeChangeListener({ isInTouchMode: Boolean -> + if (isInTouchMode) { + releaseTrack() + } + }) + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + treeObserver = getViewTreeObserver() + treeObserver.addOnTouchModeChangeListener(touchModeListener) + } + + override fun onDetachedFromWindow() { + if (treeObserver == null || !treeObserver!!.isAlive()) { + treeObserver = getViewTreeObserver() + } + treeObserver!!.removeOnTouchModeChangeListener(touchModeListener) + treeObserver = null + super.onDetachedFromWindow() + } + + private fun releaseTrack() { + if (listener != null && listener!!.isSeeking) { + listener!!.onStopTrackingTouch(this) + } + } + + private class NestedListener(private val delegate: OnSeekBarChangeListener) : OnSeekBarChangeListener { + var isSeeking: Boolean = false + public override fun onProgressChanged(seekBar: SeekBar, progress: Int, + fromUser: Boolean) { + if (!seekBar.isInTouchMode() && !isSeeking && fromUser) { + isSeeking = true + onStartTrackingTouch(seekBar) + } + delegate.onProgressChanged(seekBar, progress, fromUser) + } + + public override fun onStartTrackingTouch(seekBar: SeekBar) { + isSeeking = true + delegate.onStartTrackingTouch(seekBar) + } + + public override fun onStopTrackingTouch(seekBar: SeekBar) { + isSeeking = false + delegate.onStopTrackingTouch(seekBar) + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusOverlayView.java b/app/src/main/java/org/schabi/newpipe/views/FocusOverlayView.java deleted file mode 100644 index 29c38511c..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/FocusOverlayView.java +++ /dev/null @@ -1,293 +0,0 @@ -/* - * 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 androidx.appcompat.view.WindowCallbackWrapper; - -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 WindowCallbackWrapper(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); - } - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusOverlayView.kt b/app/src/main/java/org/schabi/newpipe/views/FocusOverlayView.kt new file mode 100644 index 000000000..c18f829ae --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/FocusOverlayView.kt @@ -0,0 +1,241 @@ +/* + * 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.ViewTreeObserver.OnDrawListener +import android.view.ViewTreeObserver.OnGlobalFocusChangeListener +import android.view.ViewTreeObserver.OnGlobalLayoutListener +import android.view.ViewTreeObserver.OnScrollChangedListener +import android.view.ViewTreeObserver.OnTouchModeChangeListener +import android.view.Window +import androidx.annotation.RequiresApi +import androidx.appcompat.view.WindowCallbackWrapper +import org.schabi.newpipe.R +import java.lang.ref.WeakReference + +class FocusOverlayView(context: Context) : Drawable(), OnGlobalFocusChangeListener, OnDrawListener, OnGlobalLayoutListener, OnScrollChangedListener, OnTouchModeChangeListener { + private var isInTouchMode: Boolean = false + private val focusRect: Rect = Rect() + private val rectPaint: Paint = Paint() + private val animator: Handler = object : Handler(Looper.getMainLooper()) { + public override fun handleMessage(msg: Message) { + updateRect() + } + } + private var focused: WeakReference? = null + + init { + rectPaint.setStyle(Paint.Style.STROKE) + rectPaint.setStrokeWidth(2f) + rectPaint.setColor(context.getResources().getColor(R.color.white)) + } + + public override fun onGlobalFocusChanged(oldFocus: View, newFocus: View) { + if (newFocus != null) { + focused = WeakReference(newFocus) + } else { + focused = null + } + updateRect() + animator.sendEmptyMessageDelayed(0, 1000) + } + + private fun updateRect() { + val focusedView: View? = if (focused == null) null else focused!!.get() + val l: Int = focusRect.left + val r: Int = focusRect.right + val t: Int = focusRect.top + val b: Int = 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 fun isShown(view: View): Boolean { + return (view.getWidth() != 0) && (view.getHeight() != 0) && view.isShown() + } + + public override fun onDraw() { + updateRect() + } + + public override fun onScrollChanged() { + updateRect() + animator.removeMessages(0) + animator.sendEmptyMessageDelayed(0, 1000) + } + + public override fun onGlobalLayout() { + updateRect() + animator.sendEmptyMessageDelayed(0, 1000) + } + + public override fun onTouchModeChanged(inTouchMode: Boolean) { + isInTouchMode = inTouchMode + if (inTouchMode) { + updateRect() + } else { + invalidateSelf() + } + } + + fun setCurrentFocus(newFocus: View?) { + if (newFocus == null) { + return + } + isInTouchMode = newFocus.isInTouchMode() + onGlobalFocusChanged(null, newFocus) + } + + public override fun draw(canvas: Canvas) { + if (!isInTouchMode && focusRect.width() != 0) { + canvas.drawRect(focusRect, rectPaint) + } + } + + public override fun getOpacity(): Int { + return PixelFormat.TRANSPARENT + } + + public override fun setAlpha(alpha: Int) {} + public override fun setColorFilter(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 fun shouldClearFocusRect(focusedView: View?, focusedRect: Rect): Boolean { + return focusedView == null || (focusedRect == getBounds()) + } + + private fun onKey(event: KeyEvent) { + if (event.getAction() != KeyEvent.ACTION_DOWN) { + return + } + updateRect() + animator.sendEmptyMessageDelayed(0, 100) + } + + companion object { + fun setupFocusObserver(dialog: Dialog) { + val displayRect: Rect = Rect() + val window: Window? = dialog.getWindow() + assert(window != null) + val decor: View = window!!.getDecorView() + decor.getWindowVisibleDisplayFrame(displayRect) + val overlay: FocusOverlayView = FocusOverlayView(dialog.getContext()) + overlay.setBounds(0, 0, displayRect.width(), displayRect.height()) + setupOverlay(window, overlay) + } + + fun setupFocusObserver(activity: Activity) { + val displayRect: Rect = Rect() + val window: Window = activity.getWindow() + val decor: View = window.getDecorView() + decor.getWindowVisibleDisplayFrame(displayRect) + val overlay: FocusOverlayView = FocusOverlayView(activity) + overlay.setBounds(0, 0, displayRect.width(), displayRect.height()) + setupOverlay(window, overlay) + } + + private fun setupOverlay(window: Window?, overlay: FocusOverlayView) { + val decor: ViewGroup = window!!.getDecorView() as ViewGroup + decor.getOverlay().add(overlay) + fixFocusHierarchy(decor) + val observer: ViewTreeObserver = 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(object : WindowCallbackWrapper(window.getCallback()) { + public override fun dispatchKeyEvent(event: KeyEvent): Boolean { + val res: Boolean = super.dispatchKeyEvent(event) + overlay.onKey(event) + return res + } + }) + } + + private fun fixFocusHierarchy(decor: View) { + // 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 is ViewGroup)) { + return + } + clearFocusObstacles(decor) + } + + @RequiresApi(api = Build.VERSION_CODES.O) + private fun clearFocusObstacles(viewGroup: ViewGroup) { + viewGroup.setTouchscreenBlocksFocus(false) + if (viewGroup.isKeyboardNavigationCluster()) { + viewGroup.setKeyboardNavigationCluster(false) + return // clusters aren't supposed to nest + } + val childCount: Int = viewGroup.getChildCount() + for (i in 0 until childCount) { + val view: View = viewGroup.getChildAt(i) + if (view is ViewGroup) { + clearFocusObstacles(view) + } + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/NewPipeEditText.java b/app/src/main/java/org/schabi/newpipe/views/NewPipeEditText.java deleted file mode 100644 index f0993055e..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/NewPipeEditText.java +++ /dev/null @@ -1,46 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/NewPipeEditText.kt b/app/src/main/java/org/schabi/newpipe/views/NewPipeEditText.kt new file mode 100644 index 000000000..adc68d64f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/NewPipeEditText.kt @@ -0,0 +1,33 @@ +package org.schabi.newpipe.views + +import android.content.Context +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatEditText +import org.schabi.newpipe.util.NewPipeTextViewHelper +import org.schabi.newpipe.util.external_communication.ShareUtils + +/** + * An [AppCompatEditText] which uses [ShareUtils.shareText] + * when sharing selected text by using the `Share` command of the floating actions. + * + * + * + * This class allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing + * text from [AppCompatEditText] on EMUI devices. + * + */ +class NewPipeEditText : AppCompatEditText { + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context, + attrs: AttributeSet?, + defStyleAttr: Int) : super(context, attrs, defStyleAttr) + + public override fun onTextContextMenuItem(id: Int): Boolean { + if (id == android.R.id.shareText) { + NewPipeTextViewHelper.shareSelectedTextWithShareUtils(this) + return true + } + return super.onTextContextMenuItem(id) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/NewPipeRecyclerView.java b/app/src/main/java/org/schabi/newpipe/views/NewPipeRecyclerView.kt similarity index 50% rename from app/src/main/java/org/schabi/newpipe/views/NewPipeRecyclerView.java rename to app/src/main/java/org/schabi/newpipe/views/NewPipeRecyclerView.kt index 23b961297..19900beaf 100644 --- a/app/src/main/java/org/schabi/newpipe/views/NewPipeRecyclerView.java +++ b/app/src/main/java/org/schabi/newpipe/views/NewPipeRecyclerView.kt @@ -15,208 +15,181 @@ * You should have received a copy of the GNU General Public License * along with NewPipe. If not, see . */ -package org.schabi.newpipe.views; +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 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.recyclerview.widget.RecyclerView -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.recyclerview.widget.RecyclerView; +class NewPipeRecyclerView : RecyclerView { + private val focusRect: Rect = Rect() + private val tempFocus: Rect = Rect() + private var allowDpadScroll: Boolean = true -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(); + constructor(context: Context) : super(context) { + init() } - public NewPipeRecyclerView(@NonNull final Context context, - @Nullable final AttributeSet attrs) { - super(context, attrs); - - init(); + constructor(context: Context, + attrs: AttributeSet?) : super(context, attrs) { + init() } - public NewPipeRecyclerView(@NonNull final Context context, - @Nullable final AttributeSet attrs, final int defStyle) { - super(context, attrs, defStyle); - - init(); + constructor(context: Context, + attrs: AttributeSet?, defStyle: Int) : super(context, attrs, defStyle) { + init() } - private void init() { - setFocusable(true); - - setDescendantFocusability(FOCUS_AFTER_DESCENDANTS); + private fun init() { + setFocusable(true) + setDescendantFocusability(FOCUS_AFTER_DESCENDANTS) } - public void setFocusScrollAllowed(final boolean allowed) { - this.allowDpadScroll = allowed; + fun setFocusScrollAllowed(allowed: Boolean) { + allowDpadScroll = allowed } - @Override - public View focusSearch(final View focused, final int direction) { + public override fun focusSearch(focused: View, direction: Int): View { // 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; + return null } - @Override - protected void removeDetachedView(final View child, final boolean animate) { + override fun removeDetachedView(child: View, animate: Boolean) { 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(); + requestFocus() } - - super.removeDetachedView(child, animate); + 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(); + public override fun dispatchUnhandledMove(focused: View, direction: Int): Boolean { + 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); + val focusedItem: View? = findContainingItemView(focused) if (focusedItem != null) { - focusedItem.getHitRect(focusRect); + focusedItem.getHitRect(focusRect) } } // call focusSearch() to initiate layout, but disregard returned View for now - final View adapterResult = super.focusSearch(focused, direction); + val adapterResult: View? = super.focusSearch(focused, direction) if (adapterResult != null && !isOutside(adapterResult)) { - adapterResult.requestFocus(direction); - return true; + 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; + return true } - - if (focused != this && direction == FOCUS_DOWN && !allowDpadScroll) { - Log.i(TAG, "Consuming downward scroll: content load in progress"); - 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; + return true } - if (adapterResult != null) { - adapterResult.requestFocus(direction); - return true; + adapterResult.requestFocus(direction) + return true } - - return super.dispatchUnhandledMove(focused, direction); + return super.dispatchUnhandledMove(focused, direction) } - private boolean tryFocusFinder(final int direction) { + private fun tryFocusFinder(direction: Int): Boolean { 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; + return false } - - final FocusFinder finder = FocusFinder.getInstance(); + val finder: FocusFinder = 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); + val root: ViewGroup = getRootView() as ViewGroup + tempFocus.set(focusRect) + root.offsetDescendantRectToMyCoords(this, tempFocus) + val focusFinderResult: View? = finder.findNextFocusFromRect(root, tempFocus, direction) if (focusFinderResult != null && !isOutside(focusFinderResult)) { - focusFinderResult.requestFocus(direction); - return true; + 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); + var parent: ViewGroup = getParent() as ViewGroup + while (parent !== root) { + tempFocus.set(focusRect) + parent.offsetDescendantRectToMyCoords(this, tempFocus) + val candidate: View? = finder.findNextFocusFromRect(parent, tempFocus, direction) if (candidate != null && candidate.requestFocus(direction)) { - return true; + return true + } + parent = parent.getParent() as ViewGroup + } + return false + } + + private fun arrowScroll(direction: Int): Boolean { + when (direction) { + FOCUS_DOWN -> { + if (!canScrollVertically(1)) { + return false + } + scrollBy(0, 100) } - 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: + FOCUS_UP -> { if (!canScrollVertically(-1)) { - return false; + 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; - } + scrollBy(0, -100) + } - return true; + FOCUS_LEFT -> { + if (!canScrollHorizontally(-1)) { + return false + } + scrollBy(-100, 0) + } + + FOCUS_RIGHT -> { + if (!canScrollHorizontally(-1)) { + return false + } + scrollBy(100, 0) + } + + else -> return false + } + return true } - private boolean isOutside(final View view) { - return findContainingItemView(view) == null; + private fun isOutside(view: View): Boolean { + return findContainingItemView(view) == null + } + + companion object { + private val TAG: String = "NewPipeRecyclerView" } } diff --git a/app/src/main/java/org/schabi/newpipe/views/NewPipeTextView.java b/app/src/main/java/org/schabi/newpipe/views/NewPipeTextView.java deleted file mode 100644 index dd3f20f40..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/NewPipeTextView.java +++ /dev/null @@ -1,58 +0,0 @@ -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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/NewPipeTextView.kt b/app/src/main/java/org/schabi/newpipe/views/NewPipeTextView.kt new file mode 100644 index 000000000..02fda2b8a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/NewPipeTextView.kt @@ -0,0 +1,44 @@ +package org.schabi.newpipe.views + +import android.content.Context +import android.text.method.MovementMethod +import android.util.AttributeSet +import androidx.appcompat.widget.AppCompatTextView +import org.schabi.newpipe.util.NewPipeTextViewHelper +import org.schabi.newpipe.util.external_communication.ShareUtils + +/** + * An [AppCompatTextView] which uses [ShareUtils.shareText] + * when sharing selected text by using the `Share` command of the floating actions. + * + * + * + * This class allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing + * text from [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. + * + */ +class NewPipeTextView : AppCompatTextView { + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor(context: Context, + attrs: AttributeSet?, + defStyleAttr: Int) : super(context, attrs, defStyleAttr) + + public override fun setText(text: CharSequence, type: BufferType) { + // 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). + val movementMethod: MovementMethod = getMovementMethod() + super.setText(text, type) + setMovementMethod(movementMethod) + } + + public override fun onTextContextMenuItem(id: Int): Boolean { + if (id == android.R.id.shareText) { + NewPipeTextViewHelper.shareSelectedTextWithShareUtils(this) + return true + } + return super.onTextContextMenuItem(id) + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/ScrollableTabLayout.java b/app/src/main/java/org/schabi/newpipe/views/ScrollableTabLayout.java deleted file mode 100644 index fb21a8083..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/ScrollableTabLayout.java +++ /dev/null @@ -1,134 +0,0 @@ -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); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/ScrollableTabLayout.kt b/app/src/main/java/org/schabi/newpipe/views/ScrollableTabLayout.kt new file mode 100644 index 000000000..d75a05fb3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/ScrollableTabLayout.kt @@ -0,0 +1,117 @@ +package org.schabi.newpipe.views + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import com.google.android.material.tabs.TabLayout +import org.schabi.newpipe.views.ScrollableTabLayout +import kotlin.math.max + +/** + * A TabLayout that is scrollable when tabs exceed its width. + * Hides when there are less than 2 tabs. + */ +class ScrollableTabLayout : TabLayout { + private var layoutWidth: Int = 0 + private var prevVisibility: Int = GONE + + constructor(context: Context?) : super((context)!!) + constructor(context: Context?, attrs: AttributeSet?) : super((context)!!, attrs) + constructor(context: Context?, attrs: AttributeSet?, + defStyleAttr: Int) : super((context)!!, attrs, defStyleAttr) + + override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, + b: Int) { + super.onLayout(changed, l, t, r, b) + remeasureTabs() + } + + override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { + super.onSizeChanged(w, h, oldw, oldh) + layoutWidth = w + } + + public override fun addTab(tab: Tab, position: Int, setSelected: Boolean) { + 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() + } + } + + public override fun removeTabAt(position: Int) { + 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 fun onVisibilityChanged(changedView: View, visibility: Int) { + 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 == INVISIBLE) { + remeasureTabs() + } + prevVisibility = visibility + } + } + + var mode: Int + get() = super.mode + private set(mode) { + if (mode == getTabMode()) { + return + } + setTabMode(mode) + } + + /** + * Make ScrollableTabLayout not visible if there are less than two tabs. + */ + private fun hasMultipleTabs() { + if (getTabCount() > 1) { + setVisibility(VISIBLE) + } else { + setVisibility(GONE) + } + } + + /** + * Calculate minimal width required by tabs and set tabMode accordingly. + */ + private fun remeasureTabs() { + if (prevVisibility != VISIBLE) { + return + } + if (layoutWidth == 0) { + return + } + val count: Int = getTabCount() + var contentWidth: Int = 0 + for (i in 0 until count) { + val child: View = getTabAt(i)!!.view + if (child.getVisibility() == VISIBLE) { + // Use tab's minimum requested width should actual content be too small + (contentWidth += max(child.getMinimumWidth().toDouble(), child.getMeasuredWidth().toDouble())).toInt() + } + } + if (contentWidth > layoutWidth) { + this.mode = MODE_SCROLLABLE + } else { + this.mode = MODE_FIXED + } + } + + companion object { + private val TAG: String = ScrollableTabLayout::class.java.getSimpleName() + } +} diff --git a/app/src/main/java/org/schabi/newpipe/views/SuperScrollLayoutManager.java b/app/src/main/java/org/schabi/newpipe/views/SuperScrollLayoutManager.java deleted file mode 100644 index 62465d2a4..000000000 --- a/app/src/main/java/org/schabi/newpipe/views/SuperScrollLayoutManager.java +++ /dev/null @@ -1,174 +0,0 @@ -/* - * 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); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/views/SuperScrollLayoutManager.kt b/app/src/main/java/org/schabi/newpipe/views/SuperScrollLayoutManager.kt new file mode 100644 index 000000000..4e1f12ba4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/views/SuperScrollLayoutManager.kt @@ -0,0 +1,129 @@ +/* + * 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.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView + +class SuperScrollLayoutManager(context: Context?) : LinearLayoutManager(context) { + private val handy: Rect = Rect() + private val focusables: ArrayList = ArrayList() + public override fun requestChildRectangleOnScreen(parent: RecyclerView, + child: View, + rect: Rect, + immediate: Boolean, + focusedChildVisible: Boolean): Boolean { + 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) + } + + public override fun onInterceptFocusSearch(focused: View, direction: Int): View? { + val focusedItem: View? = findContainingItemView(focused) + if (focusedItem == null) { + return super.onInterceptFocusSearch(focused, direction) + } + val listDirection: Int = 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 + val recycler: ViewGroup = focusedItem.getParent() as ViewGroup + val sourcePosition: Int = getPosition(focusedItem) + if (sourcePosition == 0 && listDirection < 0) { + return super.onInterceptFocusSearch(focused, direction) + } + var preferred: View? = null + var distance: Int = Int.MAX_VALUE + focusables.clear() + recycler.addFocusables(focusables, direction, if (recycler.isInTouchMode()) View.FOCUSABLES_TOUCH_MODE else View.FOCUSABLES_ALL) + try { + for (view: View in 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 + } + val candidate: Int = getDistance(sourcePosition, view, listDirection) + if (candidate < 0) { + continue + } + if (candidate < distance) { + distance = candidate + preferred = view + } + } + } finally { + focusables.clear() + } + return preferred + } + + private fun getAbsoluteDirection(direction: Int): Int { + when (direction) { + View.FOCUS_FORWARD -> return 1 + View.FOCUS_BACKWARD -> return -1 + else -> {} + } + if (getOrientation() == RecyclerView.HORIZONTAL) { + when (direction) { + View.FOCUS_LEFT -> return if (getReverseLayout()) 1 else -1 + View.FOCUS_RIGHT -> return if (getReverseLayout()) -1 else 1 + else -> {} + } + } else { + when (direction) { + View.FOCUS_UP -> return if (getReverseLayout()) 1 else -1 + View.FOCUS_DOWN -> return if (getReverseLayout()) -1 else 1 + else -> {} + } + } + return 0 + } + + private fun getDistance(sourcePosition: Int, candidate: View, direction: Int): Int { + val itemView: View? = findContainingItemView(candidate) + if (itemView == null) { + return -1 + } + val position: Int = getPosition(itemView) + return direction * (position - sourcePosition) + } +} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java b/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java deleted file mode 100644 index 84e968b43..000000000 --- a/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java +++ /dev/null @@ -1,208 +0,0 @@ -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(); - } -} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadInitializer.kt b/app/src/main/java/us/shandian/giga/get/DownloadInitializer.kt new file mode 100644 index 000000000..78afbcec8 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/DownloadInitializer.kt @@ -0,0 +1,168 @@ +package us.shandian.giga.get + +import android.util.Log +import org.schabi.newpipe.BuildConfig.DEBUG +import us.shandian.giga.util.Utility +import java.io.IOException +import java.io.InterruptedIOException +import java.net.HttpURLConnection +import java.nio.channels.ClosedByInterruptException + +class DownloadInitializer internal constructor(private val mMission: DownloadMission) : Thread() { + private var mConn: HttpURLConnection? = null + private fun dispose() { + try { + mConn!!.getInputStream().close() + } catch (e: Exception) { + // nothing to do + } + } + + public override fun run() { + if (mMission.current > 0) mMission.resetState(false, true, DownloadMission.Companion.ERROR_NOTHING) + var retryCount: Int = 0 + var httpCode: Int = 204 + while (true) { + try { + if (mMission.blocks == null && mMission.current == 0) { + // calculate the whole size of the mission + var finalLength: Long = 0 + var lowestSize: Long = Long.MAX_VALUE + var i: Int = 0 + while (i < mMission.urls.size && mMission.running) { + mConn = mMission.openConnection(mMission.urls.get(i), true, 0, 0) + mMission.establishConnection(mId, mConn) + dispose() + if (interrupted()) return + val length: Long = Utility.getTotalContentLength(mConn) + if (i == 0) { + httpCode = mConn!!.getResponseCode() + mMission.length = length + } + if (length > 0) finalLength += length + if (length < lowestSize) lowestSize = length + i++ + } + 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.get(0) = RESERVE_SPACE_DEFAULT.toLong() + } else { + // use the smallest resource size to download, otherwise, use the maximum + mMission.offsets.get(0) = if (lowestSize < RESERVE_SPACE_MAXIMUM) lowestSize else RESERVE_SPACE_MAXIMUM.toLong() + } + } + } else { + // ask for the current resource length + mConn = mMission.openConnection(true, 0, 0) + mMission.establishConnection(mId, mConn) + dispose() + if (!mMission.running || interrupted()) return + httpCode = mConn!!.getResponseCode() + mMission.length = Utility.getTotalContentLength(mConn) + } + if (mMission.length == 0L || httpCode == 204) { + mMission.notifyError(DownloadMission.Companion.ERROR_HTTP_NO_CONTENT, null) + return + } + + // check for dynamic generated content + if (mMission.length == -1L && mConn!!.getResponseCode() == 200) { + mMission.blocks = IntArray(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 || interrupted()) return + synchronized(mMission.LOCK, { + if (mConn!!.getResponseCode() == 206) { + if (mMission.threadCount > 1) { + var count: Int = (mMission.length / DownloadMission.Companion.BLOCK_SIZE).toInt() + if ((count * DownloadMission.Companion.BLOCK_SIZE) < mMission.length) count++ + mMission.blocks = IntArray(count) + } else { + // if one thread is required don't calculate blocks, is useless + mMission.blocks = IntArray(0) + mMission.unknownLength = false + } + if (DEBUG) { + Log.d(TAG, "http response code = " + mConn!!.getResponseCode()) + } + } else { + // Fallback to single thread + mMission.blocks = IntArray(0) + mMission.unknownLength = false + if (DEBUG) { + Log.d(TAG, "falling back due http response code = " + mConn!!.getResponseCode()) + } + } + }) + if (!mMission.running || interrupted()) return + } + mMission.storage!!.getStream().use({ fs -> + fs.setLength(mMission.offsets.get(mMission.current) + mMission.length) + fs.seek(mMission.offsets.get(mMission.current)) + }) + if (!mMission.running || interrupted()) return + if (!mMission.unknownLength && mMission.recoveryInfo != null) { + val entityTag: String = mConn!!.getHeaderField("ETAG") + val lastModified: String = mConn!!.getHeaderField("Last-Modified") + val recovery: MissionRecoveryInfo? = mMission.recoveryInfo!!.get(mMission.current) + if (!TextUtils.isEmpty(entityTag)) { + recovery!!.validateCondition = entityTag + } else if (!TextUtils.isEmpty(lastModified)) { + recovery!!.validateCondition = lastModified // Note: this is less precise + } else { + recovery!!.validateCondition = null + } + } + mMission.running = false + break + } catch (e: InterruptedIOException) { + return + } catch (e: ClosedByInterruptException) { + return + } catch (e: Exception) { + if (!mMission.running || super.isInterrupted()) return + if (e is DownloadMission.HttpError && e.statusCode == DownloadMission.Companion.ERROR_HTTP_FORBIDDEN) { + // for youtube streams. The url has expired + interrupt() + mMission.doRecover(DownloadMission.Companion.ERROR_HTTP_FORBIDDEN) + return + } + if (e is IOException && e.message!!.contains("Permission denied")) { + mMission.notifyError(DownloadMission.Companion.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() + } + + public override fun interrupt() { + super.interrupt() + if (mConn != null) dispose() + } + + companion object { + private val TAG: String = "DownloadInitializer" + val mId: Int = 0 + private val RESERVE_SPACE_DEFAULT: Int = 5 * 1024 * 1024 // 5 MiB + private val RESERVE_SPACE_MAXIMUM: Int = 150 * 1024 * 1024 // 150 MiB + } +} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMission.java b/app/src/main/java/us/shandian/giga/get/DownloadMission.java deleted file mode 100644 index 04930b002..000000000 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ /dev/null @@ -1,853 +0,0 @@ -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() { - return errCode == ERROR_PROGRESS_LOST || 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 - } -} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMission.kt b/app/src/main/java/us/shandian/giga/get/DownloadMission.kt new file mode 100644 index 000000000..c57a98ac3 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.kt @@ -0,0 +1,760 @@ +package us.shandian.giga.get + +import android.os.Handler +import android.util.Log +import org.schabi.newpipe.BuildConfig.DEBUG +import us.shandian.giga.util.Utility +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 kotlin.math.max +import kotlin.math.min + +class DownloadMission(urls: Array, storage: StoredFileHelper?, kind: Char, psInstance: Postprocessing?) : Mission() { + /** + * The urls of the file to download + */ + var urls: Array + + /** + * Number of bytes downloaded and written + */ + @Volatile + var done: Long = 0 + + /** + * Indicates a file generated dynamically on the web server + */ + var unknownLength: Boolean = false + + /** + * offset in the file where the data should be written + */ + var offsets: LongArray + + /** + * Indicates if the post-processing state: + * 0: ready + * 1: running + * 2: completed + * 3: hold + */ + @Volatile + var psState: Int = 0 + + /** + * the post-processing algorithm instance + */ + var psAlgorithm: Postprocessing? + + /** + * The current resource to download, `urls[current]` and `offsets[current]` + */ + var current: Int = 0 + + /** + * Metadata where the mission state is saved + */ + @Transient + var metadata: File? = null + + /** + * maximum attempts + */ + @Transient + var maxRetry: Int + + /** + * Approximated final length, this represent the sum of all resources sizes + */ + var nearLength: Long = 0 + + /** + * Download blocks, the size is multiple of [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. + */ + var blocks: IntArray? + + /** + * Download/File resume offset in fallback mode (if applicable) [DownloadRunnableFallback] + */ + @Volatile + var fallbackResumeOffset: Long = 0 + + /** + * Maximum of download threads running, chosen by the user + */ + var threadCount: Int = 3 + + /** + * information required to recover a download + */ + var recoveryInfo: Array? + + @Transient + private var finishCount: Int = 0 + + @Volatile + @Transient + var running: Boolean = false + var enqueued: Boolean + var errCode: Int = ERROR_NOTHING + var errObject: Exception? = null + + @Transient + var mHandler: Handler? = null + + @Transient + private var blockAcquired: BooleanArray? + + @Transient + private var writingToFileNext: Long = 0 + + @Volatile + @Transient + private var writingToFile: Boolean = false + val LOCK: Any = Lock() + + @Transient + var threads: Array = arrayOfNulls(0) + + @Transient + var init: Thread? = null + + init { + if (Objects.requireNonNull(urls).size < 1) throw IllegalArgumentException("urls array is empty") + this.urls = urls + this.kind = kind + offsets = LongArray(urls.size) + enqueued = true + maxRetry = 3 + this.storage = storage + psAlgorithm = psInstance + if (DEBUG && (psInstance == null) && (urls.size > 1)) { + Log.w(TAG, "mission created with multiple urls ¿missing post-processing algorithm?") + } + } + + /** + * Acquire a block + * + * @return the block or `null` if no more blocks left + */ + fun acquireBlock(): Block? { + synchronized(LOCK, { + for (i in blockAcquired!!.indices) { + if (!blockAcquired!!.get(i) && blocks!!.get(i) >= 0) { + val block: Block = Block() + block.position = i + block.done = blocks!!.get(i) + blockAcquired!!.get(i) = true + return block + } + } + }) + return null + } + + /** + * Release an block + * + * @param position the index of the block + * @param done amount of bytes downloaded + */ + fun releaseBlock(position: Int, done: Int) { + synchronized(LOCK, { + blockAcquired!!.get(position) = false + blocks!!.get(position) = done + }) + } + + /** + * Opens a connection + * + * @param headRequest `true` for use `HEAD` request method, otherwise, `GET` is used + * @param rangeStart range start + * @param rangeEnd range end + * @return a [URLConnection][java.net.URLConnection] linking to the URL. + * @throws IOException if an I/O exception occurs. + */ + @Throws(IOException::class) + fun openConnection(headRequest: Boolean, rangeStart: Long, rangeEnd: Long): HttpURLConnection { + return openConnection(urls.get(current), headRequest, rangeStart, rangeEnd) + } + + @Throws(IOException::class) + fun openConnection(url: String?, headRequest: Boolean, rangeStart: Long, rangeEnd: Long): HttpURLConnection { + val conn: HttpURLConnection = URL(url).openConnection() as HttpURLConnection + conn.setInstanceFollowRedirects(true) + conn.setRequestProperty("User-Agent", DownloaderImpl.Companion.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) { + var req: String? = "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 + */ + @Throws(IOException::class, HttpError::class) + fun establishConnection(threadId: Int, conn: HttpURLConnection?) { + val statusCode: Int = conn!!.getResponseCode() + if (DEBUG) { + Log.d(TAG, threadId.toString() + ":[request] Range=" + conn.getRequestProperty("Range")) + Log.d(TAG, threadId.toString() + ":[response] Code=" + statusCode) + Log.d(TAG, threadId.toString() + ":[response] Content-Length=" + conn.getContentLength()) + Log.d(TAG, threadId.toString() + ":[response] Content-Range=" + conn.getHeaderField("Content-Range")) + } + when (statusCode) { + 204, 205, 207 -> throw HttpError(statusCode) + 416 -> return // let the download thread handle this error + else -> if (statusCode < 200 || statusCode > 299) { + throw HttpError(statusCode) + } + } + } + + private fun notify(what: Int) { + mHandler!!.obtainMessage(what, this).sendToTarget() + } + + @Synchronized + fun notifyProgress(deltaLen: Long) { + 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 + fun notifyError(err: Exception?) { + Log.e(TAG, "notifyError()", err) + if (err is FileNotFoundException) { + notifyError(ERROR_FILE_CREATION, null) + } else if (err is SSLException) { + notifyError(ERROR_SSL_EXCEPTION, null) + } else if (err is HttpError) { + notifyError(err.statusCode, null) + } else if (err is ConnectException) { + notifyError(ERROR_CONNECT_HOST, null) + } else if (err is UnknownHostException) { + notifyError(ERROR_UNKNOWN_HOST, null) + } else if (err is SocketTimeoutException) { + notifyError(ERROR_TIMEOUT, null) + } else { + notifyError(ERROR_UNKNOWN_EXCEPTION, err) + } + } + + @Synchronized + fun notifyError(code: Int, err: Exception?) { + var code: Int = code + var err: Exception? = err + Log.e(TAG, "notifyError() code = " + code, err) + if (err != null && err.cause is ErrnoException) { + val errno: Int = (err.cause as ErrnoException?).errno + if (errno == OsConstants.ENOSPC) { + code = ERROR_INSUFFICIENT_STORAGE + err = null + } else if (errno == OsConstants.EACCES) { + code = ERROR_PERMISSION_DENIED + err = null + } + } + if (err is IOException) { + if (err.message!!.contains("Permission denied")) { + code = ERROR_PERMISSION_DENIED + err = null + } else if (err.message!!.contains("ENOSPC")) { + code = ERROR_INSUFFICIENT_STORAGE + err = null + } else if (!storage!!.canWrite()) { + code = ERROR_FILE_CREATION + err = null + } + } + errCode = code + errObject = err + when (code) { + ERROR_SSL_EXCEPTION, ERROR_UNKNOWN_HOST, ERROR_CONNECT_HOST, ERROR_TIMEOUT -> {} + else -> // also checks for server errors + if (code < 500 || code > 599) enqueued = false + } + notify(DownloadManagerService.Companion.MESSAGE_ERROR) + if (running) pauseThreads() + } + + @Synchronized + fun notifyFinished() { + if (current < urls.size) { + if (++finishCount < threads.size) return + if (DEBUG) { + Log.d(TAG, "onFinish: downloaded " + (current + 1) + "/" + urls.size) + } + current++ + if (current < urls.size) { + // prepare next sub-mission + offsets.get(current) = offsets.get(current - 1) + length + initializer() + return + } + } + if (psAlgorithm != null && psState == 0) { + threads = arrayOf( + runAsync(1, Runnable({ doPostprocessing() })) + ) + return + } + + + // this mission is fully finished + unknownLength = false + enqueued = false + running = false + deleteThisFromFile() + notify(DownloadManagerService.Companion.MESSAGE_FINISHED) + } + + private fun notifyPostProcessing(state: Int) { + val action: String + when (state) { + 1 -> action = "Running" + 2 -> action = "Completed" + else -> 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. + */ + fun start() { + if (running || isFinished() || (urls.size < 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.size) { + notifyFinished() + return + } + notify(DownloadManagerService.Companion.MESSAGE_RUNNING) + if (urls.get(current) == null) { + doRecover(ERROR_RESOURCE_GONE) + return + } + if (blocks == null) { + initializer() + return + } + init = null + finishCount = 0 + blockAcquired = BooleanArray(blocks!!.size) + if (blocks!!.size < 1) { + threads = arrayOf(runAsync(1, DownloadRunnableFallback(this))) + } else { + var remainingBlocks: Int = 0 + for (block: Int in blocks!!) if (block >= 0) remainingBlocks++ + if (remainingBlocks < 1) { + notifyFinished() + return + } + threads = arrayOfNulls(min(threadCount.toDouble(), remainingBlocks.toDouble()).toInt()) + for (i in threads.indices) { + threads.get(i) = runAsync(i + 1, DownloadRunnable(this, i)) + } + } + } + + /** + * Pause the mission + */ + fun pause() { + if (!running) return + if (isPsRunning()) { + if (DEBUG) { + Log.w(TAG, "pause during post-processing is not applicable.") + } + return + } + running = false + notify(DownloadManagerService.Companion.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 fun pauseThreads() { + running = false + joinForThreads(-1) + writeThisToFile() + } + + /** + * Removes the downloaded file and the meta file + */ + public override fun delete(): Boolean { + if (psAlgorithm != null) psAlgorithm.cleanupTemporalDir() + notify(DownloadManagerService.Companion.MESSAGE_DELETED) + val res: Boolean = deleteThisFromFile() + if (!super.delete()) return false + return res + } + + /** + * Resets the mission state + * + * @param rollback `true` true to forget all progress, otherwise, `false` + * @param persistChanges `true` to commit changes to the metadata file, otherwise, `false` + */ + fun resetState(rollback: Boolean, persistChanges: Boolean, errorCode: Int) { + length = 0 + errCode = errorCode + errObject = null + unknownLength = false + threads = arrayOfNulls(0) + fallbackResumeOffset = 0 + blocks = null + blockAcquired = null + if (rollback) current = 0 + if (persistChanges) writeThisToFile() + } + + private fun initializer() { + init = runAsync(DownloadInitializer.Companion.mId, DownloadInitializer(this)) + } + + private fun writeThisToFileAsync() { + runAsync(-2, Runnable({ writeThisToFile() })) + } + + /** + * Write this [DownloadMission] to the meta file asynchronously + * if no thread is already running. + */ + fun writeThisToFile() { + synchronized(LOCK, { + if (metadata == null) return + Utility.writeToFile(metadata!!, this) + writingToFile = false + }) + } + + /** + * Indicates if the download if fully finished + * + * @return true, otherwise, false + */ + fun isFinished(): Boolean { + return current >= urls.size && (psAlgorithm == null || psState == 2) + } + + /** + * Indicates if the download file is corrupt due a failed post-processing + * + * @return `true` if this mission is unrecoverable + */ + fun isPsFailed(): Boolean { + when (errCode) { + ERROR_POSTPROCESSING, ERROR_POSTPROCESSING_STOPPED -> return psAlgorithm.worksOnSameFile + } + return false + } + + /** + * Indicates if a post-processing algorithm is running + * + * @return true, otherwise, false + */ + fun isPsRunning(): Boolean { + return psAlgorithm != null && (psState == 1 || psState == 3) + } + + /** + * Indicated if the mission is ready + * + * @return true, otherwise, false + */ + fun isInitialized(): Boolean { + return blocks != null // DownloadMissionInitializer was executed + } + + /** + * Gets the approximated final length of the file + * + * @return the length in bytes + */ + fun getLength(): Long { + var calculated: Long + if (psState == 1 || psState == 3) { + return length + } + calculated = offsets.get(if (current < offsets.size) current else (offsets.size - 1)) + length + calculated -= offsets.get(0) // don't count reserved space + return max(calculated.toDouble(), nearLength.toDouble()).toLong() + } + + /** + * set this mission state on the queue + * + * @param queue true to add to the queue, otherwise, false + */ + fun setEnqueued(queue: Boolean) { + enqueued = queue + writeThisToFileAsync() + } + + /** + * Attempts to continue a blocked post-processing + * + * @param recover `true` to retry, otherwise, `false` to cancel + */ + fun psContinue(recover: Boolean) { + psState = 1 + errCode = if (recover) ERROR_NOTHING else ERROR_POSTPROCESSING + threads.get(0)!!.interrupt() + } + + /** + * Indicates whatever the backed storage is invalid + * + * @return `true`, if storage is invalid and cannot be used + */ + fun hasInvalidStorage(): Boolean { + return (errCode == ERROR_PROGRESS_LOST) || (storage == null) || !storage!!.existsAsFile() + } + + /** + * Indicates whatever is possible to start the mission + * + * @return `true` is this mission its "healthy", otherwise, `false` + */ + fun isCorrupt(): Boolean { + if (urls.size < 1) return false + return (isPsFailed() || errCode == ERROR_POSTPROCESSING_HOLD) || isFinished() + } + + /** + * Indicates if mission urls has expired and there an attempt to renovate them + * + * @return `true` if the mission is running a recovery procedure, otherwise, `false` + */ + fun isRecovering(): Boolean { + return (threads.size > 0) && threads.get(0) is DownloadMissionRecover && threads.get(0).isAlive() + } + + private fun doPostprocessing() { + errCode = ERROR_NOTHING + errObject = null + val thread: Thread = Thread.currentThread() + notifyPostProcessing(1) + if (DEBUG) { + thread.setName("[" + TAG + "] ps = " + psAlgorithm + " filename = " + storage!!.getName()) + } + var exception: Exception? = null + try { + psAlgorithm.run(this) + } catch (err: Exception) { + Log.e(TAG, "Post-processing failed. " + psAlgorithm.toString(), err) + if (err is InterruptedIOException || err is ClosedByInterruptException || thread.isInterrupted()) { + notifyError(ERROR_POSTPROCESSING_STOPPED, null) + return + } + if (errCode == ERROR_NOTHING) errCode = ERROR_POSTPROCESSING + exception = err + } finally { + notifyPostProcessing(if (errCode == ERROR_NOTHING) 2 else 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 + */ + fun doRecover(errorCode: Int) { + Log.i(TAG, "Attempting to recover the mission: " + storage!!.getName()) + if (recoveryInfo == null) { + notifyError(errorCode, null) + urls = arrayOfNulls(0) // mark this mission as dead + return + } + joinForThreads(0) + threads = arrayOf( + runAsync(DownloadMissionRecover.Companion.mID, DownloadMissionRecover(this, errorCode)) + ) + } + + private fun deleteThisFromFile(): Boolean { + synchronized(LOCK, { + val res: Boolean = 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 `run` method is invoked. + */ + private fun runAsync(id: Int, who: Runnable): Thread { + return runAsync(id, Thread(who)) + } + + /** + * run a new thread + * + * @param id id of new thread (used for debugging only) + * @param who the Thread whose `run` method is invoked when this thread is started + * @return the passed thread + */ + private fun runAsync(id: Int, who: Thread): Thread { + // 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 `millis` milliseconds for the thread to die + * + * @param millis the time to wait in milliseconds + */ + private fun joinForThreads(millis: Int) { + val currentThread: Thread = Thread.currentThread() + if ((init != null) && (init !== currentThread) && init!!.isAlive()) { + init!!.interrupt() + if (millis > 0) { + try { + init!!.join(millis.toLong()) + } catch (e: InterruptedException) { + 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? in threads) { + if (!thread!!.isAlive() || thread === Thread.currentThread()) continue + thread.interrupt() + } + try { + for (thread: Thread? in threads) { + if (!thread!!.isAlive()) continue + if (DEBUG) { + Log.w(TAG, "thread alive: " + thread.getName()) + } + if (millis > 0) thread.join(millis.toLong()) + } + } catch (e: InterruptedException) { + throw RuntimeException("A download thread is still running", e) + } + } + + internal class HttpError(val statusCode: Int) : Exception() { + public override fun getMessage(): String { + return "HTTP " + statusCode + } + } + + class Block() { + var position: Int = 0 + var done: Int = 0 + } + + private class Lock() : Serializable { // java.lang.Object cannot be used because is not serializable + } + + companion object { + private val serialVersionUID: Long = 6L // last bump: 07 october 2019 + val BUFFER_SIZE: Int = 64 * 1024 + val BLOCK_SIZE: Int = 512 * 1024 + private val TAG: String = "DownloadMission" + val ERROR_NOTHING: Int = -1 + val ERROR_PATH_CREATION: Int = 1000 + val ERROR_FILE_CREATION: Int = 1001 + val ERROR_UNKNOWN_EXCEPTION: Int = 1002 + val ERROR_PERMISSION_DENIED: Int = 1003 + val ERROR_SSL_EXCEPTION: Int = 1004 + val ERROR_UNKNOWN_HOST: Int = 1005 + val ERROR_CONNECT_HOST: Int = 1006 + val ERROR_POSTPROCESSING: Int = 1007 + val ERROR_POSTPROCESSING_STOPPED: Int = 1008 + val ERROR_POSTPROCESSING_HOLD: Int = 1009 + val ERROR_INSUFFICIENT_STORAGE: Int = 1010 + val ERROR_PROGRESS_LOST: Int = 1011 + val ERROR_TIMEOUT: Int = 1012 + val ERROR_RESOURCE_GONE: Int = 1013 + val ERROR_HTTP_NO_CONTENT: Int = 204 + val ERROR_HTTP_FORBIDDEN: Int = 403 + } +} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java b/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java deleted file mode 100644 index e001c6f3f..000000000 --- a/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java +++ /dev/null @@ -1,321 +0,0 @@ -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(); - } -} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.kt b/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.kt new file mode 100644 index 000000000..afe936afa --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.kt @@ -0,0 +1,277 @@ +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 + +class DownloadMissionRecover internal constructor(private val mMission: DownloadMission, private val mErrCode: Int) : Thread() { + private val mNotInitialized: Boolean + private var mConn: HttpURLConnection? = null + private var mRecovery: MissionRecoveryInfo? = null + private var mExtractor: StreamExtractor? = null + + init { + mNotInitialized = mMission.blocks == null && mMission.current == 0 + } + + public override fun run() { + if (mMission.source == null) { + mMission.notifyError(mErrCode, null) + return + } + var err: Exception? = null + var attempt: Int = 0 + while (attempt++ < mMission.maxRetry) { + try { + tryRecover() + return + } catch (e: InterruptedIOException) { + return + } catch (e: ClosedByInterruptException) { + return + } catch (e: Exception) { + if (!mMission.running || super.isInterrupted()) return + err = e + } + } + + // give up + mMission.notifyError(mErrCode, err) + } + + @Throws(ExtractionException::class, IOException::class, DownloadMission.HttpError::class) + private fun tryRecover() { + if (mExtractor == null) { + try { + val svr: StreamingService = NewPipe.getServiceByUrl(mMission.source) + mExtractor = svr.getStreamExtractor(mMission.source) + mExtractor.fetchPage() + } catch (e: ExtractionException) { + 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.get(mMission.current) = null + mRecovery = mMission.recoveryInfo!!.get(mMission.current) + resolveStream() + return + } + Log.w(TAG, "mission is not fully initialized, this will take a while") + try { + while (mMission.current < mMission.urls.size) { + mRecovery = mMission.recoveryInfo!!.get(mMission.current) + if (test()) { + mMission.current++ + continue + } + if (!mMission.running) return + resolveStream() + if (!mMission.running) return + + // before continue, check if the current stream was resolved + if (mMission.urls.get(mMission.current) == null) { + break + } + mMission.current++ + } + } finally { + mMission.current = 0 + } + mMission.writeThisToFile() + if (!mMission.running || super.isInterrupted()) return + mMission.running = false + mMission.start() + } + + @Throws(IOException::class, ExtractionException::class, DownloadMission.HttpError::class) + private fun resolveStream() { + // FIXME: this getErrorMessage() always returns "video is unavailable" + /*if (mExtractor.getErrorMessage() != null) { + mMission.notifyError(mErrCode, new ExtractionException(mExtractor.getErrorMessage())); + return; + }*/ + var url: String? = null + when (mRecovery!!.kind) { + 'a' -> for (audio: AudioStream in mExtractor!!.getAudioStreams()) { + if ((audio.getAverageBitrate() == mRecovery!!.desiredBitrate + ) && (audio.getFormat() == mRecovery!!.format + ) && (audio.getDeliveryMethod() == DeliveryMethod.PROGRESSIVE_HTTP)) { + url = audio.getContent() + break + } + } + + 'v' -> { + val videoStreams: List + if (mRecovery!!.isDesired2) videoStreams = mExtractor!!.getVideoOnlyStreams() else videoStreams = mExtractor!!.getVideoStreams() + for (video: VideoStream in videoStreams) { + if (((video.getResolution() == mRecovery!!.desired) && (video.getFormat() == mRecovery!!.format + ) && (video.getDeliveryMethod() == DeliveryMethod.PROGRESSIVE_HTTP))) { + url = video.getContent() + break + } + } + } + + 's' -> for (subtitles: SubtitlesStream in mExtractor!!.getSubtitles(mRecovery + .format)) { + val tag: String = subtitles.getLanguageTag() + if (((tag == mRecovery!!.desired) && (subtitles.isAutoGenerated() == mRecovery!!.isDesired2 + ) && (subtitles.getDeliveryMethod() == DeliveryMethod.PROGRESSIVE_HTTP))) { + url = subtitles.getContent() + break + } + } + + else -> throw RuntimeException("Unknown stream type") + } + resolve(url) + } + + @Throws(IOException::class, DownloadMission.HttpError::class) + private fun resolve(url: String?) { + if (mRecovery!!.validateCondition == null) { + Log.w(TAG, "validation condition not defined, the resource can be stale") + } + if (mMission.unknownLength || mRecovery!!.validateCondition == 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!!.validateCondition) + mMission.establishConnection(mID, mConn) + val code: Int = mConn!!.getResponseCode() + when (code) { + 200, 413 -> { + // stale + recover(url, true) + return + } + + 206 -> { + // in case of validation using the Last-Modified date, check the resource length + val contentRange: LongArray = parseContentRange(mConn!!.getHeaderField("Content-Range")) + val lengthMismatch: Boolean = contentRange.get(2) != -1L && contentRange.get(2) != mMission.length + recover(url, lengthMismatch) + return + } + } + throw DownloadMission.HttpError(code) + } finally { + disconnect() + } + } + + private fun recover(url: String?, stale: Boolean) { + Log.i(TAG, String.format("recover() name=%s isStale=%s url=%s", mMission.storage!!.getName(), stale, url)) + mMission.urls.get(mMission.current) = url + if (url == null) { + mMission.urls = arrayOfNulls(0) + mMission.notifyError(DownloadMission.Companion.ERROR_RESOURCE_GONE, null) + return + } + if (mNotInitialized) return + if (stale) { + mMission.resetState(false, false, DownloadMission.Companion.ERROR_NOTHING) + } + mMission.writeThisToFile() + if (!mMission.running || super.isInterrupted()) return + mMission.running = false + mMission.start() + } + + private fun parseContentRange(value: String): LongArray { + var value: String? = value + val range: LongArray = LongArray(3) + if (value == null) { + // this never should happen + return range + } + try { + value = value.trim({ it <= ' ' }) + if (!value.startsWith("bytes")) { + return range // unknown range type + } + val space: Int = value.lastIndexOf(' ') + 1 + val dash: Int = value.indexOf('-', space) + 1 + val bar: Int = value.indexOf('/', dash) + + // start + range.get(0) = value.substring(space, dash - 1).toLong() + + // end + range.get(1) = value.substring(dash, bar).toLong() + + // resource length + value = value.substring(bar + 1) + if ((value == "*")) { + range.get(2) = -1 // unknown length received from the server but should be valid + } else { + range.get(2) = value.toLong() + } + } catch (e: Exception) { + // nothing to do + } + return range + } + + private fun test(): Boolean { + if (mMission.urls.get(mMission.current) == null) return false + try { + mConn = mMission.openConnection(mMission.urls.get(mMission.current), true, -1, -1) + mMission.establishConnection(mID, mConn) + if (mConn!!.getResponseCode() == 200) return true + } catch (e: Exception) { + // nothing to do + } finally { + disconnect() + } + return false + } + + private fun disconnect() { + try { + try { + mConn!!.getInputStream().close() + } finally { + mConn!!.disconnect() + } + } catch (e: Exception) { + // nothing to do + } finally { + mConn = null + } + } + + public override fun interrupt() { + super.interrupt() + if (mConn != null) disconnect() + } + + companion object { + private val TAG: String = "DownloadMissionRecover" + val mID: Int = -3 + } +} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java b/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java deleted file mode 100644 index 6f504cea3..000000000 --- a/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java +++ /dev/null @@ -1,184 +0,0 @@ -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 - } - } - -} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadRunnable.kt b/app/src/main/java/us/shandian/giga/get/DownloadRunnable.kt new file mode 100644 index 000000000..47b24a8c4 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/DownloadRunnable.kt @@ -0,0 +1,145 @@ +package us.shandian.giga.get + +import android.util.Log +import org.schabi.newpipe.BuildConfig.DEBUG +import java.io.IOException +import java.net.HttpURLConnection +import java.nio.channels.ClosedByInterruptException +import java.util.Objects + +/** + * Runnable to download blocks of a file until the file is completely downloaded, + * an error occurs or the process is stopped. + */ +class DownloadRunnable internal constructor(mission: DownloadMission, private val mId: Int) : Thread() { + private val mMission: DownloadMission + private var mConn: HttpURLConnection? = null + + init { + mMission = Objects.requireNonNull(mission) + } + + private fun releaseBlock(block: DownloadMission.Block, remain: Long) { + // set the block offset to -1 if it is completed + mMission.releaseBlock(block.position, if (remain < 0) -1 else block.done) + } + + public override fun run() { + var retry: Boolean = false + var block: DownloadMission.Block? = null + var retryCount: Int = 0 + val f: SharpStream? + try { + f = mMission.storage!!.getStream() + } catch (e: IOException) { + mMission.notifyError(e) // this never should happen + return + } + while (mMission.running && mMission.errCode == DownloadMission.Companion.ERROR_NOTHING) { + if (!retry) { + block = mMission.acquireBlock() + } + if (block == null) { + if (DEBUG) Log.d(TAG, mId.toString() + ":no more blocks left, exiting") + break + } + if (DEBUG) { + if (retry) Log.d(TAG, mId.toString() + ":retry block at position=" + block.position + " from the start") else Log.d(TAG, mId.toString() + ":acquired block at position=" + block.position + " done=" + block.done) + } + var start: Long = block.position.toLong() * DownloadMission.Companion.BLOCK_SIZE + var end: Long = start + DownloadMission.Companion.BLOCK_SIZE - 1 + start += block.done.toLong() + 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.toLong()) + block.done = 0 + retry = true + mConn!!.disconnect() + continue + } + throw DownloadMission.HttpError(416) + } + retry = false + + // The server may be ignoring the range request + if (mConn!!.getResponseCode() != 206) { + if (DEBUG) { + Log.e(TAG, mId.toString() + ":Unsupported " + mConn!!.getResponseCode()) + } + mMission.notifyError(DownloadMission.HttpError(mConn!!.getResponseCode())) + break + } + f.seek(mMission.offsets.get(mMission.current) + start) + mConn!!.getInputStream().use({ `is` -> + val buf: ByteArray = ByteArray(DownloadMission.Companion.BUFFER_SIZE) + var len: Int + + // use always start <= end + // fixes a deadlock because in some videos, youtube is sending one byte alone + while ((start <= end) && mMission.running && ((`is`.read(buf, 0, buf.size).also({ len = it })) != -1)) { + f.write(buf, 0, len) + start += len.toLong() + block.done += len + mMission.notifyProgress(len.toLong()) + } + }) + if (DEBUG && mMission.running) { + Log.d(TAG, mId.toString() + ":position " + block.position + " stopped " + start + "/" + end) + } + } catch (e: Exception) { + if (!mMission.running || e is ClosedByInterruptException) break + if (e is DownloadMission.HttpError && e.statusCode == DownloadMission.Companion.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(DownloadMission.Companion.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.Companion.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.") + } + } + + public override fun interrupt() { + super.interrupt() + try { + if (mConn != null) mConn!!.disconnect() + } catch (e: Exception) { + // nothing to do + } + } + + companion object { + private val TAG: String = "DownloadRunnable" + } +} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java b/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java deleted file mode 100644 index eed5db463..000000000 --- a/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java +++ /dev/null @@ -1,155 +0,0 @@ -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 = "DownloadRunnableFallback"; - - 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]; - } - - 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 - } - - } - } -} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.kt b/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.kt new file mode 100644 index 000000000..9fa023b60 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.kt @@ -0,0 +1,117 @@ +package us.shandian.giga.get + +import android.util.Log +import org.schabi.newpipe.BuildConfig.DEBUG +import us.shandian.giga.util.Utility +import java.io.IOException +import java.io.InputStream +import java.net.HttpURLConnection +import java.nio.channels.ClosedByInterruptException + +/** + * Single-threaded fallback mode + */ +class DownloadRunnableFallback internal constructor(private val mMission: DownloadMission) : Thread() { + private var mRetryCount: Int = 0 + private var mIs: InputStream? = null + private var mF: SharpStream? = null + private var mConn: HttpURLConnection? = null + private fun dispose() { + try { + try { + if (mIs != null) mIs!!.close() + } finally { + mConn!!.disconnect() + } + } catch (e: IOException) { + // nothing to do + } + if (mF != null) mF.close() + } + + public override fun run() { + val done: Boolean + var start: Long = mMission.fallbackResumeOffset + if (DEBUG && !mMission.unknownLength && (start > 0)) { + Log.i(TAG, "Resuming a single-thread download at " + start) + } + try { + val rangeStart: Long = if ((mMission.unknownLength || start < 1)) -1 else start + val mId: Int = 1 + mConn = mMission.openConnection(false, rangeStart, -1) + if (mRetryCount == 0 && rangeStart == -1L) { + // 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 DownloadMission.HttpError(416) + } + + // secondary check for the file length + if (!mMission.unknownLength) mMission.unknownLength = Utility.getContentLength(mConn) == -1L + if (mMission.unknownLength || mConn!!.getResponseCode() == 200) { + // restart amount of bytes downloaded + mMission.done = mMission.offsets.get(mMission.current) - mMission.offsets.get(0) + } + mF = mMission.storage!!.getStream() + mF.seek(mMission.offsets.get(mMission.current) + start) + mIs = mConn!!.getInputStream() + val buf: ByteArray = ByteArray(DownloadMission.Companion.BUFFER_SIZE) + var len: Int = 0 + while (mMission.running && (mIs.read(buf, 0, buf.size).also({ len = it })) != -1) { + mF.write(buf, 0, len) + start += len.toLong() + mMission.notifyProgress(len.toLong()) + } + dispose() + + // if thread goes interrupted check if the last part is written. This avoid re-download the whole file + done = len == -1 + } catch (e: Exception) { + dispose() + mMission.fallbackResumeOffset = start + if (!mMission.running || e is ClosedByInterruptException) return + if (e is DownloadMission.HttpError && e.statusCode == DownloadMission.Companion.ERROR_HTTP_FORBIDDEN) { + // for youtube streams. The url has expired, recover + dispose() + mMission.doRecover(DownloadMission.Companion.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 + } + } + + public override fun interrupt() { + super.interrupt() + if (mConn != null) { + try { + mConn!!.disconnect() + } catch (e: Exception) { + // nothing to do + } + } + } + + companion object { + private val TAG: String = "DownloadRunnableFallback" + } +} diff --git a/app/src/main/java/us/shandian/giga/get/FinishedMission.java b/app/src/main/java/us/shandian/giga/get/FinishedMission.java deleted file mode 100644 index 29f3c6296..000000000 --- a/app/src/main/java/us/shandian/giga/get/FinishedMission.java +++ /dev/null @@ -1,18 +0,0 @@ -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; - } - -} diff --git a/app/src/main/java/us/shandian/giga/get/FinishedMission.kt b/app/src/main/java/us/shandian/giga/get/FinishedMission.kt new file mode 100644 index 000000000..137455f3c --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/FinishedMission.kt @@ -0,0 +1,12 @@ +package us.shandian.giga.get + +class FinishedMission : Mission { + constructor() + constructor(mission: DownloadMission) { + source = mission.source + length = mission.length + timestamp = mission.timestamp + kind = mission.kind + storage = mission.storage + } +} diff --git a/app/src/main/java/us/shandian/giga/get/Mission.java b/app/src/main/java/us/shandian/giga/get/Mission.java deleted file mode 100644 index 77b9c1e33..000000000 --- a/app/src/main/java/us/shandian/giga/get/Mission.java +++ /dev/null @@ -1,64 +0,0 @@ -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()); - } -} diff --git a/app/src/main/java/us/shandian/giga/get/Mission.kt b/app/src/main/java/us/shandian/giga/get/Mission.kt new file mode 100644 index 000000000..b88a7aa5e --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/Mission.kt @@ -0,0 +1,60 @@ +package us.shandian.giga.get + +import org.schabi.newpipe.streams.io.StoredFileHelper +import java.io.Serializable +import java.util.Calendar + +abstract class Mission() : Serializable { + /** + * Source url of the resource + */ + var source: String? = null + + /** + * Length of the current resource + */ + var length: Long = 0 + + /** + * creation timestamp (and maybe unique identifier) + */ + var timestamp: Long = 0 + fun getTimestamp(): Long { + return timestamp + } + + /** + * pre-defined content type + */ + var kind: Char = 0.toChar() + + /** + * The downloaded file + */ + var storage: StoredFileHelper? = null + + /** + * Delete the downloaded file + * + * @return `true] if and only if the file is successfully deleted, otherwise, { false}` + */ + open fun delete(): Boolean { + if (storage != null) return storage!!.delete() + return true + } + + /** + * Indicate if this mission is deleted whatever is stored + */ + @Transient + var deleted: Boolean = false + public override fun toString(): String { + val calendar: Calendar = Calendar.getInstance() + calendar.setTimeInMillis(timestamp) + return "[" + calendar.getTime().toString() + "] " + (if (storage!!.isInvalid()) storage!!.getName() else storage!!.getUri()) + } + + companion object { + private val serialVersionUID: Long = 1L // last bump: 27 march 2019 + } +} diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java deleted file mode 100644 index 704385212..000000000 --- a/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java +++ /dev/null @@ -1,234 +0,0 @@ -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.getColumnIndex(KEY_SOURCE))); - values.put(KEY_DONE, cursor.getString(cursor.getColumnIndex(KEY_DONE))); - values.put(KEY_TIMESTAMP, cursor.getLong(cursor.getColumnIndex(KEY_TIMESTAMP))); - values.put(KEY_KIND, cursor.getString(cursor.getColumnIndex(KEY_KIND))); - values.put(KEY_PATH, Uri.fromFile( - new File( - cursor.getString(cursor.getColumnIndex(KEY_LOCATION)), - cursor.getString(cursor.getColumnIndex(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.getColumnIndex(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); - } - } -} diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.kt b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.kt new file mode 100644 index 000000000..e8c7e7299 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.kt @@ -0,0 +1,194 @@ +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 org.schabi.newpipe.streams.io.StoredFileHelper +import us.shandian.giga.get.DownloadMission +import us.shandian.giga.get.FinishedMission +import us.shandian.giga.get.Mission +import java.io.File +import java.util.Objects + +/** + * SQLite helper to store finished [us.shandian.giga.get.FinishedMission]'s + */ +class FinishedMissionStore(private val context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) { + public override fun onCreate(db: SQLiteDatabase) { + db.execSQL(MISSIONS_CREATE_TABLE) + } + + public override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { + var oldVersion: Int = oldVersion + if (oldVersion == 2) { + db.execSQL("ALTER TABLE " + MISSIONS_TABLE_NAME_v2 + " ADD COLUMN " + KEY_KIND + " TEXT;") + oldVersion++ + } + if (oldVersion == 3) { + val KEY_LOCATION: String = "location" + val KEY_NAME: String = "name" + db.execSQL(MISSIONS_CREATE_TABLE) + val cursor: Cursor = db.query(MISSIONS_TABLE_NAME_v2, null, null, + null, null, null, KEY_TIMESTAMP) + val count: Int = cursor.getCount() + if (count > 0) { + db.beginTransaction() + while (cursor.moveToNext()) { + val values: ContentValues = ContentValues() + values.put(KEY_SOURCE, cursor.getString(cursor.getColumnIndex(KEY_SOURCE))) + values.put(KEY_DONE, cursor.getString(cursor.getColumnIndex(KEY_DONE))) + values.put(KEY_TIMESTAMP, cursor.getLong(cursor.getColumnIndex(KEY_TIMESTAMP))) + values.put(KEY_KIND, cursor.getString(cursor.getColumnIndex(KEY_KIND))) + values.put(KEY_PATH, Uri.fromFile( + File( + cursor.getString(cursor.getColumnIndex(KEY_LOCATION)), + cursor.getString(cursor.getColumnIndex(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 fun getValuesOfMission(downloadMission: Mission): ContentValues { + val values: ContentValues = 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, downloadMission.kind.toString()) + return values + } + + private fun getMissionFromCursor(cursor: Cursor): FinishedMission { + var kind: String? = Objects.requireNonNull(cursor).getString(cursor.getColumnIndex(KEY_KIND)) + if (kind == null || kind.isEmpty()) kind = "?" + val path: String = cursor.getString(cursor.getColumnIndexOrThrow(KEY_PATH)) + val mission: FinishedMission = 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.get(0) + try { + mission.storage = StoredFileHelper(context, null, Uri.parse(path), "") + } catch (e: Exception) { + Log.e("FinishedMissionStore", "failed to load the storage path of: " + path, e) + mission.storage = StoredFileHelper(null, path, "", "") + } + return mission + } + + ////////////////////////////////// + // Data source methods + /////////////////////////////////// + fun loadFinishedMissions(): ArrayList { + val database: SQLiteDatabase = getReadableDatabase() + val cursor: Cursor = database.query(FINISHED_TABLE_NAME, null, null, + null, null, null, KEY_TIMESTAMP + " DESC") + val count: Int = cursor.getCount() + if (count == 0) return ArrayList(1) + val result: ArrayList = ArrayList(count) + while (cursor.moveToNext()) { + result.add(getMissionFromCursor(cursor)) + } + return result + } + + fun addFinishedMission(downloadMission: DownloadMission) { + val values: ContentValues = getValuesOfMission(Objects.requireNonNull(downloadMission)) + val database: SQLiteDatabase = getWritableDatabase() + database.insert(FINISHED_TABLE_NAME, null, values) + } + + fun deleteMission(mission: Mission) { + val ts: String = Objects.requireNonNull(mission).timestamp.toString() + val database: SQLiteDatabase = getWritableDatabase() + if (mission is FinishedMission) { + if (mission.storage!!.isInvalid()) { + database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ?", arrayOf(ts)) + } else { + database.delete(FINISHED_TABLE_NAME, KEY_TIMESTAMP + " = ? AND " + KEY_PATH + " = ?", arrayOf( + ts, mission.storage!!.getUri().toString() + )) + } + } else { + throw UnsupportedOperationException("DownloadMission") + } + } + + fun updateMission(mission: Mission) { + val values: ContentValues = getValuesOfMission(Objects.requireNonNull(mission)) + val database: SQLiteDatabase = getWritableDatabase() + val ts: String = mission.timestamp.toString() + val rowsAffected: Int + if (mission is FinishedMission) { + if (mission.storage!!.isInvalid()) { + rowsAffected = database.update(FINISHED_TABLE_NAME, values, KEY_TIMESTAMP + " = ?", arrayOf(ts)) + } else { + rowsAffected = database.update(FINISHED_TABLE_NAME, values, KEY_PATH + " = ?", arrayOf( + mission.storage!!.getUri().toString() + )) + } + } else { + throw UnsupportedOperationException("DownloadMission") + } + if (rowsAffected != 1) { + Log.e("FinishedMissionStore", "Expected 1 row to be affected by update but got " + rowsAffected) + } + } + + companion object { + // TODO: use NewPipeSQLiteHelper ('s constants) when playlist branch is merged (?) + private val DATABASE_NAME: String = "downloads.db" + private val DATABASE_VERSION: Int = 4 + + /** + * The table name of download missions (old) + */ + private val MISSIONS_TABLE_NAME_v2: String = "download_missions" + + /** + * The table name of download missions + */ + private val FINISHED_TABLE_NAME: String = "finished_missions" + + /** + * The key to the urls of a mission + */ + private val KEY_SOURCE: String = "url" + + /** + * The key to the done. + */ + private val KEY_DONE: String = "bytes_downloaded" + private val KEY_TIMESTAMP: String = "timestamp" + private val KEY_KIND: String = "kind" + private val KEY_PATH: String = "path" + + /** + * The statement to create the table + */ + private val MISSIONS_CREATE_TABLE: String = ("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 + "));") + } +} diff --git a/app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java b/app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java deleted file mode 100644 index f7edf3975..000000000 --- a/app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java +++ /dev/null @@ -1,155 +0,0 @@ -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) { - } - -} diff --git a/app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.kt b/app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.kt new file mode 100644 index 000000000..d9b9d9ecc --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.kt @@ -0,0 +1,124 @@ +package us.shandian.giga.io + +import org.schabi.newpipe.streams.io.SharpStream +import java.io.IOException +import kotlin.math.min + +class ChunkFileInputStream(private var source: SharpStream?, private val offset: Long, end: Long, private val onProgress: ProgressReport?) : SharpStream() { + private val length: Long + private var position: Long = 0 + private var progressReport: Long + + init { + length = end - offset + progressReport = REPORT_INTERVAL.toLong() + if (length < 1) { + source!!.close() + throw IOException("The chunk is empty or invalid") + } + if (source!!.length() < end) { + try { + throw 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 + */ + fun getFilePointer(): Long { + return offset + position + } + + @Throws(IOException::class) + public override fun read(): Int { + if ((position + 1) > length) { + return 0 + } + val res: Int = source!!.read() + if (res >= 0) { + position++ + } + return res + } + + @Throws(IOException::class) + public override fun read(b: ByteArray): Int { + return read(b, 0, b.size) + } + + @Throws(IOException::class) + public override fun read(b: ByteArray?, off: Int, len: Int): Int { + var len: Int = len + if ((position + len) > length) { + len = (length - position).toInt() + } + if (len == 0) { + return 0 + } + val res: Int = source!!.read(b, off, len) + position += res.toLong() + if (onProgress != null && position > progressReport) { + onProgress.report(position) + progressReport = position + REPORT_INTERVAL + } + return res + } + + @Throws(IOException::class) + public override fun skip(pos: Long): Long { + var pos: Long = pos + pos = min((pos + position).toDouble(), length.toDouble()).toLong() + if (pos == 0L) { + return 0 + } + source!!.seek(offset + pos) + val oldPos: Long = position + position = pos + return pos - oldPos + } + + public override fun available(): Long { + return length - position + } + + public override fun close() { + source!!.close() + source = null + } + + public override fun isClosed(): Boolean { + return source == null + } + + @Throws(IOException::class) + public override fun rewind() { + position = 0 + source!!.seek(offset) + } + + public override fun canRewind(): Boolean { + return true + } + + public override fun canRead(): Boolean { + return true + } + + public override fun canWrite(): Boolean { + return false + } + + public override fun write(value: Byte) {} + public override fun write(buffer: ByteArray?) {} + public override fun write(buffer: ByteArray?, offset: Int, count: Int) {} + + companion object { + private val REPORT_INTERVAL: Int = 256 * 1024 + } +} diff --git a/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java b/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java deleted file mode 100644 index 4473fa7f9..000000000 --- a/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java +++ /dev/null @@ -1,486 +0,0 @@ -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 - ); - } - } -} diff --git a/app/src/main/java/us/shandian/giga/io/CircularFileWriter.kt b/app/src/main/java/us/shandian/giga/io/CircularFileWriter.kt new file mode 100644 index 000000000..a297b7fc8 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/CircularFileWriter.kt @@ -0,0 +1,433 @@ +package us.shandian.giga.io + +import org.schabi.newpipe.streams.io.SharpStream +import java.io.File +import java.io.IOException +import java.util.Objects +import kotlin.math.max +import kotlin.math.min + +class CircularFileWriter(target: SharpStream?, temp: File?, checker: OffsetChecker) : SharpStream() { + private val callback: OffsetChecker + var onProgress: ProgressReport? = null + var onWriteError: WriteErrorHandle? = null + private var reportPosition: Long + private var maxLengthKnown: Long = -1 + private var out: BufferedFile? + private var aux: BufferedFile? + + init { + Objects.requireNonNull(checker) + if (!temp!!.exists()) { + if (!temp.createNewFile()) { + throw IOException("Cannot create a temporal file") + } + } + aux = BufferedFile((temp)) + out = BufferedFile(target) + callback = checker + reportPosition = NOTIFY_BYTES_INTERVAL.toLong() + } + + @Throws(IOException::class) + private fun flushAuxiliar(amount: Long) { + var amount: Long = amount + if (aux!!.length < 1) { + return + } + out!!.flush() + aux!!.flush() + val underflow: Boolean = aux!!.offset < aux!!.length || out!!.offset < out!!.length + val buffer: ByteArray = ByteArray(COPY_BUFFER_SIZE) + aux!!.target!!.seek(0) + out!!.target!!.seek(out!!.length) + var length: Long = amount + while (length > 0) { + var read: Int = min(length.toDouble(), Int.MAX_VALUE) as Int + read = aux!!.target!!.read(buffer, 0, min(read.toDouble(), buffer.size.toDouble()).toInt()) + if (read < 1) { + amount -= length + break + } + out!!.writeProof(buffer, read) + length -= read.toLong() + } + 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 + var readOffset: Long = amount + var writeOffset: Long = 0 + aux!!.length -= amount + length = aux!!.length + while (length > 0) { + var read: Int = min(length.toDouble(), Int.MAX_VALUE) as Int + read = aux!!.target!!.read(buffer, 0, min(read.toDouble(), buffer.size.toDouble()).toInt()) + aux!!.target!!.seek(writeOffset) + aux!!.writeProof(buffer, read) + writeOffset += read.toLong() + readOffset += read.toLong() + length -= read.toLong() + aux!!.target!!.seek(readOffset) + } + aux!!.target!!.setLength(aux!!.length) + return + } + if (aux!!.length > THRESHOLD_AUX_LENGTH) { + aux!!.target!!.setLength(THRESHOLD_AUX_LENGTH.toLong()) // 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 + */ + @Throws(IOException::class) + fun finalizeFile(): Long { + flushAuxiliar(aux!!.length) + out!!.flush() + + // change file length (if required) + val length: Long = max(maxLengthKnown.toDouble(), out!!.length.toDouble()).toLong() + if (length != out!!.target!!.length()) { + out!!.target!!.setLength(length) + } + close() + return length + } + + /** + * Close the file without flushing any buffer + */ + public override fun close() { + if (out != null) { + out!!.close() + out = null + } + if (aux != null) { + aux!!.close() + aux = null + } + } + + @Throws(IOException::class) + public override fun write(b: Byte) { + write(byteArrayOf(b), 0, 1) + } + + @Throws(IOException::class) + public override fun write(b: ByteArray?) { + write(b, 0, b!!.size) + } + + @Throws(IOException::class) + public override fun write(b: ByteArray?, off: Int, len: Int) { + var off: Int = off + var len: Int = len + if (len == 0) { + return + } + var available: Long + val offsetOut: Long = out!!.getOffset() + val offsetAux: Long = aux!!.getOffset() + val end: Long = callback.check() + if (end == -1L) { + available = Int.MAX_VALUE.toLong() + } else if (end < offsetOut) { + throw IOException("The reported offset is invalid: " + end + "<" + offsetOut) + } else { + available = end - offsetOut + } + val usingAux: Boolean = aux!!.length > 0 && offsetOut >= out!!.length + val underflow: Boolean = offsetAux < aux!!.length || offsetOut < out!!.length + if (usingAux) { + // before continue calculate the final length of aux + var length: Long = 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 + } + val length: Int = min(len.toDouble(), (min(Int.MAX_VALUE, available.toDouble()) as Int).toDouble()).toInt() + out!!.write(b, off, length) + len -= length + off += length + if (len > 0) { + aux!!.write(b, off, len) + } + } + if (onProgress != null) { + val absoluteOffset: Long = out!!.getOffset() + aux!!.getOffset() + if (absoluteOffset > reportPosition) { + reportPosition = absoluteOffset + NOTIFY_BYTES_INTERVAL + onProgress!!.report(absoluteOffset) + } + } + } + + @Throws(IOException::class) + public override fun flush() { + aux!!.flush() + out!!.flush() + val total: Long = out!!.length + aux!!.length + if (total > maxLengthKnown) { + maxLengthKnown = total // save the current file length in case the method {@code rewind()} is called + } + } + + @Throws(IOException::class) + public override fun skip(amount: Long): Long { + seek(out!!.getOffset() + aux!!.getOffset() + amount) + return amount + } + + @Throws(IOException::class) + public override fun rewind() { + if (onProgress != null) { + onProgress!!.report(0) // rollback the whole progress + } + seek(0) + reportPosition = NOTIFY_BYTES_INTERVAL.toLong() + } + + @Throws(IOException::class) + public override fun seek(offset: Long) { + val total: Long = out!!.length + aux!!.length + if (offset == total) { + // do not ignore the seek offset if a underflow exists + val relativeOffset: Long = out!!.getOffset() + aux!!.getOffset() + if (relativeOffset == total) { + return + } + } + + // flush everything, avoid any underflow + flush() + if (offset < 0 || offset > total) { + throw 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) + } + } + + public override fun isClosed(): Boolean { + return out == null + } + + public override fun canRewind(): Boolean { + return true + } + + public override fun canWrite(): Boolean { + return true + } + + public override fun canSeek(): Boolean { + return true + } + + // + public override fun canRead(): Boolean { + return false + } + + public override fun read(): Int { + throw UnsupportedOperationException("write-only") + } + + public override fun read(buffer: ByteArray + ): Int { + throw UnsupportedOperationException("write-only") + } + + public override fun read(buffer: ByteArray?, offset: Int, count: Int + ): Int { + throw UnsupportedOperationException("write-only") + } + + public override fun available(): Long { + throw UnsupportedOperationException("write-only") + } + + // + open 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 + */ + fun check(): Long + } + + open interface WriteErrorHandle { + /** + * Attempts to handle a I/O exception + * + * @param err the cause + * @return `true` to retry and continue, otherwise, `false` + * and throw the exception + */ + fun handle(err: Exception?): Boolean + } + + internal inner class BufferedFile { + val target: SharpStream? + var offset: Long = 0 + var length: Long = 0 + private var queue: ByteArray? = ByteArray(QUEUE_BUFFER_SIZE) + private var queueSize: Int = 0 + + constructor(file: File) { + target = FileStream(file) + } + + constructor(target: SharpStream?) { + this.target = target + } + + fun getOffset(): Long { + return offset + queueSize // absolute offset in the file + } + + fun close() { + queue = null + target!!.close() + } + + @Throws(IOException::class) + fun write(b: ByteArray?, off: Int, len: Int) { + var off: Int = off + var len: Int = len + while (len > 0) { + // if the queue is full, the method available() will flush the queue + val read: Int = min(available().toDouble(), len.toDouble()).toInt() + + // enqueue incoming buffer + System.arraycopy(b, off, queue, queueSize, read) + queueSize += read + len -= read + off += read + } + val total: Long = offset + queueSize + if (total > length) { + length = total // save length + } + } + + @Throws(IOException::class) + fun flush() { + writeProof(queue, queueSize) + offset += queueSize.toLong() + queueSize = 0 + } + + @Throws(IOException::class) + protected fun rewind() { + offset = 0 + target!!.seek(0) + } + + @Throws(IOException::class) + fun available(): Int { + if (queueSize >= queue!!.size) { + flush() + return queue!!.size + } + return queue!!.size - queueSize + } + + @Throws(IOException::class) + fun reset() { + offset = 0 + length = 0 + target!!.seek(0) + } + + @Throws(IOException::class) + fun seek(absoluteOffset: Long) { + if (absoluteOffset == offset) { + return // nothing to do + } + offset = absoluteOffset + target!!.seek(absoluteOffset) + } + + @Throws(IOException::class) + fun writeProof(buffer: ByteArray?, length: Int) { + if (onWriteError == null) { + target!!.write(buffer, 0, length) + return + } + while (true) { + try { + target!!.write(buffer, 0, length) + return + } catch (e: Exception) { + if (!onWriteError!!.handle(e)) { + throw e // give up + } + } + } + } + + public override fun toString(): String { + var absLength: String? + try { + absLength = target!!.length().toString() + } catch (e: IOException) { + absLength = "[" + e.getLocalizedMessage() + "]" + } + return String.format( + "offset=%s length=%s queue=%s absLength=%s", + offset, length, queueSize, absLength + ) + } + } + + companion object { + private val QUEUE_BUFFER_SIZE: Int = 8 * 1024 // 8 KiB + private val COPY_BUFFER_SIZE: Int = 128 * 1024 // 128 KiB + private val NOTIFY_BYTES_INTERVAL: Int = 64 * 1024 // 64 KiB + private val THRESHOLD_AUX_LENGTH: Int = 15 * 1024 * 1024 // 15 MiB + } +} diff --git a/app/src/main/java/us/shandian/giga/io/FileStream.java b/app/src/main/java/us/shandian/giga/io/FileStream.java deleted file mode 100644 index bbc56b20c..000000000 --- a/app/src/main/java/us/shandian/giga/io/FileStream.java +++ /dev/null @@ -1,131 +0,0 @@ -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(); - } -} diff --git a/app/src/main/java/us/shandian/giga/io/FileStream.kt b/app/src/main/java/us/shandian/giga/io/FileStream.kt new file mode 100644 index 000000000..cea147b0d --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/FileStream.kt @@ -0,0 +1,118 @@ +package us.shandian.giga.io + +import org.schabi.newpipe.streams.io.SharpStream +import java.io.File +import java.io.IOException +import java.io.RandomAccessFile + +/** + * @author kapodamy + */ +class FileStream : SharpStream { + var source: RandomAccessFile? + + constructor(target: File) { + source = RandomAccessFile(target, "rw") + } + + constructor(path: String) { + source = RandomAccessFile(path, "rw") + } + + @Throws(IOException::class) + public override fun read(): Int { + return source!!.read() + } + + @Throws(IOException::class) + public override fun read(b: ByteArray): Int { + return source!!.read(b) + } + + @Throws(IOException::class) + public override fun read(b: ByteArray?, off: Int, len: Int): Int { + return source!!.read(b, off, len) + } + + @Throws(IOException::class) + public override fun skip(pos: Long): Long { + return source!!.skipBytes(pos.toInt()).toLong() + } + + public override fun available(): Long { + try { + return source!!.length() - source!!.getFilePointer() + } catch (e: IOException) { + return 0 + } + } + + public override fun close() { + if (source == null) return + try { + source!!.close() + } catch (err: IOException) { + // nothing to do + } + source = null + } + + public override fun isClosed(): Boolean { + return source == null + } + + @Throws(IOException::class) + public override fun rewind() { + source!!.seek(0) + } + + public override fun canRewind(): Boolean { + return true + } + + public override fun canRead(): Boolean { + return true + } + + public override fun canWrite(): Boolean { + return true + } + + public override fun canSeek(): Boolean { + return true + } + + public override fun canSetLength(): Boolean { + return true + } + + @Throws(IOException::class) + public override fun write(value: Byte) { + source!!.write(value.toInt()) + } + + @Throws(IOException::class) + public override fun write(buffer: ByteArray?) { + source!!.write(buffer) + } + + @Throws(IOException::class) + public override fun write(buffer: ByteArray?, offset: Int, count: Int) { + source!!.write(buffer, offset, count) + } + + @Throws(IOException::class) + public override fun setLength(length: Long) { + source!!.setLength(length) + } + + @Throws(IOException::class) + public override fun seek(offset: Long) { + source!!.seek(offset) + } + + @Throws(IOException::class) + public override fun length(): Long { + return source!!.length() + } +} diff --git a/app/src/main/java/us/shandian/giga/io/FileStreamSAF.java b/app/src/main/java/us/shandian/giga/io/FileStreamSAF.java deleted file mode 100644 index b7dd0a103..000000000 --- a/app/src/main/java/us/shandian/giga/io/FileStreamSAF.java +++ /dev/null @@ -1,150 +0,0 @@ -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(); - } -} diff --git a/app/src/main/java/us/shandian/giga/io/FileStreamSAF.kt b/app/src/main/java/us/shandian/giga/io/FileStreamSAF.kt new file mode 100644 index 000000000..6e3ee4841 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/FileStreamSAF.kt @@ -0,0 +1,132 @@ +package us.shandian.giga.io + +import android.content.ContentResolver +import android.net.Uri +import android.os.ParcelFileDescriptor +import android.util.Log +import org.schabi.newpipe.streams.io.SharpStream +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.nio.channels.FileChannel + +class FileStreamSAF(contentResolver: ContentResolver, fileUri: Uri) : SharpStream() { + private val `in`: FileInputStream + private val out: FileOutputStream + private val channel: FileChannel + private val file: ParcelFileDescriptor? + private var disposed: Boolean = false + + init { + // 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 IOException("Cannot get the ParcelFileDescriptor for " + fileUri.toString()) + } + `in` = FileInputStream(file.getFileDescriptor()) + out = FileOutputStream(file.getFileDescriptor()) + channel = out.getChannel() // or use in.getChannel() + } + + @Throws(IOException::class) + public override fun read(): Int { + return `in`.read() + } + + @Throws(IOException::class) + public override fun read(buffer: ByteArray): Int { + return `in`.read(buffer) + } + + @Throws(IOException::class) + public override fun read(buffer: ByteArray?, offset: Int, count: Int): Int { + return `in`.read(buffer, offset, count) + } + + @Throws(IOException::class) + public override fun skip(amount: Long): Long { + return `in`.skip(amount) // ¿or use channel.position(channel.position() + amount)? + } + + public override fun available(): Long { + try { + return `in`.available().toLong() + } catch (e: IOException) { + return 0 // ¡but not -1! + } + } + + @Throws(IOException::class) + public override fun rewind() { + seek(0) + } + + public override fun close() { + try { + disposed = true + file!!.close() + `in`.close() + out.close() + channel.close() + } catch (e: IOException) { + Log.e("FileStreamSAF", "close() error", e) + } + } + + public override fun isClosed(): Boolean { + return disposed + } + + public override fun canRewind(): Boolean { + return true + } + + public override fun canRead(): Boolean { + return true + } + + public override fun canWrite(): Boolean { + return true + } + + public override fun canSetLength(): Boolean { + return true + } + + public override fun canSeek(): Boolean { + return true + } + + @Throws(IOException::class) + public override fun write(value: Byte) { + out.write(value.toInt()) + } + + @Throws(IOException::class) + public override fun write(buffer: ByteArray?) { + out.write(buffer) + } + + @Throws(IOException::class) + public override fun write(buffer: ByteArray?, offset: Int, count: Int) { + out.write(buffer, offset, count) + } + + @Throws(IOException::class) + public override fun setLength(length: Long) { + channel.truncate(length) + } + + @Throws(IOException::class) + public override fun seek(offset: Long) { + channel.position(offset) + } + + @Throws(IOException::class) + public override fun length(): Long { + return channel.size() + } +} diff --git a/app/src/main/java/us/shandian/giga/io/ProgressReport.java b/app/src/main/java/us/shandian/giga/io/ProgressReport.kt similarity index 50% rename from app/src/main/java/us/shandian/giga/io/ProgressReport.java rename to app/src/main/java/us/shandian/giga/io/ProgressReport.kt index e382747f6..df221dd48 100644 --- a/app/src/main/java/us/shandian/giga/io/ProgressReport.java +++ b/app/src/main/java/us/shandian/giga/io/ProgressReport.kt @@ -1,11 +1,10 @@ -package us.shandian.giga.io; - -public interface ProgressReport { +package us.shandian.giga.io +open interface ProgressReport { /** * Report the size of the new file * * @param progress the new size */ - void report(long progress); + fun report(progress: Long) } \ No newline at end of file diff --git a/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java b/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java deleted file mode 100644 index aa5170908..000000000 --- a/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.java +++ /dev/null @@ -1,41 +0,0 @@ -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; - } -} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.kt b/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.kt new file mode 100644 index 000000000..35ca4d24a --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/M4aNoDash.kt @@ -0,0 +1,29 @@ +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 + +internal class M4aNoDash() : Postprocessing(false, true, Postprocessing.Companion.ALGORITHM_M4A_NO_DASH) { + @Throws(IOException::class) + public override fun test(vararg sources: SharpStream): Boolean { + // check if the mp4 file is DASH (youtube) + val reader: Mp4DashReader = Mp4DashReader(sources.get(0)) + reader.parse() + when (reader.getBrands().get(0)) { + 0x64617368, 0x69736F35 -> return true + else -> return false + } + } + + @Throws(IOException::class) + public override fun process(out: SharpStream?, vararg sources: SharpStream): Int { + val muxer: Mp4FromDashWriter = Mp4FromDashWriter(sources.get(0)) + muxer.setMainBrand(0x4D344120) // binary string "M4A " + muxer.parseSources() + muxer.selectTracks(0) + muxer.build(out) + return Postprocessing.Companion.OK_RESULT.toInt() + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java deleted file mode 100644 index 74cb43116..000000000 --- a/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.java +++ /dev/null @@ -1,27 +0,0 @@ -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; - } - -} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.kt b/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.kt new file mode 100644 index 000000000..5f0200c0f --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/Mp4FromDashMuxer.kt @@ -0,0 +1,19 @@ +package us.shandian.giga.postprocessing + +import org.schabi.newpipe.streams.Mp4FromDashWriter +import org.schabi.newpipe.streams.io.SharpStream +import java.io.IOException + +/** + * @author kapodamy + */ +internal class Mp4FromDashMuxer() : Postprocessing(true, true, Postprocessing.Companion.ALGORITHM_MP4_FROM_DASH_MUXER) { + @Throws(IOException::class) + public override fun process(out: SharpStream?, vararg sources: SharpStream): Int { + val muxer: Mp4FromDashWriter = Mp4FromDashWriter(*sources) + muxer.parseSources() + muxer.selectTracks(0, 0) + muxer.build(out) + return Postprocessing.Companion.OK_RESULT.toInt() + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java deleted file mode 100644 index dc46ced5d..000000000 --- a/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java +++ /dev/null @@ -1,44 +0,0 @@ -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); - demuxer.parseSource(); - demuxer.selectTrack(0); - demuxer.build(); - - return OK_RESULT; - } -} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.kt b/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.kt new file mode 100644 index 000000000..38036fcc2 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.kt @@ -0,0 +1,28 @@ +package us.shandian.giga.postprocessing + +import org.schabi.newpipe.streams.OggFromWebMWriter +import org.schabi.newpipe.streams.io.SharpStream +import java.io.IOException +import java.nio.ByteBuffer + +internal class OggFromWebmDemuxer() : Postprocessing(true, true, Postprocessing.Companion.ALGORITHM_OGG_FROM_WEBM_DEMUXER) { + @Throws(IOException::class) + public override fun test(vararg sources: SharpStream): Boolean { + val buffer: ByteBuffer = ByteBuffer.allocate(4) + sources.get(0).read(buffer.array()) + when (buffer.getInt()) { + 0x1a45dfa3 -> return true // webm/mkv + 0x4F676753 -> return false // ogg + } + throw UnsupportedOperationException("file not recognized, failed to demux the audio stream") + } + + @Throws(IOException::class) + public override fun process(out: SharpStream, vararg sources: SharpStream): Int { + val demuxer: OggFromWebMWriter = OggFromWebMWriter(sources.get(0), out) + demuxer.parseSource() + demuxer.selectTrack(0) + demuxer.build() + return Postprocessing.Companion.OK_RESULT.toInt() + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java deleted file mode 100644 index 7f5c85d27..000000000 --- a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java +++ /dev/null @@ -1,258 +0,0 @@ -package us.shandian.giga.postprocessing; - -import android.util.Log; - -import androidx.annotation.NonNull; - -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) { - 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; - 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; - - 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(); - } -} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.kt b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.kt new file mode 100644 index 000000000..c0952af83 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.kt @@ -0,0 +1,213 @@ +package us.shandian.giga.postprocessing + +import android.util.Log +import org.schabi.newpipe.streams.io.SharpStream +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.CircularFileWriter.WriteErrorHandle +import us.shandian.giga.io.ProgressReport +import java.io.File +import java.io.IOException +import java.io.Serializable +import kotlin.math.max + +abstract class Postprocessing // for debugging only +internal constructor( + /** + * Indicates whether the selected algorithm needs space reserved at the beginning of the file + */ + var reserveSpace: Boolean, + /** + * Get a boolean value that indicate if the given algorithm work on the same + * file + */ + var worksOnSameFile: Boolean, + /** + * Gets the given algorithm short name + */ + private val name: String) : Serializable { + private var args: Array? + + @Transient + private var mission: DownloadMission? = null + + @Transient + private var tempFile: File? = null + fun setTemporalDir(directory: File) { + val rnd: Long = ((Math.random() * 100000.0f).toInt()).toLong() + tempFile = File(directory, rnd.toString() + "_" + System.nanoTime() + ".tmp") + } + + fun cleanupTemporalDir() { + if (tempFile != null && tempFile!!.exists()) { + try { + tempFile!!.delete() + } catch (e: Exception) { + // nothing to do + } + } + } + + @Throws(IOException::class) + fun run(target: DownloadMission?) { + mission = target + var result: Int + var finalLength: Long = -1 + mission!!.done = 0 + val length: Long = mission!!.storage!!.length() - mission!!.offsets.get(0) + mission!!.length = max(length.toDouble(), mission!!.nearLength.toDouble()).toLong() + val readProgress: ProgressReport = ProgressReport({ position: Long -> + var position: Long = position + position -= mission!!.offsets.get(0) + if (position > mission!!.done) mission!!.done = position + }) + if (worksOnSameFile) { + val sources: Array = arrayOfNulls(mission!!.urls.size) + try { + var i: Int = 0 + var j: Int = 1 + while (i < sources.size) { + val source: SharpStream? = mission!!.storage!!.getStream() + val end: Long = if (j < sources.size) mission!!.offsets.get(j) else source!!.length() + sources.get(i) = ChunkFileInputStream(source, mission!!.offsets.get(i), end, readProgress) + i++ + j++ + } + if (test(*sources)) { + for (source: SharpStream? in sources) source!!.rewind() + val checker: OffsetChecker = OffsetChecker({ + for (source: ChunkFileInputStream? in 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@OffsetChecker source.getFilePointer() - 1 + } + -1 + }) + CircularFileWriter( + mission!!.storage!!.getStream(), tempFile, checker).use({ out -> + out.onProgress = ProgressReport({ position: Long -> mission!!.done = position }) + out.onWriteError = WriteErrorHandle({ err: Exception? -> + mission!!.psState = 3 + mission!!.notifyError(DownloadMission.Companion.ERROR_POSTPROCESSING_HOLD, err) + try { + synchronized(this, { while (mission!!.psState == 3) (this as Object).wait() }) + } catch (e: InterruptedException) { + // nothing to do + Log.e(javaClass.getSimpleName(), "got InterruptedException") + } + mission!!.errCode == DownloadMission.Companion.ERROR_NOTHING + }) + result = process(out, *sources) + if (result == OK_RESULT.toInt()) finalLength = out.finalizeFile() + }) + } else { + result = OK_RESULT.toInt() + } + } finally { + for (source: SharpStream? in sources) { + if (source != null && !source.isClosed()) { + source.close() + } + } + if (tempFile != null) { + tempFile!!.delete() + tempFile = null + } + } + } else { + result = if (test()) process(null) else OK_RESULT.toInt() + } + if (result == OK_RESULT.toInt()) { + if (finalLength != -1L) { + mission!!.length = finalLength + } + } else { + mission!!.errCode = DownloadMission.Companion.ERROR_POSTPROCESSING + mission!!.errObject = RuntimeException("post-processing algorithm returned " + result) + } + if (result != OK_RESULT.toInt() && worksOnSameFile) mission!!.storage!!.delete() + mission = null + } + + /** + * Test if the post-processing algorithm can be skipped + * + * @param sources files to be processed + * @return `true` if the post-processing is required, otherwise, `false` + * @throws IOException if an I/O error occurs. + */ + @Throws(IOException::class) + open fun test(vararg sources: SharpStream): Boolean { + return true + } + + /** + * Abstract method to execute the post-processing algorithm + * + * @param out output stream + * @param sources files to be processed + * @return an error code, `OK_RESULT` means the operation was successful + * @throws IOException if an I/O error occurs. + */ + @Throws(IOException::class) + abstract fun process(out: SharpStream?, vararg sources: SharpStream): Int + fun getArgumentAt(index: Int, defaultValue: String): String { + if (args == null || index >= args!!.size) { + return defaultValue + } + return args!!.get(index) + } + + public override fun toString(): String { + val str: StringBuilder = StringBuilder() + str.append("{ name=").append(name).append('[') + if (args != null) { + for (arg: String? in args!!) { + str.append(", ") + str.append(arg) + } + str.delete(0, 1) + } + return str.append("] }").toString() + } + + companion object { + @Transient + val OK_RESULT: Byte = DownloadMission.Companion.ERROR_NOTHING.toByte() + + @Transient + val ALGORITHM_TTML_CONVERTER: String = "ttml" + + @Transient + val ALGORITHM_WEBM_MUXER: String = "webm" + + @Transient + val ALGORITHM_MP4_FROM_DASH_MUXER: String = "mp4D-mp4" + + @Transient + val ALGORITHM_M4A_NO_DASH: String = "mp4D-m4a" + + @Transient + val ALGORITHM_OGG_FROM_WEBM_DEMUXER: String = "webm-ogg-d" + fun getAlgorithm(algorithmName: String, args: Array?): Postprocessing { + val instance: Postprocessing + when (algorithmName) { + ALGORITHM_TTML_CONVERTER -> instance = TtmlConverter() + ALGORITHM_WEBM_MUXER -> instance = WebMMuxer() + ALGORITHM_MP4_FROM_DASH_MUXER -> instance = Mp4FromDashMuxer() + ALGORITHM_M4A_NO_DASH -> instance = M4aNoDash() + ALGORITHM_OGG_FROM_WEBM_DEMUXER -> instance = OggFromWebmDemuxer() + else -> throw UnsupportedOperationException("Unimplemented post-processing algorithm: " + algorithmName) + } + instance.args = args + return instance + } + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.java b/app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.java deleted file mode 100644 index 8ed0dfae5..000000000 --- a/app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.java +++ /dev/null @@ -1,50 +0,0 @@ -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 (Exception err) { - Log.e(TAG, "subtitle parse failed", err); - return err instanceof IOException ? 1 : 8; - } - - 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); - } - -} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.kt b/app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.kt new file mode 100644 index 000000000..3d2c352cf --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/TtmlConverter.kt @@ -0,0 +1,40 @@ +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 + */ +internal class TtmlConverter() : Postprocessing(false, true, Postprocessing.Companion.ALGORITHM_TTML_CONVERTER) { + @Throws(IOException::class) + public override fun process(out: SharpStream?, vararg sources: SharpStream): Int { + // check if the subtitle is already in srt and copy, this should never happen + val format: String? = getArgumentAt(0, null) + val ignoreEmptyFrames: Boolean = (getArgumentAt(1, "true") == "true") + if (format == null || (format == "ttml")) { + val writer: SrtFromTtmlWriter = SrtFromTtmlWriter(out, ignoreEmptyFrames) + try { + writer.build(sources.get(0)) + } catch (err: Exception) { + Log.e(TAG, "subtitle parse failed", err) + return if (err is IOException) 1 else 8 + } + return Postprocessing.Companion.OK_RESULT.toInt() + } else if ((format == "srt")) { + val buffer: ByteArray = ByteArray(8 * 1024) + var read: Int + while ((sources.get(0).read(buffer).also({ read = it })) > 0) { + out!!.write(buffer, 0, read) + } + return Postprocessing.Companion.OK_RESULT.toInt() + } + throw UnsupportedOperationException("Can't convert this subtitle, unimplemented format: " + format) + } + + companion object { + private val TAG: String = "TtmlConverter" + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java deleted file mode 100644 index ea1676482..000000000 --- a/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.java +++ /dev/null @@ -1,44 +0,0 @@ -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; - } - -} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.kt b/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.kt new file mode 100644 index 000000000..3c589be8c --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/WebMMuxer.kt @@ -0,0 +1,36 @@ +package us.shandian.giga.postprocessing + +import org.schabi.newpipe.streams.WebMReader +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 + */ +internal class WebMMuxer() : Postprocessing(true, true, Postprocessing.Companion.ALGORITHM_WEBM_MUXER) { + @Throws(IOException::class) + public override fun process(out: SharpStream?, vararg sources: SharpStream): Int { + val muxer: WebMWriter = WebMWriter(*sources) + muxer.parseSources() + + // youtube uses a webm with a fake video track that acts as a "cover image" + val indexes: IntArray = IntArray(sources.size) + var i: Int = 0 + while (i < sources.size) { + val tracks: Array? = muxer.getTracksFromSource(i) + for (j in tracks!!.indices) { + if (tracks.get(j)!!.kind == WebMReader.TrackKind.Audio) { + indexes.get(i) = j + i = sources.size + break + } + } + i++ + } + muxer.selectTracks(*indexes) + muxer.build(out) + return Postprocessing.Companion.OK_RESULT.toInt() + } +} diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManager.java b/app/src/main/java/us/shandian/giga/service/DownloadManager.java deleted file mode 100644 index 9b90fa14b..000000000 --- a/app/src/main/java/us/shandian/giga/service/DownloadManager.java +++ /dev/null @@ -1,719 +0,0 @@ -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; - -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 || mis.isFinished() || mis.hasInvalidStorage()) { - //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); - mis.storage.invalidate(); - exists = false; - } - - if (mis.isPsRunning()) { - if (mis.psAlgorithm.worksOnSameFile) { - // Incomplete post-processing results in a corrupted download file - // because the selected algorithm works on the same file to save space. - // the file will be deleted if the storage API - // is Java IO (avoid showing the "Save as..." dialog) - 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); - - // the progress is lost, reset mission state - if (mis.isInitialized()) - mis.resetState(true, true, DownloadMission.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) { - synchronized (this) { - if (mission instanceof DownloadMission) { - mMissionsPending.remove(mission); - } else if (mission instanceof FinishedMission) { - mMissionsFinished.remove(mission); - mFinishedMissionStore.deleteMission(mission); - } - - 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 != DownloadMission.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 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); - - // hide missions (if required) - remove.removeIf(mission -> 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); - } - } - -} diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManager.kt b/app/src/main/java/us/shandian/giga/service/DownloadManager.kt new file mode 100644 index 000000000..f7ff6cba9 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/service/DownloadManager.kt @@ -0,0 +1,598 @@ +package us.shandian.giga.service + +import android.content.Context +import android.os.Handler +import android.util.Log +import org.schabi.newpipe.BuildConfig.DEBUG +import us.shandian.giga.util.Utility +import java.io.File +import java.io.IOException +import java.util.Collections +import java.util.function.Predicate +import java.util.function.ToLongFunction + +class DownloadManager internal constructor(context: Context, handler: Handler, storageVideo: StoredDirectoryHelper?, storageAudio: StoredDirectoryHelper?) { + enum class NetworkState { + Unavailable, + Operating, + MeteredOperating + } + + private val mFinishedMissionStore: FinishedMissionStore + private val mMissionsPending: ArrayList = ArrayList() + private val mMissionsFinished: ArrayList + private val mHandler: Handler + private val mPendingMissionsDir: File? + private var mLastNetworkStatus: NetworkState = NetworkState.Unavailable + var mPrefMaxRetry: Int = 0 + var mPrefMeteredDownloads: Boolean = false + var mPrefQueueLimit: Boolean = false + private var mSelfMissionsControl: Boolean = false + var mMainStorageAudio: StoredDirectoryHelper? + var mMainStorageVideo: StoredDirectoryHelper? + + /** + * Create a new instance + * + * @param context Context for the data source for finished downloads + * @param handler Thread required for Messaging + */ + init { + if (DEBUG) { + Log.d(TAG, "new DownloadManager instance. 0x" + Integer.toHexString(this.hashCode())) + } + mFinishedMissionStore = FinishedMissionStore(context) + mHandler = handler + mMainStorageAudio = storageAudio + mMainStorageVideo = storageVideo + mMissionsFinished = loadFinishedMissions() + mPendingMissionsDir = getPendingDir(context) + loadPendingMissions(context) + } + + /** + * Loads finished missions from the data source and forgets finished missions whose file does + * not exist anymore. + */ + private fun loadFinishedMissions(): ArrayList { + val finishedMissions: ArrayList = mFinishedMissionStore.loadFinishedMissions() + + // check if the files exists, otherwise, forget the download + for (i in finishedMissions.indices.reversed()) { + val mission: FinishedMission = finishedMissions.get(i) + if (!mission.storage.existsAsFile()) { + if (DEBUG) Log.d(TAG, "downloaded file removed: " + mission.storage.getName()) + mFinishedMissionStore.deleteMission(mission) + finishedMissions.removeAt(i) + } + } + return finishedMissions + } + + private fun loadPendingMissions(ctx: Context) { + val subs: Array? = mPendingMissionsDir!!.listFiles() + if (subs == null) { + Log.e(TAG, "listFiles() returned null") + return + } + if (subs.size < 1) { + return + } + if (DEBUG) { + Log.d(TAG, "Loading pending downloads from directory: " + mPendingMissionsDir.getAbsolutePath()) + } + val tempDir: File? = pickAvailableTemporalDir(ctx) + Log.i(TAG, "using '" + tempDir + "' as temporal directory") + for (sub: File in subs) { + if (!sub.isFile()) continue + if ((sub.getName() == ".tmp")) continue + val mis: DownloadMission? = Utility.readFromFile(sub) + if ((mis == null) || mis.isFinished() || mis.hasInvalidStorage()) { + sub.delete() + continue + } + mis.threads = arrayOfNulls(0) + var exists: Boolean + try { + mis.storage = StoredFileHelper.Companion.deserialize(mis.storage, ctx) + exists = !mis.storage.isInvalid() && mis.storage.existsAsFile() + } catch (ex: Exception) { + Log.e(TAG, "Failed to load the file source of " + mis.storage.toString(), ex) + mis.storage.invalidate() + exists = false + } + if (mis.isPsRunning()) { + if (mis.psAlgorithm.worksOnSameFile) { + // Incomplete post-processing results in a corrupted download file + // because the selected algorithm works on the same file to save space. + // the file will be deleted if the storage API + // is Java IO (avoid showing the "Save as..." dialog) + 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.Companion.ERROR_POSTPROCESSING_STOPPED + } else if (!exists) { + tryRecover(mis) + + // the progress is lost, reset mission state + if (mis.isInitialized()) mis.resetState(true, true, DownloadMission.Companion.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(ToLongFunction({ obj: DownloadMission? -> obj.getTimestamp() }))) + } + + /** + * Start a new download mission + * + * @param mission the new download mission to add and run (if possible) + */ + fun startMission(mission: DownloadMission) { + synchronized(this, { + mission.timestamp = System.currentTimeMillis() + mission.mHandler = mHandler + mission.maxRetry = mPrefMaxRetry + + // create metadata file + while (true) { + mission.metadata = File(mPendingMissionsDir, mission.timestamp.toString()) + if (!mission.metadata.isFile() && !mission.metadata.exists()) { + try { + if (!mission.metadata.createNewFile()) throw RuntimeException("Cant create download metadata file") + } catch (e: IOException) { + throw 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.Companion.ERROR_FILE_CREATION + if (mission.errObject != null) mission.errObject = IOException("DownloadMission.storage == NULL") + return + } + val start: Boolean = !mPrefQueueLimit || getRunningMissionsCount() < 1 + if (canDownloadInCurrentNetwork() && start) { + mission.start() + } + }) + } + + fun resumeMission(mission: DownloadMission) { + if (!mission.running) { + mission.start() + } + } + + fun pauseMission(mission: DownloadMission) { + if (mission.running) { + mission.setEnqueued(false) + mission.pause() + } + } + + fun deleteMission(mission: Mission) { + synchronized(this, { + if (mission is DownloadMission) { + mMissionsPending.remove(mission) + } else if (mission is FinishedMission) { + mMissionsFinished.remove(mission) + mFinishedMissionStore.deleteMission(mission) + } + mission.delete() + }) + } + + fun forgetMission(storage: StoredFileHelper) { + synchronized(this, { + val mission: Mission? = getAnyMission(storage) + if (mission == null) return + if (mission is DownloadMission) { + mMissionsPending.remove(mission) + } else if (mission is FinishedMission) { + mMissionsFinished.remove(mission) + mFinishedMissionStore.deleteMission(mission) + } + mission.storage = null + mission.delete() + }) + } + + fun tryRecover(mission: DownloadMission) { + val mainStorage: StoredDirectoryHelper? = 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 + val newStorage: StoredFileHelper? = 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 + */ + private fun getPendingMission(storage: StoredFileHelper): DownloadMission? { + for (mission: DownloadMission in mMissionsPending) { + if (mission.storage.equals(storage)) { + return mission + } + } + return null + } + + /** + * Get the index into [.mMissionsFinished] of a finished mission by its path, return + * `-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 [ ][.loadFinishedMissions]) and `-1` is returned. + * + * @param storage where the file would be stored + * @return the mission index or -1 if no such mission exists + */ + private fun getFinishedMissionIndex(storage: StoredFileHelper): Int { + for (i in mMissionsFinished.indices) { + 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() == 0L) { + if (DEBUG) { + Log.d(TAG, "matched downloaded file removed: " + storage.getName()) + } + mFinishedMissionStore.deleteMission(mMissionsFinished.get(i)) + mMissionsFinished.removeAt(i) + return -1 // finished mission whose associated file was removed + } + return i + } + } + return -1 + } + + private fun getAnyMission(storage: StoredFileHelper): Mission? { + synchronized(this, { + val mission: Mission? = getPendingMission(storage) + if (mission != null) return mission + val idx: Int = getFinishedMissionIndex(storage) + if (idx >= 0) return mMissionsFinished.get(idx) + }) + return null + } + + fun getRunningMissionsCount(): Int { + var count: Int = 0 + synchronized(this, { + for (mission: DownloadMission in mMissionsPending) { + if (mission.running && !mission.isPsFailed() && !mission.isFinished()) count++ + } + }) + return count + } + + fun pauseAllMissions(force: Boolean) { + synchronized(this, { + for (mission: DownloadMission in mMissionsPending) { + if (!mission.running || mission.isPsRunning() || mission.isFinished()) continue + if (force) { + // avoid waiting for threads + mission.init = null + mission.threads = arrayOfNulls(0) + } + mission.pause() + } + }) + } + + fun startAllMissions() { + synchronized(this, { + for (mission: DownloadMission in mMissionsPending) { + if (mission.running || mission.isCorrupt()) continue + mission.start() + } + }) + } + + /** + * Set a pending download as finished + * + * @param mission the desired mission + */ + fun setFinished(mission: DownloadMission?) { + synchronized(this, { + mMissionsPending.remove(mission) + mMissionsFinished.add(0, 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 + */ + fun runMissions(): Boolean { + synchronized(this, { + if (mMissionsPending.size < 1) return false + if (!canDownloadInCurrentNetwork()) return false + if (mPrefQueueLimit) { + for (mission: DownloadMission in mMissionsPending) if (!mission.isFinished() && mission.running) return true + } + var flag: Boolean = false + for (mission: DownloadMission in mMissionsPending) { + if (mission.running || !mission.enqueued || mission.isFinished()) continue + resumeMission(mission) + if (mission.errCode != DownloadMission.Companion.ERROR_NOTHING) continue + if (mPrefQueueLimit) return true + flag = true + } + return flag + }) + } + + fun getIterator(): MissionIterator { + mSelfMissionsControl = true + return MissionIterator() + } + + /** + * Forget all finished downloads, but, doesn't delete any file + */ + fun forgetFinishedDownloads() { + synchronized(this, { + for (mission: FinishedMission? in mMissionsFinished) { + mFinishedMissionStore.deleteMission(mission) + } + mMissionsFinished.clear() + }) + } + + private fun canDownloadInCurrentNetwork(): Boolean { + if (mLastNetworkStatus == NetworkState.Unavailable) return false + return !(mPrefMeteredDownloads && mLastNetworkStatus == NetworkState.MeteredOperating) + } + + fun handleConnectivityState(currentStatus: NetworkState, updateOnly: Boolean) { + if (currentStatus == mLastNetworkStatus) return + mLastNetworkStatus = currentStatus + if (currentStatus == NetworkState.Unavailable) return + if (!mSelfMissionsControl || updateOnly) { + return // don't touch anything without the user interaction + } + val isMetered: Boolean = mPrefMeteredDownloads && mLastNetworkStatus == NetworkState.MeteredOperating + synchronized(this, { + for (mission: DownloadMission in 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 + } + } + }) + } + + fun updateMaximumAttempts() { + synchronized(this, { for (mission: DownloadMission in mMissionsPending) mission.maxRetry = mPrefMaxRetry }) + } + + fun checkForExistingMission(storage: StoredFileHelper): MissionState { + synchronized(this, { + val pending: DownloadMission? = 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 if (pending.running) MissionState.PendingRunning else MissionState.Pending + } + } + }) + return MissionState.None + } + + private fun getMainStorage(tag: String): StoredDirectoryHelper? { + if ((tag == TAG_AUDIO)) return mMainStorageAudio + if ((tag == TAG_VIDEO)) return mMainStorageVideo + Log.w(TAG, "Unknown download category, not [audio video]: " + tag) + return null // this never should happen + } + + inner class MissionIterator() : DiffUtil.Callback() { + val FINISHED: Any = Any() + val PENDING: Any = Any() + var snapshot: ArrayList? + var current: ArrayList? = null + var hidden: ArrayList + var hasFinished: Boolean = false + + init { + hidden = ArrayList(2) + snapshot = getSpecialItems() + } + + private fun getSpecialItems(): ArrayList { + synchronized(this@DownloadManager, { + val pending: ArrayList = ArrayList(mMissionsPending) + val finished: ArrayList = ArrayList(mMissionsFinished) + val remove: MutableList = ArrayList(hidden) + + // hide missions (if required) + remove.removeIf(Predicate({ mission: Mission -> pending.remove(mission) || finished.remove(mission) })) + var fakeTotal: Int = pending.size + if (fakeTotal > 0) fakeTotal++ + fakeTotal += finished.size + if (finished.size > 0) fakeTotal++ + val list: ArrayList = 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 + }) + } + + fun getItem(position: Int): MissionItem { + val `object`: Any = snapshot!!.get(position) + if (`object` === PENDING) return MissionItem(SPECIAL_PENDING) + if (`object` === FINISHED) return MissionItem(SPECIAL_FINISHED) + return MissionItem(SPECIAL_NOTHING, `object` as Mission?) + } + + fun getSpecialAtItem(position: Int): Int { + val `object`: Any = snapshot!!.get(position) + if (`object` === PENDING) return SPECIAL_PENDING + if (`object` === FINISHED) return SPECIAL_FINISHED + return SPECIAL_NOTHING + } + + fun start() { + current = getSpecialItems() + } + + fun end() { + snapshot = current + current = null + } + + fun hide(mission: Mission) { + hidden.add(mission) + } + + fun unHide(mission: Mission) { + hidden.remove(mission) + } + + fun hasFinishedMissions(): Boolean { + 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 + */ + fun hasValidPendingMissions(): BooleanArray { + var running: Boolean = false + var paused: Boolean = false + synchronized(this@DownloadManager, { + for (mission: DownloadMission in mMissionsPending) { + if (hidden.contains(mission) || mission.isCorrupt()) continue + if (mission.running) running = true else paused = true + } + }) + return booleanArrayOf(running, paused) + } + + public override fun getOldListSize(): Int { + return snapshot!!.size + } + + public override fun getNewListSize(): Int { + return current!!.size + } + + public override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + return snapshot!!.get(oldItemPosition) === current!!.get(newItemPosition) + } + + public override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean { + val x: Any = snapshot!!.get(oldItemPosition) + val y: Any = current!!.get(newItemPosition) + if (x is Mission && y is Mission) { + return (x as Mission).storage.equals((y as Mission).storage) + } + return false + } + } + + class MissionItem @JvmOverloads internal constructor(var special: Int, m: Mission? = null) { + var mission: Mission? + + init { + mission = m + } + } + + companion object { + private val TAG: String = DownloadManager::class.java.getSimpleName() + val SPECIAL_NOTHING: Int = 0 + val SPECIAL_PENDING: Int = 1 + val SPECIAL_FINISHED: Int = 2 + val TAG_AUDIO: String = "audio" + val TAG_VIDEO: String = "video" + private val DOWNLOADS_METADATA_FOLDER: String = "pending_downloads" + private fun getPendingDir(context: Context): File? { + var dir: File? = context.getExternalFilesDir(DOWNLOADS_METADATA_FOLDER) + if (testDir(dir)) return dir + dir = File(context.getFilesDir(), DOWNLOADS_METADATA_FOLDER) + if (testDir(dir)) return dir + throw RuntimeException("path to pending downloads are not accessible") + } + + private fun testDir(dir: File?): Boolean { + 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 + } + val tmp: File = File(dir, ".tmp") + if (!tmp.createNewFile()) return false + return tmp.delete() // if the file was created, SHOULD BE deleted too + } catch (e: Exception) { + Log.e(TAG, "testDir() failed: " + dir.getAbsolutePath(), e) + return false + } + } + + private fun isDirectoryAvailable(directory: File?): Boolean { + return (directory != null) && directory.canWrite() && directory.exists() + } + + fun pickAvailableTemporalDir(ctx: Context): File? { + var dir: File? = 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 RuntimeException("Not temporal directories are available") + } + } +} diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java deleted file mode 100755 index 45211211f..000000000 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ /dev/null @@ -1,588 +0,0 @@ -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.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_SOURCE = "DownloadManagerService.extra.source"; - 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 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 source source url of the resource - * @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, String source, 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_SOURCE, source) - .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()); - - 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); - String source = intent.getStringExtra(EXTRA_SOURCE); - long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0); - String tag = intent.getStringExtra(EXTRA_STORAGE_TAG); - 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); - - final DownloadMission mission = new DownloadMission(urls, storage, kind, ps); - mission.threadCount = threads; - mission.source = source; - 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; - } - - } - -} diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.kt b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.kt new file mode 100755 index 000000000..135f52154 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.kt @@ -0,0 +1,455 @@ +package us.shandian.giga.service + +import android.R +import android.app.Notification +import android.app.Service +import android.content.Context +import android.graphics.Bitmap +import android.net.Network +import android.net.Uri +import android.os.Binder +import android.os.Handler +import android.os.Message +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.preference.PreferenceManager +import org.schabi.newpipe.BuildConfig.APPLICATION_ID +import org.schabi.newpipe.util.Localization +import java.io.File +import java.io.IOException +import java.util.Objects + +class DownloadManagerService() : Service() { + private var mBinder: DownloadManagerBinder? = null + private var mManager: DownloadManager? = null + private var mNotification: Notification? = null + private var mHandler: Handler? = null + private var mForeground: Boolean = false + private var mNotificationManager: NotificationManager? = null + private var mDownloadNotificationEnable: Boolean = true + private var downloadDoneCount: Int = 0 + private var downloadDoneNotification: NotificationCompat.Builder? = null + private var downloadDoneList: StringBuilder? = null + private val mEchoObservers: MutableList = ArrayList(1) + private var mConnectivityManager: ConnectivityManager? = null + private var mNetworkStateListenerL: ConnectivityManager.NetworkCallback? = null + private var mPrefs: SharedPreferences? = null + private val mPrefChangeListener: OnSharedPreferenceChangeListener = OnSharedPreferenceChangeListener({ prefs: SharedPreferences, key: String -> handlePreferenceChange(prefs, key) }) + private var mLockAcquired: Boolean = false + private var mLock: LockManager? = null + private var downloadFailedNotificationID: Int = DOWNLOADS_NOTIFICATION_ID + 1 + private var downloadFailedNotification: NotificationCompat.Builder? = null + private val mFailedDownloads: SparseArrayCompat = SparseArrayCompat(5) + private var icLauncher: Bitmap? = null + private var icDownloadDone: Bitmap? = null + private var icDownloadFailed: Bitmap? = null + private var mOpenDownloadList: PendingIntent? = null + + /** + * notify media scanner on downloaded media file ... + * + * @param file the downloaded file uri + */ + private fun notifyMediaScanner(file: Uri) { + sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, file)) + } + + public override fun onCreate() { + super.onCreate() + if (DEBUG) { + Log.d(TAG, "onCreate") + } + mBinder = DownloadManagerBinder() + mHandler = Handler(Handler.Callback({ msg: Message -> handleMessage(msg) })) + mPrefs = PreferenceManager.getDefaultSharedPreferences(this) + mManager = DownloadManager(this, mHandler!!, loadMainVideoStorage(), loadMainAudioStorage()) + val openDownloadListIntent: Intent = Intent(this, DownloadActivity::class.java) + .setAction(Intent.ACTION_MAIN) + mOpenDownloadList = PendingIntentCompat.getActivity(this, 0, + openDownloadListIntent, + PendingIntent.FLAG_UPDATE_CURRENT, false) + icLauncher = BitmapFactory.decodeResource(getResources(), R.mipmap.ic_launcher) + val builder: NotificationCompat.Builder = NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) + .setContentIntent(mOpenDownloadList) + .setSmallIcon(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.java) + mConnectivityManager = ContextCompat.getSystemService(this, + ConnectivityManager::class.java) + mNetworkStateListenerL = object : ConnectivityManager.NetworkCallback() { + public override fun onAvailable(network: Network) { + handleConnectivityState(false) + } + + public override fun onLost(network: Network) { + handleConnectivityState(false) + } + } + mConnectivityManager.registerNetworkCallback(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 = LockManager(this) + } + + public override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { + if (DEBUG) { + Log.d(TAG, if (intent == null) "Restarting" else "Starting") + } + if (intent == null) return START_NOT_STICKY + Log.i(TAG, "Got intent: " + intent) + val action: String? = intent.getAction() + if (action != null) { + if ((action == Intent.ACTION_RUN)) { + mHandler!!.post(Runnable({ startMission(intent) })) + } else if (downloadDoneNotification != null) { + if ((action == ACTION_RESET_DOWNLOAD_FINISHED) || (action == ACTION_OPEN_DOWNLOADS_FINISHED)) { + downloadDoneCount = 0 + downloadDoneList!!.setLength(0) + } + if ((action == ACTION_OPEN_DOWNLOADS_FINISHED)) { + startActivity(Intent(this, DownloadActivity::class.java) + .setAction(Intent.ACTION_MAIN) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + return START_NOT_STICKY + } + } + return START_STICKY + } + + public override fun 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) + } + + public override fun onBind(intent: Intent): IBinder? { + return mBinder + } + + private fun handleMessage(msg: Message): Boolean { + if (mHandler == null) return true + val mission: DownloadMission = msg.obj as DownloadMission + when (msg.what) { + MESSAGE_FINISHED -> { + notifyMediaScanner(mission.storage.getUri()) + notifyFinishedDownload(mission.storage.getName()) + mManager!!.setFinished(mission) + handleConnectivityState(false) + updateForegroundState(mManager!!.runMissions()) + } + + MESSAGE_RUNNING -> updateForegroundState(true) + MESSAGE_ERROR -> { + notifyFailedDownload(mission) + handleConnectivityState(false) + updateForegroundState(mManager!!.runMissions()) + } + + MESSAGE_PAUSED -> updateForegroundState(mManager!!.getRunningMissionsCount() > 0) + } + if (msg.what != MESSAGE_ERROR) mFailedDownloads.remove(mFailedDownloads.indexOfValue(mission)) + for (observer: Handler.Callback in mEchoObservers) observer.handleMessage(msg) + return true + } + + private fun handleConnectivityState(updateOnly: Boolean) { + val info: NetworkInfo? = mConnectivityManager.getActiveNetworkInfo() + val status: DownloadManager.NetworkState + if (info == null) { + status = DownloadManager.NetworkState.Unavailable + Log.i(TAG, "Active network [connectivity is unavailable]") + } else { + val connected: Boolean = info.isConnected() + val metered: Boolean = mConnectivityManager.isActiveNetworkMetered() + if (connected) status = if (metered) DownloadManager.NetworkState.MeteredOperating else DownloadManager.NetworkState.Operating else status = DownloadManager.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 fun handlePreferenceChange(prefs: SharedPreferences, key: String) { + if ((getString(R.string.downloads_maximum_retry) == key)) { + try { + val value: String? = prefs.getString(key, getString(R.string.downloads_maximum_retry_default)) + mManager!!.mPrefMaxRetry = if (value == null) 0 else value.toInt() + } catch (e: Exception) { + mManager!!.mPrefMaxRetry = 0 + } + mManager!!.updateMaximumAttempts() + } else if ((getString(R.string.downloads_cross_network) == key)) { + mManager!!.mPrefMeteredDownloads = prefs.getBoolean(key, false) + } else if ((getString(R.string.downloads_queue_limit) == key)) { + mManager!!.mPrefQueueLimit = prefs.getBoolean(key, true) + } else if ((getString(R.string.download_path_video_key) == key)) { + mManager!!.mMainStorageVideo = loadMainVideoStorage() + } else if ((getString(R.string.download_path_audio_key) == key)) { + mManager!!.mMainStorageAudio = loadMainAudioStorage() + } + } + + fun updateForegroundState(state: Boolean) { + if (state == mForeground) return + if (state) { + startForeground(FOREGROUND_NOTIFICATION_ID, mNotification) + } else { + ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE) + } + manageLock(state) + mForeground = state + } + + private fun startMission(intent: Intent) { + val urls: Array = intent.getStringArrayExtra(EXTRA_URLS) + val path: Uri = IntentCompat.getParcelableExtra(intent, EXTRA_PATH, Uri::class.java) + val parentPath: Uri = IntentCompat.getParcelableExtra(intent, EXTRA_PARENT_PATH, Uri::class.java) + val threads: Int = intent.getIntExtra(EXTRA_THREADS, 1) + val kind: Char = intent.getCharExtra(EXTRA_KIND, '?') + val psName: String? = intent.getStringExtra(EXTRA_POSTPROCESSING_NAME) + val psArgs: Array = intent.getStringArrayExtra(EXTRA_POSTPROCESSING_ARGS) + val source: String = intent.getStringExtra(EXTRA_SOURCE) + val nearLength: Long = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0) + val tag: String = intent.getStringExtra(EXTRA_STORAGE_TAG) + val recovery: ArrayList = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_RECOVERY_INFO, + MissionRecoveryInfo::class.java) + Objects.requireNonNull>(recovery) + val storage: StoredFileHelper + try { + storage = StoredFileHelper(this, parentPath, path, tag) + } catch (e: IOException) { + throw RuntimeException(e) // this never should happen + } + val ps: Postprocessing? + if (psName == null) ps = null else ps = Postprocessing.Companion.getAlgorithm(psName, psArgs) + val mission: DownloadMission = DownloadMission(urls, storage, kind, ps) + mission.threadCount = threads + mission.source = source + mission.nearLength = nearLength + mission.recoveryInfo = recovery.toTypedArray() + if (ps != null) ps.setTemporalDir(DownloadManager.Companion.pickAvailableTemporalDir(this)) + handleConnectivityState(true) // first check the actual network status + mManager!!.startMission(mission) + } + + fun notifyFinishedDownload(name: String) { + if (!mDownloadNotificationEnable || mNotificationManager == null) { + return + } + if (downloadDoneNotification == null) { + downloadDoneList = StringBuilder(name.length) + icDownloadDone = BitmapFactory.decodeResource(getResources(), R.drawable.stat_sys_download_done) + downloadDoneNotification = NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) + .setAutoCancel(true) + .setLargeIcon(icDownloadDone) + .setSmallIcon(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(NotificationCompat.BigTextStyle() + .setBigContentTitle(Localization.downloadCount(this, downloadDoneCount)) + .bigText(name) + ) + } else { + downloadDoneList!!.append('\n') + downloadDoneList!!.append(name) + downloadDoneNotification!!.setStyle(NotificationCompat.BigTextStyle().bigText(downloadDoneList)) + downloadDoneNotification!!.setContentTitle(Localization.downloadCount(this, downloadDoneCount)) + downloadDoneNotification!!.setContentText(downloadDoneList) + } + mNotificationManager.notify(DOWNLOADS_NOTIFICATION_ID, downloadDoneNotification!!.build()) + } + + fun notifyFailedDownload(mission: DownloadMission) { + if (!mDownloadNotificationEnable || mFailedDownloads.containsValue(mission)) return + val id: Int = downloadFailedNotificationID++ + mFailedDownloads.put(id, mission) + if (downloadFailedNotification == null) { + icDownloadFailed = BitmapFactory.decodeResource(getResources(), R.drawable.stat_sys_warning) + downloadFailedNotification = NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) + .setAutoCancel(true) + .setLargeIcon(icDownloadFailed) + .setSmallIcon(R.drawable.stat_sys_warning) + .setContentIntent(mOpenDownloadList) + } + downloadFailedNotification!!.setContentTitle(getString(R.string.download_failed)) + downloadFailedNotification!!.setContentText(mission.storage.getName()) + downloadFailedNotification!!.setStyle(NotificationCompat.BigTextStyle() + .bigText(mission.storage.getName())) + mNotificationManager.notify(id, downloadFailedNotification!!.build()) + } + + private fun makePendingIntent(action: String): PendingIntent { + val intent: Intent = Intent(this, DownloadManagerService::class.java).setAction(action) + return PendingIntentCompat.getService(this, intent.hashCode(), intent, + PendingIntent.FLAG_UPDATE_CURRENT, false) + } + + private fun manageLock(acquire: Boolean) { + if (acquire == mLockAcquired) return + if (acquire) mLock.acquireWifiAndCpu() else mLock.releaseWifiAndCpu() + mLockAcquired = acquire + } + + private fun loadMainVideoStorage(): StoredDirectoryHelper? { + return loadMainStorage(R.string.download_path_video_key, DownloadManager.Companion.TAG_VIDEO) + } + + private fun loadMainAudioStorage(): StoredDirectoryHelper? { + return loadMainStorage(R.string.download_path_audio_key, DownloadManager.Companion.TAG_AUDIO) + } + + private fun loadMainStorage(@StringRes prefKey: Int, tag: String): StoredDirectoryHelper? { + var path: String? = mPrefs.getString(getString(prefKey), null) + if (path == null || path.isEmpty()) return null + if (path.get(0) == File.separatorChar) { + Log.i(TAG, "Old save path style present: " + path) + path = "" + mPrefs.edit().putString(getString(prefKey), "").apply() + } + try { + return StoredDirectoryHelper(this, Uri.parse(path), tag) + } catch (e: Exception) { + 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 + //////////////////////////////////////////////////////////////////////////////////////////////// + inner class DownloadManagerBinder() : Binder() { + fun getDownloadManager(): DownloadManager? { + return mManager + } + + fun getMainStorageVideo(): StoredDirectoryHelper? { + return mManager!!.mMainStorageVideo + } + + fun getMainStorageAudio(): StoredDirectoryHelper? { + return mManager!!.mMainStorageAudio + } + + fun askForSavePath(): Boolean { + return mPrefs.getBoolean( + this@DownloadManagerService.getString(R.string.downloads_storage_ask), + false + ) + } + + fun addMissionEventListener(handler: Handler.Callback) { + mEchoObservers.add(handler) + } + + fun removeMissionEventListener(handler: Handler.Callback) { + mEchoObservers.remove(handler) + } + + fun clearDownloadNotifications() { + if (mNotificationManager == null) return + if (downloadDoneNotification != null) { + mNotificationManager.cancel(DOWNLOADS_NOTIFICATION_ID) + downloadDoneList!!.setLength(0) + downloadDoneCount = 0 + } + if (downloadFailedNotification != null) { + while (downloadFailedNotificationID > DOWNLOADS_NOTIFICATION_ID) { + mNotificationManager.cancel(downloadFailedNotificationID) + downloadFailedNotificationID-- + } + mFailedDownloads.clear() + downloadFailedNotificationID++ + } + } + + fun enableNotifications(enable: Boolean) { + mDownloadNotificationEnable = enable + } + } + + companion object { + private val TAG: String = "DownloadManagerService" + val MESSAGE_RUNNING: Int = 0 + val MESSAGE_PAUSED: Int = 1 + val MESSAGE_FINISHED: Int = 2 + val MESSAGE_ERROR: Int = 3 + val MESSAGE_DELETED: Int = 4 + private val FOREGROUND_NOTIFICATION_ID: Int = 1000 + private val DOWNLOADS_NOTIFICATION_ID: Int = 1001 + private val EXTRA_URLS: String = "DownloadManagerService.extra.urls" + private val EXTRA_KIND: String = "DownloadManagerService.extra.kind" + private val EXTRA_THREADS: String = "DownloadManagerService.extra.threads" + private val EXTRA_POSTPROCESSING_NAME: String = "DownloadManagerService.extra.postprocessingName" + private val EXTRA_POSTPROCESSING_ARGS: String = "DownloadManagerService.extra.postprocessingArgs" + private val EXTRA_SOURCE: String = "DownloadManagerService.extra.source" + private val EXTRA_NEAR_LENGTH: String = "DownloadManagerService.extra.nearLength" + private val EXTRA_PATH: String = "DownloadManagerService.extra.storagePath" + private val EXTRA_PARENT_PATH: String = "DownloadManagerService.extra.storageParentPath" + private val EXTRA_STORAGE_TAG: String = "DownloadManagerService.extra.storageTag" + private val EXTRA_RECOVERY_INFO: String = "DownloadManagerService.extra.recoveryInfo" + private val ACTION_RESET_DOWNLOAD_FINISHED: String = APPLICATION_ID + ".reset_download_finished" + private val ACTION_OPEN_DOWNLOADS_FINISHED: String = APPLICATION_ID + ".open_downloads_finished" + + /** + * 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 `null` to ignore. + * @param source source url of the resource + * @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 + */ + fun startMission(context: Context?, urls: Array?, storage: StoredFileHelper, + kind: Char, threads: Int, source: String?, psName: String?, + psArgs: Array?, nearLength: Long, + recoveryInfo: ArrayList?) { + val intent: Intent = Intent(context, DownloadManagerService::class.java) + .setAction(Intent.ACTION_RUN) + .putExtra(EXTRA_URLS, urls) + .putExtra(EXTRA_KIND, kind) + .putExtra(EXTRA_THREADS, threads) + .putExtra(EXTRA_SOURCE, source) + .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()) + context!!.startService(intent) + } + } +} diff --git a/app/src/main/java/us/shandian/giga/service/MissionState.java b/app/src/main/java/us/shandian/giga/service/MissionState.java deleted file mode 100644 index 2d7802ff5..000000000 --- a/app/src/main/java/us/shandian/giga/service/MissionState.java +++ /dev/null @@ -1,5 +0,0 @@ -package us.shandian.giga.service; - -public enum MissionState { - None, Pending, PendingRunning, Finished -} diff --git a/app/src/main/java/us/shandian/giga/service/MissionState.kt b/app/src/main/java/us/shandian/giga/service/MissionState.kt new file mode 100644 index 000000000..de0f3455a --- /dev/null +++ b/app/src/main/java/us/shandian/giga/service/MissionState.kt @@ -0,0 +1,8 @@ +package us.shandian.giga.service + +enum class MissionState { + None, + Pending, + PendingRunning, + Finished +} diff --git a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java deleted file mode 100644 index 31e7f663d..000000000 --- a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java +++ /dev/null @@ -1,977 +0,0 @@ -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 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 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); - } else { - h.progress.setMarquee(false); - h.status.setText("100%"); - h.progress.setProgress(1.0f); - h.size.setText(Utility.formatBytes(item.mission.length)); - } - } - - @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 intent = new Intent(Intent.ACTION_VIEW); - intent.setDataAndType(resolveShareableUri(mission), mimeType); - intent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); - intent.addFlags(FLAG_GRANT_PREFIX_URI_PERMISSION); - ShareUtils.openIntentInApp(mContext, intent); - } - - 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 = new Intent(Intent.ACTION_CHOOSER); - intent.putExtra(Intent.EXTRA_INTENT, shareIntent); - // 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("]"); - - String service; - try { - service = NewPipe.getServiceByUrl(mission.source).getServiceInfo().getName(); - } catch (Exception e) { - service = ErrorInfo.SERVICE_NONE; - } - - ErrorUtil.createNotification(mContext, - new ErrorInfo(ErrorInfo.Companion.throwableToStringList(mission.errObject), action, - service, request.toString(), 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); - 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) { - switch (id) { - case R.id.start: - h.status.setText(UNDEFINED_PROGRESS); - mDownloadManager.resumeMission(mission); - return true; - case R.id.pause: - mDownloadManager.pauseMission(mission); - return true; - case R.id.error_message_view: - showError(mission); - return true; - case R.id.queue: - boolean flag = !h.queue.isChecked(); - h.queue.setChecked(flag); - mission.setEnqueued(flag); - updateProgress(h); - return true; - case 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; - case R.id.cancel: - mission.psContinue(false); - return false; - } - } - - switch (id) { - case R.id.menu_item_share: - shareFile(h.item.mission); - return true; - case R.id.delete: - mDeleter.append(h.item.mission); - applyChanges(); - checkMasterButtonsVisibility(); - return true; - case R.id.md5: - case R.id.sha1: - 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()); - final StoredFileHelper storage = h.item.mission.storage; - 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; - case R.id.source: - /*Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(h.item.mission.source)); - mContext.startActivity(intent);*/ - 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; - default: - 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; - 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); - - 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); - } -} diff --git a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.kt b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.kt new file mode 100644 index 000000000..b58e51a13 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.kt @@ -0,0 +1,773 @@ +package us.shandian.giga.ui.adapter + +import android.content.Context +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.Menu +import android.view.MenuItem +import android.view.View +import android.widget.ImageView +import android.widget.PopupMenu +import androidx.appcompat.app.AlertDialog +import androidx.core.app.NotificationCompat +import io.reactivex.rxjava3.core.Observable +import io.reactivex.rxjava3.functions.Consumer +import io.reactivex.rxjava3.schedulers.Schedulers +import org.schabi.newpipe.BuildConfig +import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.util.Localization +import us.shandian.giga.service.DownloadManager +import us.shandian.giga.util.Utility +import java.io.File +import java.net.URI +import java.util.Arrays +import java.util.concurrent.Callable +import kotlin.math.ceil + +class MissionAdapter(context: Context?, downloadManager: DownloadManager, emptyMessage: View?, root: View?) : RecyclerView.Adapter(), Handler.Callback { + private val mContext: Context + private val mInflater: LayoutInflater + private val mDownloadManager: DownloadManager + private val mDeleter: Deleter + private var mLayout: Int + private val mIterator: MissionIterator? + private val mPendingDownloadsItems: ArrayList = ArrayList() + private val mHandler: Handler + private var mClear: MenuItem? = null + private var mStartButton: MenuItem? = null + private var mPauseButton: MenuItem? = null + private val mEmptyMessage: View? + private var mRecover: RecoverHelper? = null + private val mView: View? + private val mHidden: ArrayList + private var mSnackbar: Snackbar? = null + private val compositeDisposable: CompositeDisposable = CompositeDisposable() + + init { + mContext = (context)!! + mDownloadManager = downloadManager + mInflater = LayoutInflater.from(mContext) + mLayout = R.layout.mission_item + mHandler = Handler(context!!.getMainLooper()) + mEmptyMessage = emptyMessage + mIterator = downloadManager.getIterator() + mDeleter = Deleter(root, mContext, this, mDownloadManager, mIterator, mHandler) + mView = root + mHidden = ArrayList() + checkEmptyMessageVisibility() + onResume() + } + + public override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + when (viewType) { + DownloadManager.Companion.SPECIAL_PENDING, DownloadManager.Companion.SPECIAL_FINISHED -> return ViewHolderHeader(mInflater.inflate(R.layout.missions_header, parent, false)) + } + return ViewHolderItem(mInflater.inflate(mLayout, parent, false)) + } + + public override fun onViewRecycled(view: RecyclerView.ViewHolder) { + super.onViewRecycled(view) + if (view is ViewHolderHeader) return + val h: ViewHolderItem = view + if (h.item.mission is DownloadMission) { + mPendingDownloadsItems.remove(h) + if (mPendingDownloadsItems.size < 1) { + checkMasterButtonsVisibility() + } + } + h.popupMenu.dismiss() + h.item = null + h.resetSpeedMeasure() + } + + @SuppressLint("SetTextI18n") + public override fun onBindViewHolder(view: RecyclerView.ViewHolder, @SuppressLint("RecyclerView") pos: Int) { + val item: MissionItem = mIterator.getItem(pos) + if (view is ViewHolderHeader) { + if (item.special == DownloadManager.Companion.SPECIAL_NOTHING) return + val str: Int + if (item.special == DownloadManager.Companion.SPECIAL_PENDING) { + str = R.string.missions_header_pending + } else { + str = R.string.missions_header_finished + if (mClear != null) mClear!!.setVisible(true) + } + (view as ViewHolderHeader).header.setText(str) + return + } + val h: ViewHolderItem = view + h.item = item + val type: Utility.FileType? = 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 is DownloadMission) { + val mission: DownloadMission = item.mission as DownloadMission + var length: String? = Utility.formatBytes(mission.getLength()) + if (mission.running && !mission.isPsRunning()) length += " --.- kB/s" + h.size.setText(length) + h.pause.setTitle(if (mission.unknownLength) R.string.stop else R.string.pause) + updateProgress(h) + mPendingDownloadsItems.add(h) + } else { + h.progress.setMarquee(false) + h.status.setText("100%") + h.progress.setProgress(1.0) + h.size.setText(Utility.formatBytes(item.mission.length)) + } + } + + public override fun getItemCount(): Int { + return mIterator.getOldListSize() + } + + public override fun getItemViewType(position: Int): Int { + return mIterator.getSpecialAtItem(position) + } + + @SuppressLint("DefaultLocale") + private fun updateProgress(h: ViewHolderItem?) { + if ((h == null) || (h.item == null) || h.item.mission is FinishedMission) return + val mission: DownloadMission = h.item.mission as DownloadMission + val done: Double = mission.done.toDouble() + val length: Long = mission.getLength() + val now: Long = System.currentTimeMillis() + val hasError: Boolean = mission.errCode != DownloadMission.Companion.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)) + val progress: Double + if (mission.unknownLength) { + progress = Double.NaN + h.progress.setProgress(0.0) + } else { + progress = done / length + } + if (hasError) { + h.progress.setProgress(if (isNotFinite(progress)) 1.0 else 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 val state: Int + val sizeStr: String = Utility.formatBytes(length) + " " + if (mission.isPsFailed() || mission.errCode == DownloadMission.Companion.ERROR_POSTPROCESSING_HOLD) { + h.size.setText(sizeStr) + return + } else if (!mission.running) { + state = if (mission.enqueued) R.string.queued else 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 + "(" + mContext.getString(state) + ")") + h.resetSpeedMeasure() + return + } + if (h.lastTimestamp < 0) { + h.size.setText(sizeStr) + h.lastTimestamp = now + h.lastDone = done + return + } + val deltaTime: Long = now - h.lastTimestamp + val deltaDone: Double = done - h.lastDone + if (h.lastDone > done) { + h.lastDone = done + h.size.setText(sizeStr) + return + } + if (deltaDone > 0 && deltaTime > 0) { + val speed: Float = ((deltaDone * 1000.0) / deltaTime).toFloat() + var averageSpeed: Float = speed + if (h.lastSpeedIdx < 0) { + Arrays.fill(h.lastSpeed, speed) + h.lastSpeedIdx = 0 + } else { + for (i in h.lastSpeed.indices) { + averageSpeed += h.lastSpeed.get(i) + } + averageSpeed /= h.lastSpeed.size + 1.0f + } + val speedStr: String? = Utility.formatSpeed(averageSpeed.toDouble()) + val etaStr: String + if (mission.unknownLength) { + etaStr = "" + } else { + val eta: Long = ceil((length - done) / averageSpeed).toLong() + etaStr = Utility.formatBytes(done.toLong()) + "/" + Utility.stringifySeconds(eta) + " " + } + h.size.setText(sizeStr + etaStr + speedStr) + h.lastTimestamp = now + h.lastDone = done + h.lastSpeed.get(h.lastSpeedIdx++) = speed + if (h.lastSpeedIdx >= h.lastSpeed.size) h.lastSpeedIdx = 0 + } + } + + private fun viewWithFileProvider(mission: Mission) { + if (checkInvalidFile(mission)) return + val mimeType: String? = resolveMimeType(mission) + if (BuildConfig.DEBUG) Log.v(TAG, ("Mime: " + mimeType + " package: " + BuildConfig.APPLICATION_ID).toString() + ".provider") + val intent: Intent = Intent(Intent.ACTION_VIEW) + intent.setDataAndType(resolveShareableUri(mission), mimeType) + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + intent.addFlags(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION) + ShareUtils.openIntentInApp(mContext, intent) + } + + private fun shareFile(mission: Mission) { + if (checkInvalidFile(mission)) return + val shareIntent: Intent = Intent(Intent.ACTION_SEND) + shareIntent.setType(resolveMimeType(mission)) + shareIntent.putExtra(Intent.EXTRA_STREAM, resolveShareableUri(mission)) + shareIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + val intent: Intent = Intent(Intent.ACTION_CHOOSER) + intent.putExtra(Intent.EXTRA_INTENT, shareIntent) + // 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(Intent.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](https://stackoverflow.com/questions/38200282/android-os-fileuriexposedexception-file-storage-emulated-0-test-txt-exposed) + */ + private fun resolveShareableUri(mission: Mission): Uri { + if (mission.storage.isDirect()) { + return FileProvider.getUriForFile( + mContext, + BuildConfig.APPLICATION_ID + ".provider", + File(URI.create(mission.storage.getUri().toString())) + ) + } else { + return mission.storage.getUri() + } + } + + private fun checkInvalidFile(mission: Mission): Boolean { + if (mission.storage.existsAsFile()) return false + Toast.makeText(mContext, R.string.missing_file, Toast.LENGTH_SHORT).show() + return true + } + + private fun getViewHolder(mission: Any): ViewHolderItem? { + for (h: ViewHolderItem in mPendingDownloadsItems) { + if (h.item.mission === mission) return h + } + return null + } + + public override fun handleMessage(msg: Message): Boolean { + if (mStartButton != null && mPauseButton != null) { + checkMasterButtonsVisibility() + } + when (msg.what) { + DownloadManagerService.Companion.MESSAGE_ERROR, DownloadManagerService.Companion.MESSAGE_FINISHED, DownloadManagerService.Companion.MESSAGE_DELETED, DownloadManagerService.Companion.MESSAGE_PAUSED -> {} + else -> return false + } + val h: ViewHolderItem? = getViewHolder(msg.obj) + if (h == null) return false + when (msg.what) { + DownloadManagerService.Companion.MESSAGE_FINISHED, DownloadManagerService.Companion.MESSAGE_DELETED -> { + // DownloadManager should mark the download as finished + applyChanges() + return true + } + } + updateProgress(h) + return true + } + + private fun showError(mission: DownloadMission) { + @StringRes var msg: Int = R.string.general_error + var msgEx: String? = null + when (mission.errCode) { + 416 -> msg = R.string.error_http_unsupported_range + 404 -> msg = R.string.error_http_not_found + DownloadMission.Companion.ERROR_NOTHING -> return // this never should happen + DownloadMission.Companion.ERROR_FILE_CREATION -> msg = R.string.error_file_creation + DownloadMission.Companion.ERROR_HTTP_NO_CONTENT -> msg = R.string.error_http_no_content + DownloadMission.Companion.ERROR_PATH_CREATION -> msg = R.string.error_path_creation + DownloadMission.Companion.ERROR_PERMISSION_DENIED -> msg = R.string.permission_denied + DownloadMission.Companion.ERROR_SSL_EXCEPTION -> msg = R.string.error_ssl_exception + DownloadMission.Companion.ERROR_UNKNOWN_HOST -> msg = R.string.error_unknown_host + DownloadMission.Companion.ERROR_CONNECT_HOST -> msg = R.string.error_connect_host + DownloadMission.Companion.ERROR_POSTPROCESSING_STOPPED -> msg = R.string.error_postprocessing_stopped + DownloadMission.Companion.ERROR_POSTPROCESSING, DownloadMission.Companion.ERROR_POSTPROCESSING_HOLD -> { + showError(mission, UserAction.DOWNLOAD_POSTPROCESSING, R.string.error_postprocessing_failed) + return + } + + DownloadMission.Companion.ERROR_INSUFFICIENT_STORAGE -> msg = R.string.error_insufficient_storage_left + DownloadMission.Companion.ERROR_UNKNOWN_EXCEPTION -> if (mission.errObject != null) { + showError(mission, UserAction.DOWNLOAD_FAILED, R.string.general_error) + return + } else { + msg = R.string.msg_error + break + } + + DownloadMission.Companion.ERROR_PROGRESS_LOST -> msg = R.string.error_progress_lost + DownloadMission.Companion.ERROR_TIMEOUT -> msg = R.string.error_timeout + DownloadMission.Companion.ERROR_RESOURCE_GONE -> msg = R.string.error_download_resource_gone + else -> 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 + } + } + val builder: AlertDialog.Builder = 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 val mMsg: Int = msg + builder.setPositiveButton(R.string.error_report_title, DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> showError(mission, UserAction.DOWNLOAD_FAILED, mMsg) }) + ) + } + builder.setNegativeButton(R.string.ok, DialogInterface.OnClickListener({ dialog: DialogInterface, which: Int -> dialog.cancel() })) + .setTitle(mission.storage.getName()) + .show() + } + + private fun showError(mission: DownloadMission, action: UserAction, @StringRes reason: Int) { + val request: StringBuilder = StringBuilder(256) + request.append(mission.source) + request.append(" [") + if (mission.recoveryInfo != null) { + for (recovery: MissionRecoveryInfo in mission.recoveryInfo) request.append(' ') + .append(recovery.toString()) + .append(' ') + } + request.append("]") + var service: String? + try { + service = NewPipe.getServiceByUrl(mission.source).getServiceInfo().getName() + } catch (e: Exception) { + service = ErrorInfo.SERVICE_NONE + } + ErrorUtil.createNotification(mContext, + ErrorInfo(ErrorInfo.throwableToStringList(mission.errObject), action, + (service)!!, request.toString(), reason)) + } + + fun clearFinishedDownloads(delete: Boolean) { + if (delete && mIterator.hasFinishedMissions() && mHidden.isEmpty()) { + for (i in 0 until mIterator.getOldListSize()) { + val mission: FinishedMission? = if (mIterator.getItem(i).mission is FinishedMission) mIterator.getItem(i).mission as FinishedMission? else null + if (mission != null) { + mIterator.hide(mission) + mHidden.add(mission) + } + } + applyChanges() + val msg: String? = Localization.deletedDownloadCount(mContext, mHidden.size) + mSnackbar = Snackbar.make(mView, msg, Snackbar.LENGTH_INDEFINITE) + mSnackbar.setAction(R.string.undo, View.OnClickListener({ s: View? -> + val i: MutableIterator = mHidden.iterator() + while (i.hasNext()) { + mIterator.unHide(i.next()) + i.remove() + } + applyChanges() + mHandler.removeCallbacksAndMessages(DELETE) + })) + mSnackbar.setActionTextColor(Color.YELLOW) + mSnackbar.show() + HandlerCompat.postDelayed(mHandler, Runnable({ deleteFinishedDownloads() }), DELETE, 5000) + } else if (!delete) { + mDownloadManager.forgetFinishedDownloads() + applyChanges() + } + } + + private fun deleteFinishedDownloads() { + if (mSnackbar != null) mSnackbar.dismiss() + val i: MutableIterator = mHidden.iterator() + while (i.hasNext()) { + val mission: Mission? = i.next() + if (mission != null) { + mDownloadManager.deleteMission(mission) + mContext.sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, mission.storage.getUri())) + } + i.remove() + } + } + + private fun handlePopupItem(h: ViewHolderItem, option: MenuItem): Boolean { + if (h.item == null) return true + val id: Int = option.getItemId() + val mission: DownloadMission? = if (h.item.mission is DownloadMission) h.item.mission as DownloadMission? else null + if (mission != null) { + when (id) { + R.id.start -> { + h.status.setText(UNDEFINED_PROGRESS) + mDownloadManager.resumeMission(mission) + return true + } + + R.id.pause -> { + mDownloadManager.pauseMission(mission) + return true + } + + R.id.error_message_view -> { + showError(mission) + return true + } + + R.id.queue -> { + val flag: Boolean = !h.queue.isChecked() + h.queue.setChecked(flag) + mission.setEnqueued(flag) + updateProgress(h) + return true + } + + 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 + } + + R.id.cancel -> { + mission.psContinue(false) + return false + } + } + } + when (id) { + R.id.menu_item_share -> { + shareFile(h.item.mission) + return true + } + + R.id.delete -> { + mDeleter.append(h.item.mission) + applyChanges() + checkMasterButtonsVisibility() + return true + } + + R.id.md5, R.id.sha1 -> { + val notificationManager: NotificationManager = ContextCompat.getSystemService(mContext, NotificationManager::class.java) + val progressNotificationBuilder: NotificationCompat.Builder = 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()) + val storage: StoredFileHelper = h.item.mission.storage + compositeDisposable.add( + Observable.fromCallable(Callable({ Utility.checksum(storage, id) })) + .subscribeOn(Schedulers.computation()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(Consumer({ result: String? -> + ShareUtils.copyToClipboard(mContext, result) + notificationManager.cancel(HASH_NOTIFICATION_ID) + })) + ) + return true + } + + R.id.source -> { + /*Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(h.item.mission.source)); + mContext.startActivity(intent);*/try { + val intent: Intent = NavigationHelper.getIntentByLink(mContext, h.item.mission.source) + intent.addFlags(Intent.FLAG_ACTIVITY_PREVIOUS_IS_TOP) + mContext.startActivity(intent) + } catch (e: Exception) { + Log.w(TAG, "Selected item has a invalid source", e) + } + return true + } + + else -> return false + } + } + + fun applyChanges() { + mIterator.start() + DiffUtil.calculateDiff(mIterator, true).dispatchUpdatesTo(this) + mIterator.end() + checkEmptyMessageVisibility() + if (mClear != null) mClear!!.setVisible(mIterator.hasFinishedMissions()) + } + + fun forceUpdate() { + mIterator.start() + mIterator.end() + for (item: ViewHolderItem in mPendingDownloadsItems) { + item.resetSpeedMeasure() + } + notifyDataSetChanged() + } + + fun setLinear(isLinear: Boolean) { + mLayout = if (isLinear) R.layout.mission_item_linear else R.layout.mission_item + } + + fun setClearButton(clearButton: MenuItem) { + if (mClear == null) clearButton.setVisible(mIterator.hasFinishedMissions()) + mClear = clearButton + } + + fun setMasterButtons(startButton: MenuItem?, pauseButton: MenuItem?) { + val init: Boolean = mStartButton == null || mPauseButton == null + mStartButton = startButton + mPauseButton = pauseButton + if (init) checkMasterButtonsVisibility() + } + + private fun checkEmptyMessageVisibility() { + val flag: Int = if (mIterator.getOldListSize() > 0) View.GONE else View.VISIBLE + if (mEmptyMessage!!.getVisibility() != flag) mEmptyMessage.setVisibility(flag) + } + + fun checkMasterButtonsVisibility() { + val state: BooleanArray = mIterator.hasValidPendingMissions() + Log.d(TAG, "checkMasterButtonsVisibility() running=" + state.get(0) + " paused=" + state.get(1)) + setButtonVisible(mPauseButton, state.get(0)) + setButtonVisible(mStartButton, state.get(1)) + } + + fun refreshMissionItems() { + for (h: ViewHolderItem in mPendingDownloadsItems) { + if ((h.item.mission as DownloadMission).running) continue + updateProgress(h) + h.resetSpeedMeasure() + } + } + + fun onDestroy() { + compositeDisposable.dispose() + mDeleter.dispose() + } + + fun onResume() { + mDeleter.resume() + HandlerCompat.postDelayed(mHandler, Runnable({ updater() }), UPDATER, 0) + } + + fun onPaused() { + mDeleter.pause() + mHandler.removeCallbacksAndMessages(UPDATER) + } + + fun recoverMission(mission: DownloadMission) { + val h: ViewHolderItem? = getViewHolder(mission) + if (h == null) return + mission.errObject = null + mission.resetState(true, false, DownloadMission.Companion.ERROR_NOTHING) + h.status.setText(UNDEFINED_PROGRESS) + h.size.setText(Utility.formatBytes(mission.getLength())) + h.progress.setMarquee(true) + mDownloadManager.resumeMission(mission) + } + + private fun updater() { + for (h: ViewHolderItem in mPendingDownloadsItems) { + // check if the mission is running first + if (!(h.item.mission as DownloadMission).running) continue + updateProgress(h) + } + HandlerCompat.postDelayed(mHandler, Runnable({ updater() }), UPDATER, 1000) + } + + private fun isNotFinite(value: Double): Boolean { + return java.lang.Double.isNaN(value) || java.lang.Double.isInfinite(value) + } + + fun setRecover(callback: RecoverHelper) { + mRecover = callback + } + + internal inner class ViewHolderItem(view: View?) : RecyclerView.ViewHolder(view) { + var item: MissionItem? = null + var status: TextView + var icon: ImageView + var name: TextView + var size: TextView + var progress: ProgressDrawable + var popupMenu: PopupMenu + var retry: MenuItem + var cancel: MenuItem + var start: MenuItem + var pause: MenuItem + var open: MenuItem + var queue: MenuItem + var showError: MenuItem + var delete: MenuItem + var source: MenuItem + var checksum: MenuItem + var lastTimestamp: Long = -1 + var lastDone: Double = 0.0 + var lastSpeedIdx: Int = 0 + var lastSpeed: FloatArray = FloatArray(3) + var estimatedTimeArrival: String = UNDEFINED_ETA + + init { + progress = 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) + name.setSelected(true) + val button: ImageView = itemView.findViewById(R.id.item_more) + popupMenu = buildPopup(button) + button.setOnClickListener(View.OnClickListener({ v: View? -> showPopupMenu() })) + val 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(View.OnClickListener({ v: View? -> if (item.mission is FinishedMission) viewWithFileProvider(item.mission) })) + itemView.setOnLongClickListener(OnLongClickListener({ v: View -> + v.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) + showPopupMenu() + true + })) + } + + private fun 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) + val mission: DownloadMission? = if (item.mission is DownloadMission) item.mission as DownloadMission? else null + if (mission != null) { + if (mission.hasInvalidStorage()) { + retry.setVisible(true) + delete.setVisible(true) + showError.setVisible(true) + } else if (mission.isPsRunning()) { + when (mission.errCode) { + DownloadMission.Companion.ERROR_INSUFFICIENT_STORAGE, DownloadMission.Companion.ERROR_POSTPROCESSING_HOLD -> { + retry.setVisible(true) + cancel.setVisible(true) + showError.setVisible(true) + } + } + } else { + if (mission.running) { + pause.setVisible(true) + } else { + if (mission.errCode != DownloadMission.Companion.ERROR_NOTHING) { + showError.setVisible(true) + } + queue.setChecked(mission.enqueued) + delete.setVisible(true) + val flag: Boolean = !mission.isPsFailed() && mission.urls.size > 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 fun buildPopup(button: View): PopupMenu { + val popup: PopupMenu = PopupMenu(mContext, button) + popup.inflate(R.menu.mission) + popup.setOnMenuItemClickListener(PopupMenu.OnMenuItemClickListener({ option: MenuItem -> handlePopupItem(this, option) })) + return popup + } + + fun resetSpeedMeasure() { + estimatedTimeArrival = UNDEFINED_ETA + lastTimestamp = -1 + lastSpeedIdx = -1 + } + } + + internal class ViewHolderHeader(view: View?) : RecyclerView.ViewHolder(view) { + var header: TextView + + init { + header = itemView.findViewById(R.id.item_name) + } + } + + open interface RecoverHelper { + fun tryRecover(mission: DownloadMission?) + } + + companion object { + private val TAG: String = "MissionAdapter" + private val UNDEFINED_PROGRESS: String = "--.-%" + private val DEFAULT_MIME_TYPE: String = "*/*" + private val UNDEFINED_ETA: String = "--:--" + private val UPDATER: String = "updater" + private val DELETE: String = "deleteFinishedDownloads" + private val HASH_NOTIFICATION_ID: Int = 123790 + private fun resolveMimeType(mission: Mission): String? { + var mimeType: String? + if (!mission.storage.isInvalid()) { + mimeType = mission.storage.getType() + if ((mimeType != null) && (mimeType.length > 0) && !(mimeType == StoredFileHelper.Companion.DEFAULT_MIME)) return mimeType + } + val ext: String? = Utility.getFileExt(mission.storage.getName()) + if (ext == null) return DEFAULT_MIME_TYPE + mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext.substring(1)) + return if (mimeType == null) DEFAULT_MIME_TYPE else mimeType + } + + private fun setButtonVisible(button: MenuItem?, visible: Boolean) { + if (button!!.isVisible() != visible) button.setVisible(visible) + } + } +} diff --git a/app/src/main/java/us/shandian/giga/ui/common/Deleter.java b/app/src/main/java/us/shandian/giga/ui/common/Deleter.java deleted file mode 100644 index 1902076d6..000000000 --- a/app/src/main/java/us/shandian/giga/ui/common/Deleter.java +++ /dev/null @@ -1,143 +0,0 @@ -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 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; - 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) { - /* 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, item); - - show(); - } - - private void forget() { - mIterator.unHide(items.remove(0)); - 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; - - String msg = mContext.getString(R.string.file_deleted).concat(":\n").concat(items.get(0).storage.getName()); - - 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) { - Mission mission = items.remove(0); - if (mission.deleted) continue; - - mIterator.unHide(mission); - mDownloadManager.deleteMission(mission); - - 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 (Mission mission : items) mDownloadManager.deleteMission(mission); - items = null; - } -} diff --git a/app/src/main/java/us/shandian/giga/ui/common/Deleter.kt b/app/src/main/java/us/shandian/giga/ui/common/Deleter.kt new file mode 100644 index 000000000..c27eecce9 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/ui/common/Deleter.kt @@ -0,0 +1,109 @@ +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 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 + +class Deleter(private val mView: View, private val mContext: Context, private val mAdapter: MissionAdapter, private val mDownloadManager: DownloadManager, private val mIterator: MissionIterator, private val mHandler: Handler) { + private var snackbar: Snackbar? = null + private var items: ArrayList? + private var running: Boolean = true + + init { + items = ArrayList(2) + } + + fun append(item: Mission) { + /* 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, item) + show() + } + + private fun forget() { + mIterator.unHide(items!!.removeAt(0)) + mAdapter.applyChanges() + show() + } + + private fun show() { + if (items!!.size < 1) return + pause() + running = true + HandlerCompat.postDelayed(mHandler, Runnable({ next() }), NEXT, DELAY.toLong()) + } + + private operator fun next() { + if (items!!.size < 1) return + val msg: String = mContext.getString(R.string.file_deleted) + ":\n" + items!!.get(0).storage!!.getName() + snackbar = Snackbar.make(mView, msg, Snackbar.LENGTH_INDEFINITE) + snackbar!!.setAction(R.string.undo, View.OnClickListener({ s: View? -> forget() })) + snackbar!!.setActionTextColor(Color.YELLOW) + snackbar!!.show() + HandlerCompat.postDelayed(mHandler, Runnable({ commit() }), COMMIT, TIMEOUT.toLong()) + } + + private fun commit() { + if (items!!.size < 1) return + while (items!!.size > 0) { + val mission: Mission = items!!.removeAt(0) + if (mission.deleted) continue + mIterator.unHide(mission) + mDownloadManager.deleteMission(mission) + if (mission is FinishedMission) { + mContext.sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, mission.storage!!.getUri())) + } + break + } + if (items!!.size < 1) { + pause() + return + } + show() + } + + fun pause() { + running = false + mHandler.removeCallbacksAndMessages(NEXT) + mHandler.removeCallbacksAndMessages(SHOW) + mHandler.removeCallbacksAndMessages(COMMIT) + if (snackbar != null) snackbar!!.dismiss() + } + + fun resume() { + if (!running) { + HandlerCompat.postDelayed(mHandler, Runnable({ show() }), SHOW, DELAY_RESUME.toLong()) + } + } + + fun dispose() { + if (items!!.size < 1) return + pause() + for (mission: Mission in items!!) mDownloadManager.deleteMission(mission) + items = null + } + + companion object { + private val COMMIT: String = "commit" + private val NEXT: String = "next" + private val SHOW: String = "show" + private val TIMEOUT: Int = 5000 // ms + private val DELAY: Int = 350 // ms + private val DELAY_RESUME: Int = 400 // ms + } +} diff --git a/app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.java b/app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.java deleted file mode 100644 index 2a8077d51..000000000 --- a/app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.java +++ /dev/null @@ -1,132 +0,0 @@ -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(); - } -} diff --git a/app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.kt b/app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.kt new file mode 100644 index 000000000..144933ee3 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.kt @@ -0,0 +1,112 @@ +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 + +class ProgressDrawable // marquee disabled +() : Drawable() { + private var mProgress: Float = 0f + private var mBackgroundColor: Int = 0 + private var mForegroundColor: Int = 0 + private var mMarqueeHandler: Handler? = null + private var mMarqueeProgress: Float = 0.0f + private var mMarqueeLine: Path? = null + private var mMarqueeSize: Int = 0 + private var mMarqueeNext: Long = 0 + fun setColors(@ColorInt background: Int, @ColorInt foreground: Int) { + mBackgroundColor = background + mForegroundColor = foreground + } + + fun setProgress(progress: Double) { + mProgress = progress.toFloat() + invalidateSelf() + } + + fun setMarquee(marquee: Boolean) { + if (marquee == (mMarqueeLine != null)) { + return + } + mMarqueeLine = if (marquee) Path() else null + mMarqueeHandler = if (marquee) Handler(Looper.getMainLooper()) else null + mMarqueeSize = 0 + mMarqueeNext = 0 + } + + public override fun draw(canvas: Canvas) { + var width: Int = getBounds().width() + val height: Int = getBounds().height() + val paint: Paint = Paint() + paint.setColor(mBackgroundColor) + canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint) + paint.setColor(mForegroundColor) + if (mMarqueeLine != null) { + if (mMarqueeSize < 1) setupMarquee(width, height) + var size: Int = mMarqueeSize + val paint2: Paint = Paint() + paint2.setColor(mForegroundColor) + paint2.setStrokeWidth(size.toFloat()) + paint2.setStyle(Paint.Style.STROKE) + size *= 2 + if (mMarqueeProgress >= size) { + mMarqueeProgress = 1f + } else { + mMarqueeProgress++ + } + + // render marquee + width += size * 2 + val marquee: Path = Path() + var i: Int = -size + while (i < width) { + marquee.addPath(mMarqueeLine!!, (i.toFloat() + mMarqueeProgress), 0f) + i += size + } + marquee.close() + canvas.drawPath(marquee, paint2) // draw marquee + if (System.currentTimeMillis() >= mMarqueeNext) { + // program next update + mMarqueeNext = System.currentTimeMillis() + MARQUEE_INTERVAL + mMarqueeHandler!!.postDelayed(Runnable({ invalidateSelf() }), MARQUEE_INTERVAL.toLong()) + } + return + } + canvas.drawRect(0f, 0f, ((mProgress * width).toInt()).toFloat(), height.toFloat(), paint) + } + + public override fun setAlpha(alpha: Int) { + // Unsupported + } + + public override fun setColorFilter(filter: ColorFilter?) { + // Unsupported + } + + public override fun getOpacity(): Int { + return PixelFormat.OPAQUE + } + + public override fun onBoundsChange(rect: Rect) { + if (mMarqueeLine != null) setupMarquee(rect.width(), rect.height()) + } + + private fun setupMarquee(width: Int, height: Int) { + mMarqueeSize = ((width * 10.0f) / 100.0f).toInt() // the size is 10% of the width + mMarqueeLine!!.rewind() + mMarqueeLine!!.moveTo(-mMarqueeSize.toFloat(), -mMarqueeSize.toFloat()) + mMarqueeLine!!.lineTo((-mMarqueeSize * 4).toFloat(), (height + mMarqueeSize).toFloat()) + mMarqueeLine!!.close() + } + + companion object { + private val MARQUEE_INTERVAL: Int = 150 + } +} diff --git a/app/src/main/java/us/shandian/giga/ui/common/ToolbarActivity.java b/app/src/main/java/us/shandian/giga/ui/common/ToolbarActivity.java deleted file mode 100644 index 1542d3ff0..000000000 --- a/app/src/main/java/us/shandian/giga/ui/common/ToolbarActivity.java +++ /dev/null @@ -1,24 +0,0 @@ -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(); -} diff --git a/app/src/main/java/us/shandian/giga/ui/common/ToolbarActivity.kt b/app/src/main/java/us/shandian/giga/ui/common/ToolbarActivity.kt new file mode 100644 index 000000000..82e7f2529 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/ui/common/ToolbarActivity.kt @@ -0,0 +1,18 @@ +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 + +abstract class ToolbarActivity() : AppCompatActivity() { + protected var mToolbar: Toolbar? = null + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(getLayoutResource()) + mToolbar = findViewById(R.id.toolbar) + setSupportActionBar(mToolbar) + } + + protected abstract fun getLayoutResource(): Int +} diff --git a/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java b/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java deleted file mode 100644 index 690ed4a97..000000000 --- a/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java +++ /dev/null @@ -1,341 +0,0 @@ -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) { - switch (item.getItemId()) { - case R.id.switch_mode: - mLinear = !mLinear; - updateList(); - return true; - case R.id.clear_list: - showClearDownloadHistoryPrompt(); - return true; - case R.id.start_downloads: - mBinder.getDownloadManager().startAllMissions(); - return true; - case R.id.pause_downloads: - mBinder.getDownloadManager().pauseAllMissions(false); - mAdapter.refreshMissionItems();// update items view - default: - 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(); - } - } -} diff --git a/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.kt b/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.kt new file mode 100644 index 000000000..bc457710e --- /dev/null +++ b/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.kt @@ -0,0 +1,294 @@ +package us.shandian.giga.ui.fragment + +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.DialogInterface +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.ActivityResultCallback +import androidx.activity.result.ActivityResultLauncher +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.Fragment +import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup +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 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 +import us.shandian.giga.ui.adapter.MissionAdapter.RecoverHelper +import java.io.File +import java.io.IOException + +class MissionsFragment() : Fragment() { + private var mPrefs: SharedPreferences? = null + private var mLinear: Boolean = false + private var mSwitch: MenuItem? = null + private var mClear: MenuItem? = null + private var mStart: MenuItem? = null + private var mPause: MenuItem? = null + private var mList: RecyclerView? = null + private var mEmpty: View? = null + private var mAdapter: MissionAdapter? = null + private var mGridManager: GridLayoutManager? = null + private var mLinearManager: LinearLayoutManager? = null + private var mContext: Context? = null + private var mBinder: DownloadManagerBinder? = null + private var mForceUpdate: Boolean = false + private var unsafeMissionTarget: DownloadMission? = null + private val requestDownloadSaveAsLauncher: ActivityResultLauncher = registerForActivityResult(StartActivityForResult(), ActivityResultCallback({ result: ActivityResult -> requestDownloadSaveAsResult(result) })) + private val mConnection: ServiceConnection = object : ServiceConnection { + public override fun onServiceConnected(name: ComponentName, binder: IBinder) { + mBinder = binder as DownloadManagerBinder? + mBinder!!.clearDownloadNotifications() + mAdapter = MissionAdapter(mContext, (mBinder!!.getDownloadManager())!!, mEmpty, getView()) + mAdapter!!.setRecover(RecoverHelper({ mission: DownloadMission -> recoverMission(mission) })) + setAdapterButtons() + mBinder!!.addMissionEventListener(mAdapter!!) + mBinder!!.enableNotifications(false) + updateList() + } + + public override fun onServiceDisconnected(name: ComponentName) { + // What to do? + } + } + + public override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val v: View = inflater.inflate(R.layout.missions, container, false) + mPrefs = PreferenceManager.getDefaultSharedPreferences(requireActivity()) + mLinear = mPrefs.getBoolean("linear", false) + + // Bind the service + mContext!!.bindService(Intent(mContext, DownloadManagerService::class.java), 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 = GridLayoutManager(getActivity(), SPAN_SIZE) + mGridManager!!.setSpanSizeLookup(object : SpanSizeLookup() { + public override fun getSpanSize(position: Int): Int { + when (mAdapter!!.getItemViewType(position)) { + DownloadManager.Companion.SPECIAL_PENDING, DownloadManager.Companion.SPECIAL_FINISHED -> return SPAN_SIZE + else -> return 1 + } + } + }) + mLinearManager = LinearLayoutManager(getActivity()) + setHasOptionsMenu(true) + return v + } + + /** + * Added in API level 23. + */ + public override fun onAttach(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 + */ + @Suppress("deprecation") + public override fun onAttach(activity: Activity) { + super.onAttach(activity) + mContext = activity + } + + public override fun onDestroy() { + super.onDestroy() + if (mBinder == null || mAdapter == null) return + mBinder!!.removeMissionEventListener(mAdapter!!) + mBinder!!.enableNotifications(true) + mContext!!.unbindService(mConnection) + mAdapter!!.onDestroy() + mBinder = null + mAdapter = null + } + + public override fun 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) + } + + public override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.getItemId()) { + R.id.switch_mode -> { + mLinear = !mLinear + updateList() + return true + } + + R.id.clear_list -> { + showClearDownloadHistoryPrompt() + return true + } + + R.id.start_downloads -> { + mBinder!!.getDownloadManager()!!.startAllMissions() + return true + } + + R.id.pause_downloads -> { + mBinder!!.getDownloadManager()!!.pauseAllMissions(false) + mAdapter!!.refreshMissionItems() // update items view + return super.onOptionsItemSelected(item) + } + + else -> return super.onOptionsItemSelected(item) + } + } + + fun showClearDownloadHistoryPrompt() { + // ask the user whether he wants to just clear history or instead delete files on disk + 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, DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> mAdapter!!.clearFinishedDownloads(false) })) + .setNeutralButton(R.string.cancel, null) + .setPositiveButton(R.string.delete_downloaded_files, DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> showDeleteDownloadedFilesConfirmationPrompt() })) + .show() + } + + fun showDeleteDownloadedFilesConfirmationPrompt() { + // make sure the user confirms once more before deleting files on disk + AlertDialog.Builder((mContext)!!) + .setTitle(R.string.delete_downloaded_files_confirm) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.ok, DialogInterface.OnClickListener({ dialog: DialogInterface?, which: Int -> mAdapter!!.clearFinishedDownloads(true) })) + .show() + } + + private fun 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(if (mLinear) R.drawable.ic_apps else R.drawable.ic_list) + mSwitch!!.setTitle(if (mLinear) R.string.grid else R.string.list) + mPrefs!!.edit().putBoolean("linear", mLinear).apply() + } + } + + private fun setAdapterButtons() { + if ((mClear == null) || (mStart == null) || (mPause == null)) return + mAdapter!!.setClearButton(mClear!!) + mAdapter!!.setMasterButtons(mStart, mPause) + } + + private fun recoverMission(mission: DownloadMission) { + unsafeMissionTarget = mission + val initialPath: Uri? + if (NewPipeSettings.useStorageAccessFramework(mContext)) { + initialPath = null + } else { + val initialSavePath: File + if ((DownloadManager.Companion.TAG_AUDIO == 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.Companion.getNewPicker((mContext)!!, mission.storage!!.getName(), + (mission.storage!!.getType())!!, initialPath), + TAG, + mContext + ) + } + + public override fun 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) + } + + public override fun onPause() { + super.onPause() + if (mAdapter != null) { + mForceUpdate = true + mBinder!!.removeMissionEventListener(mAdapter!!) + mAdapter!!.onPaused() + } + if (mBinder != null) mBinder!!.enableNotifications(true) + } + + private fun requestDownloadSaveAsResult(result: ActivityResult) { + if (result.getResultCode() != Activity.RESULT_OK) { + return + } + if (unsafeMissionTarget == null || result.getData() == null) { + return + } + try { + var fileUri: Uri? = result.getData()!!.getData() + if (fileUri!!.getAuthority() != null && FilePickerActivityHelper.Companion.isOwnFileUri((mContext)!!, (fileUri))) { + fileUri = Uri.fromFile(Utils.getFileForUri((fileUri))) + } + val tag: String? = unsafeMissionTarget!!.storage!!.getTag() + unsafeMissionTarget!!.storage = StoredFileHelper(mContext, null, (fileUri)!!, tag) + mAdapter!!.recoverMission(unsafeMissionTarget) + } catch (e: IOException) { + Toast.makeText(mContext, R.string.general_error, Toast.LENGTH_LONG).show() + } + } + + companion object { + private val TAG: String = "MissionsFragment" + private val SPAN_SIZE: Int = 2 + } +} diff --git a/app/src/main/java/us/shandian/giga/util/Utility.java b/app/src/main/java/us/shandian/giga/util/Utility.java deleted file mode 100644 index c75269757..000000000 --- a/app/src/main/java/us/shandian/giga/util/Utility.java +++ /dev/null @@ -1,290 +0,0 @@ -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 - } - - /** - * Get amount of free system's memory. - * @return free memory (bytes) - */ - public static long getSystemFreeMemory() { - try { - final StatFs statFs = new StatFs(Environment.getExternalStorageDirectory().getPath()); - return statFs.getAvailableBlocksLong() * statFs.getBlockSizeLong(); - } catch (final Exception e) { - // do nothing - } - return -1; - } - - 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); - } -} diff --git a/app/src/main/java/us/shandian/giga/util/Utility.kt b/app/src/main/java/us/shandian/giga/util/Utility.kt new file mode 100644 index 000000000..4224afa07 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/util/Utility.kt @@ -0,0 +1,235 @@ +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.core.content.ContextCompat +import com.google.android.exoplayer2.util.Util +import okio.ByteString +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 + +object Utility { + /** + * Get amount of free system's memory. + * @return free memory (bytes) + */ + fun getSystemFreeMemory(): Long { + try { + val statFs: StatFs = StatFs(Environment.getExternalStorageDirectory().getPath()) + return statFs.getAvailableBlocksLong() * statFs.getBlockSizeLong() + } catch (e: Exception) { + // do nothing + } + return -1 + } + + fun formatBytes(bytes: Long): String { + val 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 / 1024.0) + } else if (bytes < 1024 * 1024 * 1024) { + return String.format(locale, "%.2f MB", bytes / 1024.0 / 1024.0) + } else { + return String.format(locale, "%.2f GB", bytes / 1024.0 / 1024.0 / 1024.0) + } + } + + fun formatSpeed(speed: Double): String { + val 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) + } + } + + fun writeToFile(file: File, serializable: Serializable) { + try { + ObjectOutputStream(BufferedOutputStream(FileOutputStream(file))).use({ objectOutputStream -> objectOutputStream.writeObject(serializable) }) + } catch (e: Exception) { + //nothing to do + } + //nothing to do + } + + fun readFromFile(file: File?): T? { + var `object`: T? + try { + ObjectInputStream(FileInputStream(file)).use({ objectInputStream -> `object` = objectInputStream.readObject() as T }) + } catch (e: Exception) { + Log.e("Utility", "Failed to deserialize the object", e) + `object` = null + } + return `object` + } + + fun getFileExt(url: String): String? { + var url: String = url + var index: Int + if ((url.indexOf("?").also({ index = it })) > -1) { + url = url.substring(0, index) + } + index = url.lastIndexOf(".") + if (index == -1) { + return null + } else { + var ext: String = url.substring(index) + if ((ext.indexOf("%").also({ index = it })) > -1) { + ext = ext.substring(0, index) + } + if ((ext.indexOf("/").also({ index = it })) > -1) { + ext = ext.substring(0, index) + } + return ext.lowercase(Locale.getDefault()) + } + } + + fun getFileType(kind: Char, file: String): FileType { + when (kind) { + 'v' -> return FileType.VIDEO + 'a' -> return FileType.MUSIC + 's' -> return FileType.SUBTITLE + } + 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 + fun getBackgroundForFileType(ctx: Context?, type: FileType?): Int { + val colorRes: Int + when (type) { + FileType.MUSIC -> colorRes = R.color.audio_left_to_load_color + FileType.VIDEO -> colorRes = R.color.video_left_to_load_color + FileType.SUBTITLE -> colorRes = R.color.subtitle_left_to_load_color + else -> colorRes = R.color.gray + } + return ContextCompat.getColor((ctx)!!, colorRes) + } + + @ColorInt + fun getForegroundForFileType(ctx: Context?, type: FileType?): Int { + val colorRes: Int + when (type) { + FileType.MUSIC -> colorRes = R.color.audio_already_load_color + FileType.VIDEO -> colorRes = R.color.video_already_load_color + FileType.SUBTITLE -> colorRes = R.color.subtitle_already_load_color + else -> colorRes = R.color.gray + } + return ContextCompat.getColor((ctx)!!, colorRes) + } + + @DrawableRes + fun getIconForFileType(type: FileType?): Int { + when (type) { + FileType.MUSIC -> return R.drawable.ic_headset + FileType.VIDEO -> return R.drawable.ic_movie + FileType.SUBTITLE -> return R.drawable.ic_subtitles + else -> return R.drawable.ic_movie + } + } + + @Throws(IOException::class) + fun checksum(source: StoredFileHelper, algorithmId: Int): String { + var byteString: ByteString + SharpInputStream(source.getStream()).use({ inputStream -> 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() + } + + fun mkdir(p: File, allDirs: Boolean): Boolean { + if (p.exists()) return true + if (allDirs) p.mkdirs() else p.mkdir() + return p.exists() + } + + fun getContentLength(connection: HttpURLConnection?): Long { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + return connection!!.getContentLengthLong() + } + try { + return connection!!.getHeaderField("Content-Length").toLong() + } catch (err: Exception) { + // 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 + */ + fun getTotalContentLength(connection: HttpURLConnection?): Long { + try { + if (connection!!.getResponseCode() == 206) { + val rangeStr: String = connection.getHeaderField("Content-Range") + val bytesStr: String = rangeStr.split("/".toRegex(), limit = 2).toTypedArray().get(1) + return bytesStr.toLong() + } else { + return getContentLength(connection) + } + } catch (err: Exception) { + // nothing to do + } + return -1 + } + + private fun pad(number: Int): String { + return if (number < 10) ("0" + number) else number.toString() + } + + fun stringifySeconds(seconds: Long): String { + val h: Int = Math.floorDiv(seconds, 3600).toInt() + val m: Int = Math.floorDiv(seconds - (h * 3600L), 60).toInt() + val s: Int = (seconds - (h * 3600) - (m * 60)).toInt() + var str: String = "" + if (h < 1 && m < 1) { + str = "00:" + } else { + if (h > 0) str = pad(h) + ":" + if (m > 0) str += pad(m) + ":" + } + return str + pad(s) + } + + enum class FileType { + VIDEO, + MUSIC, + SUBTITLE, + UNKNOWN + } +} diff --git a/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueTest.java b/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueTest.java index 022089f37..15562729b 100644 --- a/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueTest.java +++ b/app/src/test/java/org/schabi/newpipe/player/playqueue/PlayQueueTest.java @@ -77,22 +77,22 @@ public class PlayQueueTest { @Test public void outOfBoundIsComplete() { - doReturn(true).when(nonEmptyQueue).isComplete(); + doReturn(true).when(nonEmptyQueue).isComplete; nonEmptyQueue.setIndex(7); assertEquals(2, nonEmptyQueue.getIndex()); - doReturn(true).when(emptyQueue).isComplete(); + doReturn(true).when(emptyQueue).isComplete; emptyQueue.setIndex(2); assertEquals(0, emptyQueue.getIndex()); } @Test public void outOfBoundsNotComplete() { - doReturn(false).when(nonEmptyQueue).isComplete(); + doReturn(false).when(nonEmptyQueue).isComplete; nonEmptyQueue.setIndex(7); assertEquals(SIZE - 1, nonEmptyQueue.getIndex()); - doReturn(false).when(emptyQueue).isComplete(); + doReturn(false).when(emptyQueue).isComplete; emptyQueue.setIndex(2); assertEquals(0, emptyQueue.getIndex()); } @@ -102,11 +102,11 @@ public class PlayQueueTest { nonEmptyQueue.setIndex(0); assertEquals(0, nonEmptyQueue.getIndex()); - doReturn(true).when(emptyQueue).isComplete(); + doReturn(true).when(emptyQueue).isComplete; emptyQueue.setIndex(0); assertEquals(0, emptyQueue.getIndex()); - doReturn(false).when(emptyQueue).isComplete(); + doReturn(false).when(emptyQueue).isComplete; emptyQueue.setIndex(0); assertEquals(0, emptyQueue.getIndex()); } @@ -118,7 +118,7 @@ public class PlayQueueTest { nonEmptyQueue.setIndex(3); assertTrue(nonEmptyQueue.previous()); - assertEquals("URL_0", Objects.requireNonNull(nonEmptyQueue.getItem()).getUrl()); + assertEquals("URL_0", Objects.requireNonNull(nonEmptyQueue.getItem()).url); } } @@ -139,8 +139,8 @@ public class PlayQueueTest { @Test public void inBounds() { - assertEquals("TARGET_URL", Objects.requireNonNull(queue.getItem(3)).getUrl()); - assertEquals("OTHER_URL", Objects.requireNonNull(queue.getItem(1)).getUrl()); + assertEquals("TARGET_URL", Objects.requireNonNull(queue.getItem(3)).url); + assertEquals("OTHER_URL", Objects.requireNonNull(queue.getItem(1)).url); } @Test