Repository: recloudstream/cloudstream Branch: master Commit: 19efb1ffc3f7 Files: 1156 Total size: 6.6 MB Directory structure: gitextract_bfixog4j/ ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── application-bug.yml │ │ ├── config.yml │ │ └── feature-request.yml │ ├── locales.py │ └── workflows/ │ ├── build_to_archive.yml │ ├── generate_dokka.yml │ ├── issue_action.yml │ ├── prerelease.yml │ ├── pull_request.yml │ └── update_locales.yml ├── .gitignore ├── AI-POLICY.md ├── LICENSE ├── README.md ├── app/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── lint.xml │ ├── proguard-rules.pro │ └── src/ │ ├── androidTest/ │ │ └── java/ │ │ └── com/ │ │ └── lagradost/ │ │ └── cloudstream3/ │ │ └── ExampleInstrumentedTest.kt │ ├── debug/ │ │ └── res/ │ │ ├── drawable/ │ │ │ └── ic_banner_foreground.xml │ │ ├── drawable-anydpi-v24/ │ │ │ └── ic_stat_name.xml │ │ ├── drawable-v24/ │ │ │ ├── ic_banner_background.xml │ │ │ └── ic_launcher_foreground.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_banner.xml │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ └── values/ │ │ ├── ic_launcher_background.xml │ │ └── strings.xml │ ├── main/ │ │ ├── AndroidManifest.xml │ │ ├── java/ │ │ │ └── com/ │ │ │ └── lagradost/ │ │ │ └── cloudstream3/ │ │ │ ├── AcraApplication.kt │ │ │ ├── CloudStreamApp.kt │ │ │ ├── CommonActivity.kt │ │ │ ├── DownloaderTestImpl.kt │ │ │ ├── MainActivity.kt │ │ │ ├── actions/ │ │ │ │ ├── AlwaysAskAction.kt │ │ │ │ ├── OpenInAppAction.kt │ │ │ │ ├── VideoClickAction.kt │ │ │ │ └── temp/ │ │ │ │ ├── Aria2Package.kt │ │ │ │ ├── BiglyBTPackage.kt │ │ │ │ ├── CloudStreamPackage.kt │ │ │ │ ├── CopyClipboardAction.kt │ │ │ │ ├── JustPlayerPackage.kt │ │ │ │ ├── LibreTorrentPackage.kt │ │ │ │ ├── MpvKtPackage.kt │ │ │ │ ├── MpvPackage.kt │ │ │ │ ├── NextPlayerPackage.kt │ │ │ │ ├── PlayInBrowserAction.kt │ │ │ │ ├── PlayMirrorAction.kt │ │ │ │ ├── ViewM3U8Action.kt │ │ │ │ ├── VlcPackage.kt │ │ │ │ ├── WebVideoCastPackage.kt │ │ │ │ └── fcast/ │ │ │ │ ├── FcastAction.kt │ │ │ │ ├── FcastManager.kt │ │ │ │ ├── FcastSession.kt │ │ │ │ └── Packets.kt │ │ │ ├── mvvm/ │ │ │ │ └── Lifecycle.kt │ │ │ ├── network/ │ │ │ │ ├── CloudflareKiller.kt │ │ │ │ ├── DdosGuardKiller.kt │ │ │ │ ├── DohProviders.kt │ │ │ │ └── RequestsHelper.kt │ │ │ ├── plugins/ │ │ │ │ ├── Plugin.kt │ │ │ │ ├── PluginManager.kt │ │ │ │ ├── RepositoryManager.kt │ │ │ │ └── VotingApi.kt │ │ │ ├── receivers/ │ │ │ │ └── VideoDownloadRestartReceiver.kt │ │ │ ├── services/ │ │ │ │ ├── BackupWorkManager.kt │ │ │ │ ├── DownloadQueueService.kt │ │ │ │ ├── PackageInstallerService.kt │ │ │ │ ├── SubscriptionWorkManager.kt │ │ │ │ └── VideoDownloadService.kt │ │ │ ├── subtitles/ │ │ │ │ ├── AbstractSubProvider.kt │ │ │ │ └── AbstractSubtitleEntities.kt │ │ │ ├── syncproviders/ │ │ │ │ ├── AccountManager.kt │ │ │ │ ├── AuthAPI.kt │ │ │ │ ├── AuthRepo.kt │ │ │ │ ├── BackupAPI.kt │ │ │ │ ├── SubtitleAPI.kt │ │ │ │ ├── SubtitleRepo.kt │ │ │ │ ├── SyncAPI.kt │ │ │ │ ├── SyncRepo.kt │ │ │ │ └── providers/ │ │ │ │ ├── Addic7ed.kt │ │ │ │ ├── AniListApi.kt │ │ │ │ ├── KitsuApi.kt │ │ │ │ ├── LocalList.kt │ │ │ │ ├── MALApi.kt │ │ │ │ ├── OpenSubtitlesApi.kt │ │ │ │ ├── SimklApi.kt │ │ │ │ ├── SubSource.kt │ │ │ │ └── Subdl.kt │ │ │ ├── ui/ │ │ │ │ ├── APIRepository.kt │ │ │ │ ├── BaseAdapter.kt │ │ │ │ ├── BaseFragment.kt │ │ │ │ ├── ControllerActivity.kt │ │ │ │ ├── CustomRecyclerViews.kt │ │ │ │ ├── EasterEggMonkeFragment.kt │ │ │ │ ├── MiniControllerFragment.kt │ │ │ │ ├── NonFinalAdapterListUpdateCallback.kt │ │ │ │ ├── WatchType.kt │ │ │ │ ├── WebviewFragment.kt │ │ │ │ ├── account/ │ │ │ │ │ ├── AccountAdapter.kt │ │ │ │ │ ├── AccountHelper.kt │ │ │ │ │ ├── AccountSelectActivity.kt │ │ │ │ │ ├── AccountSelectLinearItemDecoration.kt │ │ │ │ │ └── AccountViewModel.kt │ │ │ │ ├── download/ │ │ │ │ │ ├── DownloadAdapter.kt │ │ │ │ │ ├── DownloadButtonSetup.kt │ │ │ │ │ ├── DownloadChildFragment.kt │ │ │ │ │ ├── DownloadFragment.kt │ │ │ │ │ ├── DownloadViewModel.kt │ │ │ │ │ ├── button/ │ │ │ │ │ │ ├── BaseFetchButton.kt │ │ │ │ │ │ ├── DownloadButton.kt │ │ │ │ │ │ ├── PieFetchButton.kt │ │ │ │ │ │ └── ProgressBarAnimation.kt │ │ │ │ │ └── queue/ │ │ │ │ │ ├── DownloadQueueAdapter.kt │ │ │ │ │ ├── DownloadQueueFragment.kt │ │ │ │ │ └── DownloadQueueViewModel.kt │ │ │ │ ├── home/ │ │ │ │ │ ├── HomeChildItemAdapter.kt │ │ │ │ │ ├── HomeFragment.kt │ │ │ │ │ ├── HomeParentItemAdapter.kt │ │ │ │ │ ├── HomeParentItemAdapterPreview.kt │ │ │ │ │ ├── HomeScrollAdapter.kt │ │ │ │ │ ├── HomeScrollTransformer.kt │ │ │ │ │ └── HomeViewModel.kt │ │ │ │ ├── library/ │ │ │ │ │ ├── LibraryFragment.kt │ │ │ │ │ ├── LibraryScrollTransformer.kt │ │ │ │ │ ├── LibraryViewModel.kt │ │ │ │ │ ├── LoadingPosterAdapter.kt │ │ │ │ │ ├── PageAdapter.kt │ │ │ │ │ └── ViewpagerAdapter.kt │ │ │ │ ├── player/ │ │ │ │ │ ├── AbstractPlayerFragment.kt │ │ │ │ │ ├── CS3IPlayer.kt │ │ │ │ │ ├── CustomSubripParser.kt │ │ │ │ │ ├── CustomSubtitleDecoderFactory.kt │ │ │ │ │ ├── DownloadFileGenerator.kt │ │ │ │ │ ├── DownloadedPlayerActivity.kt │ │ │ │ │ ├── ExtractorLinkGenerator.kt │ │ │ │ │ ├── FixedNextRenderersFactory.kt │ │ │ │ │ ├── FullScreenPlayer.kt │ │ │ │ │ ├── GeneratorPlayer.kt │ │ │ │ │ ├── IGenerator.kt │ │ │ │ │ ├── IPlayer.kt │ │ │ │ │ ├── LinkGenerator.kt │ │ │ │ │ ├── OfflinePlaybackHelper.kt │ │ │ │ │ ├── OutlineSpan.kt │ │ │ │ │ ├── PlayerGeneratorViewModel.kt │ │ │ │ │ ├── PlayerPipHelper.kt │ │ │ │ │ ├── PlayerSubtitleHelper.kt │ │ │ │ │ ├── PreviewGenerator.kt │ │ │ │ │ ├── RepoLinkGenerator.kt │ │ │ │ │ ├── RoundedBackgroundColorSpan.kt │ │ │ │ │ ├── SSLTrustManager.kt │ │ │ │ │ ├── SubtitleOffsetItemAdapter.kt │ │ │ │ │ ├── Torrent.kt │ │ │ │ │ ├── UpdatedDefaultExtractorsFactory.kt │ │ │ │ │ ├── UpdatedMatroskaExtractor.kt │ │ │ │ │ └── source_priority/ │ │ │ │ │ ├── PriorityAdapter.kt │ │ │ │ │ ├── ProfilesAdapter.kt │ │ │ │ │ ├── QualityDataHelper.kt │ │ │ │ │ ├── QualityProfileDialog.kt │ │ │ │ │ └── SourcePriorityDialog.kt │ │ │ │ ├── quicksearch/ │ │ │ │ │ └── QuickSearchFragment.kt │ │ │ │ ├── result/ │ │ │ │ │ ├── ActorAdaptor.kt │ │ │ │ │ ├── EpisodeAdapter.kt │ │ │ │ │ ├── ImageAdapter.kt │ │ │ │ │ ├── LinearListLayout.kt │ │ │ │ │ ├── ResultFragment.kt │ │ │ │ │ ├── ResultFragmentPhone.kt │ │ │ │ │ ├── ResultFragmentTv.kt │ │ │ │ │ ├── ResultTrailerPlayer.kt │ │ │ │ │ ├── ResultViewModel2.kt │ │ │ │ │ ├── SelectAdaptor.kt │ │ │ │ │ └── SyncViewModel.kt │ │ │ │ ├── search/ │ │ │ │ │ ├── SearchAdaptor.kt │ │ │ │ │ ├── SearchFragment.kt │ │ │ │ │ ├── SearchHelper.kt │ │ │ │ │ ├── SearchHistoryAdaptor.kt │ │ │ │ │ ├── SearchResultBuilder.kt │ │ │ │ │ ├── SearchSuggestionAdapter.kt │ │ │ │ │ ├── SearchSuggestionApi.kt │ │ │ │ │ ├── SearchViewModel.kt │ │ │ │ │ └── SyncSearchViewModel.kt │ │ │ │ ├── settings/ │ │ │ │ │ ├── AccountAdapter.kt │ │ │ │ │ ├── Globals.kt │ │ │ │ │ ├── LogcatAdapter.kt │ │ │ │ │ ├── SettingsAccount.kt │ │ │ │ │ ├── SettingsFragment.kt │ │ │ │ │ ├── SettingsGeneral.kt │ │ │ │ │ ├── SettingsPlayer.kt │ │ │ │ │ ├── SettingsProviders.kt │ │ │ │ │ ├── SettingsUI.kt │ │ │ │ │ ├── SettingsUpdates.kt │ │ │ │ │ ├── extensions/ │ │ │ │ │ │ ├── ExtensionsFragment.kt │ │ │ │ │ │ ├── ExtensionsViewModel.kt │ │ │ │ │ │ ├── PluginAdapter.kt │ │ │ │ │ │ ├── PluginDetailsFragment.kt │ │ │ │ │ │ ├── PluginsFragment.kt │ │ │ │ │ │ ├── PluginsViewModel.kt │ │ │ │ │ │ └── RepoAdapter.kt │ │ │ │ │ ├── testing/ │ │ │ │ │ │ ├── TestFragment.kt │ │ │ │ │ │ ├── TestResultAdapter.kt │ │ │ │ │ │ ├── TestView.kt │ │ │ │ │ │ └── TestViewModel.kt │ │ │ │ │ └── utils/ │ │ │ │ │ └── DirectoryPicker.kt │ │ │ │ ├── setup/ │ │ │ │ │ ├── SetupFragmentExtensions.kt │ │ │ │ │ ├── SetupFragmentLanguage.kt │ │ │ │ │ ├── SetupFragmentLayout.kt │ │ │ │ │ ├── SetupFragmentMedia.kt │ │ │ │ │ └── SetupFragmentProviderLanguage.kt │ │ │ │ └── subtitles/ │ │ │ │ ├── ChromecastSubtitlesFragment.kt │ │ │ │ └── SubtitlesFragment.kt │ │ │ ├── utils/ │ │ │ │ ├── AniSkip.kt │ │ │ │ ├── AppContextUtils.kt │ │ │ │ ├── BackPressedCallbackHelper.kt │ │ │ │ ├── BackupUtils.kt │ │ │ │ ├── BiometricAuthenticator.kt │ │ │ │ ├── CastHelper.kt │ │ │ │ ├── CastOptionsProvider.kt │ │ │ │ ├── ConsistentLiveData.kt │ │ │ │ ├── DataStore.kt │ │ │ │ ├── DataStoreHelper.kt │ │ │ │ ├── DownloadFileWorkManager.kt │ │ │ │ ├── Event.kt │ │ │ │ ├── FillerEpisodeCheck.kt │ │ │ │ ├── IDisposable.kt │ │ │ │ ├── ImageModuleCoil.kt │ │ │ │ ├── ImageUtil.kt │ │ │ │ ├── InAppUpdater.kt │ │ │ │ ├── IntentHelpers.kt │ │ │ │ ├── PackageInstaller.kt │ │ │ │ ├── PercentageCropImageView.kt │ │ │ │ ├── PowerManagerAPI.kt │ │ │ │ ├── SingleSelectionHelper.kt │ │ │ │ ├── SnackbarHelper.kt │ │ │ │ ├── SubtitleUtils.kt │ │ │ │ ├── SyncUtil.kt │ │ │ │ ├── TestingUtils.kt │ │ │ │ ├── TextUtil.kt │ │ │ │ ├── TvChannelUtils.kt │ │ │ │ ├── UIHelper.kt │ │ │ │ ├── Vector2.kt │ │ │ │ ├── VideoDownloadHelper.kt │ │ │ │ └── downloader/ │ │ │ │ ├── DownloadFileManagement.kt │ │ │ │ ├── DownloadManager.kt │ │ │ │ ├── DownloadObjects.kt │ │ │ │ ├── DownloadQueueManager.kt │ │ │ │ └── DownloadUtils.kt │ │ │ └── widget/ │ │ │ ├── CenterZoomLayoutManager.kt │ │ │ ├── FlowLayout.kt │ │ │ └── LinearRecycleViewLayoutManager.kt │ │ └── res/ │ │ ├── anim/ │ │ │ ├── enter_anim.xml │ │ │ ├── exit_anim.xml │ │ │ ├── go_left.xml │ │ │ ├── go_right.xml │ │ │ ├── pop_enter.xml │ │ │ ├── pop_exit.xml │ │ │ ├── rotate_around_center_point.xml │ │ │ ├── rotate_left.xml │ │ │ └── rotate_right.xml │ │ ├── color/ │ │ │ ├── black_button_ripple.xml │ │ │ ├── button_selector_color.xml │ │ │ ├── check_selection_color.xml │ │ │ ├── chip_color.xml │ │ │ ├── chip_color_text.xml │ │ │ ├── color_primary_transparent.xml │ │ │ ├── item_select_color.xml │ │ │ ├── item_select_color_tv.xml │ │ │ ├── player_button_tv.xml │ │ │ ├── player_on_button_tv.xml │ │ │ ├── player_on_button_tv_attr.xml │ │ │ ├── selectable_black.xml │ │ │ ├── selectable_white.xml │ │ │ ├── tag_stroke_color.xml │ │ │ ├── text_selection_color.xml │ │ │ ├── toggle_button.xml │ │ │ ├── toggle_button_outline.xml │ │ │ ├── toggle_button_text.xml │ │ │ ├── toggle_selector.xml │ │ │ ├── white_attr_20.xml │ │ │ └── white_transparent_toggle.xml │ │ ├── drawable/ │ │ │ ├── arrow_and_edge_24px.xml │ │ │ ├── arrow_or_edge_24px.xml │ │ │ ├── arrows_input_24px.xml │ │ │ ├── background_shadow.xml │ │ │ ├── baseline_description_24.xml │ │ │ ├── baseline_downloading_24.xml │ │ │ ├── baseline_fullscreen_24.xml │ │ │ ├── baseline_fullscreen_exit_24.xml │ │ │ ├── baseline_grid_view_24.xml │ │ │ ├── baseline_headphones_24.xml │ │ │ ├── baseline_help_outline_24.xml │ │ │ ├── baseline_list_alt_24.xml │ │ │ ├── baseline_network_ping_24.xml │ │ │ ├── baseline_notifications_none_24.xml │ │ │ ├── baseline_remove_24.xml │ │ │ ├── baseline_restore_page_24.xml │ │ │ ├── baseline_save_as_24.xml │ │ │ ├── baseline_skip_previous_24.xml │ │ │ ├── baseline_stop_24.xml │ │ │ ├── baseline_sync_24.xml │ │ │ ├── baseline_text_snippet_24.xml │ │ │ ├── baseline_theaters_24.xml │ │ │ ├── benene.xml │ │ │ ├── bg_color_both.xml │ │ │ ├── bg_color_bottom.xml │ │ │ ├── bg_color_center.xml │ │ │ ├── bg_color_top.xml │ │ │ ├── bg_imdb_badge.xml │ │ │ ├── bookmark_star_24px.xml │ │ │ ├── circle_shape.xml │ │ │ ├── circle_shape_dotted.xml │ │ │ ├── circular_progress_bar.xml │ │ │ ├── circular_progress_bar_clockwise.xml │ │ │ ├── circular_progress_bar_counter_clockwise.xml │ │ │ ├── circular_progress_bar_filled.xml │ │ │ ├── circular_progress_bar_small_to_large.xml │ │ │ ├── circular_progress_bar_top_to_bottom.xml │ │ │ ├── clear_all_24px.xml │ │ │ ├── cloud_2.xml │ │ │ ├── cloud_2_gradient.xml │ │ │ ├── cloud_2_gradient_beta.xml │ │ │ ├── cloud_2_gradient_beta_old.xml │ │ │ ├── cloud_2_gradient_debug.xml │ │ │ ├── cloud_2_solid.xml │ │ │ ├── custom_rating_bar.xml │ │ │ ├── dashed_line_horizontal.xml │ │ │ ├── default_cover.xml │ │ │ ├── delete_all.xml │ │ │ ├── dialog__window_background.xml │ │ │ ├── download_icon_done.xml │ │ │ ├── download_icon_error.xml │ │ │ ├── download_icon_load.xml │ │ │ ├── download_icon_pause.xml │ │ │ ├── dub_bg_color.xml │ │ │ ├── episodes_shadow.xml │ │ │ ├── go_back_30.xml │ │ │ ├── go_forward_30.xml │ │ │ ├── home_alt.xml │ │ │ ├── home_icon_filled_24.xml │ │ │ ├── home_icon_outline_24.xml │ │ │ ├── home_icon_selector.xml │ │ │ ├── hourglass_24.xml │ │ │ ├── ic_anilist_icon.xml │ │ │ ├── ic_banner_foreground.xml │ │ │ ├── ic_baseline_add_24.xml │ │ │ ├── ic_baseline_arrow_back_24.xml │ │ │ ├── ic_baseline_arrow_back_ios_24.xml │ │ │ ├── ic_baseline_arrow_forward_24.xml │ │ │ ├── ic_baseline_aspect_ratio_24.xml │ │ │ ├── ic_baseline_autorenew_24.xml │ │ │ ├── ic_baseline_bookmark_24.xml │ │ │ ├── ic_baseline_bookmark_border_24.xml │ │ │ ├── ic_baseline_brightness_1_24.xml │ │ │ ├── ic_baseline_brightness_2_24.xml │ │ │ ├── ic_baseline_brightness_3_24.xml │ │ │ ├── ic_baseline_brightness_4_24.xml │ │ │ ├── ic_baseline_brightness_5_24.xml │ │ │ ├── ic_baseline_brightness_6_24.xml │ │ │ ├── ic_baseline_brightness_7_24.xml │ │ │ ├── ic_baseline_check_24.xml │ │ │ ├── ic_baseline_check_24_listview.xml │ │ │ ├── ic_baseline_clear_24.xml │ │ │ ├── ic_baseline_close_24.xml │ │ │ ├── ic_baseline_collections_bookmark_24.xml │ │ │ ├── ic_baseline_color_lens_24.xml │ │ │ ├── ic_baseline_construction_24.xml │ │ │ ├── ic_baseline_delete_outline_24.xml │ │ │ ├── ic_baseline_developer_mode_24.xml │ │ │ ├── ic_baseline_discord_24.xml │ │ │ ├── ic_baseline_dns_24.xml │ │ │ ├── ic_baseline_edit_24.xml │ │ │ ├── ic_baseline_equalizer_24.xml │ │ │ ├── ic_baseline_exit_24.xml │ │ │ ├── ic_baseline_extension_24.xml │ │ │ ├── ic_baseline_fast_forward_24.xml │ │ │ ├── ic_baseline_favorite_24.xml │ │ │ ├── ic_baseline_favorite_border_24.xml │ │ │ ├── ic_baseline_film_roll_24.xml │ │ │ ├── ic_baseline_filter_list_24.xml │ │ │ ├── ic_baseline_folder_open_24.xml │ │ │ ├── ic_baseline_hd_24.xml │ │ │ ├── ic_baseline_hearing_24.xml │ │ │ ├── ic_baseline_keyboard_arrow_down_24.xml │ │ │ ├── ic_baseline_keyboard_arrow_left_24.xml │ │ │ ├── ic_baseline_keyboard_arrow_right_24.xml │ │ │ ├── ic_baseline_language_24.xml │ │ │ ├── ic_baseline_more_vert_24.xml │ │ │ ├── ic_baseline_north_west_24.xml │ │ │ ├── ic_baseline_notifications_active_24.xml │ │ │ ├── ic_baseline_ondemand_video_24.xml │ │ │ ├── ic_baseline_open_in_new_24.xml │ │ │ ├── ic_baseline_pause_24.xml │ │ │ ├── ic_baseline_people_24.xml │ │ │ ├── ic_baseline_picture_in_picture_alt_24.xml │ │ │ ├── ic_baseline_play_arrow_24.xml │ │ │ ├── ic_baseline_playlist_play_24.xml │ │ │ ├── ic_baseline_public_24.xml │ │ │ ├── ic_baseline_remove_red_eye_24.xml │ │ │ ├── ic_baseline_replay_24.xml │ │ │ ├── ic_baseline_restart_24.xml │ │ │ ├── ic_baseline_resume_arrow.xml │ │ │ ├── ic_baseline_resume_arrow2.xml │ │ │ ├── ic_baseline_skip_next_24.xml │ │ │ ├── ic_baseline_skip_next_24_big.xml │ │ │ ├── ic_baseline_skip_next_rounded_24.xml │ │ │ ├── ic_baseline_sort_24.xml │ │ │ ├── ic_baseline_speed_24.xml │ │ │ ├── ic_baseline_star_24.xml │ │ │ ├── ic_baseline_star_border_24.xml │ │ │ ├── ic_baseline_storage_24.xml │ │ │ ├── ic_baseline_subtitles_24.xml │ │ │ ├── ic_baseline_system_update_24.xml │ │ │ ├── ic_baseline_text_format_24.xml │ │ │ ├── ic_baseline_thumb_down_24.xml │ │ │ ├── ic_baseline_thumb_up_24.xml │ │ │ ├── ic_baseline_touch_app_24.xml │ │ │ ├── ic_baseline_tune_24.xml │ │ │ ├── ic_baseline_tv_24.xml │ │ │ ├── ic_baseline_visibility_off_24.xml │ │ │ ├── ic_baseline_volume_down_24.xml │ │ │ ├── ic_baseline_volume_mute_24.xml │ │ │ ├── ic_baseline_volume_up_24.xml │ │ │ ├── ic_baseline_warning_24.xml │ │ │ ├── ic_battery.xml │ │ │ ├── ic_cloudstream_monochrome.xml │ │ │ ├── ic_cloudstream_monochrome_big.xml │ │ │ ├── ic_cloudstreamlogotv.xml │ │ │ ├── ic_cloudstreamlogotv_2.xml │ │ │ ├── ic_cloudstreamlogotv_pre.xml │ │ │ ├── ic_cloudstreamlogotv_pre_2.xml │ │ │ ├── ic_dashboard_black_24dp.xml │ │ │ ├── ic_filled_notifications_24dp.xml │ │ │ ├── ic_fingerprint.xml │ │ │ ├── ic_github_logo.xml │ │ │ ├── ic_home_black_24dp.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── ic_launcher_foreground.xml │ │ │ ├── ic_mic.xml │ │ │ ├── ic_network_stream.xml │ │ │ ├── ic_notifications_black_24dp.xml │ │ │ ├── ic_offline_pin_24.xml │ │ │ ├── ic_outline_account_circle_24.xml │ │ │ ├── ic_outline_home_24.xml │ │ │ ├── ic_outline_info_24.xml │ │ │ ├── ic_outline_notifications_24dp.xml │ │ │ ├── ic_outline_remove_red_eye_24.xml │ │ │ ├── ic_outline_settings_24.xml │ │ │ ├── ic_outline_share_24.xml │ │ │ ├── ic_outline_subtitles_24.xml │ │ │ ├── ic_outline_voice_over_off_24.xml │ │ │ ├── ic_refresh.xml │ │ │ ├── indicator_background.xml │ │ │ ├── kid_star_24px.xml │ │ │ ├── kitsu_icon.xml │ │ │ ├── library_icon.xml │ │ │ ├── library_icon_filled.xml │ │ │ ├── library_icon_selector.xml │ │ │ ├── mal_logo.xml │ │ │ ├── material_outline_background.xml │ │ │ ├── monke_benene.xml │ │ │ ├── monke_burrito.xml │ │ │ ├── monke_coco.xml │ │ │ ├── monke_cookie.xml │ │ │ ├── monke_drink.xml │ │ │ ├── monke_flusdered.xml │ │ │ ├── monke_funny.xml │ │ │ ├── monke_like.xml │ │ │ ├── monke_party.xml │ │ │ ├── monke_sob.xml │ │ │ ├── netflix_download.xml │ │ │ ├── netflix_download_batch.xml │ │ │ ├── netflix_pause.xml │ │ │ ├── netflix_play.xml │ │ │ ├── netflix_skip_back.xml │ │ │ ├── netflix_skip_forward.xml │ │ │ ├── notifications_icon_selector.xml │ │ │ ├── open_subtitles_icon.xml │ │ │ ├── outline.xml │ │ │ ├── outline_big_15_gray.xml │ │ │ ├── outline_big_20.xml │ │ │ ├── outline_big_20_gray.xml │ │ │ ├── outline_big_25_gray.xml │ │ │ ├── outline_big_35_gray.xml │ │ │ ├── outline_bookmark_add_24.xml │ │ │ ├── outline_card.xml │ │ │ ├── outline_drawable.xml │ │ │ ├── outline_drawable_forced.xml │ │ │ ├── outline_drawable_forced_round.xml │ │ │ ├── outline_drawable_less.xml │ │ │ ├── outline_drawable_less_inset.xml │ │ │ ├── outline_drawable_round_20.xml │ │ │ ├── outline_less.xml │ │ │ ├── pause_to_play.xml │ │ │ ├── pin_ic.xml │ │ │ ├── play_button.xml │ │ │ ├── play_button_transparent.xml │ │ │ ├── play_to_pause.xml │ │ │ ├── player_button_tv.xml │ │ │ ├── player_button_tv_attr.xml │ │ │ ├── player_button_tv_attr_no_bg.xml │ │ │ ├── player_gradient_tv.xml │ │ │ ├── preview_seekbar_24.xml │ │ │ ├── progress_drawable_vertical.xml │ │ │ ├── question_mark_24.xml │ │ │ ├── quick_novel_icon.xml │ │ │ ├── rating_bg_color.xml │ │ │ ├── rating_empty.xml │ │ │ ├── rating_fill.xml │ │ │ ├── rddone.xml │ │ │ ├── rderror.xml │ │ │ ├── rdload.xml │ │ │ ├── rdpause.xml │ │ │ ├── round_keyboard_arrow_up_24.xml │ │ │ ├── rounded_dialog.xml │ │ │ ├── rounded_outline.xml │ │ │ ├── rounded_progress.xml │ │ │ ├── rounded_select_ripple.xml │ │ │ ├── screen_rotation.xml │ │ │ ├── search_background.xml │ │ │ ├── search_icon.xml │ │ │ ├── settings_alt.xml │ │ │ ├── settings_icon_filled.xml │ │ │ ├── settings_icon_outline.xml │ │ │ ├── settings_icon_selector.xml │ │ │ ├── simkl_logo.xml │ │ │ ├── solid_primary.xml │ │ │ ├── speedup.xml │ │ │ ├── splash_background.xml │ │ │ ├── storage_bar_left.xml │ │ │ ├── storage_bar_left_box.xml │ │ │ ├── storage_bar_mid.xml │ │ │ ├── storage_bar_mid_box.xml │ │ │ ├── storage_bar_right.xml │ │ │ ├── storage_bar_right_box.xml │ │ │ ├── sub_bg_color.xml │ │ │ ├── subdl_logo_big.xml │ │ │ ├── subtitles_background_gradient.xml │ │ │ ├── sun_1.xml │ │ │ ├── sun_2.xml │ │ │ ├── sun_3.xml │ │ │ ├── sun_4.xml │ │ │ ├── sun_5.xml │ │ │ ├── sun_6.xml │ │ │ ├── sun_7.xml │ │ │ ├── sun_7_24.xml │ │ │ ├── tab_selector.xml │ │ │ ├── title_24px.xml │ │ │ ├── title_shadow.xml │ │ │ ├── type_bg_color.xml │ │ │ ├── video_bottom_button.xml │ │ │ ├── video_frame.xml │ │ │ ├── video_locked.xml │ │ │ ├── video_outline.xml │ │ │ ├── video_pause.xml │ │ │ ├── video_play.xml │ │ │ ├── video_tap_button.xml │ │ │ ├── video_tap_button_always_white.xml │ │ │ ├── video_tap_button_skip.xml │ │ │ └── video_unlocked.xml │ │ ├── drawable-v24/ │ │ │ ├── ic_banner_background.xml │ │ │ ├── ic_banner_foreground.xml │ │ │ └── ic_launcher_foreground.xml │ │ ├── font/ │ │ │ └── google_sans.xml │ │ ├── layout/ │ │ │ ├── account_edit_dialog.xml │ │ │ ├── account_list_item.xml │ │ │ ├── account_list_item_add.xml │ │ │ ├── account_list_item_edit.xml │ │ │ ├── account_managment.xml │ │ │ ├── account_select_linear.xml │ │ │ ├── account_single.xml │ │ │ ├── account_switch.xml │ │ │ ├── activity_account_select.xml │ │ │ ├── activity_main.xml │ │ │ ├── activity_main_tv.xml │ │ │ ├── add_account_input.xml │ │ │ ├── add_remove_sites.xml │ │ │ ├── add_repo_input.xml │ │ │ ├── add_site_input.xml │ │ │ ├── bottom_input_dialog.xml │ │ │ ├── bottom_loading.xml │ │ │ ├── bottom_resultview_preview.xml │ │ │ ├── bottom_resultview_preview_tv.xml │ │ │ ├── bottom_selection_dialog.xml │ │ │ ├── bottom_selection_dialog_direct.xml │ │ │ ├── bottom_text_dialog.xml │ │ │ ├── cast_item.xml │ │ │ ├── chromecast_subtitle_settings.xml │ │ │ ├── confirm_exit_dialog.xml │ │ │ ├── custom_preference_category_material.xml │ │ │ ├── custom_preference_material.xml │ │ │ ├── custom_preference_widget_seekbar.xml │ │ │ ├── device_auth.xml │ │ │ ├── dialog_loading.xml │ │ │ ├── dialog_online_subtitles.xml │ │ │ ├── download_button.xml │ │ │ ├── download_button_layout.xml │ │ │ ├── download_button_view.xml │ │ │ ├── download_child_episode.xml │ │ │ ├── download_header_episode.xml │ │ │ ├── download_queue_item.xml │ │ │ ├── empty_layout.xml │ │ │ ├── extra_brightness_overlay.xml │ │ │ ├── fragment_child_downloads.xml │ │ │ ├── fragment_download_queue.xml │ │ │ ├── fragment_downloads.xml │ │ │ ├── fragment_easter_egg_monke.xml │ │ │ ├── fragment_extensions.xml │ │ │ ├── fragment_home.xml │ │ │ ├── fragment_home_head.xml │ │ │ ├── fragment_home_head_tv.xml │ │ │ ├── fragment_home_tv.xml │ │ │ ├── fragment_library.xml │ │ │ ├── fragment_library_tv.xml │ │ │ ├── fragment_player.xml │ │ │ ├── fragment_player_tv.xml │ │ │ ├── fragment_plugin_details.xml │ │ │ ├── fragment_plugins.xml │ │ │ ├── fragment_result.xml │ │ │ ├── fragment_result_swipe.xml │ │ │ ├── fragment_result_tv.xml │ │ │ ├── fragment_search.xml │ │ │ ├── fragment_search_tv.xml │ │ │ ├── fragment_setup_extensions.xml │ │ │ ├── fragment_setup_language.xml │ │ │ ├── fragment_setup_layout.xml │ │ │ ├── fragment_setup_media.xml │ │ │ ├── fragment_setup_provider_languages.xml │ │ │ ├── fragment_testing.xml │ │ │ ├── fragment_trailer.xml │ │ │ ├── fragment_webview.xml │ │ │ ├── home_episodes_expanded.xml │ │ │ ├── home_remove_grid.xml │ │ │ ├── home_remove_grid_expanded.xml │ │ │ ├── home_result_big_grid.xml │ │ │ ├── home_result_grid.xml │ │ │ ├── home_result_grid_expanded.xml │ │ │ ├── home_scroll_view.xml │ │ │ ├── home_scroll_view_tv.xml │ │ │ ├── home_select_mainpage.xml │ │ │ ├── homepage_parent.xml │ │ │ ├── homepage_parent_emulator.xml │ │ │ ├── homepage_parent_tv.xml │ │ │ ├── item_logcat.xml │ │ │ ├── library_viewpager_page.xml │ │ │ ├── loading_downloads.xml │ │ │ ├── loading_episode.xml │ │ │ ├── loading_line.xml │ │ │ ├── loading_line_short.xml │ │ │ ├── loading_line_short_center.xml │ │ │ ├── loading_list.xml │ │ │ ├── loading_poster.xml │ │ │ ├── loading_poster_dynamic.xml │ │ │ ├── lock_pin_dialog.xml │ │ │ ├── logcat.xml │ │ │ ├── main_settings.xml │ │ │ ├── options_popup_tv.xml │ │ │ ├── player_custom_layout.xml │ │ │ ├── player_custom_layout_tv.xml │ │ │ ├── player_prioritize_item.xml │ │ │ ├── player_quality_profile_dialog.xml │ │ │ ├── player_quality_profile_item.xml │ │ │ ├── player_select_source_and_subs.xml │ │ │ ├── player_select_source_priority.xml │ │ │ ├── player_select_tracks.xml │ │ │ ├── provider_list.xml │ │ │ ├── provider_test_item.xml │ │ │ ├── quick_search.xml │ │ │ ├── rail_footer.xml │ │ │ ├── rail_header.xml │ │ │ ├── repository_item.xml │ │ │ ├── repository_item_tv.xml │ │ │ ├── result_episode.xml │ │ │ ├── result_episode_large.xml │ │ │ ├── result_mini_image.xml │ │ │ ├── result_poster.xml │ │ │ ├── result_recommendations.xml │ │ │ ├── result_selection.xml │ │ │ ├── result_sync.xml │ │ │ ├── result_tag.xml │ │ │ ├── search_history_footer.xml │ │ │ ├── search_history_item.xml │ │ │ ├── search_result_compact.xml │ │ │ ├── search_result_grid.xml │ │ │ ├── search_result_grid_expanded.xml │ │ │ ├── search_result_super_compact.xml │ │ │ ├── search_suggestion_footer.xml │ │ │ ├── search_suggestion_item.xml │ │ │ ├── settings_title_top.xml │ │ │ ├── sort_bottom_footer_add_choice.xml │ │ │ ├── sort_bottom_sheet.xml │ │ │ ├── sort_bottom_single_choice.xml │ │ │ ├── sort_bottom_single_choice_color.xml │ │ │ ├── sort_bottom_single_choice_double_text.xml │ │ │ ├── sort_bottom_single_choice_no_checkmark.xml │ │ │ ├── sort_bottom_single_provider_choice.xml │ │ │ ├── speed_dialog.xml │ │ │ ├── standard_toolbar.xml │ │ │ ├── stream_input.xml │ │ │ ├── subtitle_offset.xml │ │ │ ├── subtitle_offset_item.xml │ │ │ ├── subtitle_settings.xml │ │ │ ├── subtitle_settings_dialog.xml │ │ │ ├── toast.xml │ │ │ ├── trailer_custom_layout.xml │ │ │ ├── tvtypes_chips.xml │ │ │ ├── tvtypes_chips_scroll.xml │ │ │ └── view_test.xml │ │ ├── layout-port/ │ │ │ ├── player_select_source_and_subs.xml │ │ │ ├── player_select_source_priority.xml │ │ │ └── subtitle_offset.xml │ │ ├── menu/ │ │ │ ├── bottom_nav_menu.xml │ │ │ ├── cast_expanded_controller_menu.xml │ │ │ ├── download_queue.xml │ │ │ ├── library_menu.xml │ │ │ └── repository.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_banner.xml │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ ├── navigation/ │ │ │ └── mobile_navigation.xml │ │ ├── values/ │ │ │ ├── array.xml │ │ │ ├── attrs.xml │ │ │ ├── colors.xml │ │ │ ├── dimens.xml │ │ │ ├── donottranslate-strings.xml │ │ │ ├── ic_launcher_background.xml │ │ │ ├── strings.xml │ │ │ └── styles.xml │ │ ├── values-arz/ │ │ │ └── strings.xml │ │ ├── values-b+af/ │ │ │ └── strings.xml │ │ ├── values-b+am/ │ │ │ └── strings.xml │ │ ├── values-b+apc/ │ │ │ └── strings.xml │ │ ├── values-b+ar/ │ │ │ └── strings.xml │ │ ├── values-b+ars/ │ │ │ └── strings.xml │ │ ├── values-b+as/ │ │ │ └── strings.xml │ │ ├── values-b+az/ │ │ │ └── strings.xml │ │ ├── values-b+bg/ │ │ │ └── strings.xml │ │ ├── values-b+bn/ │ │ │ └── strings.xml │ │ ├── values-b+ckb/ │ │ │ └── strings.xml │ │ ├── values-b+cs/ │ │ │ └── strings.xml │ │ ├── values-b+de/ │ │ │ └── strings.xml │ │ ├── values-b+el/ │ │ │ └── strings.xml │ │ ├── values-b+eo/ │ │ │ └── strings.xml │ │ ├── values-b+es/ │ │ │ ├── array.xml │ │ │ └── strings.xml │ │ ├── values-b+fa/ │ │ │ └── strings.xml │ │ ├── values-b+fil/ │ │ │ └── strings.xml │ │ ├── values-b+fr/ │ │ │ └── strings.xml │ │ ├── values-b+gl/ │ │ │ └── strings.xml │ │ ├── values-b+hi/ │ │ │ └── strings.xml │ │ ├── values-b+hr/ │ │ │ └── strings.xml │ │ ├── values-b+hu/ │ │ │ └── strings.xml │ │ ├── values-b+in/ │ │ │ └── strings.xml │ │ ├── values-b+it/ │ │ │ └── strings.xml │ │ ├── values-b+iw/ │ │ │ └── strings.xml │ │ ├── values-b+ja/ │ │ │ └── strings.xml │ │ ├── values-b+kn/ │ │ │ └── strings.xml │ │ ├── values-b+ko/ │ │ │ └── strings.xml │ │ ├── values-b+lt/ │ │ │ └── strings.xml │ │ ├── values-b+lv/ │ │ │ └── strings.xml │ │ ├── values-b+mk/ │ │ │ └── strings.xml │ │ ├── values-b+ml/ │ │ │ └── strings.xml │ │ ├── values-b+ms/ │ │ │ └── strings.xml │ │ ├── values-b+mt/ │ │ │ └── strings.xml │ │ ├── values-b+my/ │ │ │ └── strings.xml │ │ ├── values-b+ne/ │ │ │ └── strings.xml │ │ ├── values-b+nl/ │ │ │ └── strings.xml │ │ ├── values-b+nn/ │ │ │ └── strings.xml │ │ ├── values-b+no/ │ │ │ └── strings.xml │ │ ├── values-b+or/ │ │ │ └── strings.xml │ │ ├── values-b+pl/ │ │ │ ├── array.xml │ │ │ └── strings.xml │ │ ├── values-b+pt/ │ │ │ └── strings.xml │ │ ├── values-b+pt+BR/ │ │ │ └── strings.xml │ │ ├── values-b+qt/ │ │ │ └── strings.xml │ │ ├── values-b+ro/ │ │ │ └── strings.xml │ │ ├── values-b+ru/ │ │ │ └── strings.xml │ │ ├── values-b+sk/ │ │ │ └── strings.xml │ │ ├── values-b+so/ │ │ │ └── strings.xml │ │ ├── values-b+sv/ │ │ │ └── strings.xml │ │ ├── values-b+ta/ │ │ │ └── strings.xml │ │ ├── values-b+ti/ │ │ │ └── strings.xml │ │ ├── values-b+tl/ │ │ │ └── strings.xml │ │ ├── values-b+tr/ │ │ │ ├── array.xml │ │ │ └── strings.xml │ │ ├── values-b+uk/ │ │ │ └── strings.xml │ │ ├── values-b+ur/ │ │ │ └── strings.xml │ │ ├── values-b+vi/ │ │ │ ├── array.xml │ │ │ └── strings.xml │ │ ├── values-b+zh/ │ │ │ └── strings.xml │ │ ├── values-b+zh+TW/ │ │ │ └── strings.xml │ │ ├── values-be/ │ │ │ └── strings.xml │ │ ├── values-ca/ │ │ │ └── strings.xml │ │ └── xml/ │ │ ├── backup_descriptor.xml │ │ ├── data_extraction_rules.xml │ │ ├── provider_paths.xml │ │ ├── settings_account.xml │ │ ├── settings_general.xml │ │ ├── settings_player.xml │ │ ├── settings_providers.xml │ │ ├── settings_ui.xml │ │ └── settings_updates.xml │ ├── prerelease/ │ │ └── res/ │ │ ├── drawable/ │ │ │ └── ic_banner_foreground.xml │ │ ├── drawable-v24/ │ │ │ ├── ic_banner_background.xml │ │ │ └── ic_launcher_foreground.xml │ │ ├── mipmap-anydpi-v26/ │ │ │ ├── ic_banner.xml │ │ │ ├── ic_launcher.xml │ │ │ └── ic_launcher_round.xml │ │ └── values/ │ │ ├── ic_launcher_background.xml │ │ └── strings.xml │ └── test/ │ └── java/ │ └── com/ │ └── lagradost/ │ └── cloudstream3/ │ ├── ProviderTests.kt │ └── SubtitleSelectionTest.kt ├── build.gradle.kts ├── discoverium.yml ├── docs/ │ ├── .gitignore │ └── build.gradle.kts ├── fastlane/ │ └── metadata/ │ └── android/ │ ├── af/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── am/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── apc/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ar/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ar-SA/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── as/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── be/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── bg/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ca/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ckb/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── cs-CZ/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── de-DE/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── el-GR/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── en-US/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── es-AR/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── es-ES/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── fa-IR/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── fr-FR/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── hi-IN/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── hr/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── hu-HU/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── id/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── it-IT/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ja-JP/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ko-KR/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── lt/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── lv/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── mk-MK/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ml-IN/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── mt/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── nl-NL/ │ │ ├── short_description.txt │ │ └── title.txt │ ├── no-NO/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── or/ │ │ └── changelogs/ │ │ └── 2.txt │ ├── pa/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── pl-PL/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── pt/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── pt-BR/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ro/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ru-RU/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── sk/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── sv-SE/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ta-IN/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── tr-TR/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── uk/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── ur/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── vi/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ ├── zh-CN/ │ │ ├── changelogs/ │ │ │ └── 2.txt │ │ ├── full_description.txt │ │ ├── short_description.txt │ │ └── title.txt │ └── zh-TW/ │ ├── changelogs/ │ │ └── 2.txt │ ├── full_description.txt │ ├── short_description.txt │ └── title.txt ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── jitpack.yml ├── library/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── lint.xml │ └── src/ │ ├── androidMain/ │ │ ├── AndroidManifest.xml │ │ └── kotlin/ │ │ └── com/ │ │ └── lagradost/ │ │ ├── api/ │ │ │ ├── ContextHelper.android.kt │ │ │ └── Log.kt │ │ └── cloudstream3/ │ │ ├── network/ │ │ │ └── WebViewResolver.android.kt │ │ └── utils/ │ │ └── Coroutines.android.kt │ ├── commonMain/ │ │ └── kotlin/ │ │ └── com/ │ │ └── lagradost/ │ │ ├── api/ │ │ │ ├── ContextHelper.kt │ │ │ └── Log.kt │ │ └── cloudstream3/ │ │ ├── MainAPI.kt │ │ ├── MainActivity.kt │ │ ├── ParCollections.kt │ │ ├── extractors/ │ │ │ ├── Acefile.kt │ │ │ ├── Bigwarp.kt │ │ │ ├── Blogger.kt │ │ │ ├── ByseSX.kt │ │ │ ├── Cda.kt │ │ │ ├── CineMMRedirect.kt │ │ │ ├── CloudMailRuExtractor.kt │ │ │ ├── ContentXExtractor.kt │ │ │ ├── Dailymotion.kt │ │ │ ├── DoodExtractor.kt │ │ │ ├── Embedgram.kt │ │ │ ├── EmturbovidExtractor.kt │ │ │ ├── Evolaod.kt │ │ │ ├── Fastream.kt │ │ │ ├── Filemoon.kt │ │ │ ├── Filesim.kt │ │ │ ├── GDMirrorbot.kt │ │ │ ├── GUpload.kt │ │ │ ├── GamoVideo.kt │ │ │ ├── Gdriveplayer.kt │ │ │ ├── GenericM3U8.kt │ │ │ ├── Gofile.kt │ │ │ ├── GoodstreamExtractor.kt │ │ │ ├── HDMomPlayerExtractor.kt │ │ │ ├── HDPlayerSystemExtractor.kt │ │ │ ├── HDStreamAbleExtractor.kt │ │ │ ├── HotlingerExtractor.kt │ │ │ ├── HubCloud.kt │ │ │ ├── Hxfile.kt │ │ │ ├── InternetArchive.kt │ │ │ ├── JWPlayer.kt │ │ │ ├── Jeniusplay.kt │ │ │ ├── Krakenfiles.kt │ │ │ ├── Linkbox.kt │ │ │ ├── LuluStream.kt │ │ │ ├── M3u8Manifest.kt │ │ │ ├── MailRuExtractor.kt │ │ │ ├── Maxstream.kt │ │ │ ├── Mediafire.kt │ │ │ ├── Minoplres.kt │ │ │ ├── MixDrop.kt │ │ │ ├── Moviehab.kt │ │ │ ├── Mp4Upload.kt │ │ │ ├── MultiQuality.kt │ │ │ ├── Mvidoo.kt │ │ │ ├── OdnoklassnikiExtractor.kt │ │ │ ├── OkRuExtractor.kt │ │ │ ├── PeaceMakerstExtractor.kt │ │ │ ├── Pelisplus.kt │ │ │ ├── PixelDrainExtractor.kt │ │ │ ├── PlayLtXyz.kt │ │ │ ├── PlayerVoxzer.kt │ │ │ ├── Rabbitstream.kt │ │ │ ├── RapidVidExtractor.kt │ │ │ ├── SBPlay.kt │ │ │ ├── SecvideoOnline.kt │ │ │ ├── Sendvid.kt │ │ │ ├── SibNetExtractor.kt │ │ │ ├── SobreatsesuypExtractor.kt │ │ │ ├── StreamEmbed.kt │ │ │ ├── StreamSB.kt │ │ │ ├── StreamSilk.kt │ │ │ ├── StreamTape.kt │ │ │ ├── StreamWishExtractor.kt │ │ │ ├── Streamhub.kt │ │ │ ├── Streamlare.kt │ │ │ ├── StreamoUpload.kt │ │ │ ├── Streamplay.kt │ │ │ ├── Streamup.kt │ │ │ ├── Supervideo.kt │ │ │ ├── TRsTXExtractor.kt │ │ │ ├── Tantifilm.kt │ │ │ ├── TauVideoExtractor.kt │ │ │ ├── Up4Stream.kt │ │ │ ├── UpstreamExtractor.kt │ │ │ ├── Uqload.kt │ │ │ ├── Userload.kt │ │ │ ├── Userscloud.kt │ │ │ ├── Uservideo.kt │ │ │ ├── Vicloud.kt │ │ │ ├── VidHidePro.kt │ │ │ ├── VidMoxyExtractor.kt │ │ │ ├── VidNest.kt │ │ │ ├── VidStack.kt │ │ │ ├── Videa.kt │ │ │ ├── VideoSeyredExtractor.kt │ │ │ ├── VidhideExtractor.kt │ │ │ ├── Vidmoly.kt │ │ │ ├── Vido.kt │ │ │ ├── Vidoza.kt │ │ │ ├── Vidsonic.kt │ │ │ ├── Vidstream.kt │ │ │ ├── Vinovo.kt │ │ │ ├── VkExtractor.kt │ │ │ ├── Voe.kt │ │ │ ├── Vtbe.kt │ │ │ ├── WatchSB.kt │ │ │ ├── Wibufile.kt │ │ │ ├── XStreamCdn.kt │ │ │ ├── YourUpload.kt │ │ │ ├── YoutubeExtractor.kt │ │ │ ├── Zplayer.kt │ │ │ └── helper/ │ │ │ ├── AesHelper.kt │ │ │ ├── AsianEmbedHelper.kt │ │ │ ├── CryptoJSHelper.kt │ │ │ ├── GogoHelper.kt │ │ │ ├── NineAnimeHelper.kt │ │ │ ├── VstreamhubHelper.kt │ │ │ └── WcoHelper.kt │ │ ├── metaproviders/ │ │ │ ├── CrossTmdbProvider.kt │ │ │ ├── MyDramaList.kt │ │ │ ├── SyncRedirector.kt │ │ │ ├── TmdbProvider.kt │ │ │ └── TraktProvider.kt │ │ ├── mvvm/ │ │ │ └── ArchComponentExt.kt │ │ ├── network/ │ │ │ └── WebViewResolver.kt │ │ ├── plugins/ │ │ │ ├── BasePlugin.kt │ │ │ └── CloudstreamPlugin.kt │ │ ├── syncproviders/ │ │ │ └── SyncAPI.kt │ │ └── utils/ │ │ ├── AppDebug.kt │ │ ├── AppUtils.kt │ │ ├── Coroutines.kt │ │ ├── ExtractorApi.kt │ │ ├── HlsPlaylistParser.kt │ │ ├── JsHunter.kt │ │ ├── JsUnpacker.kt │ │ ├── M3u8Helper.kt │ │ ├── StringUtils.kt │ │ ├── SubtitleHelper.kt │ │ └── UnshortenUrl.kt │ └── jvmMain/ │ └── kotlin/ │ └── com/ │ └── lagradost/ │ ├── api/ │ │ ├── ContextHelper.jvm.kt │ │ └── Log.kt │ └── cloudstream3/ │ ├── network/ │ │ └── WebViewResolver.jvm.kt │ └── utils/ │ └── Coroutines.jvm.kt └── settings.gradle.kts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/ISSUE_TEMPLATE/application-bug.yml ================================================ name: 🐞 Application Issue Report description: Report a issue in CloudStream labels: [bug] body: - type: textarea id: reproduce-steps attributes: label: Steps to reproduce description: Provide an example of the issue. placeholder: | Example: 1. First step 2. Second step 3. Issue here validations: required: true - type: textarea id: expected-behavior attributes: label: Expected behavior placeholder: | Example: "This should happen..." validations: required: true - type: textarea id: actual-behavior attributes: label: Actual behavior placeholder: | Example: "This happened instead..." validations: required: true - type: input id: cloudstream-version attributes: label: Cloudstream version and commit hash description: | You can find your Cloudstream version in **Settings**. Commit hash is the 7 character string next to the version. placeholder: | Example: "2.8.16 a49f466" validations: required: true - type: input id: android-version attributes: label: Android version description: | You can find this somewhere in your Android settings. placeholder: | Example: "Android 12" validations: required: true - type: textarea id: logcat attributes: label: Logcat placeholder: | To get logcat please go to Settings > Updates and backup > Show logcat 🐈. You can attach a file or link to some pastebin service if the file is too big. render: java - type: textarea id: other-details attributes: label: Other details placeholder: | Additional details and attachments. - type: checkboxes id: acknowledgements attributes: label: Acknowledgements description: Your issue will be closed if you haven't done these steps. options: - label: I am sure my issue is related to the app and **NOT some extension**. required: true - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue. required: true - label: I have written a short but informative title. required: true - label: I have updated the app to pre-release version **[Latest](https://github.com/recloudstream/cloudstream/releases)**. required: true - label: I will fill out all of the requested information in this form. required: true ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: Request a new provider or report bug with an existing provider url: https://github.com/recloudstream about: EXTREMELY IMPORTANT - Please do not report any provider bugs here or request new providers. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the discord. - name: Discord url: https://discord.gg/5Hus6fM about: Join our discord for faster support on smaller issues. ================================================ FILE: .github/ISSUE_TEMPLATE/feature-request.yml ================================================ name: ⭐ Feature request description: Suggest a feature to improve the app labels: [enhancement] body: - type: textarea id: feature-description attributes: label: Describe your suggested feature description: How can an existing source be improved? placeholder: | Example: "It should work like this..." validations: required: true - type: textarea id: other-details attributes: label: Other details placeholder: | Additional details and attachments. - type: checkboxes id: acknowledgements attributes: label: Acknowledgements description: Your issue will be closed if you haven't done these steps. options: - label: My suggestion is **NOT** about adding a new provider required: true - label: I have searched the existing issues and this is a new ticket, **NOT** a duplicate or related to another open issue. required: true ================================================ FILE: .github/locales.py ================================================ import re import glob import requests import lxml.etree as ET # builtin library doesn't preserve comments SETTINGS_PATH = "app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt" START_MARKER = "/* begin language list */" END_MARKER = "/* end language list */" XML_NAME = "app/src/main/res/values-b+" ISO_MAP_URL = "https://raw.githubusercontent.com/haliaeetus/iso-639/master/data/iso_639-1.min.json" INDENT = " "*4 iso_map = requests.get(ISO_MAP_URL, timeout=300).json() # Load settings file src = open(SETTINGS_PATH, "r", encoding='utf-8').read() before_src, rest = src.split(START_MARKER) rest, after_src = rest.split(END_MARKER) # Load already added langs languages = {} for lang in re.finditer(r'Pair\("(.*)", "(.*)"\)', rest): name, iso = lang.groups() languages[iso] = name # Add not yet added langs for folder in glob.glob(f"{XML_NAME}*"): iso = folder[len(XML_NAME):].replace("+", "-") if iso not in languages.keys(): entry = iso_map.get(iso.lower(), {'nativeName':iso}) # fallback to iso code if not found languages[iso] = entry['nativeName'].split(',')[0] # first name if there are multiple # Create pairs pairs = [] for iso in sorted(languages, key=lambda iso: languages[iso].lower()): # sort by language name name = languages[iso] pairs.append(f'{INDENT}Pair("{name}", "{iso}"),') # Update settings file open(SETTINGS_PATH, "w+",encoding='utf-8').write( before_src + START_MARKER + "\n" + "\n".join(pairs) + "\n" + END_MARKER + after_src ) # Go through each values.xml file and fix escaped \@string for file in glob.glob(f"{XML_NAME}*/strings.xml"): try: tree = ET.parse(file) for child in tree.getroot(): if not child.text: continue if child.text.startswith("\\@string/"): print(f"[{file}] fixing {child.attrib['name']}") child.text = child.text.replace("\\@string/", "@string/") with open(file, 'wb') as fp: fp.write(b'\n') tree.write(fp, encoding="utf-8", method="xml", pretty_print=True, xml_declaration=False) except ET.ParseError as ex: print(f"[{file}] {ex}") ================================================ FILE: .github/workflows/build_to_archive.yml ================================================ name: Archive build on: push: branches: [ master ] paths-ignore: - '*.md' - '*.json' - '**/wcokey.txt' workflow_dispatch: concurrency: group: "Archive-build" cancel-in-progress: true jobs: build: runs-on: ubuntu-latest steps: - name: Generate access token id: generate_token uses: tibdex/github-app-token@v2 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/secrets" - name: Generate access token (archive) id: generate_archive_token uses: tibdex/github-app-token@v2 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/cloudstream-archive" - uses: actions/checkout@v6 - name: Set up JDK 17 uses: actions/setup-java@v5 with: distribution: temurin java-version: 17 - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Fetch keystore id: fetch_keystore run: | TMP_KEYSTORE_FILE_PATH="${RUNNER_TEMP}"/keystore mkdir -p "${TMP_KEYSTORE_FILE_PATH}" curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "${TMP_KEYSTORE_FILE_PATH}/prerelease_keystore.keystore" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore.jks" curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "keystore_password.txt" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore_password.txt" KEY_PWD="$(cat keystore_password.txt)" echo "::add-mask::${KEY_PWD}" echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Run Gradle run: ./gradlew assemblePrerelease env: SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} - uses: actions/checkout@v6 with: repository: "recloudstream/cloudstream-archive" token: ${{ steps.generate_archive_token.outputs.token }} path: "archive" - name: Move build run: cp app/build/outputs/apk/prerelease/release/*.apk "archive/$(git rev-parse --short HEAD).apk" - name: Push archive run: | cd $GITHUB_WORKSPACE/archive git config --local user.email "actions@github.com" git config --local user.name "GitHub Actions" git add . git commit --amend -m "Build $GITHUB_SHA" || exit 0 # do not error if nothing to commit git push --force ================================================ FILE: .github/workflows/generate_dokka.yml ================================================ name: Dokka on: push: branches: [ master ] paths-ignore: - '*.md' concurrency: group: "dokka" cancel-in-progress: true jobs: build: runs-on: ubuntu-latest steps: - name: Generate access token id: generate_token uses: tibdex/github-app-token@v2 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/dokka" - name: Checkout uses: actions/checkout@v6 with: path: "src" - name: Checkout dokka uses: actions/checkout@v6 with: repository: "recloudstream/dokka" path: "dokka" token: ${{ steps.generate_token.outputs.token }} - name: Clean old builds run: | cd $GITHUB_WORKSPACE/dokka/ rm -rf "./app" rm -rf "./library" - name: Set up JDK 17 uses: actions/setup-java@v5 with: distribution: temurin java-version: 17 - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Set up Android SDK uses: android-actions/setup-android@v3 - name: Generate Dokka run: | cd $GITHUB_WORKSPACE/src/ chmod +x gradlew ./gradlew docs:dokkaGeneratePublicationHtml - name: Copy Dokka run: cp -r $GITHUB_WORKSPACE/src/docs/build/dokka/html/* $GITHUB_WORKSPACE/dokka/ - name: Push builds run: | cd $GITHUB_WORKSPACE/dokka touch .nojekyll git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com" git config --local user.name "recloudstream[bot]" git add . git commit --amend -m "Generate dokka for recloudstream/cloudstream@${GITHUB_SHA}" || exit 0 # do not error if nothing to commit git push --force ================================================ FILE: .github/workflows/issue_action.yml ================================================ name: Issue automatic actions on: issues: types: [opened] jobs: issue-moderator: runs-on: ubuntu-latest steps: - name: Generate access token id: generate_token uses: tibdex/github-app-token@v2 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} - name: Similarity analysis id: similarity uses: actions-cool/issues-similarity-analysis@v1 with: token: ${{ steps.generate_token.outputs.token }} filter-threshold: 0.60 title-excludes: '' comment-title: | ### Your issue looks similar to these issues: Please close if duplicate. comment-body: '${index}. ${similarity} #${number}' - name: Label if possible duplicate if: steps.similarity.outputs.similar-issues-found =='true' uses: actions/github-script@v8 with: github-token: ${{ steps.generate_token.outputs.token }} script: | github.rest.issues.addLabels({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, labels: ["possible duplicate"] }) - uses: actions/checkout@v6 - name: Automatically close issues that dont follow the issue template uses: lucasbento/auto-close-issues@v1.0.2 with: github-token: ${{ steps.generate_token.outputs.token }} issue-close-message: | @${issue.user.login}: hello! :wave: This issue is being automatically closed because it does not follow the issue template." closed-issues-label: "invalid" - name: Check if issue mentions a provider id: provider_check env: GH_TEXT: "${{ github.event.issue.title }} ${{ github.event.issue.body }}" run: | wget --output-document check_issue.py "https://raw.githubusercontent.com/recloudstream/.github/master/.github/check_issue.py" pip3 install httpx RES="$(python3 ./check_issue.py)" echo "name=${RES}" >> $GITHUB_OUTPUT - name: Comment if issue mentions a provider if: steps.provider_check.outputs.name != 'none' uses: actions-cool/issues-helper@v3 with: actions: 'create-comment' token: ${{ steps.generate_token.outputs.token }} body: | Hello ${{ github.event.issue.user.login }}. Please do not report any provider bugs here. This repository does not contain any providers. Please find the appropriate repository and report your issue there or join the [discord](https://discord.gg/5Hus6fM). Found provider name: `${{ steps.provider_check.outputs.name }}` - name: Label if mentions provider if: steps.provider_check.outputs.name != 'none' uses: actions/github-script@v8 with: github-token: ${{ steps.generate_token.outputs.token }} script: | github.rest.issues.addLabels({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, labels: ["possible provider issue"] }) - name: Add eyes reaction to all issues uses: actions-cool/emoji-helper@v1.0.0 with: type: 'issue' token: ${{ steps.generate_token.outputs.token }} emoji: 'eyes' ================================================ FILE: .github/workflows/prerelease.yml ================================================ name: Pre-release on: push: branches: [ master ] paths-ignore: - '*.md' - '*.json' - '**/wcokey.txt' concurrency: group: "pre-release" cancel-in-progress: true jobs: build: runs-on: ubuntu-latest steps: - name: Generate access token id: generate_token uses: tibdex/github-app-token@v2 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/secrets" - uses: actions/checkout@v6 - name: Set up JDK 17 uses: actions/setup-java@v5 with: distribution: temurin java-version: 17 - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Fetch keystore id: fetch_keystore run: | TMP_KEYSTORE_FILE_PATH="${RUNNER_TEMP}"/keystore mkdir -p "${TMP_KEYSTORE_FILE_PATH}" curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "${TMP_KEYSTORE_FILE_PATH}/prerelease_keystore.keystore" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore.jks" curl -H "Authorization: token ${{ steps.generate_token.outputs.token }}" -o "keystore_password.txt" "https://raw.githubusercontent.com/recloudstream/secrets/master/keystore_password.txt" KEY_PWD="$(cat keystore_password.txt)" echo "::add-mask::${KEY_PWD}" echo "key_pwd=$KEY_PWD" >> $GITHUB_OUTPUT - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} - name: Run Gradle run: ./gradlew assemblePrerelease build androidSourcesJar makeJar env: SIGNING_KEY_ALIAS: "key0" SIGNING_KEY_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIGNING_STORE_PASSWORD: ${{ steps.fetch_keystore.outputs.key_pwd }} SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} MDL_API_KEY: ${{ secrets.MDL_API_KEY }} - name: Create pre-release uses: marvinpinto/action-automatic-releases@latest with: repo_token: "${{ secrets.GITHUB_TOKEN }}" automatic_release_tag: "pre-release" prerelease: true title: "Pre-release Build" files: | app/build/outputs/apk/prerelease/release/*.apk app/build/libs/app-sources.jar app/build/classes.jar ================================================ FILE: .github/workflows/pull_request.yml ================================================ name: Artifact Build on: [pull_request] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - name: Set up JDK 17 uses: actions/setup-java@v5 with: distribution: temurin java-version: 17 - name: Grant execute permission for gradlew run: chmod +x gradlew - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} cache-read-only: false - name: Run Gradle run: ./gradlew assemblePrereleaseDebug lint - name: Upload Artifact uses: actions/upload-artifact@v6 with: name: pull-request-build path: "app/build/outputs/apk/prerelease/debug/*.apk" ================================================ FILE: .github/workflows/update_locales.yml ================================================ name: Fix locale issues on: push: branches: [ master ] paths: - '**.xml' workflow_dispatch: concurrency: group: "locale" cancel-in-progress: true jobs: create: runs-on: ubuntu-latest steps: - name: Generate access token id: generate_token uses: tibdex/github-app-token@v2 with: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_KEY }} repository: "recloudstream/cloudstream" - uses: actions/checkout@v6 with: token: ${{ steps.generate_token.outputs.token }} - name: Install dependencies run: pip3 install lxml requests - name: Edit files run: python3 .github/locales.py - name: Commit to the repo run: | git config --local user.email "111277985+recloudstream[bot]@users.noreply.github.com" git config --local user.name "recloudstream[bot]" git add . # "echo" returns true so the build succeeds, even if no changed files git commit -m 'chore(locales): fix locale issues' || echo git push ================================================ FILE: .gitignore ================================================ /local.properties /.idea/caches /.idea/misc.xml /.idea/libraries /.idea/modules.xml /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml .DS_Store /build /captures .cxx .kotlin/* # Created by https://www.toptal.com/developers/gitignore/api/kotlin,java,android,androidstudio,visualstudiocode # Edit at https://www.toptal.com/developers/gitignore?templates=kotlin,java,android,androidstudio,visualstudiocode ### Android ### # Gradle files .gradle/ build/ # Local configuration file (sdk path, etc) local.properties # Log/OS Files *.log # Android Studio generated files and folders captures/ .externalNativeBuild/ .cxx/ *.apk output.json # IntelliJ *.iml .idea/ misc.xml deploymentTargetDropDown.xml render.experimental.xml # Keystore files *.jks *.keystore # Google Services (e.g. APIs or Firebase) google-services.json # Android Profiling *.hprof ### Android Patch ### gen-external-apklibs # Replacement of .externalNativeBuild directories introduced # with Android Studio 3.5. ### Java ### # Compiled class file *.class # Log file # BlueJ files *.ctxt # Mobile Tools for Java (J2ME) .mtj.tmp/ # Package Files # *.jar *.war *.nar *.ear *.zip *.tar.gz *.rar # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* replay_pid* ### Kotlin ### # Compiled class file # Log file # BlueJ files # Mobile Tools for Java (J2ME) # Package Files # # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml ### VisualStudioCode ### .vscode/* # Local History for Visual Studio Code .history/ # Built Visual Studio Code Extensions *.vsix ### VisualStudioCode Patch ### # Ignore all local history of files .history .ionide ### AndroidStudio ### # Covers files to be ignored for android development using Android Studio. # Built application files *.ap_ *.aab # Files for the ART/Dalvik VM *.dex # Java class files # Generated files bin/ gen/ out/ # Gradle files .gradle # Signing files .signing/ # Local configuration file (sdk path, etc) # Proguard folder generated by Eclipse proguard/ # Log Files # Android Studio /*/build/ /*/local.properties /*/out /*/*/build /*/*/production .navigation/ *.ipr *~ *.swp # Keystore files # Google Services (e.g. APIs or Firebase) # google-services.json # Android Patch # External native build folder generated in Android Studio 2.2 and later .externalNativeBuild # NDK obj/ # IntelliJ IDEA *.iws /out/ # User-specific configurations .idea/caches/ .idea/libraries/ .idea/shelf/ .idea/workspace.xml .idea/tasks.xml .idea/.name .idea/compiler.xml .idea/copyright/profiles_settings.xml .idea/encodings.xml .idea/misc.xml .idea/modules.xml .idea/scopes/scope_settings.xml .idea/dictionaries .idea/vcs.xml .idea/jsLibraryMappings.xml .idea/datasources.xml .idea/dataSources.ids .idea/sqlDataSources.xml .idea/dynamic.xml .idea/uiDesigner.xml .idea/assetWizardSettings.xml .idea/gradle.xml .idea/jarRepositories.xml .idea/navEditor.xml # Legacy Eclipse project files .classpath .project .cproject .settings/ # Mobile Tools for Java (J2ME) # Package Files # # virtual machine crash logs (Reference: http://www.java.com/en/download/help/error_hotspot.xml) ## Plugin-specific files: # mpeltonen/sbt-idea plugin .idea_modules/ # JIRA plugin atlassian-ide-plugin.xml # Mongo Explorer plugin .idea/mongoSettings.xml # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties ### AndroidStudio Patch ### !/gradle/wrapper/gradle-wrapper.jar # End of https://www.toptal.com/developers/gitignore/api/kotlin,java,android,androidstudio,visualstudiocode ================================================ FILE: AI-POLICY.md ================================================ # AI Policy AI is a great tool. However, we want you to follow these rules regarding usage of AI in order to ensure the quality of both code and discussions. 1. Always state any AI usage in pull requests and issues. 2. Always test code before making a pull request. We do not want to test your AI generated code. 3. Listen to humans over computers. Contributors to CloudStream know this codebase better than an AI. 4. You should be able to explain and fix any code you submit. We do in-depth reviews and will reject low effort contributions. ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================ # CloudStream **⚠️ Warning: By default, this app doesn't provide any video sources; you have to install extensions to add functionality to the app.** [![Discord](https://invidget.switchblade.xyz/5Hus6fM)](https://discord.gg/5Hus6fM) ## Table of Contents: + [About Us:](#about_us) + [Installation Steps:](#install_rules) + [Contributing:](#contributing) + [Issues:](#issues) + [Bugs Reports:](#bug_report) + [Enhancement:](#enhancment) + [Extension Development:](#extensions) + [Language Support:](#languages) + [Further Sources](#contact_and_sources) ## About us: **CloudStream is a media center that prioritizes and emphasizes complete freedom and flexibility for users and developers.** CloudStream is an extension-based multimedia player with tracking support. There are extensions to view videos from: + [Librevox (audio-books)](https://librivox.org/) + [Youtube](https://www.youtube.com/) + [Twitch](https://www.twitch.tv/) + [iptv-org (A collection of publicly available IPTV (Internet Protocol television) channels from all over the world.)](https://github.com/iptv-org/iptv) + [nginx](https://nginx.org/) + And more... **Please don't create illegal extensions or use any that host any copyrighted media.** For more details about our stance on the DMCA and EUCD, you can read about it on our organization: [reCloudStream](https://github.com/recloudstream) #### Important Copyright Note: Our documentation is unmaintained and open to contributions; therefore, apps and sources, extensions in recommended sources, and recommended apps are not officially moderated or endorsed by CloudStream; if you or another copyright owner identify an extension that breaches your copyright, please let us know. #### Features: + **AdFree**, No ads whatsoever + No tracking/analytics + Bookmarks + Phone and TV support + Chromecast + Extension system for personal customization ## Installation: Our documentation provides the steps to install and configure CloudStream for your streaming needs. [Getting Started With CloudStream:](https://recloudstream.github.io/csdocs/) ## Contributing: We **happily** accept any contributions to our project. To find out where you can start contributing towards the project, please look [at our issues tab](/cloudstream/issues) ### Issues: While we **actively** accept issues and pull requests, we do require you fill out an [template](https://github.com/recloudstream/cloudstream/issues/new/choose) for issues. These include the following: - [Bug Report Template: ](https://github.com/recloudstream/cloudstream/issues/new?assignees=&labels=bug&projects=&template=application-bug.yml) - For bug reports, we want as much info as possible, including your downloaded version of CloudeStream, device and updated version (if possible, current API), expected behavior of the program, and the actual behavior that the program did, most importantly we require clear, reproducible steps of the bug. If your bug can't be reproduced, it is unlikely we'll work on your issue. - [Feature Request Template: ](https://github.com/recloudstream/cloudstream/issues/new?assignees=&labels=enhancement&projects=&template=feature-request.yml) - Before adding a feature request, please check to see if a feature request already has been requested. ### Extensions: **Further details on creating extensions for CloudStream are found in our documentation.** [Guide: For Extension Developers](https://recloudstream.github.io/csdocs/devs/gettingstarted/) ## Further Sources: As well as providing clear install steps, our [website](https://dweb.link/ipns/cloudstream.on.fleek.co/) includes a wide variety of other tools, such as: - [Troubleshooting](https://recloudstream.github.io/csdocs/troubleshooting/) - [Further CloudStream Repositories](https://recloudstream.github.io/csdocs/repositories/) - Set-Up for other devices, such as: - [Android TV](https://recloudstream.github.io/csdocs/other-devices/tv/) - [Windows](https://recloudstream.github.io/csdocs/other-devices/windows/) - [Linux](https://recloudstream.github.io/csdocs/other-devices/linux/) - And more... ### Supported languages: Even if you can't contribute to the code or documentation, we always look for those who can contribute to translation and language support. Your contribution is exceptionally appreciated; you can check our translation from the figure below. Translation status ================================================ FILE: app/.gitignore ================================================ /build ================================================ FILE: app/build.gradle.kts ================================================ import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties import org.jetbrains.dokka.gradle.engine.parameters.KotlinPlatform import org.jetbrains.dokka.gradle.engine.parameters.VisibilityModifier import org.jetbrains.kotlin.gradle.dsl.JvmDefaultMode import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinJvmCompile plugins { alias(libs.plugins.android.application) alias(libs.plugins.dokka) alias(libs.plugins.kotlin.android) } val javaTarget = JvmTarget.fromTarget(libs.versions.jvmTarget.get()) val tmpFilePath = System.getProperty("user.home") + "/work/_temp/keystore/" val prereleaseStoreFile: File? = File(tmpFilePath).listFiles()?.first() fun getGitCommitHash(): String { return try { val headFile = file("${project.rootDir}/.git/HEAD") // Read the commit hash from .git/HEAD if (headFile.exists()) { val headContent = headFile.readText().trim() if (headContent.startsWith("ref:")) { val refPath = headContent.substring(5) // e.g., refs/heads/main val commitFile = file("${project.rootDir}/.git/$refPath") if (commitFile.exists()) commitFile.readText().trim() else "" } else headContent // If it's a detached HEAD (commit hash directly) } else { "" // If .git/HEAD doesn't exist }.take(7) // Return the short commit hash } catch (_: Throwable) { "" // Just return an empty string if any exception occurs } } android { @Suppress("UnstableApiUsage") testOptions { unitTests.isReturnDefaultValues = true } viewBinding { enable = true } signingConfigs { if (prereleaseStoreFile != null) { create("prerelease") { storeFile = file(prereleaseStoreFile) storePassword = System.getenv("SIGNING_STORE_PASSWORD") keyAlias = System.getenv("SIGNING_KEY_ALIAS") keyPassword = System.getenv("SIGNING_KEY_PASSWORD") } } } compileSdk = libs.versions.compileSdk.get().toInt() defaultConfig { applicationId = "com.lagradost.cloudstream3" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 67 versionName = "4.6.2" resValue("string", "commit_hash", getGitCommitHash()) manifestPlaceholders["target_sdk_version"] = libs.versions.targetSdk.get() // Reads local.properties val localProperties = gradleLocalProperties(rootDir, project.providers) buildConfigField( "long", "BUILD_DATE", "${System.currentTimeMillis()}" ) buildConfigField( "String", "SIMKL_CLIENT_ID", "\"" + (System.getenv("SIMKL_CLIENT_ID") ?: localProperties["simkl.id"]) + "\"" ) buildConfigField( "String", "SIMKL_CLIENT_SECRET", "\"" + (System.getenv("SIMKL_CLIENT_SECRET") ?: localProperties["simkl.secret"]) + "\"" ) testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { release { isDebuggable = false isMinifyEnabled = false isShrinkResources = false proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } debug { isDebuggable = true applicationIdSuffix = ".debug" proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) } } flavorDimensions.add("state") productFlavors { create("stable") { dimension = "state" } create("prerelease") { dimension = "state" applicationIdSuffix = ".prerelease" if (signingConfigs.names.contains("prerelease")) { signingConfig = signingConfigs.getByName("prerelease") } else { logger.warn("No prerelease signing config!") } versionNameSuffix = "-PRE" versionCode = (System.currentTimeMillis() / 60000).toInt() } } compileOptions { isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.toVersion(javaTarget.target) targetCompatibility = JavaVersion.toVersion(javaTarget.target) } java { // Use Java 17 toolchain even if a higher JDK runs the build. // We still use Java 8 for now which higher JDKs have deprecated. toolchain { languageVersion.set(JavaLanguageVersion.of(libs.versions.jdkToolchain.get())) } } lint { abortOnError = false checkReleaseBuilds = false } buildFeatures { buildConfig = true resValues = true } packaging { jniLibs { // Enables legacy JNI packaging to reduce APK size (similar to builds before minSdk 23). // Note: This may increase app startup time slightly. useLegacyPackaging = true } } namespace = "com.lagradost.cloudstream3" } dependencies { // Testing testImplementation(libs.junit) testImplementation(libs.json) androidTestImplementation(libs.core) implementation(libs.junit.ktx) androidTestImplementation(libs.ext.junit) androidTestImplementation(libs.espresso.core) // Android Core & Lifecycle implementation(libs.core.ktx) implementation(libs.activity.ktx) implementation(libs.appcompat) implementation(libs.fragment.ktx) implementation(libs.bundles.lifecycle) implementation(libs.bundles.navigation) // Design & UI implementation(libs.preference.ktx) implementation(libs.material) implementation(libs.constraintlayout) // Coil Image Loading implementation(libs.bundles.coil) // Media 3 (ExoPlayer) implementation(libs.bundles.media3) implementation(libs.video) // FFmpeg Decoding implementation(libs.bundles.nextlib) // PlayBack implementation(libs.colorpicker) // Subtitle Color Picker implementation(libs.newpipeextractor) // For Trailers implementation(libs.juniversalchardet) // Subtitle Decoding // UI Stuff implementation(libs.shimmer) // Shimmering Effect (Loading Skeleton) implementation(libs.palette.ktx) // Palette for Images -> Colors implementation(libs.tvprovider) implementation(libs.overlappingpanels) // Gestures implementation(libs.biometric) // Fingerprint Authentication implementation(libs.previewseekbar.media3) // SeekBar Preview implementation(libs.qrcode.kotlin) // QR Code for PIN Auth on TV // Extensions & Other Libs implementation(libs.jsoup) // HTML Parser implementation(libs.rhino) // Run JavaScript implementation(libs.fuzzywuzzy) // Library/Ext Searching with Levenshtein Distance implementation(libs.safefile) // To Prevent the URI File Fu*kery coreLibraryDesugaring(libs.desugar.jdk.libs.nio) // NIO Flavor Needed for NewPipeExtractor implementation(libs.conscrypt.android) // To Fix SSL Fu*kery on Android 9 implementation(libs.jackson.module.kotlin) // JSON Parser implementation(libs.zipline) // Torrent Support implementation(libs.torrentserver) // Downloading & Networking implementation(libs.work.runtime.ktx) implementation(libs.nicehttp) // HTTP Lib implementation(project(":library")) } tasks.register("androidSourcesJar") { archiveClassifier.set("sources") from(android.sourceSets.getByName("main").java.directories) // Full Sources } tasks.register("copyJar") { dependsOn("build", ":library:jvmJar") from( "build/intermediates/compile_app_classes_jar/prereleaseDebug/bundlePrereleaseDebugClassesToCompileJar", "../library/build/libs" ) into("build/app-classes") include("classes.jar", "library-jvm*.jar") // Remove the version rename("library-jvm.*.jar", "library-jvm.jar") } // Merge the app classes and the library classes into classes.jar tasks.register("makeJar") { // Duplicates cause hard to catch errors, better to fail at compile time. duplicatesStrategy = DuplicatesStrategy.FAIL dependsOn(tasks.getByName("copyJar")) from( zipTree("build/app-classes/classes.jar"), zipTree("build/app-classes/library-jvm.jar") ) destinationDirectory.set(layout.buildDirectory) archiveBaseName = "classes" } tasks.withType { compilerOptions { jvmTarget.set(javaTarget) jvmDefault.set(JvmDefaultMode.ENABLE) freeCompilerArgs.add("-Xannotation-default-target=param-property") optIn.addAll( "com.lagradost.cloudstream3.InternalAPI", "com.lagradost.cloudstream3.Prerelease", ) } } dokka { moduleName = "App" dokkaSourceSets { main { analysisPlatform = KotlinPlatform.JVM documentedVisibilities( VisibilityModifier.Public, VisibilityModifier.Protected ) sourceLink { localDirectory = file("..") remoteUrl("https://github.com/recloudstream/cloudstream/tree/master") remoteLineSuffix = "#L" } } } } ================================================ FILE: app/lint.xml ================================================ ================================================ FILE: app/proguard-rules.pro ================================================ # Add project specific ProGuard rules here. # You can control the set of applied configuration files using the # proguardFiles setting in build.gradle.kts. # # For more details, see # http://developer.android.com/guide/developing/tools/proguard.html # If your project uses WebView with JS, uncomment the following # and specify the fully qualified class name to the JavaScript interface # class: #-keepclassmembers class fqcn.of.javascript.interface.for.webview { # public *; #} # Uncomment this to preserve the line number information for # debugging stack traces. #-keepattributes SourceFile,LineNumberTable # If you keep the line number information, uncomment this to # hide the original source file name. #-renamesourcefileattribute SourceFile ================================================ FILE: app/src/androidTest/java/com/lagradost/cloudstream3/ExampleInstrumentedTest.kt ================================================ package com.lagradost.cloudstream3 import android.app.Activity import android.os.Bundle import android.os.PersistableBundle import android.view.LayoutInflater import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.databinding.BottomResultviewPreviewBinding import com.lagradost.cloudstream3.databinding.FragmentHomeBinding import com.lagradost.cloudstream3.databinding.FragmentHomeTvBinding import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding import com.lagradost.cloudstream3.databinding.FragmentLibraryTvBinding import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding import com.lagradost.cloudstream3.databinding.FragmentPlayerTvBinding import com.lagradost.cloudstream3.databinding.FragmentResultBinding import com.lagradost.cloudstream3.databinding.FragmentResultTvBinding import com.lagradost.cloudstream3.databinding.FragmentSearchBinding import com.lagradost.cloudstream3.databinding.FragmentSearchTvBinding import com.lagradost.cloudstream3.databinding.HomeResultGridBinding import com.lagradost.cloudstream3.databinding.HomepageParentBinding import com.lagradost.cloudstream3.databinding.HomepageParentEmulatorBinding import com.lagradost.cloudstream3.databinding.HomepageParentTvBinding import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutTvBinding import com.lagradost.cloudstream3.databinding.RepositoryItemBinding import com.lagradost.cloudstream3.databinding.RepositoryItemTvBinding import com.lagradost.cloudstream3.databinding.SearchResultGridBinding import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding import com.lagradost.cloudstream3.databinding.TrailerCustomLayoutBinding import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.TestingUtils import kotlinx.coroutines.runBlocking import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith /** * Instrumented test, which will execute on an Android device. * * See [testing documentation](http://d.android.com/tools/testing). */ class TestApplication : Activity() { override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { super.onCreate(savedInstanceState, persistentState) } } @RunWith(AndroidJUnit4::class) class ExampleInstrumentedTest { private fun getAllProviders(): Array { println("Providers: ${APIHolder.allProviders.size}") return APIHolder.allProviders.toTypedArray() //.filter { !it.usesWebView } } @Test fun providersExist() { Assert.assertTrue(getAllProviders().isNotEmpty()) println("Done providersExist") } @Throws private inline fun testAllLayouts( activity: Activity, vararg layouts: Int ) { val bind = T::class.java.methods.first { it.name == "bind" } val inflater = LayoutInflater.from(activity) for (layout in layouts) { val root = inflater.inflate(layout, null, false) bind.invoke(null, root) } } @Test @Throws fun layoutTest() { ActivityScenario.launch(MainActivity::class.java).use { scenario -> scenario.onActivity { activity: MainActivity -> // FragmentHomeHeadBinding and FragmentHomeHeadTvBinding CANT be the same //testAllLayouts(activity, R.layout.fragment_home_head, R.layout.fragment_home_head_tv) //testAllLayouts(activity, R.layout.fragment_home_head, R.layout.fragment_home_head_tv) // main cant be tested // testAllLayouts(activity,R.layout.activity_main, R.layout.activity_main_tv) // testAllLayouts(activity,R.layout.activity_main, R.layout.activity_main_tv) //testAllLayouts(activity, R.layout.activity_main_tv) testAllLayouts(activity, R.layout.bottom_resultview_preview,R.layout.bottom_resultview_preview_tv) testAllLayouts(activity, R.layout.fragment_player,R.layout.fragment_player_tv) testAllLayouts(activity, R.layout.fragment_player,R.layout.fragment_player_tv) // testAllLayouts(activity, R.layout.fragment_result,R.layout.fragment_result_tv) // testAllLayouts(activity, R.layout.fragment_result,R.layout.fragment_result_tv) testAllLayouts(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout) testAllLayouts(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout) testAllLayouts(activity, R.layout.player_custom_layout,R.layout.player_custom_layout_tv, R.layout.trailer_custom_layout) testAllLayouts(activity, R.layout.repository_item_tv, R.layout.repository_item) testAllLayouts(activity, R.layout.repository_item_tv, R.layout.repository_item) testAllLayouts(activity, R.layout.repository_item_tv, R.layout.repository_item) testAllLayouts(activity, R.layout.repository_item_tv, R.layout.repository_item) testAllLayouts(activity, R.layout.fragment_home_tv, R.layout.fragment_home) testAllLayouts(activity, R.layout.fragment_home_tv, R.layout.fragment_home) testAllLayouts(activity, R.layout.fragment_search_tv, R.layout.fragment_search) testAllLayouts(activity, R.layout.fragment_search_tv, R.layout.fragment_search) testAllLayouts(activity, R.layout.home_result_grid_expanded, R.layout.home_result_grid) //testAllLayouts(activity, R.layout.home_result_grid_expanded, R.layout.home_result_grid) ??? fails ??? testAllLayouts(activity, R.layout.search_result_grid, R.layout.search_result_grid_expanded) testAllLayouts(activity, R.layout.search_result_grid, R.layout.search_result_grid_expanded) // testAllLayouts(activity, R.layout.home_scroll_view, R.layout.home_scroll_view_tv) // testAllLayouts(activity, R.layout.home_scroll_view, R.layout.home_scroll_view_tv) testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent) testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent) testAllLayouts(activity, R.layout.homepage_parent_tv, R.layout.homepage_parent_emulator, R.layout.homepage_parent) testAllLayouts(activity, R.layout.fragment_library_tv, R.layout.fragment_library) testAllLayouts(activity, R.layout.fragment_library_tv, R.layout.fragment_library) } } } @Test @Throws(AssertionError::class) fun providerCorrectData() { val langTagsIETF = SubtitleHelper.languages.map { it.IETF_tag } Assert.assertFalse("IETFTagNames does not contain any languages", langTagsIETF.isNullOrEmpty()) for (api in getAllProviders()) { Assert.assertTrue("Api does not contain a mainUrl", api.mainUrl != "NONE") Assert.assertTrue("Api does not contain a name", api.name != "NONE") Assert.assertTrue( "Api ${api.name} does not contain a valid language code", langTagsIETF.contains(api.lang) ) Assert.assertTrue( "Api ${api.name} does not contain any supported types", api.supportedTypes.isNotEmpty() ) } println("Done providerCorrectData") } @Test fun providerCorrectHomepage() { runBlocking { getAllProviders().toList().amap { api -> TestingUtils.testHomepage(api, TestingUtils.Logger()) } } println("Done providerCorrectHomepage") } @Test fun testAllProvidersCorrect() { runBlocking { TestingUtils.getDeferredProviderTests( this, getAllProviders(), ) { _, _ -> } } } } ================================================ FILE: app/src/debug/res/drawable/ic_banner_foreground.xml ================================================ ================================================ FILE: app/src/debug/res/drawable-anydpi-v24/ic_stat_name.xml ================================================ ================================================ FILE: app/src/debug/res/drawable-v24/ic_banner_background.xml ================================================ ================================================ FILE: app/src/debug/res/drawable-v24/ic_launcher_foreground.xml ================================================ ================================================ FILE: app/src/debug/res/mipmap-anydpi-v26/ic_banner.xml ================================================ ================================================ FILE: app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml ================================================ ================================================ FILE: app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml ================================================ ================================================ FILE: app/src/debug/res/values/ic_launcher_background.xml ================================================ #000000 ================================================ FILE: app/src/debug/res/values/strings.xml ================================================ CloudStream Debug ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/AcraApplication.kt ================================================ package com.lagradost.cloudstream3 import android.content.Context import com.lagradost.api.setContext import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.removeKeys import com.lagradost.cloudstream3.utils.DataStore.setKey import java.lang.ref.WeakReference /** * Deprecated alias for CloudStreamApp for backwards compatibility with plugins. * Use CloudStreamApp instead. */ // Deprecate after next stable /*@Deprecated( message = "AcraApplication is deprecated, use CloudStreamApp instead", replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp"), level = DeprecationLevel.WARNING )*/ class AcraApplication { // All methods here can be changed to be a wrapper around CloudStream app // without a seperate deprecation after next stable. All methods should // also be deprecated at that time. companion object { // This can be removed without deprecation after next stable private var _context: WeakReference? = null /*@Deprecated( message = "AcraApplication is deprecated, use CloudStreamApp instead", replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.context"), level = DeprecationLevel.WARNING )*/ var context get() = _context?.get() internal set(value) { _context = WeakReference(value) setContext(WeakReference(value)) } /*@Deprecated( message = "AcraApplication is deprecated, use CloudStreamApp instead", replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.removeKeys(folder)"), level = DeprecationLevel.WARNING )*/ fun removeKeys(folder: String): Int? { return context?.removeKeys(folder) } /*@Deprecated( message = "AcraApplication is deprecated, use CloudStreamApp instead", replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(path, value)"), level = DeprecationLevel.WARNING )*/ fun setKey(path: String, value: T) { context?.setKey(path, value) } /*@Deprecated( message = "AcraApplication is deprecated, use CloudStreamApp instead", replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.setKey(folder, path, value)"), level = DeprecationLevel.WARNING )*/ fun setKey(folder: String, path: String, value: T) { context?.setKey(folder, path, value) } /*@Deprecated( message = "AcraApplication is deprecated, use CloudStreamApp instead", replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path, defVal)"), level = DeprecationLevel.WARNING )*/ inline fun getKey(path: String, defVal: T?): T? { return context?.getKey(path, defVal) } /*@Deprecated( message = "AcraApplication is deprecated, use CloudStreamApp instead", replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(path)"), level = DeprecationLevel.WARNING )*/ inline fun getKey(path: String): T? { return context?.getKey(path) } /*@Deprecated( message = "AcraApplication is deprecated, use CloudStreamApp instead", replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path)"), level = DeprecationLevel.WARNING )*/ inline fun getKey(folder: String, path: String): T? { return context?.getKey(folder, path) } /*@Deprecated( message = "AcraApplication is deprecated, use CloudStreamApp instead", replaceWith = ReplaceWith("com.lagradost.cloudstream3.CloudStreamApp.getKey(folder, path, defVal)"), level = DeprecationLevel.WARNING )*/ inline fun getKey(folder: String, path: String, defVal: T?): T? { return context?.getKey(folder, path, defVal) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/CloudStreamApp.kt ================================================ package com.lagradost.cloudstream3 import android.app.Activity import android.app.Application import android.content.Context import android.content.ContextWrapper import android.content.Intent import android.os.Build import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import coil3.ImageLoader import coil3.PlatformContext import coil3.SingletonImageLoader import com.lagradost.api.setContext import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.safeAsync import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser import com.lagradost.cloudstream3.utils.AppDebug import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKeys import com.lagradost.cloudstream3.utils.DataStore.removeKey import com.lagradost.cloudstream3.utils.DataStore.removeKeys import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.ImageLoader.buildImageLoader import kotlinx.coroutines.runBlocking import java.io.File import java.io.FileNotFoundException import java.io.PrintStream import java.lang.ref.WeakReference import java.util.Locale import kotlin.concurrent.thread import kotlin.system.exitProcess class ExceptionHandler( val errorFile: File, val onError: (() -> Unit) ) : Thread.UncaughtExceptionHandler { override fun uncaughtException(thread: Thread, error: Throwable) { try { val threadId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA) { thread.threadId() } else { @Suppress("DEPRECATION") thread.id } PrintStream(errorFile).use { ps -> ps.println("Currently loading extension: ${PluginManager.currentlyLoading ?: "none"}") ps.println("Fatal exception on thread ${thread.name} ($threadId)") error.printStackTrace(ps) } } catch (_: FileNotFoundException) { } try { onError() } catch (_: Exception) { } exitProcess(1) } } @Prerelease class CloudStreamApp : Application(), SingletonImageLoader.Factory { override fun onCreate() { super.onCreate() // If we want to initialize Coil as early as possible, maybe when // loading an image or GIF in a splash screen activity. // buildImageLoader(applicationContext) ExceptionHandler(filesDir.resolve("last_error")) { val intent = context!!.packageManager.getLaunchIntentForPackage(context!!.packageName) startActivity(Intent.makeRestartActivityTask(intent!!.component)) }.also { exceptionHandler = it Thread.setDefaultUncaughtExceptionHandler(it) } AppDebug.isDebug = BuildConfig.DEBUG } override fun attachBaseContext(base: Context?) { super.attachBaseContext(base) context = base // This can be removed without deprecation after next stable AcraApplication.context = context } override fun newImageLoader(context: PlatformContext): ImageLoader { // Coil module will be initialized globally when first loadImage() is invoked. return buildImageLoader(applicationContext) } companion object { var exceptionHandler: ExceptionHandler? = null /** Use to get Activity from Context. */ tailrec fun Context.getActivity(): Activity? { return when (this) { is Activity -> this is ContextWrapper -> baseContext.getActivity() else -> null } } private var _context: WeakReference? = null var context get() = _context?.get() private set(value) { _context = WeakReference(value) setContext(WeakReference(value)) } fun getKeyClass(path: String, valueType: Class): T? { return context?.getKey(path, valueType) } fun setKeyClass(path: String, value: T) { context?.setKey(path, value) } fun removeKeys(folder: String): Int? { return context?.removeKeys(folder) } fun setKey(path: String, value: T) { context?.setKey(path, value) } fun setKey(folder: String, path: String, value: T) { context?.setKey(folder, path, value) } inline fun getKey(path: String, defVal: T?): T? { return context?.getKey(path, defVal) } inline fun getKey(path: String): T? { return context?.getKey(path) } inline fun getKey(folder: String, path: String): T? { return context?.getKey(folder, path) } inline fun getKey(folder: String, path: String, defVal: T?): T? { return context?.getKey(folder, path, defVal) } fun getKeys(folder: String): List? { return context?.getKeys(folder) } fun removeKey(folder: String, path: String) { context?.removeKey(folder, path) } fun removeKey(path: String) { context?.removeKey(path) } /** If fallbackWebView is true and a fragment is supplied then it will open a WebView with the URL if the browser fails. */ fun openBrowser(url: String, fallbackWebView: Boolean = false, fragment: Fragment? = null) { context?.openBrowser(url, fallbackWebView, fragment) } /** Will fall back to WebView if in TV or emulator layout. */ fun openBrowser(url: String, activity: FragmentActivity?) { openBrowser( url, isLayout(TV or EMULATOR), activity?.supportFragmentManager?.fragments?.lastOrNull() ) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/CommonActivity.kt ================================================ package com.lagradost.cloudstream3 import android.annotation.SuppressLint import android.app.Activity import android.app.PictureInPictureParams import android.content.Context import android.content.pm.PackageManager import android.content.res.Configuration import android.content.res.Resources import android.Manifest import android.os.Build import android.util.DisplayMetrics import android.util.Log import android.view.Gravity import android.view.KeyEvent import android.view.View import android.view.View.NO_ID import android.view.ViewGroup import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.MainThread import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat import androidx.core.view.children import androidx.core.view.isNotEmpty import androidx.preference.PreferenceManager import com.google.android.gms.cast.framework.CastSession import com.google.android.material.chip.ChipGroup import com.google.android.material.navigationrail.NavigationRailView import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.databinding.ToastBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter import com.lagradost.cloudstream3.ui.home.ParentItemAdapter import com.lagradost.cloudstream3.ui.player.PlayerEventType import com.lagradost.cloudstream3.ui.player.PlayerPipHelper.isPIPPossible import com.lagradost.cloudstream3.ui.player.Torrent import com.lagradost.cloudstream3.ui.result.ActorAdaptor import com.lagradost.cloudstream3.ui.result.EpisodeAdapter import com.lagradost.cloudstream3.ui.result.ImageAdapter import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.updateTv import com.lagradost.cloudstream3.ui.settings.extensions.PluginAdapter import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UiText import java.lang.ref.WeakReference import java.util.Locale import kotlin.math.max import kotlin.math.min import org.schabi.newpipe.extractor.NewPipe enum class FocusDirection { Start, End, Up, Down, } object CommonActivity { private var _activity: WeakReference? = null var activity get() = _activity?.get() private set(value) { _activity = WeakReference(value) } @MainThread fun setActivityInstance(newActivity: Activity?) { activity = newActivity } @MainThread fun Activity?.getCastSession(): CastSession? { return (this as MainActivity?)?.mSessionManager?.currentCastSession } val displayMetrics: DisplayMetrics = Resources.getSystem().displayMetrics // screenWidth and screenHeight does always // refer to the screen while in landscape mode val screenWidth: Int get() { return max(displayMetrics.widthPixels, displayMetrics.heightPixels) } val screenHeight: Int get() { return min(displayMetrics.widthPixels, displayMetrics.heightPixels) } val screenWidthWithOrientation: Int get() { return displayMetrics.widthPixels } val screenHeightWithOrientation: Int get() { return displayMetrics.heightPixels } var isPipDesired: Boolean = false var isInPIPMode: Boolean = false val onColorSelectedEvent = Event>() val onDialogDismissedEvent = Event() var playerEventListener: ((PlayerEventType) -> Unit)? = null var keyEventListener: ((Pair) -> Boolean)? = null var appliedTheme: Int = 0 var appliedColor: Int = 0 private var currentToast: Toast? = null fun showToast(@StringRes message: Int, duration: Int? = null) { val act = activity ?: return act.runOnUiThread { showToast(act, act.getString(message), duration) } } fun showToast(message: String?, duration: Int? = null) { val act = activity ?: return act.runOnUiThread { showToast(act, message, duration) } } fun showToast(message: UiText?, duration: Int? = null) { val act = activity ?: return if (message == null) return act.runOnUiThread { showToast(act, message.asString(act), duration) } } @MainThread fun showToast(act: Activity?, text: UiText, duration: Int) { if (act == null) return text.asStringNull(act)?.let { showToast(act, it, duration) } } /** duration is Toast.LENGTH_SHORT if null*/ @MainThread fun showToast(act: Activity?, @StringRes message: Int, duration: Int? = null) { if (act == null) return showToast(act, act.getString(message), duration) } const val TAG = "COMPACT" /** duration is Toast.LENGTH_SHORT if null*/ @MainThread fun showToast(act: Activity?, message: String?, duration: Int? = null) { if (act == null || message == null) { Log.w(TAG, "invalid showToast act = $act message = $message") return } Log.i(TAG, "showToast = $message") try { currentToast?.cancel() } catch (e: Exception) { logError(e) } try { val binding = ToastBinding.inflate(act.layoutInflater) binding.text.text = message.trim() // custom toasts are deprecated and won't appear when cs3 sets minSDK to api30 (A11) val toast = Toast(act) toast.duration = duration ?: Toast.LENGTH_SHORT toast.setGravity(Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM, 0, 5.toPx) @Suppress("DEPRECATION") toast.view = binding.root // FIXME Find an alternative using default Toasts since custom toasts are deprecated and won't appear with api30 set as minSDK version. currentToast = toast toast.show() } catch (e: Exception) { logError(e) } } /** * Set locale * @param languageTag shall a IETF BCP 47 conformant tag. * Check [com.lagradost.cloudstream3.utils.SubtitleHelper]. * * See locales on: * https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-core/availableLocales.json * https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry * https://android.googlesource.com/platform/frameworks/base/+/android-16.0.0_r2/core/res/res/values/locale_config.xml * https://iso639-3.sil.org/code_tables/639/data/all */ fun setLocale(context: Context?, languageTag: String?) { if (context == null || languageTag == null) return val locale = Locale.forLanguageTag(languageTag) val resources: Resources = context.resources val config = resources.configuration Locale.setDefault(locale) config.setLocale(locale) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) context.createConfigurationContext(config) @Suppress("DEPRECATION") resources.updateConfiguration( config, resources.displayMetrics ) // FIXME this should be replaced } fun Context.updateLocale() { val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val localeCode = settingsManager.getString(getString(R.string.locale_key), null) setLocale(this, localeCode) } fun init(act: Activity) { setActivityInstance(act) ioSafe { Torrent.deleteAllFiles() } val componentActivity = activity as? ComponentActivity ?: return componentActivity.updateLocale() componentActivity.updateTv() AccountManager.initMainAPI() NewPipe.init(DownloaderTestImpl.getInstance()) MainActivity.activityResultLauncher = componentActivity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == AppCompatActivity.RESULT_OK) { val actionUid = getKey("last_click_action") ?: return@registerForActivityResult Log.d(TAG, "Loading action $actionUid result handler") val action = VideoClickActionHolder.getByUniqueId(actionUid) as? OpenInAppAction ?: return@registerForActivityResult action.onResultSafe(act, result.data) removeKey("last_click_action") removeKey("last_opened") } } // Ask for notification permissions on Android 13 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && ContextCompat.checkSelfPermission( componentActivity, Manifest.permission.POST_NOTIFICATIONS ) != PackageManager.PERMISSION_GRANTED ) { val requestPermissionLauncher = componentActivity.registerForActivityResult( ActivityResultContracts.RequestPermission() ) { isGranted: Boolean -> Log.d(TAG, "Notification permission: $isGranted") } requestPermissionLauncher.launch( Manifest.permission.POST_NOTIFICATIONS ) } } /** Enters pip mode if it is both possible and desired to do so*/ private fun Activity.enterPIPMode() { if (!isPipDesired || !this.isPIPPossible()) return try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { try { enterPictureInPictureMode(PictureInPictureParams.Builder().build()) } catch (_: Exception) { // Use fallback just in case @Suppress("DEPRECATION") enterPictureInPictureMode() } } else { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { @Suppress("DEPRECATION") enterPictureInPictureMode() } } } catch (e: Exception) { logError(e) } } fun onUserLeaveHint(act: Activity) { // On Android 12 and later we use setAutoEnterEnabled() instead. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) return act.enterPIPMode() } fun updateTheme(act: Activity) { val settingsManager = PreferenceManager.getDefaultSharedPreferences(act) if (settingsManager .getString(act.getString(R.string.app_theme_key), "AmoledLight") == "System" && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ) { loadThemes(act) } } private fun mapSystemTheme(act: Activity): Int { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { val currentNightMode = act.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK return when (currentNightMode) { Configuration.UI_MODE_NIGHT_NO -> R.style.LightMode // Night mode is not active, we're using the light theme else -> R.style.AppTheme // Night mode is active, we're using dark theme } } else { return R.style.AppTheme } } fun loadThemes(act: Activity?) { if (act == null) return val settingsManager = PreferenceManager.getDefaultSharedPreferences(act) val currentTheme = when (settingsManager.getString(act.getString(R.string.app_theme_key), "AmoledLight")) { "System" -> mapSystemTheme(act) "Black" -> R.style.AppTheme "Light" -> R.style.LightMode "Amoled" -> R.style.AmoledMode "AmoledLight" -> R.style.AmoledModeLight "Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.style.MonetMode else R.style.AppTheme "Dracula" -> R.style.DraculaMode "Lavender" -> R.style.LavenderMode "SilentBlue" -> R.style.SilentBlueMode else -> R.style.AppTheme } val currentOverlayTheme = when (settingsManager.getString(act.getString(R.string.primary_color_key), "Normal")) { "Normal" -> R.style.OverlayPrimaryColorNormal "DandelionYellow" -> R.style.OverlayPrimaryColorDandelionYellow "CarnationPink" -> R.style.OverlayPrimaryColorCarnationPink "Orange" -> R.style.OverlayPrimaryColorOrange "DarkGreen" -> R.style.OverlayPrimaryColorDarkGreen "Maroon" -> R.style.OverlayPrimaryColorMaroon "NavyBlue" -> R.style.OverlayPrimaryColorNavyBlue "Grey" -> R.style.OverlayPrimaryColorGrey "White" -> R.style.OverlayPrimaryColorWhite "CoolBlue" -> R.style.OverlayPrimaryColorCoolBlue "Brown" -> R.style.OverlayPrimaryColorBrown "Purple" -> R.style.OverlayPrimaryColorPurple "Green" -> R.style.OverlayPrimaryColorGreen "GreenApple" -> R.style.OverlayPrimaryColorGreenApple "Red" -> R.style.OverlayPrimaryColorRed "Banana" -> R.style.OverlayPrimaryColorBanana "Party" -> R.style.OverlayPrimaryColorParty "Pink" -> R.style.OverlayPrimaryColorPink "Lavender" -> R.style.OverlayPrimaryColorLavender "Monet" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.style.OverlayPrimaryColorMonet else R.style.OverlayPrimaryColorNormal "Monet2" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) R.style.OverlayPrimaryColorMonetTwo else R.style.OverlayPrimaryColorNormal else -> R.style.OverlayPrimaryColorNormal } act.theme.applyStyle(currentTheme, true) act.theme.applyStyle(currentOverlayTheme, true) appliedTheme = currentTheme appliedColor = currentOverlayTheme act.updateTv() if (isLayout(TV)) act.theme.applyStyle(R.style.AppThemeTvOverlay, true) act.theme.applyStyle( R.style.LoadedStyle, true ) // THEME IS SET BEFORE VIEW IS CREATED TO APPLY THE THEME TO THE MAIN VIEW } /** because we want closes find, aka when multiple have the same id, we go to parent until the correct one is found */ private fun localLook(from: View, id: Int): View? { if (id == NO_ID) return null var currentLook: View = from // limit to 15 look depth for (i in 0..15) { currentLook.findViewById(id)?.let { return it } currentLook = (currentLook.parent as? View) ?: break } return null } /*var currentLook: View = view while (true) { val tmpNext = currentLook.findViewById(nextId) if (tmpNext != null) { next = tmpNext break } currentLook = currentLook.parent as? View ?: break }*/ private fun View.hasContent(): Boolean { return isShown && when (this) { is ViewGroup -> this.isNotEmpty() else -> true } } /** skips the initial stage of searching for an id using the view, see getNextFocus for specification */ fun continueGetNextFocus( root: Any?, view: View, direction: FocusDirection, nextId: Int, depth: Int = 0 ): View? { if (nextId == NO_ID) return null // do an initial search for the view, in case the localLook is too deep we can use this as // an early break and backup view var next = when (root) { is Activity -> root.findViewById(nextId) is View -> root.rootView.findViewById(nextId) else -> null } ?: return null next = localLook(view, nextId) ?: next val shown = next.hasContent() // if cant focus but visible then break and let android decide // the exception if is the view is a parent and has children that wants focus val hasChildrenThatWantsFocus = (next as? ViewGroup)?.let { parent -> parent.descendantFocusability == ViewGroup.FOCUS_AFTER_DESCENDANTS && parent.isNotEmpty() } ?: false if (!next.isFocusable && shown && !hasChildrenThatWantsFocus) return null // if not shown then continue because we will "skip" over views to get to a replacement if (!shown) { // we don't want a while true loop, so we let android decide if we find a recursive view if (next == view) return null return getNextFocus(root, next, direction, depth + 1) } (when (next) { is ChipGroup -> { next.children.firstOrNull { it.isFocusable && it.isShown } } is NavigationRailView -> { next.findViewById(next.selectedItemId) ?: next.findViewById(R.id.navigation_home) } else -> null })?.let { return it } // nothing wrong with the view found, return it return next } /** recursively looks for a next focus up to a depth of 10, * this is used to override the normal shit focus system * because this application has a lot of invisible views that messes with some tv devices*/ fun getNextFocus( root: Any?, view: View?, direction: FocusDirection, depth: Int = 0 ): View? { // if input is invalid let android decide + depth test to not crash if loop is found if (view == null || depth >= 10 || root == null) { return null } var nextId = when (direction) { FocusDirection.Start -> { if (view.isRtl()) view.nextFocusRightId else view.nextFocusLeftId } FocusDirection.Up -> { view.nextFocusUpId } FocusDirection.End -> { if (view.isRtl()) view.nextFocusLeftId else view.nextFocusRightId } FocusDirection.Down -> { view.nextFocusDownId } } if (nextId == NO_ID) { // if not specified then use forward id nextId = view.nextFocusForwardId // if view is still not found to next focus then return and let android decide if (nextId == NO_ID) return null } return continueGetNextFocus(root, view, direction, nextId, depth) } fun onKeyDown(act: Activity?, keyCode: Int, event: KeyEvent?): Boolean? { // 149 keycode_numpad 5 val playerEvent = when (keyCode) { KeyEvent.KEYCODE_FORWARD, KeyEvent.KEYCODE_D, KeyEvent.KEYCODE_MEDIA_SKIP_FORWARD, KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> { PlayerEventType.SeekForward } KeyEvent.KEYCODE_A, KeyEvent.KEYCODE_MEDIA_SKIP_BACKWARD, KeyEvent.KEYCODE_MEDIA_REWIND -> { PlayerEventType.SeekBack } KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_BUTTON_R1, KeyEvent.KEYCODE_N, KeyEvent.KEYCODE_NUMPAD_2, KeyEvent.KEYCODE_CHANNEL_UP -> { PlayerEventType.NextEpisode } KeyEvent.KEYCODE_MEDIA_PREVIOUS, KeyEvent.KEYCODE_BUTTON_L1, KeyEvent.KEYCODE_B, KeyEvent.KEYCODE_NUMPAD_1, KeyEvent.KEYCODE_CHANNEL_DOWN -> { PlayerEventType.PrevEpisode } KeyEvent.KEYCODE_MEDIA_PAUSE -> { PlayerEventType.Pause } KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_BUTTON_START -> { PlayerEventType.Play } KeyEvent.KEYCODE_L, KeyEvent.KEYCODE_NUMPAD_7, KeyEvent.KEYCODE_7 -> { PlayerEventType.Lock } KeyEvent.KEYCODE_H, KeyEvent.KEYCODE_MENU -> { PlayerEventType.ToggleHide } KeyEvent.KEYCODE_M, KeyEvent.KEYCODE_VOLUME_MUTE -> { PlayerEventType.ToggleMute } KeyEvent.KEYCODE_S, KeyEvent.KEYCODE_NUMPAD_9, KeyEvent.KEYCODE_9 -> { PlayerEventType.ShowMirrors } // OpenSubtitles shortcut KeyEvent.KEYCODE_O, KeyEvent.KEYCODE_NUMPAD_8, KeyEvent.KEYCODE_8 -> { PlayerEventType.SearchSubtitlesOnline } KeyEvent.KEYCODE_E, KeyEvent.KEYCODE_NUMPAD_3, KeyEvent.KEYCODE_3 -> { PlayerEventType.ShowSpeed } KeyEvent.KEYCODE_R, KeyEvent.KEYCODE_NUMPAD_0, KeyEvent.KEYCODE_0 -> { PlayerEventType.Resize } KeyEvent.KEYCODE_C, KeyEvent.KEYCODE_NUMPAD_4, KeyEvent.KEYCODE_4 -> { PlayerEventType.SkipOp } KeyEvent.KEYCODE_V, KeyEvent.KEYCODE_NUMPAD_5, KeyEvent.KEYCODE_5 -> { PlayerEventType.SkipCurrentChapter } KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_P, KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_NUMPAD_ENTER, KeyEvent.KEYCODE_ENTER -> { // space is not captured due to navigation PlayerEventType.PlayPauseToggle } else -> return null } val listener = playerEventListener if (listener != null) { listener.invoke(playerEvent) return true } return null //when (keyCode) { // KeyEvent.KEYCODE_DPAD_CENTER -> { // println("DPAD PRESSED") // } //} } /** overrides focus and custom key events */ fun dispatchKeyEvent(act: Activity?, event: KeyEvent?): Boolean? { if (act == null) return null val currentFocus = act.currentFocus event?.keyCode?.let { keyCode -> if (currentFocus == null || event.action != KeyEvent.ACTION_DOWN) return@let val nextView = when (keyCode) { KeyEvent.KEYCODE_DPAD_LEFT -> getNextFocus( act, currentFocus, FocusDirection.Start ) KeyEvent.KEYCODE_DPAD_RIGHT -> getNextFocus( act, currentFocus, FocusDirection.End ) KeyEvent.KEYCODE_DPAD_UP -> getNextFocus( act, currentFocus, FocusDirection.Up ) KeyEvent.KEYCODE_DPAD_DOWN -> getNextFocus( act, currentFocus, FocusDirection.Down ) else -> null } // println("NEXT FOCUS : $nextView") if (nextView != null) { nextView.requestFocus() keyEventListener?.invoke(Pair(event, true)) return true } // TODO: Figure out why removing the check for SearchAutoComplete seems // to break focus on TV as it shouldn't need to be used. @SuppressLint("RestrictedApi") if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER && (act.currentFocus is SearchView || act.currentFocus is SearchView.SearchAutoComplete) ) { showInputMethod(act.currentFocus?.findFocus()) } //println("Keycode: $keyCode") //showToast( // this, // "Got Keycode $keyCode | ${KeyEvent.keyCodeToString(keyCode)} \n ${event?.action}", // Toast.LENGTH_LONG //) } // if someone else want to override the focus then don't handle the event as it is already // consumed. used in video player if (keyEventListener?.invoke(Pair(event, false)) == true) { return true } return null } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/DownloaderTestImpl.kt ================================================ package com.lagradost.cloudstream3 import okhttp3.OkHttpClient import okhttp3.RequestBody import okhttp3.RequestBody.Companion.toRequestBody 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 java.util.concurrent.TimeUnit class DownloaderTestImpl private constructor(builder: OkHttpClient.Builder) : Downloader() { private val client: OkHttpClient = builder.readTimeout(30, TimeUnit.SECONDS).build() 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 = dataToSend.toRequestBody(null, 0, dataToSend.size) } val requestBuilder: okhttp3.Request.Builder = okhttp3.Request.Builder() .method(httpMethod, requestBody).url(url) .addHeader("User-Agent", USER_AGENT) for ((headerName, headerValueList) in headers) { if (headerValueList.size > 1) { requestBuilder.removeHeader(headerName) for (headerValue in headerValueList) { requestBuilder.addHeader(headerName, headerValue) } } else if (headerValueList.size == 1) { requestBuilder.header(headerName, headerValueList[0]) } } val response = client.newCall(requestBuilder.build()).execute() if (response.code == 429) { response.close() throw ReCaptchaException("reCaptcha Challenge requested", url) } val body = response.body val responseBodyToReturn: String = body.string() val latestUrl = response.request.url.toString() return Response( response.code, response.message, response.headers.toMultimap(), responseBodyToReturn, latestUrl ) } companion object { private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" private var instance: DownloaderTestImpl? = 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 [DownloaderTestImpl] */ fun init(builder: OkHttpClient.Builder?): DownloaderTestImpl? { instance = DownloaderTestImpl( builder ?: OkHttpClient.Builder() ) return instance } fun getInstance(): DownloaderTestImpl? { if (instance == null) { init(null) } return instance } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/MainActivity.kt ================================================ package com.lagradost.cloudstream3 import android.animation.ValueAnimator import android.annotation.SuppressLint import android.app.Dialog import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.Rect import android.os.Bundle import android.util.AttributeSet import android.util.Log import android.view.Gravity import android.view.KeyEvent import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.widget.CheckBox import android.widget.ImageView import android.widget.LinearLayout import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.annotation.IdRes import androidx.annotation.MainThread import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.cardview.widget.CardView import androidx.core.content.edit import androidx.core.net.toUri import androidx.core.view.children import androidx.core.view.get import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.view.marginStart import androidx.fragment.app.FragmentActivity import androidx.lifecycle.ViewModelProvider import androidx.navigation.NavController import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavOptions import androidx.navigation.fragment.NavHostFragment import androidx.navigation.ui.setupWithNavController import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearSnapHelper import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.cast.framework.Session import com.google.android.gms.cast.framework.SessionManager import com.google.android.gms.cast.framework.SessionManagerListener import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.navigationrail.NavigationRailView import com.google.android.material.snackbar.Snackbar import com.google.common.collect.Comparators.min import com.jaredrummler.android.colorpicker.ColorPickerDialogListener import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.initAll import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.loadThemes import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.onUserLeaveHint import com.lagradost.cloudstream3.CommonActivity.screenHeight import com.lagradost.cloudstream3.CommonActivity.setActivityInstance import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CommonActivity.updateLocale import com.lagradost.cloudstream3.CommonActivity.updateTheme import com.lagradost.cloudstream3.actions.temp.fcast.FcastManager import com.lagradost.cloudstream3.databinding.ActivityMainBinding import com.lagradost.cloudstream3.databinding.ActivityMainTvBinding import com.lagradost.cloudstream3.databinding.BottomResultviewPreviewBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.network.initClient import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins import com.lagradost.cloudstream3.plugins.PluginManager.loadSinglePlugin import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver import com.lagradost.cloudstream3.services.SubscriptionWorkManager import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_PLAYER import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_REPO import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_RESUME_WATCHING import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SEARCH import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.localListApi import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO import com.lagradost.cloudstream3.ui.home.HomeViewModel import com.lagradost.cloudstream3.ui.library.LibraryViewModel import com.lagradost.cloudstream3.ui.player.BasicLink import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.ui.player.LinkGenerator import com.lagradost.cloudstream3.ui.result.LinearListLayout import com.lagradost.cloudstream3.ui.result.ResultViewModel2 import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST import com.lagradost.cloudstream3.ui.result.SyncViewModel import com.lagradost.cloudstream3.ui.search.SearchFragment import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.Globals.updateTv import com.lagradost.cloudstream3.ui.settings.SettingsGeneral import com.lagradost.cloudstream3.ui.setup.HAS_DONE_SETUP_KEY import com.lagradost.cloudstream3.ui.setup.SetupFragmentExtensions import com.lagradost.cloudstream3.utils.ApkInstaller import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.AppContextUtils.isCastApiAvailable import com.lagradost.cloudstream3.utils.AppContextUtils.isLtr import com.lagradost.cloudstream3.utils.AppContextUtils.isNetworkAvailable import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.BackupUtils.backup import com.lagradost.cloudstream3.utils.BackupUtils.setUpBackup import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback import com.lagradost.cloudstream3.utils.BiometricAuthenticator.biometricPrompt import com.lagradost.cloudstream3.utils.BiometricAuthenticator.deviceHasPasswordPinLock import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts import com.lagradost.cloudstream3.utils.DataStoreHelper.migrateResumeWatching import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.InAppUpdater.runAutoUpdate import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar import com.lagradost.cloudstream3.utils.TvChannelUtils import com.lagradost.cloudstream3.utils.UIHelper.changeStatusBarState import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getResourceColor import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.requestRW import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.USER_PROVIDER_API import com.lagradost.cloudstream3.utils.USER_SELECTED_HOMEPAGE_API import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setTextHtml import com.lagradost.cloudstream3.utils.txt import com.lagradost.safefile.SafeFile import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.io.File import java.lang.ref.WeakReference import java.net.URI import java.net.URLDecoder import java.nio.charset.Charset import kotlin.math.abs import kotlin.math.absoluteValue import kotlin.system.exitProcess import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager class MainActivity : AppCompatActivity(), ColorPickerDialogListener, BiometricCallback { companion object { var activityResultLauncher: ActivityResultLauncher? = null const val TAG = "MAINACT" const val ANIMATED_OUTLINE: Boolean = false var lastError: String? = null /** Update lastError variable based on error file, to check if app crashed. * Can be called multiple times without changing the lastError variable changing. **/ fun setLastError(context: Context) { if (lastError != null) return val errorFile = context.filesDir.resolve("last_error") if (errorFile.exists() && errorFile.isFile) { lastError = errorFile.readText(Charset.defaultCharset()) errorFile.delete() } else { lastError = null } } private const val FILE_DELETE_KEY = "FILES_TO_DELETE_KEY" const val API_NAME_EXTRA_KEY = "API_NAME_EXTRA_KEY" /** * Transient files to delete on application exit. * Deletes files on onDestroy(). */ private var filesToDelete: Set // This needs to be persistent because the application may exit without calling onDestroy. get() = getKey>(FILE_DELETE_KEY) ?: setOf() private set(value) = setKey(FILE_DELETE_KEY, value) /** * Add file to delete on Exit. */ fun deleteFileOnExit(file: File) { filesToDelete = filesToDelete + file.path } /** * Setting this will automatically enter the query in the search * next time the search fragment is opened. * This variable will clear itself after one use. Null does nothing. * * This is a very bad solution but I was unable to find a better one. **/ var nextSearchQuery: String? = null /** * Fires every time a new batch of plugins have been loaded, no guarantee about how often this is run and on which thread * Boolean signifies if stuff should be force reloaded (true if force reload, false if reload when necessary). * * The force reloading are used for plugin development to instantly reload the page on deployWithAdb * */ val afterPluginsLoadedEvent = Event() val mainPluginsLoadedEvent = Event() // homepage api, used to speed up time to load for homepage val afterRepositoryLoadedEvent = Event() // kinda shitty solution, but cant com main->home otherwise for popups val bookmarksUpdatedEvent = Event() /** * Used by DataStoreHelper to fully reload home when switching accounts */ val reloadHomeEvent = Event() /** * Used by DataStoreHelper to fully reload library when switching accounts */ val reloadLibraryEvent = Event() /** * Used by DataStoreHelper to fully reload Navigation Rail header picture */ val reloadAccountEvent = Event() /** * @return true if the str has launched an app task (be it successful or not) * @param isWebview does not handle providers and opening download page if true. Can still add repos and login. * */ @Suppress("DEPRECATION_ERROR") fun handleAppIntentUrl( activity: FragmentActivity?, str: String?, isWebview: Boolean, extraArgs: Bundle? = null ): Boolean = with(activity) { // TODO MUCH BETTER HANDLING // Invalid URIs can crash fun safeURI(uri: String) = safe { URI(uri) } if (str != null && this != null) { if (str.startsWith("https://cs.repo")) { val realUrl = "https://" + str.substringAfter("?") println("Repository url: $realUrl") loadRepository(realUrl) return true } else if (str.contains(APP_STRING)) { for (api in AccountManager.allApis) { if (api.isValidRedirectUrl(str)) { ioSafe { Log.i(TAG, "handleAppIntent $str") try { val isSuccessful = api.login(str) if (isSuccessful) { Log.i(TAG, "authenticated ${api.name}") } else { Log.i(TAG, "failed to authenticate ${api.name}") } showToast( if (isSuccessful) { txt(R.string.authenticated_user, api.name) } else { txt(R.string.authenticated_user_fail, api.name) } ) } catch (t: Throwable) { logError(t) showToast( txt(R.string.authenticated_user_fail, api.name) ) } } return true } } // This specific intent is used for the gradle deployWithAdb // https://github.com/recloudstream/gradle/blob/master/src/main/kotlin/com/lagradost/cloudstream3/gradle/tasks/DeployWithAdbTask.kt#L46 if (str == "$APP_STRING:") { ioSafe { PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins( activity ) } } } else if (safeURI(str)?.scheme == APP_STRING_REPO) { val url = str.replaceFirst(APP_STRING_REPO, "https") loadRepository(url) return true } else if (safeURI(str)?.scheme == APP_STRING_SEARCH) { val query = str.substringAfter("$APP_STRING_SEARCH://") nextSearchQuery = try { URLDecoder.decode(query, "UTF-8") } catch (t: Throwable) { logError(t) query } // Use both navigation views to support both layouts. // It might be better to use the QuickSearch. activity?.findViewById(R.id.nav_view)?.selectedItemId = R.id.navigation_search activity?.findViewById(R.id.nav_rail_view)?.selectedItemId = R.id.navigation_search } else if (safeURI(str)?.scheme == APP_STRING_PLAYER) { val uri = str.toUri() val name = uri.getQueryParameter("name") val url = URLDecoder.decode(uri.authority, "UTF-8") navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( LinkGenerator( listOf(BasicLink(url, name)), extract = true, ) ) ) } else if (safeURI(str)?.scheme == APP_STRING_RESUME_WATCHING) { val id = str.substringAfter("$APP_STRING_RESUME_WATCHING://").toIntOrNull() ?: return false ioSafe { val resumeWatchingCard = HomeViewModel.getResumeWatching()?.firstOrNull { it.id == id } ?: return@ioSafe activity.loadSearchResult( resumeWatchingCard, START_ACTION_RESUME_LATEST ) } } else if (str.startsWith(APP_STRING_SHARE)) { try { val data = str.substringAfter("$APP_STRING_SHARE:") val parts = data.split("?", limit = 2) loadResult( String(base64DecodeArray(parts[1]), Charsets.UTF_8), String(base64DecodeArray(parts[0]), Charsets.UTF_8), "" ) return true } catch (e: Exception) { showToast("Invalid Uri", Toast.LENGTH_SHORT) return false } } else if (!isWebview) { if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) { this.navigate(R.id.navigation_downloads) return true } else { val apiName = extraArgs?.getString(API_NAME_EXTRA_KEY) ?.takeIf { it.isNotBlank() } // if provided, try to match the api name instead of the api url // this is in order to also support providers that use JSON dataUrls // for example if (apiName != null) { loadResult(str, apiName, "") return true } synchronized(apis) { for (api in apis) { if (str.startsWith(api.mainUrl)) { loadResult(str, api.name, "") return true } } } } } } return false } fun centerView(view: View?) { if (view == null) return try { Log.v(TAG, "centerView: $view") val r = Rect(0, 0, 0, 0) view.getDrawingRect(r) val x = r.centerX() val y = r.centerY() val dx = r.width() / 2 //screenWidth / 2 val dy = screenHeight / 2 val r2 = Rect(x - dx, y - dy, x + dx, y + dy) view.requestRectangleOnScreen(r2, false) // TvFocus.current =TvFocus.current.copy(y=y.toFloat()) } catch (_: Throwable) { } } } var lastPopup: SearchResponse? = null fun loadPopup(result: SearchResponse, load: Boolean = true) { lastPopup = result val syncName = syncViewModel.syncName(result.apiName) // based on apiName we decide on if it is a local list or not, this is because // we want to show a bit of extra UI to sync apis if (result is SyncAPI.LibraryItem && syncName != null) { isLocalList = false syncViewModel.setSync(syncName, result.syncId) syncViewModel.updateMetaAndUser() } else { isLocalList = true syncViewModel.clear() } if (load) { viewModel.load( this, result.url, result.apiName, false, if (getApiDubstatusSettings() .contains(DubStatus.Dubbed) ) DubStatus.Dubbed else DubStatus.Subbed, null ) } else { viewModel.loadSmall(result) } } override fun onColorSelected(dialogId: Int, color: Int) { onColorSelectedEvent.invoke(Pair(dialogId, color)) } override fun onDialogDismissed(dialogId: Int) { onDialogDismissedEvent.invoke(dialogId) } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) updateLocale() // android fucks me by chaining lang when rotating the phone updateTheme(this) // Update if system theme val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment navHostFragment.navController.currentDestination?.let { updateNavBar(it) } } private fun updateNavBar(destination: NavDestination) { this.hideKeyboard() // Fucks up anime info layout since that has its own layout binding?.castMiniControllerHolder?.isVisible = !listOf( R.id.navigation_results_phone, R.id.navigation_results_tv, R.id.navigation_player ).contains(destination.id) val isNavVisible = listOf( R.id.navigation_home, R.id.navigation_search, R.id.navigation_library, R.id.navigation_downloads, R.id.navigation_settings, R.id.navigation_download_child, R.id.navigation_download_queue, R.id.navigation_subtitles, R.id.navigation_chrome_subtitles, R.id.navigation_settings_player, R.id.navigation_settings_updates, R.id.navigation_settings_ui, R.id.navigation_settings_account, R.id.navigation_settings_providers, R.id.navigation_settings_general, R.id.navigation_settings_extensions, R.id.navigation_settings_plugins, R.id.navigation_test_providers, ).contains(destination.id) /*val dontPush = listOf( R.id.navigation_home, R.id.navigation_search, R.id.navigation_results_phone, R.id.navigation_results_tv, R.id.navigation_player, R.id.navigation_quick_search, ).contains(destination.id) binding?.navHostFragment?.apply { val params = layoutParams as ConstraintLayout.LayoutParams val push = if (!dontPush && isLayout(TV or EMULATOR)) resources.getDimensionPixelSize(R.dimen.navbar_width) else 0 if (!this.isLtr()) { params.setMargins( params.leftMargin, params.topMargin, push, params.bottomMargin ) } else { params.setMargins( push, params.topMargin, params.rightMargin, params.bottomMargin ) } layoutParams = params }*/ binding?.apply { navRailView.isVisible = isNavVisible && isLandscape() navView.isVisible = isNavVisible && !isLandscape() navHostFragment.apply { val marginPx = resources.getDimensionPixelSize(R.dimen.nav_rail_view_width) layoutParams = (navHostFragment.layoutParams as ViewGroup.MarginLayoutParams).apply { marginStart = if (isNavVisible && isLandscape() && isLayout(TV or EMULATOR)) marginPx else 0 } } /** * We need to make sure if we return to a sub-fragment, * the correct navigation item is selected so that it does not * highlight the wrong one in UI. */ when (destination.id) { in listOf(R.id.navigation_downloads, R.id.navigation_download_child, R.id.navigation_download_queue) -> { navRailView.menu.findItem(R.id.navigation_downloads).isChecked = true navView.menu.findItem(R.id.navigation_downloads).isChecked = true } in listOf( R.id.navigation_settings, R.id.navigation_subtitles, R.id.navigation_chrome_subtitles, R.id.navigation_settings_player, R.id.navigation_settings_updates, R.id.navigation_settings_ui, R.id.navigation_settings_account, R.id.navigation_settings_providers, R.id.navigation_settings_general, R.id.navigation_settings_extensions, R.id.navigation_settings_plugins, R.id.navigation_test_providers ) -> { navRailView.menu.findItem(R.id.navigation_settings).isChecked = true navView.menu.findItem(R.id.navigation_settings).isChecked = true } } } } //private var mCastSession: CastSession? = null var mSessionManager: SessionManager? = null private val mSessionManagerListener: SessionManagerListener by lazy { SessionManagerListenerImpl() } private inner class SessionManagerListenerImpl : SessionManagerListener { override fun onSessionStarting(session: Session) { } override fun onSessionStarted(session: Session, sessionId: String) { invalidateOptionsMenu() } override fun onSessionStartFailed(session: Session, i: Int) { } override fun onSessionEnding(session: Session) { } override fun onSessionResumed(session: Session, wasSuspended: Boolean) { invalidateOptionsMenu() } override fun onSessionResumeFailed(session: Session, i: Int) { } override fun onSessionSuspended(session: Session, i: Int) { } override fun onSessionEnded(session: Session, error: Int) { } override fun onSessionResuming(session: Session, s: String) { } } override fun onResume() { super.onResume() afterPluginsLoadedEvent += ::onAllPluginsLoaded setActivityInstance(this) try { if (isCastApiAvailable()) { mSessionManager?.addSessionManagerListener(mSessionManagerListener) } } catch (e: Exception) { logError(e) } } override fun onPause() { super.onPause() // Start any delayed updates if (ApkInstaller.delayedInstaller?.startInstallation() == true) { Toast.makeText(this, R.string.update_started, Toast.LENGTH_LONG).show() } try { if (isCastApiAvailable()) { mSessionManager?.removeSessionManagerListener(mSessionManagerListener) //mCastSession = null } } catch (e: Exception) { logError(e) } } override fun dispatchKeyEvent(event: KeyEvent): Boolean = CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event) override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean = CommonActivity.onKeyDown(this, keyCode, event) ?: super.onKeyDown(keyCode, event) override fun onUserLeaveHint() { super.onUserLeaveHint() onUserLeaveHint(this) } @SuppressLint("ApplySharedPref") // commit since the op needs to be synchronous private fun showConfirmExitDialog(settingsManager: SharedPreferences) { val confirmBeforeExit = settingsManager.getInt(getString(R.string.confirm_exit_key), -1) if (confirmBeforeExit == 1 || (confirmBeforeExit == -1 && isLayout(PHONE))) { // finish() causes a bug on some TVs where player // may keep playing after closing the app. if (isLayout(TV)) exitProcess(0) else finish() return } val dialogView = layoutInflater.inflate(R.layout.confirm_exit_dialog, null) val dontShowAgainCheck: CheckBox = dialogView.findViewById(R.id.checkboxDontShowAgain) val builder: AlertDialog.Builder = AlertDialog.Builder(this) builder.setView(dialogView) .setTitle(R.string.confirm_exit_dialog) .setNegativeButton(R.string.no) { _, _ -> /*NO-OP*/ } .setPositiveButton(R.string.yes) { _, _ -> if (dontShowAgainCheck.isChecked) { settingsManager.edit(commit = true) { putInt(getString(R.string.confirm_exit_key), 1) } } // finish() causes a bug on some TVs where player // may keep playing after closing the app. if (isLayout(TV)) exitProcess(0) else finish() } builder.show().setDefaultFocus() } override fun onDestroy() { filesToDelete.forEach { path -> val result = File(path).deleteRecursively() if (result) { Log.d(TAG, "Deleted temporary file: $path") } else { Log.d(TAG, "Failed to delete temporary file: $path") } } filesToDelete = setOf() val broadcastIntent = Intent() broadcastIntent.action = "restart_service" broadcastIntent.setClass(this, VideoDownloadRestartReceiver::class.java) this.sendBroadcast(broadcastIntent) afterPluginsLoadedEvent -= ::onAllPluginsLoaded detachBackPressedCallback("MainActivityDefault") super.onDestroy() } override fun onNewIntent(intent: Intent) { handleAppIntent(intent) super.onNewIntent(intent) } private fun handleAppIntent(intent: Intent?) { if (intent == null) return val str = intent.dataString loadCache() handleAppIntentUrl(this, str, false, intent.extras) } private fun NavDestination.matchDestination(@IdRes destId: Int): Boolean = hierarchy.any { it.id == destId } private var lastNavTime = 0L private fun onNavDestinationSelected(item: MenuItem, navController: NavController): Boolean { val currentTime = System.currentTimeMillis() // safeDebounce: Check if a previous tap happened within the last 400ms if (currentTime - lastNavTime < 400) return false lastNavTime = currentTime val destinationId = item.itemId // Check if we are already at the selected destination if (navController.currentDestination?.id == destinationId) return false // Make all nav buttons focus on this specific view when nextFocusRightId val targetView = when (destinationId) { // Please note that if R.id.navigation_home is readded, then it will only take affect when // navigation to home for the second time as onNavDestinationSelected will not get called // when first loading up the app // R.id.navigation_home -> R.id.home_preview_change_api R.id.navigation_search -> R.id.main_search R.id.navigation_library -> R.id.main_search R.id.navigation_downloads -> R.id.download_appbar else -> null } if (targetView != null && isLayout(TV or EMULATOR)) { val fromView = binding?.navRailView if (fromView != null) { fromView.nextFocusRightId = targetView for (focusView in arrayOf( R.id.navigation_downloads, R.id.navigation_home, R.id.navigation_search, R.id.navigation_library, R.id.navigation_settings, )) { fromView.findViewById(focusView)?.nextFocusRightId = targetView } } } val builder = NavOptions.Builder().setLaunchSingleTop(true).setRestoreState(true) .setEnterAnim(R.anim.enter_anim) .setExitAnim(R.anim.exit_anim) .setPopEnterAnim(R.anim.pop_enter) .setPopExitAnim(R.anim.pop_exit) if (item.order and Menu.CATEGORY_SECONDARY == 0) { builder.setPopUpTo( navController.graph.findStartDestination().id, inclusive = false, saveState = true ) } return try { navController.navigate(destinationId, null, builder.build()) navController.currentDestination?.matchDestination(destinationId) == true } catch (e: IllegalArgumentException) { Log.e("NavigationError", "Failed to navigate: ${e.message}") false } } private val pluginsLock = Mutex() private fun onAllPluginsLoaded(success: Boolean = false) { ioSafe { pluginsLock.withLock { synchronized(allProviders) { // Load cloned sites after plugins have been loaded since clones depend on plugins. try { getKey>(USER_PROVIDER_API)?.let { list -> list.forEach { custom -> allProviders.firstOrNull { it.javaClass.simpleName == custom.parentJavaClass } ?.let { allProviders.add( it.javaClass.getDeclaredConstructor().newInstance() .apply { name = custom.name lang = custom.lang mainUrl = custom.url.trimEnd('/') canBeOverridden = false }) } } } // it.hashCode() is not enough to make sure they are distinct apis = allProviders.distinctBy { it.lang + it.name + it.mainUrl + it.javaClass.name } APIHolder.apiMap = null } catch (e: Exception) { logError(e) } } } } } lateinit var viewModel: ResultViewModel2 lateinit var syncViewModel: SyncViewModel private var libraryViewModel: LibraryViewModel? = null /** kinda dirty, however it signals that we should use the watch status as sync or not*/ var isLocalList: Boolean = false override fun onCreateView(name: String, context: Context, attrs: AttributeSet): View? { viewModel = ViewModelProvider(this)[ResultViewModel2::class.java] syncViewModel = ViewModelProvider(this)[SyncViewModel::class.java] return super.onCreateView(name, context, attrs) } private fun hidePreviewPopupDialog() { bottomPreviewPopup.dismissSafe(this) bottomPreviewPopup = null bottomPreviewBinding = null } private var bottomPreviewPopup: Dialog? = null private var bottomPreviewBinding: BottomResultviewPreviewBinding? = null private fun showPreviewPopupDialog(): BottomResultviewPreviewBinding { val ret = (bottomPreviewBinding ?: run { val builder: Dialog val layout: Int if (isLayout(PHONE)) { builder = BottomSheetDialog(this) layout = R.layout.bottom_resultview_preview } else { builder = Dialog(this, R.style.DialogHalfFullscreen) layout = R.layout.bottom_resultview_preview_tv // No way to do this in styles :( builder.window?.setGravity(Gravity.CENTER_VERTICAL or Gravity.END) } val root = layoutInflater.inflate(layout, null, false) val binding = BottomResultviewPreviewBinding.bind(root) bottomPreviewBinding = binding builder.setContentView(root) builder.setOnDismissListener { bottomPreviewPopup = null bottomPreviewBinding = null viewModel.clear() } builder.setCanceledOnTouchOutside(true) builder.show() bottomPreviewPopup = builder binding }) return ret } var binding: ActivityMainBinding? = null object TvFocus { data class FocusTarget( val width: Int, val height: Int, val x: Float, val y: Float, ) { companion object { fun lerp(a: FocusTarget, b: FocusTarget, lerp: Float): FocusTarget { val ilerp = 1 - lerp return FocusTarget( width = (a.width * ilerp + b.width * lerp).toInt(), height = (a.height * ilerp + b.height * lerp).toInt(), x = a.x * ilerp + b.x * lerp, y = a.y * ilerp + b.y * lerp ) } } } var last: FocusTarget = FocusTarget(0, 0, 0.0f, 0.0f) var current: FocusTarget = FocusTarget(0, 0, 0.0f, 0.0f) var focusOutline: WeakReference = WeakReference(null) var lastFocus: WeakReference = WeakReference(null) private val layoutListener: View.OnLayoutChangeListener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> // shitty fix for layouts lastFocus.get()?.apply { updateFocusView( this, same = true ) postDelayed({ updateFocusView( lastFocus.get(), same = false ) }, 300) } } private val attachListener: View.OnAttachStateChangeListener = object : View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View) { updateFocusView(v) } override fun onViewDetachedFromWindow(v: View) { // removes the focus view but not the listener as updateFocusView(null) will remove the listener focusOutline.get()?.isVisible = false } } /*private val scrollListener = object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { super.onScrolled(recyclerView, dx, dy) current = current.copy(x = current.x + dx, y = current.y + dy) setTargetPosition(current) } }*/ private fun setTargetPosition(target: FocusTarget) { focusOutline.get()?.apply { layoutParams = layoutParams?.apply { width = target.width height = target.height } translationX = target.x translationY = target.y bringToFront() } } private var animator: ValueAnimator? = null /** if this is enabled it will keep the focus unmoving * during listview move */ private const val NO_MOVE_LIST: Boolean = false /** If this is enabled then it will try to move the * listview focus to the left instead of center */ private const val LEFTMOST_MOVE_LIST: Boolean = true private val reflectedScroll by lazy { try { RecyclerView::class.java.declaredMethods.firstOrNull { it.name == "scrollStep" }?.also { it.isAccessible = true } } catch (t: Throwable) { null } } @MainThread fun updateFocusView(newFocus: View?, same: Boolean = false) { val focusOutline = focusOutline.get() ?: return val lastView = lastFocus.get() val exactlyTheSame = lastView == newFocus && newFocus != null if (!exactlyTheSame) { lastView?.removeOnLayoutChangeListener(layoutListener) lastView?.removeOnAttachStateChangeListener(attachListener) (lastView?.parent as? RecyclerView)?.apply { removeOnLayoutChangeListener(layoutListener) //removeOnScrollListener(scrollListener) } } val wasGone = focusOutline.isGone val visible = newFocus != null && newFocus.measuredHeight > 0 && newFocus.measuredWidth > 0 && newFocus.isShown && newFocus.tag != "tv_no_focus_tag" focusOutline.isVisible = visible if (newFocus != null) { lastFocus = WeakReference(newFocus) val parent = newFocus.parent var targetDx = 0 if (parent is RecyclerView) { val layoutManager = parent.layoutManager if (layoutManager is LinearListLayout && layoutManager.orientation == LinearLayoutManager.HORIZONTAL) { val dx = LinearSnapHelper().calculateDistanceToFinalSnap(layoutManager, newFocus) ?.get(0) if (dx != null) { val rdx = if (LEFTMOST_MOVE_LIST) { // this makes the item the leftmost in ltr, instead of center val diff = ((layoutManager.width - layoutManager.paddingStart - newFocus.measuredWidth) / 2) - newFocus.marginStart dx + if (parent.isRtl()) { -diff } else { diff } } else { if (dx > 0) dx else 0 } if (!NO_MOVE_LIST) { parent.smoothScrollBy(rdx, 0) } else { val smoothScroll = reflectedScroll if (smoothScroll == null) { parent.smoothScrollBy(rdx, 0) } else { try { // this is very fucked but because it is a protected method to // be able to compute the scroll I use reflection, scroll, then // scroll back, then smooth scroll and set the no move val out = IntArray(2) smoothScroll.invoke(parent, rdx, 0, out) val scrolledX = out[0] if (abs(scrolledX) <= 0) { // newFocus.measuredWidth*2 smoothScroll.invoke(parent, -rdx, 0, out) parent.smoothScrollBy(scrolledX, 0) if (NO_MOVE_LIST) targetDx = scrolledX } } catch (t: Throwable) { parent.smoothScrollBy(rdx, 0) } } } } } } val out = IntArray(2) newFocus.getLocationInWindow(out) val (screenX, screenY) = out var (x, y) = screenX.toFloat() to screenY.toFloat() val (currentX, currentY) = focusOutline.translationX to focusOutline.translationY if (!newFocus.isLtr()) { x = x - focusOutline.rootView.width + newFocus.measuredWidth } x -= targetDx // out of bounds = 0,0 if (screenX == 0 && screenY == 0) { focusOutline.isVisible = false } if (!exactlyTheSame) { (newFocus.parent as? RecyclerView)?.apply { addOnLayoutChangeListener(layoutListener) //addOnScrollListener(scrollListener) } newFocus.addOnLayoutChangeListener(layoutListener) newFocus.addOnAttachStateChangeListener(attachListener) } val start = FocusTarget( x = currentX, y = currentY, width = focusOutline.measuredWidth, height = focusOutline.measuredHeight ) val end = FocusTarget( x = x, y = y, width = newFocus.measuredWidth, height = newFocus.measuredHeight ) // if they are the same within then snap, aka scrolling val deltaMinX = min(end.width / 2, 60.toPx) val deltaMinY = min(end.height / 2, 60.toPx) if (start.width == end.width && start.height == end.height && (start.x - end.x).absoluteValue < deltaMinX && (start.y - end.y).absoluteValue < deltaMinY) { animator?.cancel() last = start current = end setTargetPosition(end) return } // if running then "reuse" if (animator?.isRunning == true) { current = end return } else { animator?.cancel() } last = start current = end // if previously gone, then tp if (wasGone) { setTargetPosition(current) return } // animate between a and b animator = ValueAnimator.ofFloat(0.0f, 1.0f).apply { startDelay = 0 duration = 200 addUpdateListener { animation -> val animatedValue = animation.animatedValue as Float val target = FocusTarget.lerp(last, current, minOf(animatedValue, 1.0f)) setTargetPosition(target) } start() } // post check if (!same) { newFocus.postDelayed({ updateFocusView(lastFocus.get(), same = true) }, 200) } /* the following is working, but somewhat bad code code if (!wasGone) { (focusOutline.parent as? ViewGroup)?.let { TransitionManager.endTransitions(it) TransitionManager.beginDelayedTransition( it, TransitionSet().addTransition(ChangeBounds()) .addTransition(ChangeTransform()) .setDuration(100) ) } } focusOutline.layoutParams = focusOutline.layoutParams?.apply { width = newFocus.measuredWidth height = newFocus.measuredHeight } focusOutline.translationX = x.toFloat() focusOutline.translationY = y.toFloat()*/ } } } @Suppress("DEPRECATION_ERROR") override fun onCreate(savedInstanceState: Bundle?) { app.initClient(this) val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) setLastError(this) val settingsForProvider = SettingsJson() settingsForProvider.enableAdult = settingsManager.getBoolean(getString(R.string.enable_nsfw_on_providers_key), false) MainAPI.settingsForProvider = settingsForProvider loadThemes(this) enableEdgeToEdgeCompat() setNavigationBarColorCompat(R.attr.primaryGrayBackground) updateLocale() super.onCreate(savedInstanceState) try { if (isCastApiAvailable()) { CastContext.getSharedInstance(this) { it.run() } .addOnSuccessListener { mSessionManager = it.sessionManager } } } catch (t: Throwable) { logError(t) } window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN) updateTv() // backup when we update the app, I don't trust myself to not boot lock users, might want to make this a setting? safe { val appVer = BuildConfig.VERSION_NAME val lastAppAutoBackup: String = getKey("VERSION_NAME") ?: "" if (appVer != lastAppAutoBackup) { setKey("VERSION_NAME", BuildConfig.VERSION_NAME) if (lastAppAutoBackup.isEmpty()) return@safe safe { backup(this) } safe { // Recompile oat on new version PluginManager.deleteAllOatFiles(this) } } } // just in case, MAIN SHOULD *NEVER* BOOT LOOP CRASH binding = try { if (isLayout(TV or EMULATOR)) { val newLocalBinding = ActivityMainTvBinding.inflate(layoutInflater, null, false) setContentView(newLocalBinding.root) if (isLayout(TV) && ANIMATED_OUTLINE) { TvFocus.focusOutline = WeakReference(newLocalBinding.focusOutline) newLocalBinding.root.viewTreeObserver.addOnScrollChangedListener { TvFocus.updateFocusView(TvFocus.lastFocus.get(), same = true) } newLocalBinding.root.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus -> TvFocus.updateFocusView(newFocus) } } else { newLocalBinding.focusOutline.isVisible = false } if (isLayout(TV)) { // Put here any button you don't want focusing it to center the view val exceptionButtons = listOf( //R.id.home_preview_play_btt, R.id.home_preview_info_btt, R.id.home_preview_hidden_next_focus, R.id.home_preview_hidden_prev_focus, R.id.result_play_movie_button, R.id.result_play_series_button, R.id.result_resume_series_button, R.id.result_play_trailer_button, R.id.result_bookmark_Button, R.id.result_favorite_Button, R.id.result_subscribe_Button, R.id.result_search_Button, R.id.result_episodes_show_button, ) newLocalBinding.root.viewTreeObserver.addOnGlobalFocusChangeListener { _, newFocus -> if (exceptionButtons.contains(newFocus?.id)) return@addOnGlobalFocusChangeListener centerView(newFocus) } } ActivityMainBinding.bind(newLocalBinding.root) // this may crash } else { val newLocalBinding = ActivityMainBinding.inflate(layoutInflater, null, false) setContentView(newLocalBinding.root) newLocalBinding } } catch (t: Throwable) { showToast(txt(R.string.unable_to_inflate, t.message ?: ""), Toast.LENGTH_LONG) null } binding?.apply { fixSystemBarsPadding( navView, heightResId = R.dimen.nav_view_height, padTop = false, overlayCutout = false ) fixSystemBarsPadding( navRailView, widthResId = R.dimen.nav_rail_view_width, padRight = false, padTop = false ) } // overscan val padding = settingsManager.getInt(getString(R.string.overscan_key), 0).toPx binding?.homeRoot?.setPadding(padding, padding, padding, padding) changeStatusBarState(isLayout(EMULATOR)) /** Biometric stuff for users without accounts **/ val noAccounts = settingsManager.getBoolean( getString(R.string.skip_startup_account_select_key), false ) || accounts.count() <= 1 if (isLayout(PHONE) && isAuthEnabled(this) && noAccounts) { if (deviceHasPasswordPinLock(this)) { startBiometricAuthentication(this, R.string.biometric_authentication_title, false) promptInfo?.let { prompt -> biometricPrompt?.authenticate(prompt) } // hide background while authenticating, Sorry moms & dads 🙏 binding?.navHostFragment?.isInvisible = true } } // Automatically enable jsdelivr if cant connect to raw.githubusercontent.com if (this.getKey(getString(R.string.jsdelivr_proxy_key)) == null && isNetworkAvailable()) { main { if (checkGithubConnectivity()) { this.setKey(getString(R.string.jsdelivr_proxy_key), false) } else { this.setKey(getString(R.string.jsdelivr_proxy_key), true) showSnackbar( this@MainActivity, R.string.jsdelivr_enabled, Snackbar.LENGTH_LONG, R.string.revert ) { setKey(getString(R.string.jsdelivr_proxy_key), false) } } } } ioSafe { SafeFile.check(this@MainActivity) } if (PluginManager.checkSafeModeFile()) { safe { showToast(R.string.safe_mode_file, Toast.LENGTH_LONG) } } else if (lastError == null) { ioSafe { DataStoreHelper.currentHomePage?.let { homeApi -> mainPluginsLoadedEvent.invoke(loadSinglePlugin(this@MainActivity, homeApi)) } ?: run { mainPluginsLoadedEvent.invoke(false) } ioSafe { if (settingsManager.getBoolean( getString(R.string.auto_update_plugins_key), true ) ) { PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem( this@MainActivity ) } else { ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(this@MainActivity) } //Automatically download not existing plugins, using mode specified. val autoDownloadPlugin = AutoDownloadMode.getEnum( settingsManager.getInt( getString(R.string.auto_download_plugins_key), 0 ) ) ?: AutoDownloadMode.Disable if (autoDownloadPlugin != AutoDownloadMode.Disable) { PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad( this@MainActivity, autoDownloadPlugin ) } } ioSafe { PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins( this@MainActivity, false ) } // Add your channel creation here } } else { val builder: AlertDialog.Builder = AlertDialog.Builder(this) builder.setTitle(R.string.safe_mode_title) builder.setMessage(R.string.safe_mode_description) builder.apply { setPositiveButton(R.string.safe_mode_crash_info) { _, _ -> val tbBuilder: AlertDialog.Builder = AlertDialog.Builder(context) tbBuilder.setTitle(R.string.safe_mode_title) tbBuilder.setMessage(lastError) tbBuilder.show() } setNegativeButton("Ok") { _, _ -> } } builder.show().setDefaultFocus() } fun setUserData(status: Resource?) { if (isLocalList) return bottomPreviewBinding?.apply { when (status) { is Resource.Success -> { resultviewPreviewBookmark.isEnabled = true resultviewPreviewBookmark.setText(status.value.status.stringRes) resultviewPreviewBookmark.setIconResource(status.value.status.iconRes) } is Resource.Failure -> { resultviewPreviewBookmark.isEnabled = false resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24) resultviewPreviewBookmark.text = status.errorString } else -> { resultviewPreviewBookmark.isEnabled = false resultviewPreviewBookmark.setIconResource(R.drawable.ic_baseline_bookmark_border_24) resultviewPreviewBookmark.setText(R.string.loading) } } } } fun setWatchStatus(state: WatchType?) { if (!isLocalList || state == null) return bottomPreviewBinding?.resultviewPreviewBookmark?.apply { setIconResource(state.iconRes) setText(state.stringRes) } } fun setSubscribeStatus(state: Boolean?) { bottomPreviewBinding?.resultviewPreviewSubscribe?.apply { if (state != null) { val drawable = if (state) { R.drawable.ic_baseline_notifications_active_24 } else { R.drawable.baseline_notifications_none_24 } setImageResource(drawable) } isVisible = state != null setOnClickListener { viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? -> if (newStatus == null) return@toggleSubscriptionStatus val message = if (newStatus) { // Kinda icky to have this here, but it works. SubscriptionWorkManager.enqueuePeriodicWork(context) R.string.subscription_new } else { R.string.subscription_deleted } val name = (viewModel.page.value as? Resource.Success)?.value?.title ?: txt(R.string.no_data).asStringNull(context) ?: "" showToast(txt(message, name), Toast.LENGTH_SHORT) } } } } observe(viewModel.watchStatus, ::setWatchStatus) observe(syncViewModel.userData, ::setUserData) observeNullable(viewModel.subscribeStatus, ::setSubscribeStatus) observeNullable(viewModel.page) { resource -> if (resource == null) { hidePreviewPopupDialog() return@observeNullable } when (resource) { is Resource.Failure -> { showToast(R.string.error) viewModel.clear() hidePreviewPopupDialog() } is Resource.Loading -> { showPreviewPopupDialog().apply { resultviewPreviewLoading.isVisible = true resultviewPreviewResult.isVisible = false resultviewPreviewLoadingShimmer.startShimmer() } } is Resource.Success -> { val d = resource.value showPreviewPopupDialog().apply { resultviewPreviewLoading.isVisible = false resultviewPreviewResult.isVisible = true resultviewPreviewLoadingShimmer.stopShimmer() resultviewPreviewTitle.text = d.title resultviewPreviewMetaType.setText(d.typeText) resultviewPreviewMetaYear.setText(d.yearText) resultviewPreviewMetaDuration.setText(d.durationText) resultviewPreviewMetaRating.setText(d.ratingText) resultviewPreviewDescription.setTextHtml(d.plotText) if (isLayout(PHONE)) { resultviewPreviewPoster.loadImage( d.posterImage ?: d.posterBackgroundImage, headers = d.posterHeaders ) } else { resultviewPreviewPoster.loadImage( d.posterBackgroundImage ?: d.posterImage, headers = d.posterHeaders ) } setUserData(syncViewModel.userData.value) setWatchStatus(viewModel.watchStatus.value) setSubscribeStatus(viewModel.subscribeStatus.value) resultviewPreviewBookmark.setOnClickListener { //viewModel.updateWatchStatus(WatchType.PLANTOWATCH) if (isLocalList) { val value = viewModel.watchStatus.value ?: WatchType.NONE this@MainActivity.showBottomDialog( WatchType.entries.map { getString(it.stringRes) }.toList(), value.ordinal, this@MainActivity.getString(R.string.action_add_to_bookmarks), showApply = false, {}) { viewModel.updateWatchStatus( WatchType.entries[it], this@MainActivity ) } } else { val value = (syncViewModel.userData.value as? Resource.Success)?.value?.status ?: SyncWatchType.NONE this@MainActivity.showBottomDialog( SyncWatchType.entries.map { getString(it.stringRes) }.toList(), value.ordinal, this@MainActivity.getString(R.string.action_add_to_bookmarks), showApply = false, {}) { syncViewModel.setStatus(SyncWatchType.entries[it].internalId) syncViewModel.publishUserData() } } } observeNullable(viewModel.favoriteStatus) observeFavoriteStatus@{ isFavorite -> resultviewPreviewFavorite.isVisible = isFavorite != null if (isFavorite == null) return@observeFavoriteStatus val drawable = if (isFavorite) { R.drawable.ic_baseline_favorite_24 } else { R.drawable.ic_baseline_favorite_border_24 } resultviewPreviewFavorite.setImageResource(drawable) } resultviewPreviewFavorite.setOnClickListener { viewModel.toggleFavoriteStatus(this@MainActivity) { newStatus: Boolean? -> if (newStatus == null) return@toggleFavoriteStatus val message = if (newStatus) { R.string.favorite_added } else { R.string.favorite_removed } val name = (viewModel.page.value as? Resource.Success)?.value?.title ?: txt(R.string.no_data).asStringNull(this@MainActivity) ?: "" showToast(txt(message, name), Toast.LENGTH_SHORT) } } if (isLayout(PHONE)) // dont want this clickable on tv layout resultviewPreviewDescription.setOnClickListener { view -> view.context?.let { ctx -> val builder: AlertDialog.Builder = AlertDialog.Builder(ctx, R.style.AlertDialogCustom) builder.setMessage(d.plotText.asString(ctx).html()) .setTitle(d.plotHeaderText.asString(ctx)) .show() } } resultviewPreviewMoreInfo.setOnClickListener { viewModel.clear() hidePreviewPopupDialog() lastPopup?.let { loadSearchResult(it) } } } } } } // ioSafe { // val plugins = // RepositoryParser.getRepoPlugins("https://raw.githubusercontent.com/recloudstream/TestPlugin/master/repo.json") // ?: emptyList() // plugins.map { // println("Load plugin: ${it.name} ${it.url}") // RepositoryParser.loadSiteTemp(applicationContext, it.url, it.name) // } // } // init accounts ioSafe { // we need to run this after we init all apis, otherwise currentSyncApi will fuck itself this@MainActivity.runOnUiThread { // Change library icon with logo of current api in sync libraryViewModel = ViewModelProvider(this@MainActivity)[LibraryViewModel::class.java] libraryViewModel?.currentApiName?.observe(this@MainActivity) { val syncAPI = libraryViewModel?.currentSyncApi Log.i("SYNC_API", "${syncAPI?.name}, ${syncAPI?.idPrefix}") val icon = if (syncAPI?.idPrefix == localListApi.idPrefix) { R.drawable.library_icon_selector } else { syncAPI?.icon ?: R.drawable.library_icon_selector } binding?.apply { navRailView.menu.findItem(R.id.navigation_library)?.setIcon(icon) navView.menu.findItem(R.id.navigation_library)?.setIcon(icon) } } } } SearchResultBuilder.updateCache(this) ioSafe { initAll() // No duplicates (which can happen by registerMainAPI) apis = synchronized(allProviders) { allProviders.distinctBy { it } } } // val navView: BottomNavigationView = findViewById(R.id.nav_view) setUpBackup() CommonActivity.init(this) val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment val navController = navHostFragment.navController navController.addOnDestinationChangedListener { _: NavController, navDestination: NavDestination, bundle: Bundle? -> // Intercept search and add a query updateNavBar(navDestination) if (navDestination.matchDestination(R.id.navigation_search) && !nextSearchQuery.isNullOrBlank()) { bundle?.apply { this.putString(SearchFragment.SEARCH_QUERY, nextSearchQuery) } } if (navDestination.matchDestination(R.id.navigation_home)) { attachBackPressedCallback("MainActivity") { showConfirmExitDialog(settingsManager) } } else detachBackPressedCallback("MainActivity") } //val navController = findNavController(R.id.nav_host_fragment) /*navOptions = NavOptions.Builder() .setLaunchSingleTop(true) .setEnterAnim(R.anim.nav_enter_anim) .setExitAnim(R.anim.nav_exit_anim) .setPopEnterAnim(R.anim.nav_pop_enter) .setPopExitAnim(R.anim.nav_pop_exit) .setPopUpTo(navController.graph.startDestination, false) .build()*/ val rippleColor = ColorStateList.valueOf(getResourceColor(R.attr.colorPrimary, 0.1f)) binding?.navView?.apply { itemRippleColor = rippleColor itemActiveIndicatorColor = rippleColor setupWithNavController(navController) setOnItemSelectedListener { item -> onNavDestinationSelected( item, navController ) } } binding?.navRailView?.apply { if (isLayout(PHONE)) { itemRippleColor = rippleColor itemActiveIndicatorColor = rippleColor } else { val rippleColor = ColorStateList.valueOf(getResourceColor(R.attr.textColor, 1.0f)) val rippleColorTransparent = ColorStateList.valueOf(getResourceColor(R.attr.textColor, 0.2f)) itemSpacing = 12.toPx // expandedItemSpacing does not have an attr itemRippleColor = rippleColorTransparent itemActiveIndicatorColor = rippleColor } setupWithNavController(navController) /*if (isLayout(TV or EMULATOR)) { background?.alpha = 200 } else { background?.alpha = 255 }*/ setOnItemSelectedListener { item -> onNavDestinationSelected( item, navController ) } fun noFocus(view: View) { view.tag = view.context.getString(R.string.tv_no_focus_tag) (view as? ViewGroup)?.let { for (child in it.children) { noFocus(child) } } } //noFocus(this) val navProfileRoot = findViewById(R.id.nav_footer_root) if (isLayout(TV or EMULATOR)) { val navProfilePic = findViewById(R.id.nav_footer_profile_pic) val navProfileCard = findViewById(R.id.nav_footer_profile_card) navProfileCard?.setOnClickListener { showAccountSelectLinear() } val homeViewModel = ViewModelProvider(this@MainActivity)[HomeViewModel::class.java] observe(homeViewModel.currentAccount) { currentAccount -> if (currentAccount != null) { navProfilePic?.loadImage( currentAccount.image ) navProfileRoot.isVisible = true } else { navProfileRoot.isGone = true } } } else { navProfileRoot.isGone = true } } val rail = binding?.navRailView if (rail != null) { binding?.navRailView?.labelVisibilityMode = NavigationRailView.LABEL_VISIBILITY_UNLABELED //val focus = mutableSetOf() var prevId: Int? = null var prevView: View? = null // The genius engineers at google did not actually // write a nextFocus for the navrail rail.findViewById(R.id.navigation_settings)?.nextFocusDownId = R.id.nav_footer_profile_card for (id in arrayOf( R.id.navigation_home, R.id.navigation_search, R.id.navigation_library, R.id.navigation_downloads, R.id.navigation_settings )) { val view = rail.findViewById(id) ?: continue prevId?.let { view.nextFocusUpId = it } prevView?.nextFocusDownId = id prevView = view prevId = id // Uncomment for focus expand /*if (!isLayout(TV)) { view.onFocusChangeListener = null } else { view.onFocusChangeListener = View.OnFocusChangeListener { v, hasFocus -> if (hasFocus) { focus += id binding?.navRailView?.labelVisibilityMode = NavigationRailView.LABEL_VISIBILITY_LABELED binding?.navRailView?.expand() } else { focus -= id v.post { if (focus.isEmpty()) { binding?.navRailView?.labelVisibilityMode = NavigationRailView.LABEL_VISIBILITY_UNLABELED binding?.navRailView?.collapse() } } } } }*/ } } // Navigation button long click functionality to scroll to top for (view in listOf(binding?.navView, binding?.navRailView)) { view?.findViewById(R.id.navigation_home)?.setOnLongClickListener { val recycler = binding?.root?.findViewById(R.id.home_master_recycler) recycler?.smoothScrollToPosition(0) return@setOnLongClickListener recycler != null } view?.findViewById(R.id.navigation_library)?.setOnLongClickListener { val viewPager = binding?.root?.findViewById(R.id.viewpager) ?: return@setOnLongClickListener false try { val children = (viewPager[0] as? RecyclerView)?.children ?: return@setOnLongClickListener false for (child in children) { child.findViewById(R.id.page_recyclerview) ?.smoothScrollToPosition(0) } } catch (_: IndexOutOfBoundsException) { } catch (t: Throwable) { logError(t) } return@setOnLongClickListener true } view?.findViewById(R.id.navigation_search)?.setOnLongClickListener { for (recyclerId in arrayOf( R.id.search_master_recycler, R.id.search_autofit_results, R.id.search_history_recycler )) { val recycler = binding?.root?.findViewById(recyclerId) ?: return@setOnLongClickListener false recycler.smoothScrollToPosition(0) } return@setOnLongClickListener true } view?.findViewById(R.id.navigation_downloads)?.setOnLongClickListener { val recycler: RecyclerView? = binding?.root?.findViewById(R.id.download_list) ?: binding?.root?.findViewById(R.id.download_child_list) recycler?.smoothScrollToPosition(0) return@setOnLongClickListener recycler != null } } loadCache() updateHasTrailers() /*nav_view.setOnNavigationItemSelectedListener { item -> when (item.itemId) { R.id.navigation_home -> { navController.navigate(R.id.navigation_home, null, navOptions) } R.id.navigation_search -> { navController.navigate(R.id.navigation_search, null, navOptions) } R.id.navigation_downloads -> { navController.navigate(R.id.navigation_downloads, null, navOptions) } R.id.navigation_settings -> { navController.navigate(R.id.navigation_settings, null, navOptions) } } true }*/ if (!checkWrite()) { requestRW() if (checkWrite()) return } //CastButtonFactory.setUpMediaRouteButton(this, media_route_button) // THIS IS CURRENTLY REMOVED BECAUSE HIGHER VERS OF ANDROID NEEDS A NOTIFICATION //if (!VideoDownloadManager.isMyServiceRunning(this, VideoDownloadKeepAliveService::class.java)) { // val mYourService = VideoDownloadKeepAliveService() // val mServiceIntent = Intent(this, mYourService::class.java).putExtra(START_VALUE_KEY, RESTART_ALL_DOWNLOADS_AND_QUEUE) // this.startService(mServiceIntent) //} //settingsManager.getBoolean("disable_automatic_data_downloads", true) && // TODO RETURN TO TRUE /* if (isUsingMobileData()) { Toast.makeText(this, "Downloads not resumed on mobile data", Toast.LENGTH_LONG).show() } else { val keys = getKeys(VideoDownloadManager.KEY_RESUME_PACKAGES) val resumePkg = keys.mapNotNull { k -> getKey(k) } // To remove a bug where this is permanent removeKeys(VideoDownloadManager.KEY_RESUME_PACKAGES) for (pkg in resumePkg) { // ADD ALL CURRENT DOWNLOADS VideoDownloadManager.downloadFromResume(this, pkg, false) } // ADD QUEUE // array needed because List gets cast exception to linkedList for some unknown reason val resumeQueue = getKey>(VideoDownloadManager.KEY_RESUME_QUEUE_PACKAGES) resumeQueue?.sortedBy { it.index }?.forEach { VideoDownloadManager.downloadFromResume(this, it.pkg) } }*/ /* val castContext = CastContext.getSharedInstance(applicationContext) fun buildMediaQueueItem(video: String): MediaQueueItem { // val movieMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_PHOTO) //movieMetadata.putString(MediaMetadata.KEY_TITLE, "CloudStream") val mediaInfo = MediaInfo.Builder(video.toUri().toString()) .setStreamType(MediaInfo.STREAM_TYPE_NONE) .setContentType(MimeTypes.IMAGE_JPEG) // .setMetadata(movieMetadata).build() .build() return MediaQueueItem.Builder(mediaInfo).build() }*/ /* castContext.addCastStateListener { state -> if (state == CastState.CONNECTED) { println("TESTING") val isCasting = castContext?.sessionManager?.currentCastSession?.remoteMediaClient?.currentItem != null if(!isCasting) { val castPlayer = CastPlayer(castContext) println("LOAD ITEM") castPlayer.loadItem(buildMediaQueueItem("https://cdn.discordapp.com/attachments/551382684560261121/730169809408622702/ChromecastLogo6.png"),0) } } }*/ /*thread { createISO() }*/ if (BuildConfig.DEBUG) { var providersAndroidManifestString = "Current androidmanifest should be:\n" synchronized(allProviders) { for (api in allProviders) { providersAndroidManifestString += "\n" } } println(providersAndroidManifestString) } handleAppIntent(intent) ioSafe { runAutoUpdate() } FcastManager().init(this, false) APIRepository.dubStatusActive = getApiDubstatusSettings() try { // this ensures that no unnecessary space is taken loadCache() File(filesDir, "exoplayer").deleteRecursively() // old cache deleteFileOnExit(File(cacheDir, "exoplayer")) // current cache } catch (e: Exception) { logError(e) } println("Loaded everything") ioSafe { migrateResumeWatching() } main { val channelId = TvChannelUtils.getChannelId(this@MainActivity, getString(R.string.app_name)) if (channelId == null) { Log.d("TvChannel", "Channel not found, creating") TvChannelUtils.createTvChannel(this@MainActivity) } else { Log.d("TvChannel", "Channel ID: $channelId") } } getKey(USER_SELECTED_HOMEPAGE_API)?.let { homepage -> DataStoreHelper.currentHomePage = homepage removeKey(USER_SELECTED_HOMEPAGE_API) } try { if (getKey(HAS_DONE_SETUP_KEY, false) != true) { navController.navigate(R.id.navigation_setup_language) // If no plugins bring up extensions screen } else if (PluginManager.getPluginsOnline().isEmpty() && PluginManager.getPluginsLocal().isEmpty() // && PREBUILT_REPOSITORIES.isNotEmpty() ) { navController.navigate( R.id.navigation_setup_extensions, SetupFragmentExtensions.newInstance(false) ) } } catch (e: Exception) { logError(e) } // Used to check current focus for TV // main { // while (true) { // delay(5000) // println("Current focus: $currentFocus") // showToast(this, currentFocus.toString(), Toast.LENGTH_LONG) // } // } attachBackPressedCallback("MainActivityDefault") { setNavigationBarColorCompat(R.attr.primaryGrayBackground) updateLocale() runDefault() } // Start the download queue DownloadQueueManager.init(this) } /** Biometric stuff **/ override fun onAuthenticationSuccess() { // make background (nav host fragment) visible again binding?.navHostFragment?.isInvisible = false } override fun onAuthenticationError() { finish() } suspend fun checkGithubConnectivity(): Boolean { return try { app.get( "https://raw.githubusercontent.com/recloudstream/.github/master/connectivitycheck", timeout = 5 ).text.trim() == "ok" } catch (t: Throwable) { false } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/AlwaysAskAction.kt ================================================ package com.lagradost.cloudstream3.actions import android.content.Context import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.txt class AlwaysAskAction : VideoClickAction() { override val name = txt(R.string.player_settings_always_ask) override val isPlayer = true // Only show in settings, not on a video override fun shouldShow(context: Context?, video: ResultEpisode?): Boolean = video == null override suspend fun runAction( context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int? ) { // This is handled specially in ResultViewModel2.kt by detecting the AlwaysAskAction // and showing the player selection dialog instead of executing the action directly throw NotImplementedError("AlwaysAskAction is handled specially by the calling code") } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/OpenInAppAction.kt ================================================ package com.lagradost.cloudstream3.actions import android.app.Activity import android.content.ComponentName import android.content.Context import android.content.Intent import androidx.core.content.FileProvider import androidx.core.net.toUri import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.ResultFragment import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.AppContextUtils.isAppInstalled import com.lagradost.cloudstream3.utils.DataStoreHelper import java.io.File fun updateDurationAndPosition(position: Long, duration: Long) { if (position <= 0 || duration <= 0) return val episode = getKey("last_opened") ?: return DataStoreHelper.setViewPosAndResume(episode.id, position, duration, episode, null) ResultFragment.updateUI() } /** * Util method that may be helpful for creating intents for apps that support m3u8 files. * All sources are written to a temporary m3u8 file, which is then sent to the app. */ fun makeTempM3U8Intent( context: Context, intent: Intent, result: LinkLoadingResult ) { if (result.links.size == 1) { intent.setDataAndType(result.links.first().url.toUri(), "video/*") return } intent.apply { addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION) } val outputFile = File.createTempFile("mirrorlist", ".m3u8", context.cacheDir) var text = "#EXTM3U\n#EXT-X-VERSION:3" result.links.forEach { link -> text += "\n#EXTINF:0,${link.name}\n${link.url}" } //With subtitles it doesn't work for no reason :( /*for (sub in result.subs) { val normalizedName = sub.name.replace("[^a-zA-Z0-9 ]".toRegex(), "") text += "\n#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"subs\",NAME=\"${normalizedName}\",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE=\"${sub.languageCode}\",URI=\"${sub.url}\"" }*/ text += "\n#EXT-X-ENDLIST" outputFile.writeText(text) intent.setDataAndType( FileProvider.getUriForFile( context, context.applicationContext.packageName + ".provider", outputFile ), "application/x-mpegURL" ) } abstract class OpenInAppAction( open val appName: UiText, open val packageName: String, private val intentClass: String? = null, private val action: String = Intent.ACTION_VIEW ) : VideoClickAction() { override val name: UiText get() = txt(R.string.episode_action_play_in_format, appName) override val isPlayer = true override fun shouldShow(context: Context?, video: ResultEpisode?) = context?.isAppInstalled(packageName) != false override suspend fun runAction( context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int? ) { if (context == null) return val intent = Intent(action) intent.setPackage(packageName) if (intentClass != null) { intent.component = ComponentName(packageName, intentClass) } putExtra(context, intent, video, result, index) setKey("last_opened", video) launchResult(intent) } /** * Before intent is sent, this function is called to put extra data into the intent. * @see VideoClickAction.runAction * */ @Throws abstract suspend fun putExtra( context: Context, intent: Intent, video: ResultEpisode, result: LinkLoadingResult, index: Int? ) /** * This function is called when the app is opened again after the intent was sent. * You can use it to for example update duration and position. * @see updateDurationAndPosition */ @Throws abstract fun onResult(activity: Activity, intent: Intent?) /** Safe version of onResult, we don't trust extension devs to not crash the app */ fun onResultSafe(activity: Activity, intent: Intent?) { try { onResult(activity, intent) } catch (t: Throwable) { logError(t) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/VideoClickAction.kt ================================================ package com.lagradost.cloudstream3.actions import android.app.Activity import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.os.Bundle import android.widget.Toast import androidx.core.app.ActivityOptionsCompat import com.lagradost.api.Log import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.temp.BiglyBTPackage import com.lagradost.cloudstream3.actions.temp.CopyClipboardAction import com.lagradost.cloudstream3.actions.temp.JustPlayerPackage import com.lagradost.cloudstream3.actions.temp.LibreTorrentPackage import com.lagradost.cloudstream3.actions.temp.MpvExPackage import com.lagradost.cloudstream3.actions.temp.MpvKtPackage import com.lagradost.cloudstream3.actions.temp.MpvKtPreviewPackage import com.lagradost.cloudstream3.actions.temp.MpvPackage import com.lagradost.cloudstream3.actions.temp.MpvYTDLPackage import com.lagradost.cloudstream3.actions.temp.NextPlayerPackage import com.lagradost.cloudstream3.actions.temp.PlayInBrowserAction import com.lagradost.cloudstream3.actions.temp.PlayMirrorAction import com.lagradost.cloudstream3.actions.temp.ViewM3U8Action import com.lagradost.cloudstream3.actions.temp.VlcNightlyPackage import com.lagradost.cloudstream3.actions.temp.VlcPackage import com.lagradost.cloudstream3.actions.temp.WebVideoCastPackage import com.lagradost.cloudstream3.actions.temp.fcast.FcastAction import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.UiText import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.util.concurrent.Callable import java.util.concurrent.FutureTask import kotlin.reflect.jvm.jvmName object VideoClickActionHolder { val allVideoClickActions = threadSafeListOf( // Default PlayInBrowserAction(), CopyClipboardAction(), ViewM3U8Action(), PlayMirrorAction(), // main support external apps VlcPackage(), MpvPackage(), MpvExPackage(), NextPlayerPackage(), JustPlayerPackage(), FcastAction(), LibreTorrentPackage(), BiglyBTPackage(), // forks/backup apps VlcNightlyPackage(), WebVideoCastPackage(), MpvYTDLPackage(), MpvKtPackage(), MpvKtPreviewPackage(), // Always Ask option AlwaysAskAction(), // added by plugins // ... ) init { Log.d("VideoClickActionHolder", "allVideoClickActions: ${allVideoClickActions.map { it.uniqueId() }}") } private const val ACTION_ID_OFFSET = 1000 fun makeOptionMap(activity: Activity?, video: ResultEpisode) = allVideoClickActions // We need to have index before filtering .mapIndexed { id, it -> it to id + ACTION_ID_OFFSET } .filter { it.first.shouldShowSafe(activity, video) } .map { it.first.name to it.second } fun getActionById(id: Int): VideoClickAction? = allVideoClickActions.getOrNull(id - ACTION_ID_OFFSET) fun getByUniqueId(uniqueId: String): VideoClickAction? = allVideoClickActions.firstOrNull { it.uniqueId() == uniqueId } fun uniqueIdToId(uniqueId: String?): Int? { if (uniqueId == null) return null return allVideoClickActions .mapIndexed { id, it -> it to id + ACTION_ID_OFFSET } .firstOrNull { it.first.uniqueId() == uniqueId } ?.second } fun getPlayers(activity: Activity? = null) = allVideoClickActions.filter { it.isPlayer && it.shouldShowSafe(activity, null) } } abstract class VideoClickAction { abstract val name: UiText /** if true, the app will show dialog to select source - result.links[index] */ open val oneSource : Boolean = false /** if true, this action could be selected as default player (one press action) in settings */ open val isPlayer: Boolean = false /** Which type of sources this action can handle. */ open val sourceTypes: Set = ExtractorLinkType.entries.toSet() /** Determines which plugin a given provider is from. This is the full path to the plugin. */ var sourcePlugin: String? = null /** Even if VideoClickAction should not run any UI code, startActivity requires it, * this is a wrapper for runOnUiThread in a suspended safe context that bubble up exceptions */ @Throws suspend fun uiThread(callable : Callable) : T? { val future = FutureTask{ try { Result.success(callable.call()) } catch (t : Throwable) { Result.failure(t) } } CommonActivity.activity?.runOnUiThread(future) ?: throw ErrorLoadingException("No UI Activity, this should never happened") val result = withContext(Dispatchers.IO) { return@withContext future.get() } return result.getOrThrow() } /** Internally uses activityResultLauncher, * use this when the activity has a result like watched position */ @Throws suspend fun launchResult(intent : Intent?, options : ActivityOptionsCompat? = null) { if (intent == null) { return } uiThread { MainActivity.activityResultLauncher?.launch(intent,options) } } /** Internally uses startActivity, use this when you don't * have any result that needs to be stored when exiting the activity */ @Throws suspend fun launch(intent : Intent?, bundle : Bundle? = null) { if (intent == null) { return } uiThread { CommonActivity.activity?.startActivity(intent, bundle) } } fun uniqueId() = "$sourcePlugin:${this::class.jvmName}" @Throws abstract fun shouldShow(context: Context?, video: ResultEpisode?): Boolean /** Safe version of shouldShow, as we don't trust extension devs to handle exceptions, * however no dev *should* throw in shouldShow */ fun shouldShowSafe(context: Context?, video: ResultEpisode?): Boolean { return try { shouldShow(context,video) } catch (t : Throwable) { logError(t) false } } /** * This function is called when the action is clicked. * @param context The current activity * @param video The episode/movie that was clicked * @param result The result of the link loading, contains video & subtitle links * @param index if oneSource is true, this is the index of the selected source */ @Throws abstract suspend fun runAction(context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int?) /** Safe version of runAction, as we don't trust extension devs to handle exceptions */ fun runActionSafe(context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int?) = ioSafe { try { runAction(context, video, result, index) } catch (_ : NotImplementedError) { CommonActivity.showToast("runAction has not been implemented for ${name.asStringNull(context)}, please contact the extension developer of $sourcePlugin", Toast.LENGTH_LONG) } catch (error : ErrorLoadingException) { CommonActivity.showToast(error.message, Toast.LENGTH_LONG) } catch (_: ActivityNotFoundException) { CommonActivity.showToast(R.string.app_not_found_error, Toast.LENGTH_LONG) } catch (t : Throwable) { logError(t) CommonActivity.showToast(t.toString(), Toast.LENGTH_LONG) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/temp/Aria2Package.kt ================================================ package com.lagradost.cloudstream3.actions.temp import android.app.Activity import android.content.Context import android.content.Intent import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.txt /** https://github.com/devgianlu/Aria2Android */ @Suppress("unused") class Aria2Package : OpenInAppAction( appName = txt("Aria2"), packageName = "com.gianlu.aria2android", intentClass = "com.gianlu.aria2android.MainActivity" ) { override val oneSource: Boolean = true override suspend fun putExtra( context: Context, intent: Intent, video: ResultEpisode, result: LinkLoadingResult, index: Int? ) { throw NotImplementedError("Aria2Android is missing getIntent, and onNewIntent, meaning it cant handle intents") } override fun onResult(activity: Activity, intent: Intent?) = Unit } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/temp/BiglyBTPackage.kt ================================================ package com.lagradost.cloudstream3.actions.temp import android.app.Activity import android.content.Context import android.content.Intent import androidx.core.net.toUri import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.txt /** https://github.com/BiglySoftware/BiglyBT-Android */ class BiglyBTPackage : OpenInAppAction( appName = txt("BiglyBT"), packageName = "com.biglybt.android.client", intentClass = "com.biglybt.android.client.activity.IntentHandler" ) { // Only torrents are supported by the app override val sourceTypes: Set = setOf(ExtractorLinkType.MAGNET, ExtractorLinkType.TORRENT) override val oneSource: Boolean = true override suspend fun putExtra( context: Context, intent: Intent, video: ResultEpisode, result: LinkLoadingResult, index: Int? ) { intent.data = result.links[index!!].url.toUri() } override fun onResult(activity: Activity, intent: Intent?) = Unit } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/temp/CloudStreamPackage.kt ================================================ package com.lagradost.cloudstream3.actions.temp import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.ui.player.ExtractorUri import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.player.SubtitleOrigin import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.DrmExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.newExtractorLink import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF import com.lagradost.cloudstream3.utils.txt /** * If you want to support CloudStream 3 as an external player, then this shows how to play any video link * For basic interactions, just `intent.data = uri` works * * However for more advanced use, CloudStream 3 also supports playlists of MinimalVideoLink and MinimalSubtitleLink with a `String[]` of JSON * These are passed as LINKS_EXTRA and SUBTITLE_EXTRA respectively */ @Suppress("Unused") class CloudStreamPackage : OpenInAppAction( appName = txt("CloudStream"), packageName = BuildConfig.APPLICATION_ID, //"com.lagradost.cloudstream3" or "com.lagradost.cloudstream3.prerelease" intentClass = "com.lagradost.cloudstream3.ui.player.DownloadedPlayerActivity" ) { override val oneSource: Boolean = false companion object { const val SUBTITLE_EXTRA: String = "subs" // Json of an array of MinimalVideoLink const val LINKS_EXTRA: String = "links" // Json of an array of MinimalSubtitleLink const val TITLE_EXTRA: String = "title" // Unused (String) const val ID_EXTRA: String = "id" // Identification number for the video(s), used to store start time (Int) const val POSITION_EXTRA: String = "pos" // Start time in MS (Long) const val DURATION_EXTRA: String = "dur" // Duration time in MS (Long) } data class MinimalVideoLink( @JsonProperty("uri") val uri: Uri?, @JsonProperty("url") val url: String?, @JsonProperty("mimeType") val mimeType: String = "video/mp4", @JsonProperty("name") val name: String?, @JsonProperty("headers") var headers: Map = mapOf(), @JsonProperty("quality") val quality: Int?, ) { companion object { fun fromExtractor(link: ExtractorLink): MinimalVideoLink = MinimalVideoLink( uri = null, url = link.url, name = link.name, mimeType = link.type.getMimeType(), headers = if (link.referer.isBlank()) emptyMap() else mapOf("referer" to link.referer) + link.headers, quality = link.quality ) } suspend fun toExtractorLink(): Pair = url?.let { url -> newExtractorLink( source = "NONE", name = name ?: "Unknown", url = url, type = ExtractorLinkType.entries.firstOrNull { ty -> ty.getMimeType() == mimeType } ?: ExtractorLinkType.VIDEO) { this@newExtractorLink.headers = this@MinimalVideoLink.headers this@newExtractorLink.quality = this@MinimalVideoLink.quality ?: Qualities.Unknown.value } } to uri?.let { uri -> ExtractorUri( uri = uri, name = name ?: "Unknown", ) } } data class MinimalSubtitleLink( @JsonProperty("url") val url: String, @JsonProperty("mimeType") val mimeType: String = "text/vtt", @JsonProperty("name") val name: String?, @JsonProperty("headers") var headers: Map = mapOf(), ) { companion object { fun fromSubtitle(sub: SubtitleData): MinimalSubtitleLink = MinimalSubtitleLink( url = sub.url, mimeType = sub.mimeType, name = sub.originalName, headers = sub.headers, ) } fun toSubtitleData(): SubtitleData = SubtitleData( url = url, nameSuffix = "", mimeType = mimeType, originalName = name ?: "Unknown", headers = headers, origin = SubtitleOrigin.URL, languageCode = fromCodeToLangTagIETF(name) ?: fromLanguageToTagIETF(name, true) ?: name, ) } override suspend fun putExtra( context: Context, intent: Intent, video: ResultEpisode, result: LinkLoadingResult, index: Int? ) { intent.apply { val position = getViewPos(video.id)?.position if (position != null) putExtra(POSITION_EXTRA, position) putExtra(ID_EXTRA, video.id) putExtra(TITLE_EXTRA, video.name) putExtra( SUBTITLE_EXTRA, result.subs.map { MinimalSubtitleLink.fromSubtitle(it).toJson() }.toTypedArray() ) putExtra( LINKS_EXTRA, result.links.filter { it !is ExtractorLinkPlayList && it !is DrmExtractorLink } .map { MinimalVideoLink.fromExtractor(it).toJson() }.toTypedArray() ) } } override fun onResult(activity: Activity, intent: Intent?) { // No results yet } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/temp/CopyClipboardAction.kt ================================================ package com.lagradost.cloudstream3.actions.temp import android.content.Context import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper class CopyClipboardAction: VideoClickAction() { override val name = txt("Copy to clipboard") override val oneSource = true override fun shouldShow(context: Context?, video: ResultEpisode?) = true override suspend fun runAction( context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int? ) { if (index == null) return val link = result.links.getOrNull(index) ?: return clipboardHelper(txt(link.name), link.url) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/temp/JustPlayerPackage.kt ================================================ package com.lagradost.cloudstream3.actions.temp import android.app.Activity import android.content.Context import android.content.Intent import androidx.core.net.toUri import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.txt /** https://github.com/moneytoo/Player/ */ class JustPlayerPackage : OpenInAppAction( appName = txt("JustPlayer"), packageName = "com.brouken.player", intentClass = "com.brouken.player.PlayerActivity" ) { override val sourceTypes: Set = setOf(ExtractorLinkType.VIDEO, ExtractorLinkType.M3U8, ExtractorLinkType.DASH) override val oneSource: Boolean = true override suspend fun putExtra( context: Context, intent: Intent, video: ResultEpisode, result: LinkLoadingResult, index: Int? ) { // While JustPlayer has support for subs, it cant add both subs and links at the same time // See https://github.com/moneytoo/Player/blob/49d80eb8de7a7bfc662393fdf114788fed1ebb2e/app/src/main/java/com/brouken/player/PlayerActivity.java#L794 intent.data = result.links[index!!].url.toUri() } override fun onResult(activity: Activity, intent: Intent?) = Unit } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/temp/LibreTorrentPackage.kt ================================================ package com.lagradost.cloudstream3.actions.temp import android.app.Activity import android.content.Context import android.content.Intent import androidx.core.net.toUri import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.txt /** https://github.com/proninyaroslav/libretorrent */ class LibreTorrentPackage : OpenInAppAction( appName = txt("LibreTorrent"), packageName = "org.proninyaroslav.libretorrent", intentClass = "org.proninyaroslav.libretorrent.ui.addtorrent.AddTorrentActivity" ) { // Only torrents are supported by the app override val sourceTypes: Set = setOf(ExtractorLinkType.MAGNET, ExtractorLinkType.TORRENT) override val oneSource: Boolean = true override suspend fun putExtra( context: Context, intent: Intent, video: ResultEpisode, result: LinkLoadingResult, index: Int? ) { intent.data = result.links[index!!].url.toUri() } override fun onResult(activity: Activity, intent: Intent?) = Unit } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvKtPackage.kt ================================================ package com.lagradost.cloudstream3.actions.temp import android.app.Activity import android.content.Context import android.content.Intent import androidx.core.net.toUri import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.updateDurationAndPosition import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.ExtractorLinkType class MpvKtPreviewPackage: MpvKtPackage( appName = "mpvKt Preview", packageName = "live.mehiz.mpvkt.preview", ) open class MpvKtPackage( appName: String = "mpvKt", packageName: String = "live.mehiz.mpvkt", ): OpenInAppAction( appName = txt(appName), packageName = packageName, intentClass = "live.mehiz.mpvkt.ui.player.PlayerActivity" ) { override val oneSource = true override val sourceTypes = setOf( ExtractorLinkType.VIDEO, ExtractorLinkType.DASH, ExtractorLinkType.M3U8 ) override suspend fun putExtra( context: Context, intent: Intent, video: ResultEpisode, result: LinkLoadingResult, index: Int? ) { val link = result.links.getOrNull(index ?: 0) ?: return intent.apply { putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray()) setDataAndType(link.url.toUri(), "video/*") // m3u8 plays, but changing sources feature is not available // makeTempM3U8Intent(activity, this, result) //putExtra("headers", link.headers.flatMap { listOf(it.key, it.value) }.toTypedArray()) val position = getViewPos(video.id)?.position if (position != null) putExtra("position", position.toInt()) putExtra("secure_uri", true) } } override fun onResult(activity: Activity, intent: Intent?) { val position = intent?.getIntExtra("position", -1)?.toLong() ?: -1 val duration = intent?.getIntExtra("duration", -1)?.toLong() ?: -1 updateDurationAndPosition(position, duration) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/temp/MpvPackage.kt ================================================ package com.lagradost.cloudstream3.actions.temp import android.app.Activity import android.content.Context import android.content.Intent import androidx.core.net.toUri import com.lagradost.api.Log import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.makeTempM3U8Intent import com.lagradost.cloudstream3.actions.updateDurationAndPosition import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.ExtractorLinkType // https://github.com/mpv-android/mpv-android/blob/0eb3cdc6f1632636b9c30d52ec50e4b017661980/app/src/main/java/is/xyz/mpv/MPVActivity.kt#L904 // https://mpv-android.github.io/mpv-android/intent.html //https://github.com/marlboro-advance/mpvEx class MpvExPackage: MpvPackage("mpvEx","app.marlboroadvance.mpvex","app.marlboroadvance.mpvex.ui.player.PlayerActivity") class MpvYTDLPackage : MpvPackage("MPV YTDL", "is.xyz.mpv.ytdl") { override val sourceTypes = setOf( ExtractorLinkType.VIDEO, ExtractorLinkType.DASH, ExtractorLinkType.M3U8 ) } open class MpvPackage(appName: String = "MPV", packageName: String = "is.xyz.mpv",intentClass:String = "is.xyz.mpv.MPVActivity"): OpenInAppAction( txt(appName), packageName, intentClass ) { override val oneSource = true // mpv has poor playlist support on TV override suspend fun putExtra( context: Context, intent: Intent, video: ResultEpisode, result: LinkLoadingResult, index: Int? ) { intent.apply { putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray()) putExtra("title", video.name) if (index != null) { setDataAndType((result.links.getOrNull(index)?.url ?: return).toUri(), "video/*") } else { makeTempM3U8Intent(context, this, result) } val position = getViewPos(video.id)?.position if (position != null) putExtra("position", position.toInt()) putExtra("secure_uri", true) } } override fun onResult(activity: Activity, intent: Intent?) { val position = intent?.getIntExtra("position", -1) ?: -1 val duration = intent?.getIntExtra("duration", -1) ?: -1 Log.d("MPV", "Position: $position, Duration: $duration") updateDurationAndPosition(position.toLong(), duration.toLong()) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/temp/NextPlayerPackage.kt ================================================ package com.lagradost.cloudstream3.actions.temp import android.app.Activity import android.content.Context import android.content.Intent import androidx.core.net.toUri import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.txt /** https://github.com/anilbeesetti/nextplayer */ class NextPlayerPackage : OpenInAppAction( appName = txt("NextPlayer"), packageName = "dev.anilbeesetti.nextplayer", intentClass = "dev.anilbeesetti.nextplayer.feature.player.PlayerActivity" ) { override val sourceTypes: Set = setOf(ExtractorLinkType.VIDEO, ExtractorLinkType.M3U8, ExtractorLinkType.DASH) override val oneSource: Boolean = true override suspend fun putExtra( context: Context, intent: Intent, video: ResultEpisode, result: LinkLoadingResult, index: Int? ) { intent.data = result.links[index!!].url.toUri() } override fun onResult(activity: Activity, intent: Intent?) = Unit } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayInBrowserAction.kt ================================================ package com.lagradost.cloudstream3.actions.temp import android.content.Context import android.content.Intent import androidx.core.net.toUri import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.ExtractorLinkType class PlayInBrowserAction: VideoClickAction() { override val name = txt(R.string.episode_action_play_in_format, "Browser") override val oneSource = true override val isPlayer = true override val sourceTypes: Set = setOf( ExtractorLinkType.VIDEO, ExtractorLinkType.DASH, ExtractorLinkType.M3U8 ) override fun shouldShow(context: Context?, video: ResultEpisode?) = true override suspend fun runAction( context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int? ) { val link = result.links.getOrNull(index ?: 0) ?: return val i = Intent(Intent.ACTION_VIEW) i.data = link.url.toUri() launch(i) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/temp/PlayMirrorAction.kt ================================================ package com.lagradost.cloudstream3.actions.temp import android.app.Activity import android.content.Context import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.ui.player.ExtractorUri import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.player.VideoGenerator import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.txt class PlayMirrorAction : VideoClickAction() { override val name = txt(R.string.episode_action_play_mirror) override val oneSource = true override val isPlayer = true override val sourceTypes: Set = LOADTYPE_INAPP override fun shouldShow(context: Context?, video: ResultEpisode?) = true override suspend fun runAction( context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int? ) { //Implemented a generator to handle the single val activity = context as? Activity ?: return val generatorMirror = object : VideoGenerator(listOf(video)) { override val hasCache: Boolean = false override val canSkipLoading: Boolean = false override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, offset: Int, isCasting: Boolean ): Boolean { index?.let { callback(result.links[it] to null) } result.subs.forEach { subtitle -> subtitleCallback(subtitle) } return true } } activity.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( generatorMirror, result.syncData ) ) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/temp/ViewM3U8Action.kt ================================================ package com.lagradost.cloudstream3.actions.temp import android.content.Context import android.content.Intent import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.actions.makeTempM3U8Intent import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.txt class ViewM3U8Action: VideoClickAction() { override val name = txt(R.string.episode_action_play_in_format, "m3u8 player") override val isPlayer = true override fun shouldShow(context: Context?, video: ResultEpisode?) = true override suspend fun runAction( context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int? ) { if (context == null) return val i = Intent(Intent.ACTION_VIEW) makeTempM3U8Intent(context, i, result) launch(i) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/temp/VlcPackage.kt ================================================ package com.lagradost.cloudstream3.actions.temp import android.app.Activity import android.content.Context import android.content.Intent import android.os.Build import androidx.core.net.toUri import com.lagradost.api.Log import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.actions.makeTempM3U8Intent import com.lagradost.cloudstream3.actions.updateDurationAndPosition import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_AUTO_SELECT_KEY import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos // https://github.com/videolan/vlc-android/blob/3706c4be2da6800b3d26344fc04fab03ffa4b860/application/vlc-android/src/org/videolan/vlc/gui/video/VideoPlayerActivity.kt#L1898 // https://wiki.videolan.org/Android_Player_Intents/ class VlcNightlyPackage : VlcPackage() { override val packageName = "org.videolan.vlc.debug" override val appName = txt("VLC Nightly") } open class VlcPackage: OpenInAppAction( appName = txt("VLC"), packageName = "org.videolan.vlc", intentClass = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { "org.videolan.vlc.gui.video.VideoPlayerActivity" } else { null }, action = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { "org.videolan.vlc.player.result" } else { Intent.ACTION_VIEW } ) { // while VLC supports multi links, it has poor support, so we disable it for now override val oneSource = true override suspend fun putExtra( context: Context, intent: Intent, video: ResultEpisode, result: LinkLoadingResult, index: Int? ) { if (index != null) { intent.setDataAndType(result.links[index].url.toUri(), "video/*") } else { makeTempM3U8Intent(context, intent, result) } val position = getViewPos(video.id)?.position ?: 0L intent.putExtra("from_start", false) intent.putExtra("position", position) intent.putExtra("secure_uri", true) intent.putExtra("title", video.name) val subsLang = getKey(SUBTITLE_AUTO_SELECT_KEY) ?: "en" result.subs.firstOrNull { subsLang == it.languageCode }?.let { intent.putExtra("subtitles_location", it.url) } } override fun onResult(activity: Activity, intent: Intent?) { val position = intent?.getLongExtra("extra_position", -1) ?: -1 val duration = intent?.getLongExtra("extra_duration", -1) ?: -1 Log.d("VLC", "Position: $position, Duration: $duration") updateDurationAndPosition(position, duration) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/temp/WebVideoCastPackage.kt ================================================ package com.lagradost.cloudstream3.actions.temp import android.app.Activity import android.content.Context import android.content.Intent import android.os.Bundle import androidx.core.net.toUri import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.actions.OpenInAppAction import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.ExtractorLinkType // https://www.webvideocaster.com/integrations class WebVideoCastPackage: OpenInAppAction( txt("Web Video Cast"), "com.instantbits.cast.webvideo" ) { override val oneSource = true override val sourceTypes = setOf( ExtractorLinkType.VIDEO, ExtractorLinkType.DASH, ExtractorLinkType.M3U8 ) override suspend fun putExtra( context: Context, intent: Intent, video: ResultEpisode, result: LinkLoadingResult, index: Int? ) { val link = result.links[index ?: 0] intent.apply { setDataAndType(link.url.toUri(), "video/*") val title = video.name ?: video.headerName putExtra("subs", result.subs.map { it.url.toUri() }.toTypedArray()) putExtra("title", title) video.poster?.let { putExtra("poster", it) } val headers = Bundle().apply { if (link.referer.isNotBlank()) putString("Referer", link.referer) putString("User-Agent", USER_AGENT) for ((key, value) in link.headers) { putString(key, value) } } putExtra("android.media.intent.extra.HTTP_HEADERS", headers) putExtra("secure_uri", true) } } override fun onResult(activity: Activity, intent: Intent?) = Unit } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastAction.kt ================================================ package com.lagradost.cloudstream3.actions.temp.fcast import android.content.Context import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.ui.result.LinkLoadingResult import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog class FcastAction: VideoClickAction() { override val name = txt("Fcast to device") override val oneSource = true override val sourceTypes = setOf( ExtractorLinkType.VIDEO, ExtractorLinkType.DASH, ExtractorLinkType.M3U8 ) override fun shouldShow(context: Context?, video: ResultEpisode?) = FcastManager.currentDevices.isNotEmpty() override suspend fun runAction( context: Context?, video: ResultEpisode, result: LinkLoadingResult, index: Int? ) { val link = result.links.getOrNull(index ?: 0) ?: return val devices = FcastManager.currentDevices.toList() uiThread { context?.getActivity()?.showBottomDialog( devices.map { it.name }, -1, txt(R.string.player_settings_select_cast_device).asString(context), false, {}) { val position = getViewPos(video.id)?.position castTo(devices.getOrNull(it), link, position) } } } private fun castTo(device: PublicDeviceInfo?, link: ExtractorLink, position: Long?) { val host = device?.host ?: return FcastSession(host).use { session -> session.sendMessage( Opcode.Play, PlayMessage( link.type.getMimeType(), link.url, time = position?.let { it / 1000.0 }, headers = mapOf( "referer" to link.referer, "user-agent" to USER_AGENT ) + link.headers ) ) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastManager.kt ================================================ package com.lagradost.cloudstream3.actions.temp.fcast import android.content.Context import android.net.nsd.NsdManager import android.net.nsd.NsdManager.ResolveListener import android.net.nsd.NsdServiceInfo import android.os.Build import android.os.ext.SdkExtensions import android.util.Log import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.utils.Coroutines.ioSafe class FcastManager { private var nsdManager: NsdManager? = null // Used for receiver private val registrationListenerTcp = DefaultRegistrationListener() private fun getDeviceName(): String { return "${Build.MANUFACTURER}-${Build.MODEL}" } /** * Start the fcast service * @param registerReceiver If true will register the app as a compatible fcast receiver for discovery in other app */ fun init(context: Context, registerReceiver: Boolean) = ioSafe { nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager val serviceType = "_fcast._tcp" if (registerReceiver) { val serviceName = "$APP_PREFIX-${getDeviceName()}" val serviceInfo = NsdServiceInfo().apply { this.serviceName = serviceName this.serviceType = serviceType this.port = TCP_PORT } nsdManager?.registerService( serviceInfo, NsdManager.PROTOCOL_DNS_SD, registrationListenerTcp ) } nsdManager?.discoverServices( serviceType, NsdManager.PROTOCOL_DNS_SD, DefaultDiscoveryListener() ) } fun stop() { nsdManager?.unregisterService(registrationListenerTcp) } inner class DefaultDiscoveryListener : NsdManager.DiscoveryListener { val tag = "DiscoveryListener" override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) { Log.d(tag, "Discovery failed: $serviceType, error code: $errorCode") } override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) { Log.d(tag, "Stop discovery failed: $serviceType, error code: $errorCode") } override fun onDiscoveryStarted(serviceType: String?) { Log.d(tag, "Discovery started: $serviceType") } override fun onDiscoveryStopped(serviceType: String?) { Log.d(tag, "Discovery stopped: $serviceType") } override fun onServiceFound(serviceInfo: NsdServiceInfo?) { // Safe here as, java.lang.NoClassDefFoundError: Failed resolution of: Landroid/net/nsd/NsdManager$ServiceInfoCallback safe { if (serviceInfo == null) return@safe if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion( Build.VERSION_CODES.TIRAMISU ) >= 7 ) { nsdManager?.registerServiceInfoCallback( serviceInfo, Runnable::run, object : NsdManager.ServiceInfoCallback { override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) { Log.e(tag, "Service registration failed: $errorCode") } override fun onServiceUpdated(serviceInfo: NsdServiceInfo) { Log.d( tag, "Service updated: ${serviceInfo.serviceName}," + "Net: ${serviceInfo.hostAddresses.firstOrNull()?.hostAddress}" ) synchronized(_currentDevices) { _currentDevices.removeIf { it.rawName == serviceInfo.serviceName } _currentDevices.add(PublicDeviceInfo(serviceInfo)) } } override fun onServiceLost() { Log.d(tag, "Service lost: ${serviceInfo.serviceName},") synchronized(_currentDevices) { _currentDevices.removeIf { it.rawName == serviceInfo.serviceName } } } override fun onServiceInfoCallbackUnregistered() {} }) } else { @Suppress("DEPRECATION") nsdManager?.resolveService(serviceInfo, object : ResolveListener { override fun onResolveFailed( serviceInfo: NsdServiceInfo?, errorCode: Int ) { } override fun onServiceResolved(serviceInfo: NsdServiceInfo?) { if (serviceInfo == null) return synchronized(_currentDevices) { _currentDevices.add(PublicDeviceInfo(serviceInfo)) } Log.d( tag, "Service found: ${serviceInfo.serviceName}, Net: ${serviceInfo.host.hostAddress}" ) } }) } } } override fun onServiceLost(serviceInfo: NsdServiceInfo?) { if (serviceInfo == null) return // May remove duplicates, but net and port is null here, preventing device specific identification synchronized(_currentDevices) { _currentDevices.removeAll { it.rawName == serviceInfo.serviceName } } Log.d(tag, "Service lost: ${serviceInfo.serviceName}") } } companion object { const val APP_PREFIX = "CloudStream" private val _currentDevices: MutableList = mutableListOf() val currentDevices: List = _currentDevices class DefaultRegistrationListener : NsdManager.RegistrationListener { val tag = "DiscoveryService" override fun onServiceRegistered(serviceInfo: NsdServiceInfo) { Log.d(tag, "Service registered: ${serviceInfo.serviceName}") } override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { Log.e(tag, "Service registration failed: errorCode=$errorCode") } override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) { Log.d(tag, "Service unregistered: ${serviceInfo.serviceName}") } override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { Log.e(tag, "Service unregistration failed: errorCode=$errorCode") } } const val TCP_PORT = 46899 } } class PublicDeviceInfo(serviceInfo: NsdServiceInfo) { val rawName: String = serviceInfo.serviceName val host: String? = if ( Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && SdkExtensions.getExtensionVersion( Build.VERSION_CODES.TIRAMISU ) >= 7 ) { serviceInfo.hostAddresses.firstOrNull()?.hostAddress } else { @Suppress("DEPRECATION") serviceInfo.host.hostAddress } val name = rawName.replace("-", " ") + host?.let { " $it" } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/FcastSession.kt ================================================ package com.lagradost.cloudstream3.actions.temp.fcast import android.util.Log import androidx.annotation.WorkerThread import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.safefile.closeQuietly import java.io.DataOutputStream import java.net.Socket import kotlin.jvm.Throws class FcastSession(private val hostAddress: String): AutoCloseable { val tag = "FcastSession" private var socket: Socket? = null @Throws @WorkerThread fun open(): Socket { val socket = Socket(hostAddress, FcastManager.TCP_PORT) this.socket = socket return socket } override fun close() { socket?.closeQuietly() socket = null } @Throws private fun acquireSocket(): Socket { return socket ?: open() } fun ping() { sendMessage(Opcode.Ping, null) } fun sendMessage(opcode: Opcode, message: T) { ioSafe { val socket = acquireSocket() val outputStream = DataOutputStream(socket.getOutputStream()) val json = message?.toJson() val content = json?.toByteArray() ?: ByteArray(0) // Little endian starting from 1 // https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1 val size = content.size + 1 val sizeArray = ByteArray(4) { num -> (size shr 8 * num and 0xff).toByte() } Log.d(tag, "Sending message with size: $size, opcode: $opcode") outputStream.write(sizeArray) outputStream.write(ByteArray(1) { opcode.value }) outputStream.write(content) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/actions/temp/fcast/Packets.kt ================================================ package com.lagradost.cloudstream3.actions.temp.fcast // See https://gitlab.com/futo-org/fcast/-/wikis/Protocol-version-1 enum class Opcode(val value: Byte) { None(0), Play(1), Pause(2), Resume(3), Stop(4), Seek(5), PlaybackUpdate(6), VolumeUpdate(7), SetVolume(8), PlaybackError(9), SetSpeed(10), Version(11), Ping(12), Pong(13); } data class PlayMessage( val container: String, val url: String? = null, val content: String? = null, val time: Double? = null, val speed: Double? = null, val headers: Map? = null ) data class SeekMessage( val time: Double ) data class PlaybackUpdateMessage( val generationTime: Long, val time: Double, val duration: Double, val state: Int, val speed: Double ) data class VolumeUpdateMessage( val generationTime: Long, val volume: Double ) data class PlaybackErrorMessage( val message: String ) data class SetSpeedMessage( val speed: Double ) data class SetVolumeMessage( val volume: Double ) data class VersionMessage( val version: Long ) ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/mvvm/Lifecycle.kt ================================================ package com.lagradost.cloudstream3.mvvm import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData /** NOTE: Only one observer at a time per value */ fun LifecycleOwner.observe(liveData: LiveData, action: (t: T) -> Unit) { liveData.removeObservers(this) liveData.observe(this) { it?.let { t -> action(t) } } } /** NOTE: Only one observer at a time per value */ fun LifecycleOwner.observeNullable(liveData: LiveData, action: (t: T) -> Unit) { liveData.removeObservers(this) liveData.observe(this) { action(it) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/network/CloudflareKiller.kt ================================================ package com.lagradost.cloudstream3.network import android.util.Log import android.webkit.CookieManager import androidx.annotation.AnyThread import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.debugWarning import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.nicehttp.Requests.Companion.await import com.lagradost.nicehttp.cookies import kotlinx.coroutines.runBlocking import okhttp3.Headers import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response import java.net.URI @AnyThread class CloudflareKiller : Interceptor { companion object { const val TAG = "CloudflareKiller" private val ERROR_CODES = listOf(403, 503) private val CLOUDFLARE_SERVERS = listOf("cloudflare-nginx", "cloudflare") fun parseCookieMap(cookie: String): Map { return cookie.split(";").associate { val split = it.split("=") (split.getOrNull(0)?.trim() ?: "") to (split.getOrNull(1)?.trim() ?: "") }.filter { it.key.isNotBlank() && it.value.isNotBlank() } } } init { // Needs to clear cookies between sessions to generate new cookies. safe { // This can throw an exception on unsupported devices :( CookieManager.getInstance().removeAllCookies(null) } } val savedCookies: MutableMap> = mutableMapOf() /** * Gets the headers with cookies, webview user agent included! * */ fun getCookieHeaders(url: String): Headers { val userAgentHeaders = WebViewResolver.webViewUserAgent?.let { mapOf("user-agent" to it) } ?: emptyMap() return getHeaders(userAgentHeaders, savedCookies[URI(url).host] ?: emptyMap()) } override fun intercept(chain: Interceptor.Chain): Response = runBlocking { val request = chain.request() when (val cookies = savedCookies[request.url.host]) { null -> { val response = chain.proceed(request) if(!(response.header("Server") in CLOUDFLARE_SERVERS && response.code in ERROR_CODES)) { return@runBlocking response } else { response.close() bypassCloudflare(request)?.let { Log.d(TAG, "Succeeded bypassing cloudflare: ${request.url}") return@runBlocking it } } } else -> { return@runBlocking proceed(request, cookies) } } debugWarning({ true }) { "Failed cloudflare at: ${request.url}" } return@runBlocking chain.proceed(request) } private fun getWebViewCookie(url: String): String? { return safe { CookieManager.getInstance()?.getCookie(url) } } /** * Returns true if the cf cookies were successfully fetched from the CookieManager * Also saves the cookies. * */ private fun trySolveWithSavedCookies(request: Request): Boolean { // Not sure if this takes expiration into account return getWebViewCookie(request.url.toString())?.let { cookie -> cookie.contains("cf_clearance").also { solved -> if (solved) savedCookies[request.url.host] = parseCookieMap(cookie) } } ?: false } private suspend fun proceed(request: Request, cookies: Map): Response { val userAgentMap = WebViewResolver.getWebViewUserAgent()?.let { mapOf("user-agent" to it) } ?: emptyMap() val headers = getHeaders(request.headers.toMap() + userAgentMap, cookies + request.cookies) return app.baseClient.newCall( request.newBuilder() .headers(headers) .build() ).await() } private suspend fun bypassCloudflare(request: Request): Response? { val url = request.url.toString() // If no cookies then try to get them // Remove this if statement if cookies expire if (!trySolveWithSavedCookies(request)) { Log.d(TAG, "Loading webview to solve cloudflare for ${request.url}") WebViewResolver( // Never exit based on url Regex(".^"), // Cloudflare needs default user agent userAgent = null, // Cannot use okhttp (i think intercepting cookies fails which causes the issues) useOkhttp = false, // Match every url for the requestCallBack additionalUrls = listOf(Regex(".")) ).resolveUsingWebView( url ) { trySolveWithSavedCookies(request) } } val cookies = savedCookies[request.url.host] ?: return null return proceed(request, cookies) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/network/DdosGuardKiller.kt ================================================ package com.lagradost.cloudstream3.network import androidx.annotation.AnyThread import com.lagradost.cloudstream3.app import com.lagradost.nicehttp.Requests import com.lagradost.nicehttp.cookies import kotlinx.coroutines.runBlocking import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response /** * @param alwaysBypass will pre-emptively fetch ddos guard cookies if true. * If false it will only try to get cookies when a request returns 403 * */ // As seen in https://github.com/anime-dl/anime-downloader/blob/master/anime_downloader/sites/erairaws.py @AnyThread class DdosGuardKiller(private val alwaysBypass: Boolean) : Interceptor { val savedCookiesMap = mutableMapOf>() private var ddosBypassPath: String? = null override fun intercept(chain: Interceptor.Chain): Response = runBlocking { val request = chain.request() if (alwaysBypass) return@runBlocking bypassDdosGuard(request) val response = chain.proceed(request) return@runBlocking if (response.code == 403) { bypassDdosGuard(request) } else response } private suspend fun bypassDdosGuard(request: Request): Response { ddosBypassPath = ddosBypassPath ?: Regex("'(.*?)'").find( app.get( "https://check.ddos-guard.net/check.js" ).text )?.groupValues?.get(1) val cookies = savedCookiesMap[request.url.host] // If no cookies are found fetch and save em. ?: (request.url.scheme + "://" + request.url.host + (ddosBypassPath ?: "")).let { // Somehow app.get fails Requests().get(it).cookies.also { cookies -> savedCookiesMap[request.url.host] = cookies } } val headers = getHeaders(request.headers.toMap(), cookies + request.cookies) return app.baseClient.newCall( request.newBuilder() .headers(headers) .build() ).execute() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/network/DohProviders.kt ================================================ package com.lagradost.cloudstream3.network import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.dnsoverhttps.DnsOverHttps import java.net.InetAddress /** * Based on https://github.com/tachiyomiorg/tachiyomi/blob/master/app/src/main/java/eu/kanade/tachiyomi/network/DohProviders.kt */ fun OkHttpClient.Builder.addGenericDns(url: String, ips: List) = dns( DnsOverHttps .Builder() .client(build()) .url(url.toHttpUrl()) .bootstrapDnsHosts( ips.map { InetAddress.getByName(it) } ) .build() ) fun OkHttpClient.Builder.addGoogleDns() = ( addGenericDns( "https://dns.google/dns-query", listOf( "8.8.4.4", "8.8.8.8" ) )) fun OkHttpClient.Builder.addCloudFlareDns() = ( addGenericDns( "https://cloudflare-dns.com/dns-query", // https://www.cloudflare.com/ips/ listOf( "1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001" ) )) // Commented out as it doesn't work //fun OkHttpClient.Builder.addOpenDns() = ( // addGenericDns( // "https://doh.opendns.com/dns-query", // // https://support.opendns.com/hc/en-us/articles/360038086532-Using-DNS-over-HTTPS-DoH-with-OpenDNS // listOf( // "208.67.222.222", // "208.67.220.220", // "2620:119:35::35", // "2620:119:53::53", // ) // )) fun OkHttpClient.Builder.addAdGuardDns() = ( addGenericDns( "https://dns.adguard.com/dns-query", // https://github.com/AdguardTeam/AdGuardDNS listOf( // "Non-filtering" "94.140.14.140", "94.140.14.141", ) )) fun OkHttpClient.Builder.addDNSWatchDns() = ( addGenericDns( "https://resolver2.dns.watch/dns-query", // https://dns.watch/ listOf( "84.200.69.80", "84.200.70.40", ) )) fun OkHttpClient.Builder.addQuad9Dns() = ( addGenericDns( "https://dns.quad9.net/dns-query", // https://www.quad9.net/service/service-addresses-and-features listOf( "9.9.9.9", "149.112.112.112", ) )) fun OkHttpClient.Builder.addDnsSbDns() = ( addGenericDns( "https://doh.dns.sb/dns-query", //https://dns.sb/guide/ listOf( "185.222.222.222", "45.11.45.11", ) )) fun OkHttpClient.Builder.addCanadianShieldDns() = ( addGenericDns( "https://private.canadianshield.cira.ca/dns-query", //https://www.cira.ca/en/canadian-shield/configure/summary-cira-canadian-shield-dns-resolver-addresses/ listOf( "149.112.121.10", "149.112.122.10", ) )) ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/network/RequestsHelper.kt ================================================ package com.lagradost.cloudstream3.network import android.content.Context import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.nicehttp.Requests import com.lagradost.nicehttp.ignoreAllSSLErrors import okhttp3.Cache import okhttp3.Headers import okhttp3.Headers.Companion.toHeaders import okhttp3.OkHttpClient import org.conscrypt.Conscrypt import java.io.File import java.security.Security fun Requests.initClient(context: Context) { this.baseClient = buildDefaultClient(context) } fun buildDefaultClient(context: Context): OkHttpClient { safe { Security.insertProviderAt(Conscrypt.newProvider(), 1) } val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val dns = settingsManager.getInt(context.getString(R.string.dns_pref), 0) val baseClient = OkHttpClient.Builder() .followRedirects(true) .followSslRedirects(true) .ignoreAllSSLErrors() .cache( // Note that you need to add a ResponseInterceptor to make this 100% active. // The server response dictates if and when stuff should be cached. Cache( directory = File(context.cacheDir, "http_cache"), maxSize = 50L * 1024L * 1024L // 50 MiB ) ).apply { when (dns) { 1 -> addGoogleDns() 2 -> addCloudFlareDns() // 3 -> addOpenDns() 4 -> addAdGuardDns() 5 -> addDNSWatchDns() 6 -> addQuad9Dns() 7 -> addDnsSbDns() 8 -> addCanadianShieldDns() } } // Needs to be build as otherwise the other builders will change this object .build() return baseClient } //val Request.cookies: Map // get() { // return this.headers.getCookies("Cookie") // } private val DEFAULT_HEADERS = mapOf("user-agent" to USER_AGENT) /** * Set headers > Set cookies > Default headers > Default Cookies * TODO REMOVE AND REPLACE WITH NICEHTTP */ fun getHeaders( headers: Map, cookie: Map ): Headers { val cookieMap = if (cookie.isNotEmpty()) mapOf( "Cookie" to cookie.entries.joinToString(" ") { "${it.key}=${it.value};" }) else mapOf() val tempHeaders = (DEFAULT_HEADERS + headers + cookieMap) return tempHeaders.toHeaders() } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/plugins/Plugin.kt ================================================ package com.lagradost.cloudstream3.plugins import android.content.Context import android.content.res.Resources import android.util.Log import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.actions.VideoClickActionHolder import kotlin.Throws abstract class Plugin : BasePlugin() { /** * Called when your Plugin is loaded * @param context Context */ @Throws(Throwable::class) open fun load(context: Context) { // If not overridden by an extension then try the cross-platform load() load() } /** * Used to register VideoClickAction instances * @param element VideoClickAction you want to register */ fun registerVideoClickAction(element: VideoClickAction) { Log.i(PLUGIN_TAG, "Adding ${element.name} VideoClickAction") element.sourcePlugin = this.filename synchronized(VideoClickActionHolder.allVideoClickActions) { VideoClickActionHolder.allVideoClickActions.add(element) } } /** * This will contain your resources if you specified requiresResources in gradle */ var resources: Resources? = null /** * This will add a button in the settings allowing you to add custom settings */ var openSettings: ((context: Context) -> Unit)? = null } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/plugins/PluginManager.kt ================================================ package com.lagradost.cloudstream3.plugins import android.Manifest import android.app.Activity import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context import android.content.pm.PackageManager import android.content.res.AssetManager import android.content.res.Resources import android.os.Build import android.os.Environment import android.util.Log import android.widget.Toast import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.fragment.app.FragmentActivity import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.removePluginMapping import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.AutoDownloadMode import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainAPI.Companion.settingsForProvider import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.MainActivity.Companion.lastError import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.PROVIDER_STATUS_OK import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.actions.VideoClickAction import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.debugPrint import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.plugins.RepositoryManager.ONLINE_PLUGINS_FOLDER import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES import com.lagradost.cloudstream3.plugins.RepositoryManager.downloadPluginToFile import com.lagradost.cloudstream3.plugins.RepositoryManager.getRepoPlugins import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.ExtractorApi import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename import com.lagradost.cloudstream3.utils.extractorApis import com.lagradost.cloudstream3.utils.txt import dalvik.system.PathClassLoader import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.io.File import java.io.InputStreamReader // Different keys for local and not since local can be removed at any time without app knowing, hence the local are getting rebuilt on every app start const val PLUGINS_KEY = "PLUGINS_KEY" const val PLUGINS_KEY_LOCAL = "PLUGINS_KEY_LOCAL" const val EXTENSIONS_CHANNEL_ID = "cloudstream3.extensions" const val EXTENSIONS_CHANNEL_NAME = "Extensions" const val EXTENSIONS_CHANNEL_DESCRIPT = "Extension notification channel" // Data class for internal storage data class PluginData( @JsonProperty("internalName") val internalName: String, @JsonProperty("url") val url: String?, @JsonProperty("isOnline") val isOnline: Boolean, @JsonProperty("filePath") val filePath: String, @JsonProperty("version") val version: Int, ) { fun toSitePlugin(): SitePlugin { return SitePlugin( this.filePath, PROVIDER_STATUS_OK, maxOf(1, version), 1, internalName, internalName, emptyList(), File(this.filePath).name, null, null, null, null, File(this.filePath).length() ) } } // This is used as a placeholder / not set version const val PLUGIN_VERSION_NOT_SET = Int.MIN_VALUE // This always updates const val PLUGIN_VERSION_ALWAYS_UPDATE = -1 object PluginManager { // Prevent multiple writes at once val lock = Mutex() const val TAG = "PluginManager" private var hasCreatedNotChanel = false /** * Store data about the plugin for fetching later * */ private suspend fun setPluginData(data: PluginData) { lock.withLock { if (data.isOnline) { val plugins = getPluginsOnline() val newPlugins = plugins.filter { it.filePath != data.filePath } + data setKey(PLUGINS_KEY, newPlugins) } else { val plugins = getPluginsLocal() setKey(PLUGINS_KEY_LOCAL, plugins.filter { it.filePath != data.filePath } + data) } } } private suspend fun deletePluginData(data: PluginData?) { if (data == null) return lock.withLock { if (data.isOnline) { val plugins = getPluginsOnline().filter { it.url != data.url } setKey(PLUGINS_KEY, plugins) } else { val plugins = getPluginsLocal().filter { it.filePath != data.filePath } setKey(PLUGINS_KEY_LOCAL, plugins) } } } suspend fun deleteRepositoryData(repositoryPath: String) { lock.withLock { val plugins = getPluginsOnline().filter { !it.filePath.contains(repositoryPath) } val file = File(repositoryPath) safe { if (file.exists()) file.deleteRecursively() } setKey(PLUGINS_KEY, plugins) } } /** * Deletes all generated oat files which will force Android to recompile the dex extensions. * This might fix unrecoverable SIGSEGV exceptions when old oat files are loaded in a new app update. */ fun deleteAllOatFiles(context: Context) { File("${context.filesDir}/${ONLINE_PLUGINS_FOLDER}").listFiles()?.forEach { repo -> repo.listFiles { file -> file.name == "oat" && file.isDirectory }?.forEach { file -> val success = file.deleteRecursively() Log.i(TAG, "Deleted oat directory: ${file.absolutePath} Success=$success") } } } fun getPluginsOnline(): Array { return getKey(PLUGINS_KEY) ?: emptyArray() } fun getPluginsLocal(): Array { return getKey(PLUGINS_KEY_LOCAL) ?: emptyArray() } private val CLOUD_STREAM_FOLDER = Environment.getExternalStorageDirectory().absolutePath + "/Cloudstream3/" private val LOCAL_PLUGINS_PATH = CLOUD_STREAM_FOLDER + "plugins" var currentlyLoading: String? = null // Maps filepath to plugin val plugins: MutableMap = LinkedHashMap() // Maps urls to plugin val urlPlugins: MutableMap = LinkedHashMap() private val classLoaders: MutableMap = HashMap() var loadedLocalPlugins = false private set var loadedOnlinePlugins = false private set private suspend fun maybeLoadPlugin(context: Context, file: File) { val name = file.name if (file.extension == "zip" || file.extension == "cs3") { loadPlugin( context, file, PluginData(name, null, false, file.absolutePath, PLUGIN_VERSION_NOT_SET) ) } else { Log.i(TAG, "Skipping invalid plugin file: $file") } } // Helper class for updateAllOnlinePluginsAndLoadThem data class OnlinePluginData( val savedData: PluginData, val onlineData: Pair, ) { val isOutdated = onlineData.second.version > savedData.version || onlineData.second.version == PLUGIN_VERSION_ALWAYS_UPDATE val isDisabled = onlineData.second.status == PROVIDER_STATUS_DOWN fun validOnlineData(context: Context): Boolean { return getPluginPath( context, savedData.internalName, onlineData.first ).absolutePath == savedData.filePath } } // var allCurrentOutDatedPlugins: Set = emptySet() suspend fun loadSinglePlugin(context: Context, apiName: String): Boolean { return (getPluginsOnline().firstOrNull { // Most of the time the provider ends with Provider which isn't part of the api name it.internalName.replace("provider", "", ignoreCase = true) == apiName } ?: getPluginsLocal().firstOrNull { it.internalName.replace("provider", "", ignoreCase = true) == apiName })?.let { savedData -> // OnlinePluginData(savedData, onlineData) loadPlugin( context, File(savedData.filePath), savedData ) } ?: false } /** * Needs to be run before other plugin loading because plugin loading can not be overwritten * 1. Gets all online data about the downloaded plugins * 2. If disabled do nothing * 3. If outdated download and load the plugin * 4. Else load the plugin normally * * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ @Suppress("FunctionName", "DEPRECATION_ERROR") @Deprecated( "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", replaceWith = ReplaceWith("loadPlugin"), level = DeprecationLevel.ERROR ) @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_updateAllOnlinePluginsAndLoadThem(activity: Activity) { assertNonRecursiveCallstack() // Load all plugins as fast as possible! ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(activity) afterPluginsLoadedEvent.invoke(false) val urls = (getKey>(REPOSITORIES_KEY) ?: emptyArray()) + PREBUILT_REPOSITORIES val onlinePlugins = urls.toList().amap { getRepoPlugins(it.url)?.toList() ?: emptyList() }.flatten().distinctBy { it.second.url } // Iterates over all offline plugins, compares to remote repo and returns the plugins which are outdated val outdatedPlugins = getPluginsOnline().map { savedData -> onlinePlugins .filter { onlineData -> savedData.internalName == onlineData.second.internalName } .map { onlineData -> OnlinePluginData(savedData, onlineData) }.filter { it.validOnlineData(activity) } }.flatten().distinctBy { it.onlineData.second.url } debugPrint { "Outdated plugins: ${outdatedPlugins.filter { it.isOutdated }}" } val updatedPlugins = mutableListOf() outdatedPlugins.amap { pluginData -> if (pluginData.isDisabled) { //updatedPlugins.add(activity.getString(R.string.single_plugin_disabled, pluginData.onlineData.second.name)) unloadPlugin(pluginData.savedData.filePath) } else if (pluginData.isOutdated) { downloadPlugin( activity, pluginData.onlineData.second.url, pluginData.savedData.internalName, File(pluginData.savedData.filePath), true ).let { success -> if (success) updatedPlugins.add(pluginData.onlineData.second.name) } } } main { val uitext = txt(R.string.plugins_updated, updatedPlugins.size) createNotification(activity, uitext, updatedPlugins) /*val navBadge = (activity as MainActivity).binding?.navRailView?.getOrCreateBadge(R.id.navigation_settings) navBadge?.isVisible = true navBadge?.number = 5*/ } // ioSafe { loadedOnlinePlugins = true afterPluginsLoadedEvent.invoke(false) // } Log.i(TAG, "Plugin update done!") } /** * Automatically download plugins not yet existing on local * 1. Gets all online data from online plugins repo * 2. Fetch all not downloaded plugins * 3. Download them and reload plugins * * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ @Suppress("FunctionName", "DEPRECATION_ERROR") @Deprecated( "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", replaceWith = ReplaceWith("loadPlugin"), level = DeprecationLevel.ERROR ) @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_downloadNotExistingPluginsAndLoad( activity: Activity, mode: AutoDownloadMode ) { assertNonRecursiveCallstack() val newDownloadPlugins = mutableListOf() val urls = (getKey>(REPOSITORIES_KEY) ?: emptyArray()) + PREBUILT_REPOSITORIES val onlinePlugins = urls.toList().amap { getRepoPlugins(it.url)?.toList() ?: emptyList() }.flatten().distinctBy { it.second.url } val providerLang = activity.getApiProviderLangSettings() //Log.i(TAG, "providerLang => ${providerLang.toJson()}") // Iterate online repos and returns not downloaded plugins val notDownloadedPlugins = onlinePlugins.mapNotNull { onlineData -> val sitePlugin = onlineData.second val tvtypes = sitePlugin.tvTypes ?: listOf() //Don't include empty urls if (sitePlugin.url.isBlank()) { return@mapNotNull null } if (sitePlugin.repositoryUrl.isNullOrBlank()) { return@mapNotNull null } //Omit already existing plugins if (getPluginPath(activity, sitePlugin.internalName, onlineData.first).exists()) { Log.i(TAG, "Skip > ${sitePlugin.internalName}") return@mapNotNull null } //Omit non-NSFW if mode is set to NSFW only if (mode == AutoDownloadMode.NsfwOnly) { if (!tvtypes.contains(TvType.NSFW.name)) { return@mapNotNull null } } //Omit NSFW, if disabled if (!settingsForProvider.enableAdult) { if (tvtypes.contains(TvType.NSFW.name)) { return@mapNotNull null } } //Omit lang not selected on language setting if (mode == AutoDownloadMode.FilterByLang) { val lang = sitePlugin.language ?: return@mapNotNull null //If set to 'universal', don't skip any language if (!providerLang.contains(AllLanguagesName) && !providerLang.contains(lang)) { return@mapNotNull null } //Log.i(TAG, "sitePlugin lang => $lang") } val savedData = PluginData( url = sitePlugin.url, internalName = sitePlugin.internalName, isOnline = true, filePath = "", version = sitePlugin.version ) OnlinePluginData(savedData, onlineData) } //Log.i(TAG, "notDownloadedPlugins => ${notDownloadedPlugins.toJson()}") notDownloadedPlugins.amap { pluginData -> downloadPlugin( activity, pluginData.onlineData.second.url, pluginData.savedData.internalName, pluginData.onlineData.first, !pluginData.isDisabled ).let { success -> if (success) newDownloadPlugins.add(pluginData.onlineData.second.name) } } main { val uitext = txt(R.string.plugins_downloaded, newDownloadPlugins.size) createNotification(activity, uitext, newDownloadPlugins) } // ioSafe { afterPluginsLoadedEvent.invoke(false) // } Log.i(TAG, "Plugin download done!") } @Throws private fun assertNonRecursiveCallstack() { if (Thread.currentThread().stackTrace.any { it.methodName == "loadPlugin" }) { throw Error("You tried to call a function that will recursively call loadPlugin, this will cause crashes or memory leaks. Do not do this, there is better ways to implement the feature than reloading plugins. Are you sure you read the compile error or docs?") } } /** * Use updateAllOnlinePluginsAndLoadThem * * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ @Suppress("FunctionName", "DEPRECATION_ERROR") @Deprecated( "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", replaceWith = ReplaceWith("loadPlugin"), level = DeprecationLevel.ERROR ) @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context: Context) { assertNonRecursiveCallstack() // Load all plugins as fast as possible! (getPluginsOnline()).toList().amap { pluginData -> loadPlugin( context, File(pluginData.filePath), pluginData ) } } /** * Reloads all local plugins and forces a page update, used for hot reloading with deployWithAdb * * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ @Suppress("FunctionName", "DEPRECATION_ERROR") @Throws @Deprecated( "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", replaceWith = ReplaceWith("loadPlugin"), level = DeprecationLevel.ERROR ) suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_hotReloadAllLocalPlugins(activity: FragmentActivity?) { assertNonRecursiveCallstack() Log.d(TAG, "Reloading all local plugins!") if (activity == null) return getPluginsLocal().forEach { unloadPlugin(it.filePath) } ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(activity, true) } /** * @param forceReload see afterPluginsLoadedEvent, basically a way to load all local plugins * and reload all pages even if they are previously valid * * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ @Suppress("FunctionName", "DEPRECATION_ERROR") @Deprecated( "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", replaceWith = ReplaceWith("loadPlugin"), level = DeprecationLevel.ERROR ) @Throws suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context: Context, forceReload: Boolean) { assertNonRecursiveCallstack() val dir = File(LOCAL_PLUGINS_PATH) if (!dir.exists()) { val res = dir.mkdirs() if (!res) { Log.w(TAG, "Failed to create local directories") return } } val sortedPlugins = dir.listFiles() // Always sort plugins alphabetically for reproducible results Log.d(TAG, "Files in '${LOCAL_PLUGINS_PATH}' folder: ${sortedPlugins?.size}") // Use app-specific external files directory and copy the file there. // We have to do this because on Android 14+, it otherwise gives SecurityException // due to dex files and setReadOnly seems to have no effect unless it it here. val pluginDirectory = File(context.getExternalFilesDir(null), "plugins") if (!pluginDirectory.exists()) { pluginDirectory.mkdirs() // Ensure the plugins directory exists } // Make sure all local plugins are fully refreshed. removeKey(PLUGINS_KEY_LOCAL) sortedPlugins?.sortedBy { it.name }?.amap { file -> try { val destinationFile = File(pluginDirectory, file.name) // Only copy the file if the destination file doesn't exist or if it // has been modified (check file length and modification time). if (!destinationFile.exists() || destinationFile.length() != file.length() || destinationFile.lastModified() != file.lastModified() ) { // Copy the file to the app-specific plugin directory file.copyTo(destinationFile, overwrite = true) // After copying, set the destination file's modification time // to match the source file. We do this for performance so that we // can check the modification time and not make redundant writes. destinationFile.setLastModified(file.lastModified()) } // Load the plugin after it has been copied maybeLoadPlugin(context, destinationFile) } catch (t: Throwable) { Log.e(TAG, "Failed to copy the file") logError(t) } } loadedLocalPlugins = true afterPluginsLoadedEvent.invoke(forceReload) } /** @return true if safe mode is enabled in any possible way. */ fun isSafeMode(): Boolean { return checkSafeModeFile() || lastError != null } /** * This can be used to override any extension loading to fix crashes! * @return true if safe mode file is present **/ fun checkSafeModeFile(): Boolean { return safe { val folder = File(CLOUD_STREAM_FOLDER) if (!folder.exists()) return@safe false val files = folder.listFiles { _, name -> name.equals("safe", ignoreCase = true) } files?.any() } ?: false } /** * @return True if successful, false if not * */ private suspend fun loadPlugin(context: Context, file: File, data: PluginData): Boolean { val fileName = file.nameWithoutExtension val filePath = file.absolutePath currentlyLoading = fileName Log.i(TAG, "Loading plugin: $data") return try { // In case of Android 14+ then try { // Set the file as read-only and log if it fails if (!file.setReadOnly()) { Log.e(TAG, "Failed to set read-only on plugin file: ${file.name}") } } catch (t: Throwable) { Log.e(TAG, "Failed to set dex as read-only") logError(t) } val loader = PathClassLoader(filePath, context.classLoader) var manifest: BasePlugin.Manifest loader.getResourceAsStream("manifest.json").use { stream -> if (stream == null) { Log.e(TAG, "Failed to load plugin $fileName: No manifest found") return false } InputStreamReader(stream).use { reader -> manifest = parseJson(reader, BasePlugin.Manifest::class.java) } } val name: String = manifest.name ?: "NO NAME".also { Log.d(TAG, "No manifest name for ${data.internalName}") } val version: Int = manifest.version ?: PLUGIN_VERSION_NOT_SET.also { Log.d(TAG, "No manifest version for ${data.internalName}") } @Suppress("UNCHECKED_CAST") val pluginClass: Class<*> = loader.loadClass(manifest.pluginClassName) as Class val pluginInstance: BasePlugin = pluginClass.getDeclaredConstructor().newInstance() as BasePlugin // Sets with the proper version setPluginData(data.copy(version = version)) if (plugins.containsKey(filePath)) { Log.i(TAG, "Plugin with name $name already exists") return true } pluginInstance.filename = file.absolutePath if (manifest.requiresResources) { Log.d(TAG, "Loading resources for ${data.internalName}") // based on https://stackoverflow.com/questions/7483568/dynamic-resource-loading-from-other-apk val assets = AssetManager::class.java.getDeclaredConstructor().newInstance() val addAssetPath = AssetManager::class.java.getMethod("addAssetPath", String::class.java) addAssetPath.invoke(assets, file.absolutePath) @Suppress("DEPRECATION") (pluginInstance as? Plugin)?.resources = Resources( assets, context.resources.displayMetrics, context.resources.configuration ) } plugins[filePath] = pluginInstance classLoaders[loader] = pluginInstance urlPlugins[data.url ?: filePath] = pluginInstance if (pluginInstance is Plugin) { pluginInstance.load(context) } else { pluginInstance.load() } Log.i(TAG, "Loaded plugin ${data.internalName} successfully") currentlyLoading = null true } catch (e: Throwable) { Log.e(TAG, "Failed to load $file: ${Log.getStackTraceString(e)}") showToast( // context.getActivity(), // we are not always on the main thread context.getString(R.string.plugin_load_fail).format(fileName), Toast.LENGTH_LONG ) currentlyLoading = null false } } fun unloadPlugin(absolutePath: String) { Log.i(TAG, "Unloading plugin: $absolutePath") val plugin = plugins[absolutePath] if (plugin == null) { Log.w(TAG, "Couldn't find plugin $absolutePath") return } try { plugin.beforeUnload() } catch (e: Throwable) { Log.e(TAG, "Failed to run beforeUnload $absolutePath: ${Log.getStackTraceString(e)}") } // remove all registered apis synchronized(APIHolder.apis) { APIHolder.apis.filter { api -> api.sourcePlugin == plugin.filename }.forEach { removePluginMapping(it) } } synchronized(APIHolder.allProviders) { APIHolder.allProviders.removeIf { provider: MainAPI -> provider.sourcePlugin == plugin.filename } } extractorApis.removeIf { provider: ExtractorApi -> provider.sourcePlugin == plugin.filename } synchronized(VideoClickActionHolder.allVideoClickActions) { VideoClickActionHolder.allVideoClickActions.removeIf { action: VideoClickAction -> action.sourcePlugin == plugin.filename } } classLoaders.values.removeIf { v -> v == plugin } plugins.remove(absolutePath) urlPlugins.values.removeIf { v -> v == plugin } } /** * Spits out a unique and safe filename based on name. * Used for repo folders (using repo url) and plugin file names (using internalName) * */ fun getPluginSanitizedFileName(name: String): String { return sanitizeFilename( name, true ) + "." + name.hashCode() } /** * This should not be changed as it is used to also detect if a plugin is installed! **/ fun getPluginPath( context: Context, internalName: String, repositoryUrl: String ): File { val folderName = getPluginSanitizedFileName(repositoryUrl) // Guaranteed unique val fileName = getPluginSanitizedFileName(internalName) return File("${context.filesDir}/${ONLINE_PLUGINS_FOLDER}/${folderName}/$fileName.cs3") } suspend fun downloadPlugin( activity: Activity, pluginUrl: String, internalName: String, repositoryUrl: String, loadPlugin: Boolean ): Boolean { val file = getPluginPath(activity, internalName, repositoryUrl) return downloadPlugin(activity, pluginUrl, internalName, file, loadPlugin) } suspend fun downloadPlugin( activity: Activity, pluginUrl: String, internalName: String, file: File, loadPlugin: Boolean ): Boolean { try { Log.d(TAG, "Downloading plugin: $pluginUrl to ${file.absolutePath}") // The plugin file needs to be salted with the repository url hash as to allow multiple repositories with the same internal plugin names val newFile = downloadPluginToFile(pluginUrl, file) ?: return false val data = PluginData( internalName, pluginUrl, true, newFile.absolutePath, PLUGIN_VERSION_NOT_SET ) return if (loadPlugin) { unloadPlugin(file.absolutePath) loadPlugin( activity, newFile, data ) } else { setPluginData(data) true } } catch (e: Exception) { logError(e) return false } } suspend fun deletePlugin(file: File): Boolean { val list = (getPluginsLocal() + getPluginsOnline()).filter { it.filePath == file.absolutePath } return try { if (File(file.absolutePath).delete()) { unloadPlugin(file.absolutePath) list.forEach { deletePluginData(it) } return true } false } catch (e: Exception) { false } } /** * DO NOT USE THIS IN A PLUGIN! It may case an infinite recursive loop lagging or crashing everyone's devices. * If you use it from a plugin, do not expect a stable jvmName, SO DO NOT USE IT! */ @Suppress("FunctionName", "DEPRECATION_ERROR") @Throws @Deprecated( "Calling this function from a plugin will lead to crashes, use loadPlugin and unloadPlugin", replaceWith = ReplaceWith("loadPlugin"), level = DeprecationLevel.ERROR ) suspend fun ___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity: Activity) { assertNonRecursiveCallstack() showToast(activity.getString(R.string.starting_plugin_update_manually), Toast.LENGTH_LONG) ___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(activity) afterPluginsLoadedEvent.invoke(false) val urls = (getKey>(REPOSITORIES_KEY) ?: emptyArray()) + PREBUILT_REPOSITORIES val onlinePlugins = urls.toList().amap { getRepoPlugins(it.url)?.toList() ?: emptyList() }.flatten().distinctBy { it.second.url } val allPlugins = getPluginsOnline().flatMap { savedData -> onlinePlugins .filter { it.second.internalName == savedData.internalName } .mapNotNull { onlineData -> OnlinePluginData(savedData, onlineData).takeIf { it.validOnlineData(activity) } } }.distinctBy { it.onlineData.second.url } val updatedPlugins = mutableListOf() allPlugins.amap { pluginData -> if (pluginData.isDisabled) { Log.e( "PluginManager", "Unloading disabled plugin: ${pluginData.onlineData.second.name}" ) unloadPlugin(pluginData.savedData.filePath) } else { val existingFile = File(pluginData.savedData.filePath) if (existingFile.exists()) existingFile.delete() if (downloadPlugin( activity, pluginData.onlineData.second.url, pluginData.savedData.internalName, existingFile, true ) ) { updatedPlugins.add(pluginData.onlineData.second.name) } } }.also { main { val message = if (updatedPlugins.isNotEmpty()) { activity.getString(R.string.plugins_updated_manually, updatedPlugins.size) } else { activity.getString(R.string.no_plugins_updated_manually) } showToast(message, Toast.LENGTH_LONG) val notificationText = UiText.StringResource( R.string.plugins_updated_manually, listOf(updatedPlugins.size) ) createNotification(activity, notificationText, updatedPlugins) } } loadedOnlinePlugins = true afterPluginsLoadedEvent.invoke(false) Log.i("PluginManager", "Plugin update done!") } private fun Context.createNotificationChannel() { hasCreatedNotChanel = true // Create the NotificationChannel, but only on API 26+ because // the NotificationChannel class is new and not in the support library if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val name = EXTENSIONS_CHANNEL_NAME //getString(R.string.channel_name) val descriptionText = EXTENSIONS_CHANNEL_DESCRIPT//getString(R.string.channel_description) val importance = NotificationManager.IMPORTANCE_LOW val channel = NotificationChannel(EXTENSIONS_CHANNEL_ID, name, importance).apply { description = descriptionText } // Register the channel with the system val notificationManager: NotificationManager = this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager.createNotificationChannel(channel) } } private fun createNotification( context: Context, uitext: UiText, extensions: List ): Notification? { try { if (extensions.isEmpty()) return null val content = extensions.joinToString(", ") // main { // DON'T WANT TO SLOW IT DOWN val builder = NotificationCompat.Builder(context, EXTENSIONS_CHANNEL_ID) .setAutoCancel(false) .setColorized(true) .setOnlyAlertOnce(true) .setSilent(true) .setPriority(NotificationCompat.PRIORITY_LOW) .setColor(context.colorFromAttribute(R.attr.colorPrimary)) .setContentTitle(uitext.asString(context)) //.setContentTitle(context.getString(title, extensionNames.size)) .setSmallIcon(R.drawable.ic_baseline_extension_24) .setStyle( NotificationCompat.BigTextStyle() .bigText(content) ) .setContentText(content) if (!hasCreatedNotChanel) { context.createNotificationChannel() } val notification = builder.build() // notificationId is a unique int for each notification that you must define if (ActivityCompat.checkSelfPermission( context, Manifest.permission.POST_NOTIFICATIONS ) == PackageManager.PERMISSION_GRANTED ) { NotificationManagerCompat.from(context) .notify((System.currentTimeMillis() / 1000).toInt(), notification) } return notification } catch (e: Exception) { logError(e) return null } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/plugins/RepositoryManager.kt ================================================ package com.lagradost.cloudstream3.plugins import android.content.Context import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.safeAsync import com.lagradost.cloudstream3.plugins.PluginManager.getPluginSanitizedFileName import com.lagradost.cloudstream3.plugins.PluginManager.unloadPlugin import com.lagradost.cloudstream3.ui.settings.extensions.REPOSITORIES_KEY import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.io.BufferedInputStream import java.io.File import java.io.InputStream import java.io.OutputStream /** * Comes with the app, always available in the app, non removable. * */ data class Repository( @JsonProperty("iconUrl") val iconUrl: String?, @JsonProperty("name") val name: String, @JsonProperty("description") val description: String?, @JsonProperty("manifestVersion") val manifestVersion: Int, @JsonProperty("pluginLists") val pluginLists: List ) /** * Status int as the following: * 0: Down * 1: Ok * 2: Slow * 3: Beta only * */ data class SitePlugin( // Url to the .cs3 file @JsonProperty("url") val url: String, // Status to remotely disable the provider @JsonProperty("status") val status: Int, // Integer over 0, any change of this will trigger an auto update @JsonProperty("version") val version: Int, // Unused currently, used to make the api backwards compatible? // Set to 1 @JsonProperty("apiVersion") val apiVersion: Int, // Name to be shown in app @JsonProperty("name") val name: String, // Name to be referenced internally. Separate to make name and url changes possible @JsonProperty("internalName") val internalName: String, @JsonProperty("authors") val authors: List, @JsonProperty("description") val description: String?, // Might be used to go directly to the plugin repo in the future @JsonProperty("repositoryUrl") val repositoryUrl: String?, // These types are yet to be mapped and used, ignore for now @JsonProperty("tvTypes") val tvTypes: List?, // Most often a language tag like "en" or "zh-TW" @JsonProperty("language") val language: String?, @JsonProperty("iconUrl") val iconUrl: String?, // Automatically generated by the gradle plugin @JsonProperty("fileSize") val fileSize: Long?, ) object RepositoryManager { const val ONLINE_PLUGINS_FOLDER = "Extensions" val PREBUILT_REPOSITORIES: Array by lazy { getKey("PREBUILT_REPOSITORIES") ?: emptyArray() } private val GH_REGEX = Regex("^https://raw.githubusercontent.com/([A-Za-z0-9-]+)/([A-Za-z0-9_.-]+)/(.*)$") /* Convert raw.githubusercontent.com urls to cdn.jsdelivr.net if enabled in settings */ fun convertRawGitUrl(url: String): String { if (getKey(context!!.getString(R.string.jsdelivr_proxy_key)) != true) return url val match = GH_REGEX.find(url) ?: return url val (user, repo, rest) = match.destructured return "https://cdn.jsdelivr.net/gh/$user/$repo@$rest" } suspend fun parseRepoUrl(url: String): String? { val fixedUrl = url.trim() return if (fixedUrl.contains("^https?://".toRegex())) { fixedUrl } else if (fixedUrl.contains("^(cloudstreamrepo://)|(https://cs\\.repo/\\??)".toRegex())) { fixedUrl.replace("^(cloudstreamrepo://)|(https://cs\\.repo/\\??)".toRegex(), "").let { return@let if (!it.contains("^https?://".toRegex())) "https://${it}" else fixedUrl } } else if (fixedUrl.matches("^[a-zA-Z0-9!_-]+$".toRegex())) { safeAsync { app.get("https://cutt.ly/${fixedUrl}", allowRedirects = false).let { it2 -> it2.headers["Location"]?.let { url -> if (url.startsWith("https://cutt.ly/404")) return@safeAsync null if (url.removeSuffix("/") == "https://cutt.ly") return@safeAsync null return@safeAsync url } } } } else null } suspend fun parseRepository(url: String): Repository? { return safeAsync { // Take manifestVersion and such into account later app.get(convertRawGitUrl(url)).parsedSafe() } } private suspend fun parsePlugins(pluginUrls: String): List { // Take manifestVersion and such into account later return try { val response = app.get(convertRawGitUrl(pluginUrls)) // Normal parsed function not working? // return response.parsedSafe() tryParseJson>(response.text)?.toList() ?: emptyList() } catch (t: Throwable) { logError(t) emptyList() } } /** * Gets all plugins from repositories and pairs them with the repository url * */ suspend fun getRepoPlugins(repositoryUrl: String): List>? { val repo = parseRepository(repositoryUrl) ?: return null return repo.pluginLists.amap { url -> parsePlugins(url).map { repositoryUrl to it } }.flatten() } suspend fun downloadPluginToFile( pluginUrl: String, file: File ): File? { return safeAsync { file.mkdirs() // Overwrite if exists if (file.exists()) { file.delete() } file.createNewFile() val body = app.get(convertRawGitUrl(pluginUrl)).okhttpResponse.body write(body.byteStream(), file.outputStream()) file } } fun getRepositories(): Array { return getKey(REPOSITORIES_KEY) ?: emptyArray() } // Don't want to read before we write in another thread private val repoLock = Mutex() suspend fun addRepository(repository: RepositoryData) { repoLock.withLock { val currentRepos = getRepositories() // No duplicates setKey(REPOSITORIES_KEY, (currentRepos + repository).distinctBy { it.url }) } } /** * Also deletes downloaded repository plugins * */ suspend fun removeRepository(context: Context, repository: RepositoryData) { val extensionsDir = File(context.filesDir, ONLINE_PLUGINS_FOLDER) repoLock.withLock { val currentRepos = getKey>(REPOSITORIES_KEY) ?: emptyArray() // No duplicates val newRepos = currentRepos.filter { it.url != repository.url } setKey(REPOSITORIES_KEY, newRepos) } val file = File( extensionsDir, getPluginSanitizedFileName(repository.url) ) // Unload all plugins, not using deletePlugin since we // delete all data and files in deleteRepositoryData safe { file.listFiles { plugin: File -> unloadPlugin(plugin.absolutePath) false } } PluginManager.deleteRepositoryData(file.absolutePath) } private fun write(stream: InputStream, output: OutputStream) { val input = BufferedInputStream(stream) val dataBuffer = ByteArray(512) var readBytes: Int while (input.read(dataBuffer).also { readBytes = it } != -1) { output.write(dataBuffer, 0, readBytes) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/plugins/VotingApi.kt ================================================ package com.lagradost.cloudstream3.plugins import android.util.Log import android.widget.Toast import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R import java.security.MessageDigest import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.Coroutines.main import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock object VotingApi { private const val LOGKEY = "VotingApi" private const val API_DOMAIN = "https://api.countify.xyz" private fun transformUrl(url: String): String = MessageDigest .getInstance("SHA-256") .digest("${url}#funny-salt".toByteArray()) .fold("") { str, it -> str + "%02x".format(it) } suspend fun SitePlugin.getVotes(): Int = getVotes(url) fun SitePlugin.hasVoted(): Boolean = hasVoted(url) suspend fun SitePlugin.vote(): Int = vote(url) fun SitePlugin.canVote(): Boolean = canVote(this.url) private val votesCache = mutableMapOf() private suspend fun readVote(pluginUrl: String): Int { val id = transformUrl(pluginUrl) val url = "$API_DOMAIN/get-total/$id" Log.d(LOGKEY, "Requesting GET: $url") return app.get(url).parsedSafe()?.count ?: 0 } private suspend fun writeVote(pluginUrl: String): Boolean { val id = transformUrl(pluginUrl) val url = "$API_DOMAIN/increment/$id" Log.d(LOGKEY, "Requesting POST: $url") return app.post(url, emptyMap()) .parsedSafe()?.count != null } suspend fun getVotes(pluginUrl: String): Int = votesCache[pluginUrl] ?: readVote(pluginUrl).also { votesCache[pluginUrl] = it } fun hasVoted(pluginUrl: String) = getKey("cs3-votes/${transformUrl(pluginUrl)}") ?: false fun canVote(pluginUrl: String): Boolean = PluginManager.urlPlugins.contains(pluginUrl) private val voteLock = Mutex() suspend fun vote(pluginUrl: String): Int { voteLock.withLock { if (!canVote(pluginUrl)) { main { Toast.makeText( context, R.string.extension_install_first, Toast.LENGTH_SHORT ).show() } return getVotes(pluginUrl) } if (hasVoted(pluginUrl)) { main { Toast.makeText( context, R.string.already_voted, Toast.LENGTH_SHORT ).show() } return getVotes(pluginUrl) } if (writeVote(pluginUrl)) { setKey("cs3-votes/${transformUrl(pluginUrl)}", true) votesCache[pluginUrl] = votesCache[pluginUrl]?.plus(1) ?: 1 } return getVotes(pluginUrl) } } private data class CountifyResult( val id: String? = null, val count: Int? = null ) } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/receivers/VideoDownloadRestartReceiver.kt ================================================ package com.lagradost.cloudstream3.receivers import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.util.Log class VideoDownloadRestartReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { Log.i("Broadcast Listened", "Service tried to stop") // if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { // context?.startForegroundService(Intent(context, VideoDownloadKeepAliveService::class.java).putExtra(START_VALUE_KEY, RESTART_ALL_DOWNLOADS_AND_QUEUE)) // } else { // context?.startService(Intent(context, VideoDownloadKeepAliveService::class.java).putExtra(START_VALUE_KEY, RESTART_ALL_DOWNLOADS_AND_QUEUE)) // } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/services/BackupWorkManager.kt ================================================ package com.lagradost.cloudstream3.services import android.content.Context import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC import android.os.Build.VERSION.SDK_INT import androidx.core.app.NotificationCompat import androidx.work.Constraints import androidx.work.CoroutineWorker import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ForegroundInfo import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager import androidx.work.WorkerParameters import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import java.util.concurrent.TimeUnit const val BACKUP_CHANNEL_ID = "cloudstream3.backups" const val BACKUP_WORK_NAME = "work_backup" const val BACKUP_CHANNEL_NAME = "Backups" const val BACKUP_CHANNEL_DESCRIPTION = "Notifications for background backups" const val BACKUP_NOTIFICATION_ID = 938712898 // Random unique class BackupWorkManager(val context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { companion object { fun enqueuePeriodicWork(context: Context?, intervalHours: Long) { if (context == null) return if (intervalHours == 0L) { WorkManager.getInstance(context).cancelUniqueWork(BACKUP_WORK_NAME) return } val constraints = Constraints.Builder() .setRequiresStorageNotLow(true) .build() val periodicSyncDataWork = PeriodicWorkRequest.Builder( BackupWorkManager::class.java, intervalHours, TimeUnit.HOURS ) .addTag(BACKUP_WORK_NAME) .setConstraints(constraints) .build() WorkManager.getInstance(context).enqueueUniquePeriodicWork( BACKUP_WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, periodicSyncDataWork ) // Uncomment below for testing // val oneTimeBackupWork = // OneTimeWorkRequest.Builder(BackupWorkManager::class.java) // .addTag(BACKUP_WORK_NAME) // .setConstraints(constraints) // .build() // // WorkManager.getInstance(context).enqueue(oneTimeBackupWork) } } private val backupNotificationBuilder = NotificationCompat.Builder(context, BACKUP_CHANNEL_ID) .setColorized(true) .setOnlyAlertOnce(true) .setSilent(true) .setAutoCancel(true) .setContentTitle(context.getString(R.string.pref_category_backup)) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setColor(context.colorFromAttribute(R.attr.colorPrimary)) .setSmallIcon(R.drawable.ic_cloudstream_monochrome_big) override suspend fun doWork(): Result { context.createNotificationChannel( BACKUP_CHANNEL_ID, BACKUP_CHANNEL_NAME, BACKUP_CHANNEL_DESCRIPTION ) val foregroundInfo = if (SDK_INT >= 29) ForegroundInfo( BACKUP_NOTIFICATION_ID, backupNotificationBuilder.build(), FOREGROUND_SERVICE_TYPE_DATA_SYNC ) else ForegroundInfo(BACKUP_NOTIFICATION_ID, backupNotificationBuilder.build()) setForeground(foregroundInfo) BackupUtils.backup(context) return Result.success() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/services/DownloadQueueService.kt ================================================ package com.lagradost.cloudstream3.services import android.app.Service import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC import android.os.Build.VERSION.SDK_INT import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity.Companion.lastError import com.lagradost.cloudstream3.MainActivity.Companion.setLastError import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.debugWarning import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadEvent import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet import kotlinx.coroutines.withTimeoutOrNull import kotlin.system.measureTimeMillis import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds class DownloadQueueService : Service() { companion object { const val TAG = "DownloadQueueService" const val DOWNLOAD_QUEUE_CHANNEL_ID = "cloudstream3.download.queue" const val DOWNLOAD_QUEUE_CHANNEL_NAME = "Download queue service" const val DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION = "App download queue notification." const val DOWNLOAD_QUEUE_NOTIFICATION_ID = 917194232 // Random unique @Volatile var isRunning = false fun getIntent( context: Context, ): Intent { return Intent(context, DownloadQueueService::class.java) } private val _downloadInstances: MutableStateFlow> = MutableStateFlow(emptyList()) /** Flow of all active downloads, not queued. May temporarily contain completed or failed EpisodeDownloadInstances. * Completed or failed instances are automatically removed by the download queue service. * */ val downloadInstances: StateFlow> = _downloadInstances private val totalDownloadFlow = downloadInstances.combine(DownloadQueueManager.queue) { instances, queue -> instances to queue } .combine(VideoDownloadManager.currentDownloads) { (instances, queue), currentDownloads -> Triple(instances, queue, currentDownloads) } } private val baseNotification by lazy { val intent = Intent(this, MainActivity::class.java) val pendingIntent = PendingIntentCompat.getActivity(this, 0, intent, 0, false) val activeDownloads = resources.getQuantityString(R.plurals.downloads_active, 0).format(0) val activeQueue = resources.getQuantityString(R.plurals.downloads_queued, 0).format(0) NotificationCompat.Builder(this, DOWNLOAD_QUEUE_CHANNEL_ID) .setOngoing(true) // Make it persistent .setAutoCancel(false) .setColorized(false) .setOnlyAlertOnce(true) .setSilent(true) .setShowWhen(false) // If low priority then the notification might not show :( .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setColor(this.colorFromAttribute(R.attr.colorPrimary)) .setContentText(activeDownloads) .setSubText(activeQueue) .setContentIntent(pendingIntent) .setSmallIcon(R.drawable.download_icon_load) } private fun updateNotification(context: Context, downloads: Int, queued: Int) { val activeDownloads = resources.getQuantityString(R.plurals.downloads_active, downloads).format(downloads) val activeQueue = resources.getQuantityString(R.plurals.downloads_queued, queued).format(queued) val newNotification = baseNotification .setContentText(activeDownloads) .setSubText(activeQueue) .build() safe { NotificationManagerCompat.from(context) .notify(DOWNLOAD_QUEUE_NOTIFICATION_ID, newNotification) } } // We always need to listen to events, even before the download is launched. // Stopping link loading is an event which can trigger before downloading. val downloadEventListener = { event: Pair -> when (event.second) { VideoDownloadManager.DownloadActionType.Stop -> { removeKey(KEY_RESUME_PACKAGES, event.first.toString()) removeKey(KEY_RESUME_IN_QUEUE, event.first.toString()) DownloadQueueManager.cancelDownload(event.first) } else -> {} } } @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) override fun onCreate() { isRunning = true val context: Context = this // To make code more readable Log.d(TAG, "Download queue service started.") this.createNotificationChannel( DOWNLOAD_QUEUE_CHANNEL_ID, DOWNLOAD_QUEUE_CHANNEL_NAME, DOWNLOAD_QUEUE_CHANNEL_DESCRIPTION ) if (SDK_INT >= 29) { startForeground( DOWNLOAD_QUEUE_NOTIFICATION_ID, baseNotification.build(), FOREGROUND_SERVICE_TYPE_DATA_SYNC ) } else { startForeground(DOWNLOAD_QUEUE_NOTIFICATION_ID, baseNotification.build()) } downloadEvent += downloadEventListener val queueJob = ioSafe { // Ensure this is up to date to prevent race conditions with MainActivity launches setLastError(context) // Early return, to prevent waiting for plugins in safe mode if (lastError != null) return@ioSafe // Try to ensure all plugins are loaded before starting the downloader. // To prevent infinite stalls we use a timeout of 15 seconds, it is judged as long enough val timeout = 15.seconds val timeTaken = withTimeoutOrNull(timeout) { measureTimeMillis { while (!(PluginManager.loadedOnlinePlugins && PluginManager.loadedLocalPlugins)) { delay(100.milliseconds) } } } debugWarning({ timeTaken == null || timeTaken > 3_000 }, { "Abnormally long downloader startup time of: ${timeTaken ?: timeout.inWholeMilliseconds}ms" }) debugAssert({ timeTaken == null }, { "Downloader startup should not time out" }) totalDownloadFlow .takeWhile { (instances, queue) -> // Stop if destroyed isRunning // Run as long as there is a queue to process && (instances.isNotEmpty() || queue.isNotEmpty()) // Run as long as there are no app crashes && lastError == null } .collect { (_, queue, currentDownloads) -> // Remove completed or failed val newInstances = _downloadInstances.updateAndGet { currentInstances -> currentInstances.filterNot { it.isCompleted || it.isFailed || it.isCancelled } } val maxDownloads = VideoDownloadManager.maxConcurrentDownloads(context) val currentInstanceCount = newInstances.size val newDownloads = minOf( // Cannot exceed the max downloads maxOf(0, maxDownloads - currentInstanceCount), // Cannot start more downloads than the queue size queue.size ) // Cant start multiple downloads at once. If this is rerun it may start too many downloads. if (newDownloads > 0) { _downloadInstances.update { instances -> val downloadInstance = DownloadQueueManager.popQueue(context) if (downloadInstance != null) { downloadInstance.startDownload() instances + downloadInstance } else { instances } } } // The downloads actually displayed to the user with a notification val currentVisualDownloads = currentDownloads.size + newInstances.count { currentDownloads.contains(it.downloadQueueWrapper.id) .not() } // Just the queue val currentVisualQueue = queue.size updateNotification(context, currentVisualDownloads, currentVisualQueue) } } // Stop self regardless of job outcome queueJob.invokeOnCompletion { throwable -> if (throwable != null) { logError(throwable) } safe { stopSelf() } } } override fun onDestroy() { Log.d(TAG, "Download queue service stopped.") downloadEvent -= downloadEventListener isRunning = false super.onDestroy() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { return START_STICKY // We want the service restarted if its killed } override fun onBind(intent: Intent?): IBinder? = null override fun onTimeout(reason: Int) { stopSelf() Log.e(TAG, "Service stopped due to timeout: $reason") } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/services/PackageInstallerService.kt ================================================ package com.lagradost.cloudstream3.services import android.app.NotificationManager import android.app.Service import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC import android.os.Build.VERSION.SDK_INT import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.PendingIntentCompat import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.utils.ApkInstaller import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlin.math.roundToInt class PackageInstallerService : Service() { private var installer: ApkInstaller? = null private val baseNotification by lazy { val intent = Intent(this, MainActivity::class.java) val pendingIntent = PendingIntentCompat.getActivity(this, 0, intent, 0, false) NotificationCompat.Builder(this, UPDATE_CHANNEL_ID) .setAutoCancel(false) .setColorized(true) .setOnlyAlertOnce(true) .setSilent(true) // If low priority then the notification might not show :( .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setColor(this.colorFromAttribute(R.attr.colorPrimary)) .setContentTitle(getString(R.string.update_notification_downloading)) .setContentIntent(pendingIntent) .setSmallIcon(R.drawable.rdload) } override fun onCreate() { this.createNotificationChannel( UPDATE_CHANNEL_ID, UPDATE_CHANNEL_NAME, UPDATE_CHANNEL_DESCRIPTION ) if (SDK_INT >= 29) startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build(), FOREGROUND_SERVICE_TYPE_DATA_SYNC) else startForeground(UPDATE_NOTIFICATION_ID, baseNotification.build()) } private val updateLock = Mutex() private suspend fun downloadUpdate(url: String): Boolean { try { Log.d("PackageInstallerService", "Downloading update: $url") // Delete all old updates ioSafe { val appUpdateName = "CloudStream" val appUpdateSuffix = "apk" this@PackageInstallerService.cacheDir.listFiles()?.filter { it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix }?.forEach { deleteFileOnExit(it) } } updateLock.withLock { updateNotificationProgress( 0f, ApkInstaller.InstallProgressStatus.Downloading ) val body = app.get(url).body val inputStream = body.byteStream() installer = ApkInstaller(this) val totalSize = body.contentLength() var currentSize = 0 installer?.installApk(this, inputStream, totalSize, { currentSize += it // Prevent div 0 if (totalSize == 0L) return@installApk val percentage = currentSize / totalSize.toFloat() updateNotificationProgress( percentage, ApkInstaller.InstallProgressStatus.Downloading ) }) { status -> updateNotificationProgress(0f, status) } } return true } catch (e: Exception) { logError(e) updateNotificationProgress(0f, ApkInstaller.InstallProgressStatus.Failed) return false } } private fun updateNotificationProgress( percentage: Float, state: ApkInstaller.InstallProgressStatus ) { // Log.d(LOG_TAG, "Downloading app update progress $percentage | $state") val text = when (state) { ApkInstaller.InstallProgressStatus.Installing -> R.string.update_notification_installing ApkInstaller.InstallProgressStatus.Preparing, ApkInstaller.InstallProgressStatus.Downloading -> R.string.update_notification_downloading ApkInstaller.InstallProgressStatus.Failed -> R.string.update_notification_failed } val newNotification = baseNotification .setContentTitle(getString(text)) .apply { if (state == ApkInstaller.InstallProgressStatus.Failed) { setSmallIcon(R.drawable.rderror) setAutoCancel(true) } else { setProgress( 10000, (10000 * percentage).roundToInt(), state != ApkInstaller.InstallProgressStatus.Downloading ) } } .build() val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager // Persistent notification on failure val id = if (state == ApkInstaller.InstallProgressStatus.Failed) UPDATE_NOTIFICATION_ID + 1 else UPDATE_NOTIFICATION_ID notificationManager.notify(id, newNotification) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val url = intent?.getStringExtra(EXTRA_URL) ?: return START_NOT_STICKY ioSafe { downloadUpdate(url) // Close the service after the update is done // If no sleep then the install prompt may not appear and the notification // will disappear instantly delay(10_000) this@PackageInstallerService.stopSelf() } return START_NOT_STICKY } override fun onDestroy() { installer?.unregisterInstallActionReceiver() installer = null this.stopSelf() super.onDestroy() } override fun onBind(i: Intent?): IBinder? = null override fun onTimeout(reason: Int) { stopSelf() Log.e("PackageInstallerService", "Service stopped due to timeout: $reason") } companion object { private const val EXTRA_URL = "EXTRA_URL" const val UPDATE_CHANNEL_ID = "cloudstream3.updates" const val UPDATE_CHANNEL_NAME = "App Updates" const val UPDATE_CHANNEL_DESCRIPTION = "App updates notification channel" const val UPDATE_NOTIFICATION_ID = -68454136 // Random unique fun getIntent( context: Context, url: String, ): Intent { return Intent(context, PackageInstallerService::class.java) .putExtra(EXTRA_URL, url) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/services/SubscriptionWorkManager.kt ================================================ package com.lagradost.cloudstream3.services import android.app.NotificationManager import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC import android.os.Build.VERSION.SDK_INT import androidx.core.app.NotificationCompat import androidx.core.app.PendingIntentCompat import androidx.core.net.toUri import androidx.work.* import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl import kotlinx.coroutines.withTimeoutOrNull import java.util.concurrent.TimeUnit const val SUBSCRIPTION_CHANNEL_ID = "cloudstream3.subscriptions" const val SUBSCRIPTION_WORK_NAME = "work_subscription" const val SUBSCRIPTION_CHANNEL_NAME = "Subscriptions" const val SUBSCRIPTION_CHANNEL_DESCRIPTION = "Notifications for new episodes on subscribed shows" const val SUBSCRIPTION_NOTIFICATION_ID = 938712897 // Random unique class SubscriptionWorkManager(val context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { companion object { fun enqueuePeriodicWork(context: Context?) { if (context == null) return val constraints = Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build() val periodicSyncDataWork = PeriodicWorkRequest.Builder(SubscriptionWorkManager::class.java, 6, TimeUnit.HOURS) .addTag(SUBSCRIPTION_WORK_NAME) .setConstraints(constraints) .build() WorkManager.getInstance(context).enqueueUniquePeriodicWork( SUBSCRIPTION_WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, periodicSyncDataWork ) // Uncomment below for testing // val oneTimeSyncDataWork = // OneTimeWorkRequest.Builder(SubscriptionWorkManager::class.java) // .addTag(SUBSCRIPTION_WORK_NAME) // .setConstraints(constraints) // .build() // // WorkManager.getInstance(context).enqueue(oneTimeSyncDataWork) } } private val progressNotificationBuilder = NotificationCompat.Builder(context, SUBSCRIPTION_CHANNEL_ID) .setAutoCancel(false) .setColorized(true) .setOnlyAlertOnce(true) .setSilent(true) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setColor(context.colorFromAttribute(R.attr.colorPrimary)) .setContentTitle(context.getString(R.string.subscription_in_progress_notification)) .setSmallIcon(com.google.android.gms.cast.framework.R.drawable.quantum_ic_refresh_white_24) .setProgress(0, 0, true) private val updateNotificationBuilder = NotificationCompat.Builder(context, SUBSCRIPTION_CHANNEL_ID) .setColorized(true) .setOnlyAlertOnce(true) .setAutoCancel(true) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setColor(context.colorFromAttribute(R.attr.colorPrimary)) .setSmallIcon(R.drawable.ic_cloudstream_monochrome_big) private val notificationManager: NotificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private fun updateProgress(max: Int, progress: Int, indeterminate: Boolean) { notificationManager.notify( SUBSCRIPTION_NOTIFICATION_ID, progressNotificationBuilder .setProgress(max, progress, indeterminate) .build() ) } @Suppress("DEPRECATION_ERROR") override suspend fun doWork(): Result { try { // println("Update subscriptions!") context.createNotificationChannel( SUBSCRIPTION_CHANNEL_ID, SUBSCRIPTION_CHANNEL_NAME, SUBSCRIPTION_CHANNEL_DESCRIPTION ) val foregroundInfo = if (SDK_INT >= 29) ForegroundInfo( SUBSCRIPTION_NOTIFICATION_ID, progressNotificationBuilder.build(), FOREGROUND_SERVICE_TYPE_DATA_SYNC ) else ForegroundInfo(SUBSCRIPTION_NOTIFICATION_ID, progressNotificationBuilder.build(),) setForeground(foregroundInfo) val subscriptions = getAllSubscriptions() if (subscriptions.isEmpty()) { WorkManager.getInstance(context).cancelWorkById(this.id) return Result.success() } val max = subscriptions.size var progress = 0 updateProgress(max, progress, true) // We need all plugins loaded. PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllOnlinePlugins(context) PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_loadAllLocalPlugins(context, false) subscriptions.amap { savedData -> try { val id = savedData.id ?: return@amap null val api = getApiFromNameNull(savedData.apiName) ?: return@amap null // Reasonable timeout to prevent having this worker run forever. val response = withTimeoutOrNull(60_000) { api.load(savedData.url) as? EpisodeResponse } ?: return@amap null val dubPreference = getDub(id) ?: if ( context.getApiDubstatusSettings().contains(DubStatus.Dubbed) ) { DubStatus.Dubbed } else { DubStatus.Subbed } val latestEpisodes = response.getLatestEpisodes() val latestPreferredEpisode = latestEpisodes[dubPreference] val (shouldUpdate, latestEpisode) = if (latestPreferredEpisode != null) { val latestSeenEpisode = savedData.lastSeenEpisodeCount[dubPreference] ?: Int.MIN_VALUE val shouldUpdate = latestPreferredEpisode > latestSeenEpisode shouldUpdate to latestPreferredEpisode } else { val latestEpisode = latestEpisodes[DubStatus.None] ?: Int.MIN_VALUE val latestSeenEpisode = savedData.lastSeenEpisodeCount[DubStatus.None] ?: Int.MIN_VALUE val shouldUpdate = latestEpisode > latestSeenEpisode shouldUpdate to latestEpisode } DataStoreHelper.updateSubscribedData( id, savedData, response ) if (shouldUpdate) { val updateHeader = savedData.name val updateDescription = txt( R.string.subscription_episode_released, latestEpisode, savedData.name ).asString(context) val intent = Intent(context, MainActivity::class.java).apply { data = savedData.url.toUri() flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK }.putExtra(MainActivity.API_NAME_EXTRA_KEY, api.name) val pendingIntent = PendingIntentCompat.getActivity(context, 0, intent, 0, false) val poster = ioWork { savedData.posterUrl?.let { url -> context.getImageBitmapFromUrl( url, savedData.posterHeaders ) } } val updateNotification = updateNotificationBuilder.setContentTitle(updateHeader) .setContentText(updateDescription) .setContentIntent(pendingIntent) .setLargeIcon(poster) .build() notificationManager.notify(id, updateNotification) } // You can probably get some issues here since this is async but it does not matter much. updateProgress(max, ++progress, false) } catch (t: Throwable) { logError(t) } } return Result.success() } catch (t: Throwable) { logError(t) // ye, while this is not correct, but because gods know why android just crashes // and this causes major battery usage as it retries it inf times. This is better, just // in case android decides to be android and fuck us return Result.success() } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/services/VideoDownloadService.kt ================================================ package com.lagradost.cloudstream3.services import android.app.Service import android.content.Intent import android.os.IBinder import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel import kotlinx.coroutines.launch /** Handle notification actions such as pause/resume downloads */ class VideoDownloadService : Service() { private val downloadScope = CoroutineScope(Dispatchers.Default) override fun onBind(intent: Intent?): IBinder? { return null } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent != null) { val id = intent.getIntExtra("id", -1) val type = intent.getStringExtra("type") if (id != -1 && type != null) { val state = when (type) { "resume" -> VideoDownloadManager.DownloadActionType.Resume "pause" -> VideoDownloadManager.DownloadActionType.Pause "stop" -> VideoDownloadManager.DownloadActionType.Stop else -> return START_NOT_STICKY } downloadScope.launch { VideoDownloadManager.downloadEvent.invoke(Pair(id, state)) } } } return START_NOT_STICKY } override fun onDestroy() { downloadScope.coroutineContext.cancel() super.onDestroy() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubProvider.kt ================================================ package com.lagradost.cloudstream3.subtitles import androidx.core.net.toUri import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.ui.player.SubtitleOrigin import okio.BufferedSource import okio.buffer import okio.sink import okio.source import java.io.File import java.util.zip.ZipInputStream /** * A builder for subtitle files. * @see addUrl * @see addFile */ class SubtitleResource { fun downloadFile(source: BufferedSource): File { val file = File.createTempFile("temp-subtitle", ".tmp").apply { deleteFileOnExit(this) } val sink = file.sink().buffer() sink.writeAll(source) sink.close() source.close() return file } private fun unzip(file: File): List> { val entries = mutableListOf>() ZipInputStream(file.inputStream()).use { zipInputStream -> var zipEntry = zipInputStream.nextEntry while (zipEntry != null) { val tempFile = File.createTempFile("unzipped-subtitle", ".tmp").apply { deleteFileOnExit(this) } entries.add(zipEntry.name to tempFile) tempFile.sink().buffer().use { buffer -> buffer.writeAll(zipInputStream.source()) } zipEntry = zipInputStream.nextEntry } } return entries } data class SingleSubtitleResource( val name: String?, val url: String, val origin: SubtitleOrigin ) private var resources: MutableList = mutableListOf() fun getSubtitles(): List { return resources.toList() } fun addUrl(url: String?, name: String? = null) { if (url == null) return this.resources.add( SingleSubtitleResource(name, url, SubtitleOrigin.URL) ) } fun addFile(file: File, name: String? = null) { this.resources.add( SingleSubtitleResource(name, file.toUri().toString(), SubtitleOrigin.DOWNLOADED_FILE) ) deleteFileOnExit(file) } suspend fun addZipUrl( url: String, nameGenerator: (String, File) -> String? = { _, _ -> null } ) { val source = app.get(url).okhttpResponse.body.source() val zip = downloadFile(source) val realFiles = unzip(zip) zip.deleteRecursively() realFiles.forEach { (name, subtitleFile) -> addFile(subtitleFile, nameGenerator(name, subtitleFile)) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/subtitles/AbstractSubtitleEntities.kt ================================================ package com.lagradost.cloudstream3.subtitles import com.lagradost.cloudstream3.TvType class AbstractSubtitleEntities { data class SubtitleEntity( var idPrefix : String, var name: String = "", //Title of movie/series. This is the one to be displayed when choosing. var lang: String = "en", var data: String = "", //Id or link, depends on provider how to process var type: TvType = TvType.Movie, //Movie, TV series, etc.. var source: String, var epNumber: Int? = null, var seasonNumber: Int? = null, var year: Int? = null, var isHearingImpaired: Boolean = false, var headers: Map = emptyMap() ) data class SubtitleSearch( var query: String = "", var lang: String? = null, var imdbId: String? = null, var tmdbId: Int? = null, var malId: Int? = null, var aniListId: Int? = null, var epNumber: Int? = null, var seasonNumber: Int? = null, var year: Int? = null ) } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/syncproviders/AccountManager.kt ================================================ package com.lagradost.cloudstream3.syncproviders import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed import com.lagradost.cloudstream3.syncproviders.providers.AniListApi import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi import com.lagradost.cloudstream3.syncproviders.providers.LocalList import com.lagradost.cloudstream3.syncproviders.providers.MALApi import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi import com.lagradost.cloudstream3.syncproviders.providers.SimklApi import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi import com.lagradost.cloudstream3.utils.DataStoreHelper import java.util.concurrent.TimeUnit abstract class AccountManager { companion object { const val NONE_ID: Int = -1 val malApi = MALApi() val kitsuApi = KitsuApi() val aniListApi = AniListApi() val simklApi = SimklApi() val localListApi = LocalList() val openSubtitlesApi = OpenSubtitlesApi() val addic7ed = Addic7ed() val subDlApi = SubDlApi() val subSourceApi = SubSourceApi() var cachedAccounts: MutableMap> var cachedAccountIds: MutableMap const val ACCOUNT_TOKEN = "auth_tokens" const val ACCOUNT_IDS = "auth_ids" fun accounts(prefix: String): Array { require(prefix != "NONE") return getKey>( ACCOUNT_TOKEN, "${prefix}/${DataStoreHelper.currentAccount}" ) ?: arrayOf() } fun updateAccounts(prefix: String, array: Array) { require(prefix != "NONE") setKey(ACCOUNT_TOKEN, "${prefix}/${DataStoreHelper.currentAccount}", array) synchronized(cachedAccounts) { cachedAccounts[prefix] = array } } fun updateAccountsId(prefix: String, id: Int) { require(prefix != "NONE") setKey(ACCOUNT_IDS, "${prefix}/${DataStoreHelper.currentAccount}", id) synchronized(cachedAccountIds) { cachedAccountIds[prefix] = id } } val allApis = arrayOf( SyncRepo(malApi), SyncRepo(kitsuApi), SyncRepo(aniListApi), SyncRepo(simklApi), SyncRepo(localListApi), SubtitleRepo(openSubtitlesApi), SubtitleRepo(addic7ed), SubtitleRepo(subDlApi) ) fun updateAccountIds() { val ids = mutableMapOf() for (api in allApis) { ids.put( api.idPrefix, getKey( ACCOUNT_IDS, "${api.idPrefix}/${DataStoreHelper.currentAccount}", NONE_ID ) ?: NONE_ID ) } synchronized(cachedAccountIds) { cachedAccountIds = ids } } init { val data = mutableMapOf>() val ids = mutableMapOf() for (api in allApis) { data.put(api.idPrefix, accounts(api.idPrefix)) ids.put( api.idPrefix, getKey( ACCOUNT_IDS, "${api.idPrefix}/${DataStoreHelper.currentAccount}", NONE_ID ) ?: NONE_ID ) } cachedAccounts = data cachedAccountIds = ids } // I do not want to place this in the init block as JVM initialization order is weird, and it may cause exceptions // accessing other classes fun initMainAPI() { LoadResponse.malIdPrefix = malApi.idPrefix LoadResponse.kitsuIdPrefix = kitsuApi.idPrefix LoadResponse.aniListIdPrefix = aniListApi.idPrefix LoadResponse.simklIdPrefix = simklApi.idPrefix } val subtitleProviders = arrayOf( SubtitleRepo(openSubtitlesApi), SubtitleRepo(addic7ed), SubtitleRepo(subDlApi) ) val syncApis = arrayOf( SyncRepo(malApi), SyncRepo(kitsuApi), SyncRepo(aniListApi), SyncRepo(simklApi), SyncRepo(localListApi) ) const val APP_STRING = "cloudstreamapp" const val APP_STRING_REPO = "cloudstreamrepo" const val APP_STRING_PLAYER = "cloudstreamplayer" // Instantly start the search given a query const val APP_STRING_SEARCH = "cloudstreamsearch" // Instantly resume watching a show const val APP_STRING_RESUME_WATCHING = "cloudstreamcontinuewatching" const val APP_STRING_SHARE = "csshare" fun secondsToReadable(seconds: Int, completedValue: String): String { var secondsLong = seconds.toLong() val days = TimeUnit.SECONDS .toDays(secondsLong) secondsLong -= TimeUnit.DAYS.toSeconds(days) val hours = TimeUnit.SECONDS .toHours(secondsLong) secondsLong -= TimeUnit.HOURS.toSeconds(hours) val minutes = TimeUnit.SECONDS .toMinutes(secondsLong) secondsLong -= TimeUnit.MINUTES.toSeconds(minutes) if (minutes < 0) { return completedValue } //println("$days $hours $minutes") return "${if (days != 0L) "$days" + "d " else ""}${if (hours != 0L) "$hours" + "h " else ""}${minutes}m" } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthAPI.kt ================================================ package com.lagradost.cloudstream3.syncproviders import android.util.Base64 import androidx.annotation.WorkerThread import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.ActorData import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.NextAiring import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.ShowStatus import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID import com.lagradost.cloudstream3.syncproviders.providers.Addic7ed import com.lagradost.cloudstream3.syncproviders.providers.AniListApi import com.lagradost.cloudstream3.syncproviders.providers.LocalList import com.lagradost.cloudstream3.syncproviders.providers.MALApi import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi import com.lagradost.cloudstream3.syncproviders.providers.OpenSubtitlesApi import com.lagradost.cloudstream3.syncproviders.providers.SimklApi import com.lagradost.cloudstream3.syncproviders.providers.SubDlApi import com.lagradost.cloudstream3.syncproviders.providers.SubSourceApi import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.AppContextUtils.splitQuery import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.txt import me.xdrop.fuzzywuzzy.FuzzySearch import java.net.URL import java.security.SecureRandom import java.util.Date import java.util.concurrent.TimeUnit data class AuthLoginPage( /** The website to open to authenticate */ val url: String, /** * State/control code to verify against the redirectUrl to make sure the request is valid. * This parameter will be saved, and then used in AuthAPI::login. * */ val payload: String? = null, ) data class AuthToken( /** * This is the general access tokens/api token representing a logged in user. * * `Access tokens are the thing that applications use to make API requests on behalf of a user.` * */ @JsonProperty("accessToken") val accessToken: String? = null, /** * For OAuth a special refresh token is issues to refresh the access token. * */ @JsonProperty("refreshToken") val refreshToken: String? = null, /** In UnixTime (sec) when it expires */ @JsonProperty("accessTokenLifetime") val accessTokenLifetime: Long? = null, /** In UnixTime (sec) when it expires */ @JsonProperty("refreshTokenLifetime") val refreshTokenLifetime: Long? = null, /** Sometimes AuthToken needs to be customized to store e.g. username/password, * this acts as a catch all to store text or JSON data. */ @JsonProperty("payload") val payload: String? = null, ) { fun isAccessTokenExpired(marginSec: Long = 10L) = accessTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= accessTokenLifetime fun isRefreshTokenExpired(marginSec: Long = 10L) = refreshTokenLifetime != null && (System.currentTimeMillis() / 1000) + marginSec >= refreshTokenLifetime } data class AuthUser( /** Account display-name, can also be email if name does not exist */ @JsonProperty("name") val name: String?, /** Unique account identifier, * if a subsequent login is done then it will be refused if another account with the same id exists*/ @JsonProperty("id") val id: Int, /** Profile picture URL */ @JsonProperty("profilePicture") val profilePicture: String? = null, /** Profile picture Headers of the URL */ @JsonProperty("profilePictureHeader") val profilePictureHeaders: Map? = null ) /** * Stores all information that should be used to authorize access. * Be aware that token and user may change independently when a refresh is needed, * and as such there should be no strong pairing between the two. * * Any local set/get key should use user.id.toString(), * as token.accessToken (even hashed) is unsecure, and will rotate. * */ data class AuthData( @JsonProperty("user") val user: AuthUser, @JsonProperty("token") val token: AuthToken, ) data class AuthPinData( val deviceCode: String, val userCode: String, /** QR Code url */ val verificationUrl: String, /** In seconds */ val expiresIn: Int, /** Check if the code has been verified interval */ val interval: Int, ) /** The login field requirements to display to the user */ data class AuthLoginRequirement( val password: Boolean = false, val username: Boolean = false, val email: Boolean = false, val server: Boolean = false, ) /** What the user responds to the AuthLoginRequirement */ data class AuthLoginResponse( @JsonProperty("password") val password: String?, @JsonProperty("username") val username: String?, @JsonProperty("email") val email: String?, @JsonProperty("server") val server: String?, ) /** Stateless Authentication class used for all personalized content */ abstract class AuthAPI { open val name: String = "NONE" open val idPrefix: String = "NONE" /** Drawable icon of the service */ open val icon: Int? = null /** If this service requires an account to use */ open val requiresLogin: Boolean = true /** Link to a website for creating a new account */ open val createAccountUrl: String? = null /** The sensitive redirect URL from OAuth should contain "/redirectUrlIdentifier" to trigger the login */ open val redirectUrlIdentifier: String? = null /** Has OAuth2 login support, including login, loginRequest and refreshToken */ open val hasOAuth2: Boolean = false /** Has on device pin support, aka login with a QR code */ open val hasPin: Boolean = false /** Has in app login support, aka login with a dialog */ open val hasInApp: Boolean = false /** The requirements to login in app */ open val inAppLoginRequirement: AuthLoginRequirement? = null companion object { val unixTime: Long get() = System.currentTimeMillis() / 1000L val unixTimeMs: Long get() = System.currentTimeMillis() fun splitRedirectUrl(redirectUrl: String): Map { return splitQuery( URL( redirectUrl.replace(APP_STRING, "https").replace("/#", "?") ) ) } fun generateCodeVerifier(): String { // It is recommended to use a URL-safe string as code_verifier. // See section 4 of RFC 7636 for more details. val secureRandom = SecureRandom() val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128 secureRandom.nextBytes(codeVerifierBytes) return Base64.encodeToString(codeVerifierBytes, Base64.DEFAULT).trimEnd('=') .replace("+", "-") .replace("/", "_").replace("\n", "") } } /** Is this url a valid redirect url for this service? */ @Throws open fun isValidRedirectUrl(url: String): Boolean = redirectUrlIdentifier != null && url.contains("/$redirectUrlIdentifier") /** OAuth2 login from a valid redirectUrl, and payload given in loginRequest */ @Throws open suspend fun login(redirectUrl: String, payload: String?): AuthToken? = throw NotImplementedError() /** OAuth2 login request, asking the service to provide a url to open in the browser */ @Throws open fun loginRequest(): AuthLoginPage? = throw NotImplementedError() /** Pin login request, asking the service to provide an verificationUrl to display with a QR code */ @Throws open suspend fun pinRequest(): AuthPinData? = throw NotImplementedError() /** OAuth2 token refresh, this ensures that all token passed to other functions will be valid */ @Throws open suspend fun refreshToken(token: AuthToken): AuthToken? = throw NotImplementedError() /** Pin login, this will be called periodically while logging in to check if the pin has been verified by the user */ @Throws open suspend fun login(payload: AuthPinData): AuthToken? = throw NotImplementedError() /** In app login */ @Throws open suspend fun login(form: AuthLoginResponse): AuthToken? = throw NotImplementedError() /** Get the visible user account */ @Throws open suspend fun user(token: AuthToken?): AuthUser? = throw NotImplementedError() /** * An optional security measure to make sure that even if an attacker gets ahold of the token, it will be invalid. * * Note that this will currently only be called *once* on logout, * and as such any network issues it will fail silently, and the token will not be revoked. **/ @Throws open suspend fun invalidateToken(token: AuthToken): Nothing = throw NotImplementedError() @Throws @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) fun toRepo(): AuthRepo = when (this) { is SubtitleAPI -> SubtitleRepo(this) is SyncAPI -> SyncRepo(this) else -> throw NotImplementedError("Unknown inheritance from AuthAPI") } @Suppress("DEPRECATION_ERROR") @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) fun loginInfo(): LoginInfo? { return this.toRepo().authUser()?.let { user -> LoginInfo( profilePicture = user.profilePicture, name = user.name, accountIndex = -1, ) } } @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata? { @Suppress("DEPRECATION_ERROR") return (this.toRepo() as? SyncRepo)?.library()?.getOrThrow() } @Deprecated("Please use the new API for AuthAPI", level = DeprecationLevel.ERROR) class LoginInfo( val profilePicture: String? = null, val name: String?, val accountIndex: Int, ) } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/syncproviders/AuthRepo.kt ================================================ package com.lagradost.cloudstream3.syncproviders import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.NONE_ID import com.lagradost.cloudstream3.utils.txt /** Safe abstraction for AuthAPI that provides both a catching interface, and automatic token management. */ abstract class AuthRepo(open val api: AuthAPI) { fun isValidRedirectUrl(url: String) = safe { api.isValidRedirectUrl(url) } ?: false val idPrefix get() = api.idPrefix val name get() = api.name val icon get() = api.icon val requiresLogin get() = api.requiresLogin val createAccountUrl get() = api.createAccountUrl val hasOAuth2 get() = api.hasOAuth2 val hasPin get() = api.hasPin val hasInApp get() = api.hasInApp val inAppLoginRequirement get() = api.inAppLoginRequirement val isAvailable get() = !api.requiresLogin || authUser() != null companion object { private val oauthPayload: MutableMap = mutableMapOf() } @Throws protected suspend fun freshAuth(): AuthData? { val data = authData() ?: return null if (data.token.isAccessTokenExpired()) { val newToken = api.refreshToken(data.token) ?: return null val newAuth = AuthData(user = data.user, token = newToken) refreshUser(newAuth) return newAuth } return data } @Throws fun openOAuth2Page(): Boolean { val page = api.loginRequest() ?: return false synchronized(oauthPayload) { oauthPayload.put(idPrefix, page.payload) } openBrowser(page.url) return true } fun openOAuth2PageWithToast() { try { if (!openOAuth2Page()) { showToast(txt(R.string.authenticated_user_fail, api.name)) } } catch (t: Throwable) { logError(t) if (t is ErrorLoadingException && t.message != null) { showToast(t.message) return } showToast(txt(R.string.authenticated_user_fail, api.name)) } } suspend fun logout(from: AuthUser) { val currentAccounts = AccountManager.accounts(idPrefix) val (newAccounts, oldAccounts) = currentAccounts.partition { it.user.id != from.id } if (newAccounts.size < currentAccounts.size) { AccountManager.updateAccounts(idPrefix, newAccounts.toTypedArray()) AccountManager.updateAccountsId(idPrefix, 0) } for (oldAccount in oldAccounts) { try { api.invalidateToken(oldAccount.token) } catch (_: NotImplementedError) { // no-op } catch (t: Throwable) { logError(t) } } } fun refreshUser(newAuth: AuthData) { val currentAccounts = AccountManager.accounts(idPrefix) val newAccounts = currentAccounts.map { if (it.user.id == newAuth.user.id) { newAuth } else { it } }.toTypedArray() AccountManager.updateAccounts(idPrefix, newAccounts) } fun authData(): AuthData? = synchronized(AccountManager.cachedAccountIds) { AccountManager.cachedAccountIds[idPrefix]?.let { id -> AccountManager.cachedAccounts[idPrefix]?.firstOrNull { data -> data.user.id == id } } } fun authToken(): AuthToken? = authData()?.token fun authUser(): AuthUser? = authData()?.user val accounts get() = synchronized(AccountManager.cachedAccounts) { AccountManager.cachedAccounts[idPrefix] ?: emptyArray() } var accountId get() = synchronized(AccountManager.cachedAccountIds) { AccountManager.cachedAccountIds[idPrefix] ?: NONE_ID } set(value) { AccountManager.updateAccountsId(idPrefix, value) } @Throws suspend fun pinRequest() = api.pinRequest() @Throws private suspend fun setupLogin(token: AuthToken): Boolean { val user = api.user(token) ?: return false val newAccount = AuthData( token = token, user = user, ) val currentAccounts = AccountManager.accounts(idPrefix) if (currentAccounts.any { it.user.id == newAccount.user.id }) { throw ErrorLoadingException("Already logged into this account") } val newAccounts = currentAccounts + newAccount AccountManager.updateAccounts(idPrefix, newAccounts) AccountManager.updateAccountsId(idPrefix, user.id) if (this is SyncRepo) { requireLibraryRefresh = true } return true } @Throws suspend fun login(form: AuthLoginResponse): Boolean { return setupLogin(api.login(form) ?: return false) } @Throws suspend fun login(payload: AuthPinData): Boolean { return setupLogin(api.login(payload) ?: return false) } @Throws suspend fun login(redirectUrl: String): Boolean { return setupLogin( api.login( redirectUrl, synchronized(oauthPayload) { oauthPayload[api.idPrefix] }) ?: return false ) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/syncproviders/BackupAPI.kt ================================================ package com.lagradost.cloudstream3.syncproviders /** Work in progress */ abstract class BackupAPI : AuthAPI() { open val filename : String = "cloudstream-backup.json" /** Get the backup file as a JSON string from the remote storage. Return null if not found/empty */ @Throws open suspend fun downloadFile(auth: AuthData?) : String? = throw NotImplementedError() /** Get the backup file as a JSON string from the remote storage. */ @Throws open suspend fun uploadFile(auth: AuthData?, data : String) : String? = throw NotImplementedError() } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleAPI.kt ================================================ package com.lagradost.cloudstream3.syncproviders import androidx.annotation.WorkerThread import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch import com.lagradost.cloudstream3.subtitles.SubtitleResource /** * Stateless subtitle class for external subtitles. * * All non-null `AuthToken` will be non-expired when each function is called. */ abstract class SubtitleAPI : AuthAPI() { @WorkerThread @Throws open suspend fun search(auth: AuthData?, query: SubtitleSearch): List? = throw NotImplementedError() @WorkerThread @Throws open suspend fun load(auth: AuthData?, subtitle: SubtitleEntity): String? = throw NotImplementedError() @WorkerThread @Throws open suspend fun SubtitleResource.getResources(auth: AuthData?, subtitle: SubtitleEntity) { this.addUrl(load(auth, subtitle)) } @WorkerThread @Throws suspend fun resource(auth: AuthData?, subtitle: SubtitleEntity): SubtitleResource { return SubtitleResource().apply { this.getResources(auth, subtitle) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/syncproviders/SubtitleRepo.kt ================================================ package com.lagradost.cloudstream3.syncproviders import androidx.annotation.WorkerThread import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch import com.lagradost.cloudstream3.subtitles.SubtitleResource import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf /** Stateless safe abstraction of SubtitleAPI */ class SubtitleRepo(override val api: SubtitleAPI) : AuthRepo(api) { companion object { data class SavedSearchResponse( val unixTime: Long, val response: List, val query: SubtitleSearch ) data class SavedResourceResponse( val unixTime: Long, val response: SubtitleResource, val query: SubtitleEntity ) // maybe make this a generic struct? right now there is a lot of boilerplate private val searchCache = threadSafeListOf() private var searchCacheIndex: Int = 0 private val resourceCache = threadSafeListOf() private var resourceCacheIndex: Int = 0 const val CACHE_SIZE = 20 } @WorkerThread suspend fun resource(data: SubtitleEntity): Result = runCatching { synchronized(resourceCache) { for (item in resourceCache) { // 20 min save if (item.query == data && (unixTime - item.unixTime) < 60 * 20) { return@runCatching item.response } } } val returnValue = api.resource(freshAuth(), data) synchronized(resourceCache) { val add = SavedResourceResponse(unixTime, returnValue, data) if (resourceCache.size > CACHE_SIZE) { resourceCache[resourceCacheIndex] = add // rolling cache resourceCacheIndex = (resourceCacheIndex + 1) % CACHE_SIZE } else { resourceCache.add(add) } } returnValue } @WorkerThread suspend fun search(query: SubtitleSearch): Result> { return runCatching { synchronized(searchCache) { for (item in searchCache) { // 120 min save if (item.query == query && (unixTime - item.unixTime) < 60 * 120) { return@runCatching item.response } } } val returnValue = api.search(freshAuth(), query) ?: emptyList() // only cache valid return values if (returnValue.isNotEmpty()) { val add = SavedSearchResponse(unixTime, returnValue, query) synchronized(searchCache) { if (searchCache.size > CACHE_SIZE) { searchCache[searchCacheIndex] = add // rolling cache searchCacheIndex = (searchCacheIndex + 1) % CACHE_SIZE } else { searchCache.add(add) } } } returnValue } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncAPI.kt ================================================ package com.lagradost.cloudstream3.syncproviders import androidx.annotation.WorkerThread import com.lagradost.cloudstream3.ActorData import com.lagradost.cloudstream3.NextAiring import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.ShowStatus import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.UiText import me.xdrop.fuzzywuzzy.FuzzySearch import java.util.Date /** * Stateless synchronization class, used for syncing status about a specific movie/show. * * All non-null `AuthToken` will be non-expired when each function is called. */ abstract class SyncAPI : AuthAPI() { /** * Set this to true if the user updates something on the list like watch status or score **/ open var requireLibraryRefresh: Boolean = true open val mainUrl: String = "NONE" /** Currently unused, but will be used to correctly render the UI. * This should specify what sync watch types can be used with this service. */ open val supportedWatchTypes: Set = SyncWatchType.entries.toSet() /** * Allows certain providers to open pages from * library links. **/ open val syncIdName: SyncIdName? = null /** Modify the current status of an item */ @Throws @WorkerThread open suspend fun updateStatus( auth: AuthData?, id: String, newStatus: AbstractSyncStatus ): Boolean = throw NotImplementedError() /** Get the current status of an item */ @Throws @WorkerThread open suspend fun status(auth: AuthData?, id: String): AbstractSyncStatus? = throw NotImplementedError() /** Get metadata about an item */ @Throws @WorkerThread open suspend fun load(auth: AuthData?, id: String): SyncResult? = throw NotImplementedError() /** Search this service for any results for a given query */ @Throws @WorkerThread open suspend fun search(auth: AuthData?, query: String): List? = throw NotImplementedError() /** Get the current library/bookmarks of this service */ @Throws @WorkerThread open suspend fun library(auth: AuthData?): LibraryMetadata? = throw NotImplementedError() /** Helper function, may be used in the future */ @Throws open fun urlToId(url: String): String? = null data class SyncSearchResult( override val name: String, override val apiName: String, var syncId: String, override val url: String, override var posterUrl: String?, override var type: TvType? = null, override var quality: SearchQuality? = null, override var posterHeaders: Map? = null, override var id: Int? = null, override var score: Score? = null, ) : SearchResponse abstract class AbstractSyncStatus { abstract var status: SyncWatchType abstract var score: Score? abstract var watchedEpisodes: Int? abstract var isFavorite: Boolean? abstract var maxEpisodes: Int? } data class SyncStatus( override var status: SyncWatchType, override var score: Score?, override var watchedEpisodes: Int?, override var isFavorite: Boolean? = null, override var maxEpisodes: Int? = null, ) : AbstractSyncStatus() data class SyncResult( /**Used to verify*/ var id: String, var totalEpisodes: Int? = null, var title: String? = null, var publicScore: Score? = null, /**In minutes*/ var duration: Int? = null, var synopsis: String? = null, var airStatus: ShowStatus? = null, var nextAiring: NextAiring? = null, var studio: List? = null, var genres: List? = null, var synonyms: List? = null, var trailers: List? = null, var isAdult: Boolean? = null, var posterUrl: String? = null, var backgroundPosterUrl: String? = null, /** In unixtime */ var startDate: Long? = null, /** In unixtime */ var endDate: Long? = null, var recommendations: List? = null, var nextSeason: SyncSearchResult? = null, var prevSeason: SyncSearchResult? = null, var actors: List? = null, ) data class Page( val title: UiText, var items: List ) { fun sort(method: ListSorting?, query: String? = null) { items = when (method) { ListSorting.Query -> if (query != null) { items.sortedBy { -FuzzySearch.partialRatio( query.lowercase(), it.name.lowercase() ) } } else items ListSorting.RatingHigh -> items.sortedBy { -(it.personalRating?.toInt(100) ?: 0) } ListSorting.RatingLow -> items.sortedBy { (it.personalRating?.toInt(100) ?: 0) } ListSorting.AlphabeticalA -> items.sortedBy { it.name } ListSorting.AlphabeticalZ -> items.sortedBy { it.name }.reversed() ListSorting.UpdatedNew -> items.sortedBy { it.lastUpdatedUnixTime?.times(-1) } ListSorting.UpdatedOld -> items.sortedBy { it.lastUpdatedUnixTime } ListSorting.ReleaseDateNew -> items.sortedByDescending { it.releaseDate } ListSorting.ReleaseDateOld -> items.sortedBy { it.releaseDate } else -> items } } } data class LibraryMetadata( val allLibraryLists: List, val supportedListSorting: Set ) data class LibraryList( val name: UiText, val items: List ) data class LibraryItem( override val name: String, override val url: String, /** * Unique unchanging string used for data storage. * This should be the actual id when you change scores and status * since score changes from library might get added in the future. **/ val syncId: String, val episodesCompleted: Int?, val episodesTotal: Int?, val personalRating: Score?, val lastUpdatedUnixTime: Long?, override val apiName: String, override var type: TvType?, override var posterUrl: String?, override var posterHeaders: Map?, override var quality: SearchQuality?, val releaseDate: Date?, override var id: Int? = null, val plot: String? = null, override var score: Score? = null, val tags: List? = null ) : SearchResponse } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/syncproviders/SyncRepo.kt ================================================ package com.lagradost.cloudstream3.syncproviders /** Stateless safe abstraction of SyncAPI */ class SyncRepo(override val api: SyncAPI) : AuthRepo(api) { val syncIdName = api.syncIdName var requireLibraryRefresh: Boolean get() = api.requireLibraryRefresh set(value) { api.requireLibraryRefresh = value } suspend fun updateStatus(id: String, newStatus: SyncAPI.AbstractSyncStatus): Result = runCatching { val status = api.updateStatus(freshAuth() ?: return@runCatching false, id, newStatus) requireLibraryRefresh = true status } suspend fun status(id: String): Result = runCatching { api.status(freshAuth(), id) } suspend fun load(id: String): Result = runCatching { api.load(freshAuth(), id) } suspend fun library(): Result = runCatching { api.library(freshAuth()) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Addic7ed.kt ================================================ package com.lagradost.cloudstream3.syncproviders.providers import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleEntity import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.SubtitleAPI import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName class Addic7ed : SubtitleAPI() { override val name = "Addic7ed" override val idPrefix = "addic7ed" override val requiresLogin = false companion object { const val HOST = "https://www.addic7ed.com" const val TAG = "ADDIC7ED" } private fun String.fixUrl(): String { val url = this return if (url.startsWith("/")) HOST + url else if (!url.startsWith("http")) "$HOST/$url" else url } override suspend fun search( auth: AuthData?, query: SubtitleSearch ): List? { val langTagIETF = query.lang ?: AllLanguagesName val langNumAddic7ed = langTagIETF2Addic7ed[langTagIETF]?.first ?: 0 // all languages = 0 val langName = langTagIETF2Addic7ed[langTagIETF]?.second ?: fromTagToEnglishLanguageName(langTagIETF) ?: "Completed" // this bypasses language filtering val title = query.query.trim() val epNum = query.epNumber ?: 0 val seasonNum = query.seasonNumber ?: 0 val yearNum = query.year ?: 0 val searchQuery = if (seasonNum > 0) "$title $seasonNum $epNum" else title var downloadPage = "" fun newSubtitleEntity ( displayName: String?, link: String?, isHearingImpaired: Boolean ): SubtitleEntity? { if (displayName.isNullOrBlank() || link.isNullOrBlank()) return null return SubtitleEntity( idPrefix = this.idPrefix, name = displayName, lang = langTagIETF, data = link, source = this.name, type = if (seasonNum > 0) TvType.TvSeries else TvType.Movie, epNumber = epNum, seasonNumber = seasonNum, year = yearNum, headers = mapOf("referer" to "$HOST/"), isHearingImpaired = isHearingImpaired ) } val response = app.get(url = "$HOST/search.php?search=$searchQuery&Submit=Search") val hostDocument = response.document // 1st case: found one movie or episode. Redirected to $HOST/movie/1234 or $HOST/serie/show-name/$seasonNum/$epNum/ep-name if (response.url.contains("/movie/") || response.url.contains("/serie/")) downloadPage = response.url // 2nd case: found tv series ep list. Redirected to $HOST/show/1234 else if (response.url.contains("/show/")) { val showId = response.url.substringAfterLast("/") val doc = app.get( "$HOST/ajax_loadShow.php?show=$showId&season=$seasonNum&langs=|$langNumAddic7ed|&hd=0&hi=0", referer = "$HOST/" ).document // get direct subtitles links from list return doc.select("#season tbody tr").mapNotNull { node -> if (node.select("td:eq(1)").text().toIntOrNull() == epNum) newSubtitleEntity( displayName = node.select("td:eq(2)").text() + "\n" + node.select("td:eq(4)").text(), link = node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl(), isHearingImpaired = node.select("td:eq(6)").text().isNotEmpty() ) else null } // 3rd case: found several or no results. Still in $HOST/search.php?search=title } else {// (response.url.contains("/search.php")) downloadPage = hostDocument.select("table.tabel a").selectFirst({ // tv series if (seasonNum > 0) "a[href~=serie\\/.+\\/$seasonNum\\/$epNum\\/\\w]" // movie + year else if( yearNum > 0) "a[href~=movie\\/]:contains($yearNum)" // movie else "a[href~=movie\\/]" }())?.attr("href")?.fixUrl() ?: return null } // filter download page by language. Do not work for movies :/ if (downloadPage.contains("/serie/")) downloadPage = downloadPage.substringBeforeLast("/") + "/$langNumAddic7ed" val doc = app.get(url = downloadPage).document // get subtitles links from download page return doc.select(".tabel95 .tabel95 tr:has(.language):contains($langName)").mapNotNull { node -> val displayName = doc.selectFirst("span.titulo")?.text()?.substringBefore(" Subtitle") + "\n" + node.parent()!!.select(".NewsTitle").text().substringAfter("Version ").substringBefore(", Duration") val link = node.selectFirst("a[href~=updated\\/|original\\/]")?.attr("href")?.fixUrl() val isHearingImpaired = node.parent()!!.select("tr:last-child [title=\"Hearing Impaired\"]").isNotEmpty() newSubtitleEntity(displayName, link, isHearingImpaired) } } override suspend fun load( auth: AuthData?, subtitle: SubtitleEntity ): String? { return subtitle.data } // Missing (?_?) // Pair("2", ""), // Pair("3", ""), // Pair("33", ""), // Pair("34", ""), // Do not modify unless Addic7ed changes them! // as they are the exact values from their website private val langTagIETF2Addic7ed = mapOf( "ar" to Pair("38", "Arabic"), "az" to Pair("48", "Azerbaijani"), "bg" to Pair("35", "Bulgarian"), "bn" to Pair("47", "Bengali"), "bs" to Pair("44", "Bosnian"), "ca" to Pair("12", "Català"), "cs" to Pair("14", "Czech"), "cy" to Pair("65", "Welsh"), "da" to Pair("30", "Danish"), "de" to Pair("11", "German"), "el" to Pair("27", "Greek"), "en" to Pair("1", "English"), "es-419" to Pair("6", "Spanish (Latin America)"), "es-ar" to Pair("69", "Spanish (Argentina)"), "es-es" to Pair("5", "Spanish (Spain)"), "es" to Pair("4", "Spanish"), "et" to Pair("54", "Estonian"), "eu" to Pair("13", "Euskera"), "fa" to Pair("43", "Persian"), "fi" to Pair("28", "Finnish"), "fr-ca" to Pair("53", "French (Canadian)"), "fr" to Pair("8", "French"), "gl" to Pair("15", "Galego"), "he" to Pair("23", "Hebrew"), "hi" to Pair("55", "Hindi"), "hr" to Pair("31", "Croatian"), "hu" to Pair("20", "Hungarian"), "hy" to Pair("50", "Armenian"), "id" to Pair("37", "Indonesian"), "is" to Pair("56", "Icelandic"), "it" to Pair("7", "Italian"), "ja" to Pair("32", "Japanese"), "kn" to Pair("66", "Kannada"), "ko" to Pair("42", "Korean"), "lt" to Pair("58", "Lithuanian"), "lv" to Pair("57", "Latvian"), "mk" to Pair("49", "Macedonian"), "ml" to Pair("67", "Malayalam"), "mr" to Pair("62", "Marathi"), "ms" to Pair("40", "Malay"), "nl" to Pair("17", "Dutch"), "no" to Pair("29", "Norwegian"), "pl" to Pair("21", "Polish"), "pt-br" to Pair("10", "Portuguese (Brazilian)"), "pt" to Pair("9", "Portuguese"), "ro" to Pair("26", "Romanian"), "ru" to Pair("19", "Russian"), "si" to Pair("60", "Sinhala"), "sk" to Pair("25", "Slovak"), "sl" to Pair("22", "Slovenian"), "sq" to Pair("52", "Albanian"), "sr-latn" to Pair("36", "Serbian (Latin)"), "sr" to Pair("39", "Serbian (Cyrillic)"), "sv" to Pair("18", "Swedish"), "ta" to Pair("59", "Tamil"), "te" to Pair("63", "Telugu"), "th" to Pair("46", "Thai"), "tl" to Pair("68", "Tagalog"), "tlh" to Pair("61", "Klingon"), "tr" to Pair("16", "Turkish"), "uk" to Pair("51", "Ukrainian"), "vi" to Pair("45", "Vietnamese"), "yue" to Pair("64", "Cantonese"), "zh-hans" to Pair("41", "Chinese (Simplified)"), "zh-hant" to Pair("24", "Chinese (Traditional)"), ) } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/AniListApi.kt ================================================ package com.lagradost.cloudstream3.syncproviders.providers import androidx.annotation.StringRes import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.Actor import com.lagradost.cloudstream3.ActorData import com.lagradost.cloudstream3.ActorRole import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.NextAiring import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.AuthLoginPage import com.lagradost.cloudstream3.syncproviders.AuthToken import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear import com.lagradost.cloudstream3.utils.txt import java.net.URLEncoder import java.util.Locale class AniListApi : SyncAPI() { override var name = "AniList" override val idPrefix = "anilist" val key = "6871" override val redirectUrlIdentifier = "anilistlogin" override var requireLibraryRefresh = true override val hasOAuth2 = true override var mainUrl = "https://anilist.co" override val icon = R.drawable.ic_anilist_icon override val createAccountUrl = "$mainUrl/signup" override val syncIdName = SyncIdName.Anilist override fun loginRequest(): AuthLoginPage? = AuthLoginPage("https://anilist.co/api/v2/oauth/authorize?client_id=$key&response_type=token") override suspend fun login(redirectUrl: String, payload: String?): AuthToken? { val sanitizer = splitRedirectUrl(redirectUrl) val token = AuthToken( accessToken = sanitizer["access_token"] ?: throw ErrorLoadingException("No access token"), //refreshToken = sanitizer["refresh_token"], accessTokenLifetime = unixTime + sanitizer["expires_in"]!!.toLong(), ) return token } // https://docs.anilist.co/guide/auth/ override suspend fun refreshToken(token: AuthToken): AuthToken? { // AniList access tokens are long-lived. They will remain valid for 1 year from the time they are issued. // Refresh tokens are not currently supported. Once a token expires, you will need to re-authenticate your users. return super.refreshToken(token) } override suspend fun user(token: AuthToken?): AuthUser? { val user = getUser(token ?: return null) ?: throw ErrorLoadingException("Unable to fetch user data") return AuthUser( id = user.id, name = user.name, profilePicture = user.picture, ) } override fun urlToId(url: String): String? = url.removePrefix("$mainUrl/anime/").removeSuffix("/") private fun getUrlFromId(id: Int): String { return "$mainUrl/anime/$id" } override suspend fun search(auth : AuthData?, query: String): List? { val data = searchShows(name) ?: return null return data.data?.page?.media?.map { SyncAPI.SyncSearchResult( it.title.romaji ?: return null, this.name, it.id.toString(), getUrlFromId(it.id), it.bannerImage ) } } override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? { val internalId = (Regex("anilist\\.co/anime/(\\d*)").find(id)?.groupValues?.getOrNull(1) ?: id).toIntOrNull() ?: throw ErrorLoadingException("Invalid internalId") val season = getSeason(internalId).data.media return SyncAPI.SyncResult( season.id.toString(), nextAiring = season.nextAiringEpisode?.let { NextAiring( it.episode ?: return@let null, (it.timeUntilAiring ?: return@let null) + unixTime ) }, title = season.title?.userPreferred, synonyms = season.synonyms, isAdult = season.isAdult, totalEpisodes = season.episodes, synopsis = season.description, actors = season.characters?.edges?.mapNotNull { edge -> val node = edge.node ?: return@mapNotNull null ActorData( actor = Actor( name = node.name?.userPreferred ?: node.name?.full ?: node.name?.native ?: return@mapNotNull null, image = node.image?.large ?: node.image?.medium ), role = when (edge.role) { "MAIN" -> ActorRole.Main "SUPPORTING" -> ActorRole.Supporting "BACKGROUND" -> ActorRole.Background else -> null }, voiceActor = edge.voiceActors?.firstNotNullOfOrNull { staff -> Actor( name = staff.name?.userPreferred ?: staff.name?.full ?: staff.name?.native ?: return@mapNotNull null, image = staff.image?.large ?: staff.image?.medium ) } ) }, publicScore = Score.from100(season.averageScore), recommendations = season.recommendations?.edges?.mapNotNull { rec -> val recMedia = rec.node.mediaRecommendation SyncAPI.SyncSearchResult( name = recMedia?.title?.userPreferred ?: return@mapNotNull null, this.name, recMedia.id?.toString() ?: return@mapNotNull null, getUrlFromId(recMedia.id), recMedia.coverImage?.extraLarge ?: recMedia.coverImage?.large ?: recMedia.coverImage?.medium ) }, trailers = when (season.trailer?.site?.lowercase()?.trim()) { "youtube" -> listOf("https://www.youtube.com/watch?v=${season.trailer.id}") else -> null } //TODO REST ) } override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? { val internalId = id.toIntOrNull() ?: return null val data = getDataAboutId(auth ?: return null, internalId) ?: return null return SyncAPI.SyncStatus( score = Score.from100(data.score), watchedEpisodes = data.progress, status = SyncWatchType.fromInternalId(data.type?.value ?: return null), isFavorite = data.isFavourite, maxEpisodes = data.episodes, ) } override suspend fun updateStatus( auth: AuthData?, id: String, newStatus: AbstractSyncStatus ): Boolean { return postDataAboutId( auth ?: return false, id.toIntOrNull() ?: return false, fromIntToAnimeStatus(newStatus.status.internalId), newStatus.score, newStatus.watchedEpisodes ) } companion object { const val MAX_STALE = 60 * 10 private val aniListStatusString = arrayOf("CURRENT", "COMPLETED", "PAUSED", "DROPPED", "PLANNING", "REPEATING") const val ANILIST_CACHED_LIST: String = "anilist_cached_list" private fun fixName(name: String): String { return name.lowercase(Locale.ROOT).replace(" ", "") .replace("[^a-zA-Z0-9]".toRegex(), "") } private suspend fun searchShows(name: String): GetSearchRoot? { try { val query = """ query (${"$"}id: Int, ${"$"}page: Int, ${"$"}search: String, ${"$"}type: MediaType) { Page (page: ${"$"}page, perPage: 10) { media (id: ${"$"}id, search: ${"$"}search, type: ${"$"}type) { id idMal seasonYear startDate { year month day } title { romaji } averageScore meanScore nextAiringEpisode { timeUntilAiring episode } trailer { id site thumbnail } bannerImage recommendations { nodes { id mediaRecommendation { id title { english romaji } idMal coverImage { medium large extraLarge } averageScore } } } relations { edges { id relationType(version: 2) node { format id idMal coverImage { medium large extraLarge } averageScore title { english romaji } } } } } } } """ val data = mapOf( "query" to query, "variables" to mapOf( "search" to name, "page" to 1, "type" to "ANIME" ).toJson() ) val res = app.post( "https://graphql.anilist.co/", //headers = mapOf(), data = data,//(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars)) timeout = 5000 // REASONABLE TIMEOUT ).text.replace("\\", "") return res.toKotlinObject() } catch (e: Exception) { logError(e) } return null } // Should use https://gist.github.com/purplepinapples/5dc60f15f2837bf1cea71b089cfeaa0a suspend fun getShowId(malId: String?, name: String, year: Int?): GetSearchMedia? { // Strips these from the name val blackList = listOf( "TV Dubbed", "(Dub)", "Subbed", "(TV)", "(Uncensored)", "(Censored)", "(\\d+)" // year ) val blackListRegex = Regex( """ (${ blackList.joinToString(separator = "|").replace("(", "\\(") .replace(")", "\\)") })""" ) //println("NAME $name NEW NAME ${name.replace(blackListRegex, "")}") val shows = searchShows(name.replace(blackListRegex, "")) shows?.data?.page?.media?.find { (malId ?: "NONE") == it.idMal.toString() }?.let { return it } val filtered = shows?.data?.page?.media?.filter { (((it.startDate.year ?: year.toString()) == year.toString() || year == null)) } filtered?.forEach { it.title.romaji?.let { romaji -> if (fixName(romaji) == fixName(name)) return it } } return filtered?.firstOrNull() } // Changing names of these will show up in UI enum class AniListStatusType(var value: Int, @StringRes val stringRes: Int) { Watching(0, R.string.type_watching), Completed(1, R.string.type_completed), Paused(2, R.string.type_on_hold), Dropped(3, R.string.type_dropped), Planning(4, R.string.type_plan_to_watch), ReWatching(5, R.string.type_re_watching), None(-1, R.string.none) } fun fromIntToAnimeStatus(inp: Int): AniListStatusType {//= AniListStatusType.values().first { it.value == inp } return when (inp) { -1 -> AniListStatusType.None 0 -> AniListStatusType.Watching 1 -> AniListStatusType.Completed 2 -> AniListStatusType.Paused 3 -> AniListStatusType.Dropped 4 -> AniListStatusType.Planning 5 -> AniListStatusType.ReWatching else -> AniListStatusType.None } } fun convertAniListStringToStatus(string: String): AniListStatusType { return fromIntToAnimeStatus(aniListStatusString.indexOf(string)) } private suspend fun getSeason(id: Int): SeasonResponse { val q = """ query (${'$'}id: Int = $id) { Media (id: ${'$'}id, type: ANIME) { id idMal coverImage { extraLarge large medium color } title { romaji english native userPreferred } duration episodes genres synonyms averageScore isAdult description(asHtml: false) characters(sort: ROLE page: 1 perPage: 20) { edges { role voiceActors { name { userPreferred full native } age image { large medium } } node { name { userPreferred full native } age image { large medium } } } } trailer { id site thumbnail } relations { edges { id relationType(version: 2) node { id coverImage { extraLarge large medium color } } } } recommendations { edges { node { mediaRecommendation { id coverImage { extraLarge large medium color } title { romaji english native userPreferred } } } } } nextAiringEpisode { timeUntilAiring episode } format } } """ val data = app.post( "https://graphql.anilist.co", data = mapOf("query" to q), cacheTime = 0, ).text return tryParseJson(data) ?: throw ErrorLoadingException("Error parsing $data") } } private suspend fun getDataAboutId(auth : AuthData, id: Int): AniListTitleHolder? { val q = """query (${'$'}id: Int = $id) { # Define which variables will be used in the query (id) Media (id: ${'$'}id, type: ANIME) { # Insert our variables into the query arguments (id) (type: ANIME is hard-coded in the query) id episodes isFavourite mediaListEntry { progress status score (format: POINT_100) } title { english romaji } } }""" val data = postApi(auth.token, q, true) val d = parseJson(data ?: return null) val main = d.data?.media if (main?.mediaListEntry != null) { return AniListTitleHolder( title = main.title, id = id, isFavourite = main.isFavourite, progress = main.mediaListEntry.progress, episodes = main.episodes, score = main.mediaListEntry.score, type = fromIntToAnimeStatus(aniListStatusString.indexOf(main.mediaListEntry.status)), ) } else { return AniListTitleHolder( title = main?.title, id = id, isFavourite = main?.isFavourite, progress = 0, episodes = main?.episodes, score = 0, type = AniListStatusType.None, ) } } private suspend fun postApi(token : AuthToken, q: String, cache: Boolean = false): String? { return app.post( "https://graphql.anilist.co/", headers = mapOf( "Authorization" to "Bearer ${token.accessToken ?: return null}", if (cache) "Cache-Control" to "max-stale=$MAX_STALE" else "Cache-Control" to "no-cache" ), cacheTime = 0, data = mapOf( "query" to URLEncoder.encode( q, "UTF-8" ) ), //(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars)) timeout = 5 // REASONABLE TIMEOUT ).text.replace("\\/", "/") } data class MediaRecommendation( @JsonProperty("id") val id: Int, @JsonProperty("title") val title: Title?, @JsonProperty("idMal") val idMal: Int?, @JsonProperty("coverImage") val coverImage: CoverImage?, @JsonProperty("averageScore") val averageScore: Int? ) data class FullAnilistList( @JsonProperty("data") val data: Data? ) data class CompletedAt( @JsonProperty("year") val year: Int, @JsonProperty("month") val month: Int, @JsonProperty("day") val day: Int ) data class StartedAt( @JsonProperty("year") val year: String?, @JsonProperty("month") val month: String?, @JsonProperty("day") val day: String? ) data class Title( @JsonProperty("english") val english: String?, @JsonProperty("romaji") val romaji: String? ) data class CoverImage( @JsonProperty("medium") val medium: String?, @JsonProperty("large") val large: String?, @JsonProperty("extraLarge") val extraLarge: String? ) data class Media( @JsonProperty("id") val id: Int, @JsonProperty("idMal") val idMal: Int?, @JsonProperty("season") val season: String?, @JsonProperty("seasonYear") val seasonYear: Int, @JsonProperty("format") val format: String?, //@JsonProperty("source") val source: String, @JsonProperty("episodes") val episodes: Int, @JsonProperty("title") val title: Title, @JsonProperty("description") val description: String?, @JsonProperty("coverImage") val coverImage: CoverImage, @JsonProperty("synonyms") val synonyms: List, @JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?, ) data class Entries( @JsonProperty("status") val status: String?, @JsonProperty("completedAt") val completedAt: CompletedAt, @JsonProperty("startedAt") val startedAt: StartedAt, @JsonProperty("updatedAt") val updatedAt: Int, @JsonProperty("progress") val progress: Int, @JsonProperty("score") val score: Int, @JsonProperty("private") val private: Boolean, @JsonProperty("media") val media: Media ) { fun toLibraryItem(): SyncAPI.LibraryItem { return SyncAPI.LibraryItem( // English title first this.media.title.english ?: this.media.title.romaji ?: this.media.synonyms.firstOrNull() ?: "", "https://anilist.co/anime/${this.media.id}/", this.media.id.toString(), this.progress, this.media.episodes, Score.from100(this.score), this.updatedAt.toLong(), "AniList", TvType.Anime, this.media.coverImage.extraLarge ?: this.media.coverImage.large ?: this.media.coverImage.medium, null, null, this.media.seasonYear.toYear(), null, plot = this.media.description, ) } } data class Lists( @JsonProperty("status") val status: String?, @JsonProperty("entries") val entries: List ) data class MediaListCollection( @JsonProperty("lists") val lists: List ) data class Data( @JsonProperty("MediaListCollection") val mediaListCollection: MediaListCollection ) private suspend fun getAniListAnimeListSmart(auth: AuthData): Array? { return if (requireLibraryRefresh) { val list = getFullAniListList(auth)?.data?.mediaListCollection?.lists?.toTypedArray() if (list != null) { setKey(ANILIST_CACHED_LIST, auth.user.id.toString(), list) } list } else { getKey>( ANILIST_CACHED_LIST, auth.user.id.toString() ) as? Array } } override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? { val list = getAniListAnimeListSmart(auth ?: return null)?.groupBy { convertAniListStringToStatus(it.status ?: "").stringRes }?.mapValues { group -> group.value.map { it.entries.map { entry -> entry.toLibraryItem() } }.flatten() } ?: emptyMap() // To fill empty lists when AniList does not return them val baseMap = AniListStatusType.entries.filter { it.value >= 0 }.associate { it.stringRes to emptyList() } return SyncAPI.LibraryMetadata( (baseMap + list).map { SyncAPI.LibraryList(txt(it.key), it.value) }, setOf( ListSorting.AlphabeticalA, ListSorting.AlphabeticalZ, ListSorting.UpdatedNew, ListSorting.UpdatedOld, ListSorting.ReleaseDateNew, ListSorting.ReleaseDateOld, ListSorting.RatingHigh, ListSorting.RatingLow, ) ) } private suspend fun getFullAniListList(auth : AuthData): FullAnilistList? { val userID = auth.user.id val mediaType = "ANIME" val query = """ query (${'$'}userID: Int = $userID, ${'$'}MEDIA: MediaType = $mediaType) { MediaListCollection (userId: ${'$'}userID, type: ${'$'}MEDIA) { lists { status entries { status completedAt { year month day } startedAt { year month day } updatedAt progress score (format: POINT_100) private media { id idMal season seasonYear format episodes chapters title { english romaji } coverImage { extraLarge large medium } synonyms nextAiringEpisode { timeUntilAiring episode } } } } } } """ val text = postApi(auth.token, query) return text?.toKotlinObject() } suspend fun toggleLike(auth : AuthData, id: Int): Boolean { val q = """mutation (${'$'}animeId: Int = $id) { ToggleFavourite (animeId: ${'$'}animeId) { anime { nodes { id title { romaji } } } } }""" val data = postApi(auth.token, q) return data != "" } /** Used to query a saved MediaItem on the list to get the id for removal */ data class MediaListItemRoot(@JsonProperty("data") val data: MediaListItem? = null) data class MediaListItem(@JsonProperty("MediaList") val mediaList: MediaListId? = null) data class MediaListId(@JsonProperty("id") val id: Long? = null) private suspend fun postDataAboutId( auth : AuthData, id: Int, type: AniListStatusType, score: Score?, progress: Int? ): Boolean { val userID = auth.user.id val q = // Delete item if status type is None if (type == AniListStatusType.None) { // Get list ID for deletion val idQuery = """ query MediaList(${'$'}userId: Int = $userID, ${'$'}mediaId: Int = $id) { MediaList(userId: ${'$'}userId, mediaId: ${'$'}mediaId) { id } } """ val response = postApi(auth.token, idQuery) val listId = tryParseJson(response)?.data?.mediaList?.id ?: return false """ mutation(${'$'}id: Int = $listId) { DeleteMediaListEntry(id: ${'$'}id) { deleted } } """ } else { """mutation (${'$'}id: Int = $id, ${'$'}status: MediaListStatus = ${ aniListStatusString[maxOf( 0, type.value )] }, ${if (score != null) "${'$'}scoreRaw: Int = ${score.toInt(100)}" else ""} , ${if (progress != null) "${'$'}progress: Int = $progress" else ""}) { SaveMediaListEntry (mediaId: ${'$'}id, status: ${'$'}status, scoreRaw: ${'$'}scoreRaw, progress: ${'$'}progress) { id status progress score } }""" } val data = postApi(auth.token, q) return data != "" } private suspend fun getUser(token : AuthToken): AniListUser? { val q = """ { Viewer { id name avatar { large } favourites { anime { nodes { id } } } } }""" val data = postApi(token, q) if (data.isNullOrBlank()) return null val userData = parseJson(data) val u = userData.data?.viewer ?: return null val user = AniListUser( u.id, u.name, u.avatar?.large, ) return user } suspend fun getAllSeasons(id: Int): List { val seasons = mutableListOf() suspend fun getSeasonRecursive(id: Int) { val season = getSeason(id) seasons.add(season) if (season.data.media.format?.startsWith("TV") == true) { season.data.media.relations?.edges?.forEach { if (it.node?.format != null) { if (it.relationType == "SEQUEL" && it.node.format.startsWith("TV")) { getSeasonRecursive(it.node.id) return@forEach } } } } } getSeasonRecursive(id) return seasons.toList() } data class SeasonResponse( @JsonProperty("data") val data: SeasonData, ) data class SeasonData( @JsonProperty("Media") val media: SeasonMedia, ) data class SeasonMedia( @JsonProperty("id") val id: Int?, @JsonProperty("title") val title: MediaTitle?, @JsonProperty("idMal") val idMal: Int?, @JsonProperty("format") val format: String?, @JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?, @JsonProperty("relations") val relations: SeasonEdges?, @JsonProperty("coverImage") val coverImage: MediaCoverImage?, @JsonProperty("duration") val duration: Int?, @JsonProperty("episodes") val episodes: Int?, @JsonProperty("genres") val genres: List?, @JsonProperty("synonyms") val synonyms: List?, @JsonProperty("averageScore") val averageScore: Int?, @JsonProperty("isAdult") val isAdult: Boolean?, @JsonProperty("trailer") val trailer: MediaTrailer?, @JsonProperty("description") val description: String?, @JsonProperty("characters") val characters: CharacterConnection?, @JsonProperty("recommendations") val recommendations: RecommendationConnection?, ) data class RecommendationConnection( @JsonProperty("edges") val edges: List = emptyList(), @JsonProperty("nodes") val nodes: List = emptyList(), //@JsonProperty("pageInfo") val pageInfo: PageInfo, ) data class RecommendationEdge( //@JsonProperty("rating") val rating: Int, @JsonProperty("node") val node: Recommendation, ) data class Recommendation( val id: Long, @JsonProperty("mediaRecommendation") val mediaRecommendation: SeasonMedia?, ) data class CharacterName( @JsonProperty("name") val first: String?, @JsonProperty("middle") val middle: String?, @JsonProperty("last") val last: String?, @JsonProperty("full") val full: String?, @JsonProperty("native") val native: String?, @JsonProperty("alternative") val alternative: List?, @JsonProperty("alternativeSpoiler") val alternativeSpoiler: List?, @JsonProperty("userPreferred") val userPreferred: String?, ) data class CharacterImage( @JsonProperty("large") val large: String?, @JsonProperty("medium") val medium: String?, ) data class Character( @JsonProperty("name") val name: CharacterName?, @JsonProperty("age") val age: String?, @JsonProperty("image") val image: CharacterImage?, ) data class CharacterEdge( @JsonProperty("id") val id: Int?, /** MAIN A primary character role in the media SUPPORTING A supporting character role in the media BACKGROUND A background character in the media */ @JsonProperty("role") val role: String?, @JsonProperty("name") val name: String?, @JsonProperty("voiceActors") val voiceActors: List?, @JsonProperty("favouriteOrder") val favouriteOrder: Int?, @JsonProperty("media") val media: List?, @JsonProperty("node") val node: Character?, ) data class StaffImage( @JsonProperty("large") val large: String?, @JsonProperty("medium") val medium: String?, ) data class StaffName( @JsonProperty("name") val first: String?, @JsonProperty("middle") val middle: String?, @JsonProperty("last") val last: String?, @JsonProperty("full") val full: String?, @JsonProperty("native") val native: String?, @JsonProperty("alternative") val alternative: List?, @JsonProperty("userPreferred") val userPreferred: String?, ) data class Staff( @JsonProperty("image") val image: StaffImage?, @JsonProperty("name") val name: StaffName?, @JsonProperty("age") val age: Int?, ) data class CharacterConnection( @JsonProperty("edges") val edges: List?, @JsonProperty("nodes") val nodes: List?, //@JsonProperty("pageInfo") pageInfo: PageInfo ) data class MediaTrailer( @JsonProperty("id") val id: String?, @JsonProperty("site") val site: String?, @JsonProperty("thumbnail") val thumbnail: String?, ) data class MediaCoverImage( @JsonProperty("extraLarge") val extraLarge: String?, @JsonProperty("large") val large: String?, @JsonProperty("medium") val medium: String?, @JsonProperty("color") val color: String?, ) data class SeasonNextAiringEpisode( @JsonProperty("episode") val episode: Int?, @JsonProperty("timeUntilAiring") val timeUntilAiring: Int?, ) data class SeasonEdges( @JsonProperty("edges") val edges: List?, ) data class SeasonEdge( @JsonProperty("id") val id: Int?, @JsonProperty("relationType") val relationType: String?, @JsonProperty("node") val node: SeasonNode?, ) data class AniListFavoritesMediaConnection( @JsonProperty("nodes") val nodes: List, ) data class AniListFavourites( @JsonProperty("anime") val anime: AniListFavoritesMediaConnection, ) data class MediaTitle( @JsonProperty("romaji") val romaji: String?, @JsonProperty("english") val english: String?, @JsonProperty("native") val native: String?, @JsonProperty("userPreferred") val userPreferred: String?, ) data class SeasonNode( @JsonProperty("id") val id: Int, @JsonProperty("format") val format: String?, @JsonProperty("title") val title: Title?, @JsonProperty("idMal") val idMal: Int?, @JsonProperty("coverImage") val coverImage: CoverImage?, @JsonProperty("averageScore") val averageScore: Int? // @JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?, ) data class AniListAvatar( @JsonProperty("large") val large: String?, ) data class AniListViewer( @JsonProperty("id") val id: Int, @JsonProperty("name") val name: String, @JsonProperty("avatar") val avatar: AniListAvatar?, @JsonProperty("favourites") val favourites: AniListFavourites?, ) data class AniListData( @JsonProperty("Viewer") val viewer: AniListViewer?, ) data class AniListRoot( @JsonProperty("data") val data: AniListData?, ) data class AniListUser( @JsonProperty("id") val id: Int, @JsonProperty("name") val name: String, @JsonProperty("picture") val picture: String?, ) data class LikeNode( @JsonProperty("id") val id: Int?, //@JsonProperty("idMal") public int idMal; ) data class LikePageInfo( @JsonProperty("total") val total: Int?, @JsonProperty("currentPage") val currentPage: Int?, @JsonProperty("lastPage") val lastPage: Int?, @JsonProperty("perPage") val perPage: Int?, @JsonProperty("hasNextPage") val hasNextPage: Boolean?, ) data class LikeAnime( @JsonProperty("nodes") val nodes: List?, @JsonProperty("pageInfo") val pageInfo: LikePageInfo?, ) data class LikeFavourites( @JsonProperty("anime") val anime: LikeAnime?, ) data class LikeViewer( @JsonProperty("favourites") val favourites: LikeFavourites?, ) data class LikeData( @JsonProperty("Viewer") val viewer: LikeViewer?, ) data class LikeRoot( @JsonProperty("data") val data: LikeData?, ) data class AniListTitleHolder( @JsonProperty("title") val title: Title?, @JsonProperty("isFavourite") val isFavourite: Boolean?, @JsonProperty("id") val id: Int?, @JsonProperty("progress") val progress: Int?, @JsonProperty("episodes") val episodes: Int?, @JsonProperty("score") val score: Int?, @JsonProperty("type") val type: AniListStatusType?, ) data class GetDataMediaListEntry( @JsonProperty("progress") val progress: Int?, @JsonProperty("status") val status: String?, @JsonProperty("score") val score: Int?, ) data class Nodes( @JsonProperty("id") val id: Int?, @JsonProperty("mediaRecommendation") val mediaRecommendation: MediaRecommendation? ) data class GetDataMedia( @JsonProperty("isFavourite") val isFavourite: Boolean?, @JsonProperty("episodes") val episodes: Int?, @JsonProperty("title") val title: Title?, @JsonProperty("mediaListEntry") val mediaListEntry: GetDataMediaListEntry? ) data class Recommendations( @JsonProperty("nodes") val nodes: List? ) data class GetDataData( @JsonProperty("Media") val media: GetDataMedia?, ) data class GetDataRoot( @JsonProperty("data") val data: GetDataData?, ) data class GetSearchTitle( @JsonProperty("romaji") val romaji: String?, ) data class TrailerObject( @JsonProperty("id") val id: String?, @JsonProperty("thumbnail") val thumbnail: String?, @JsonProperty("site") val site: String?, ) data class GetSearchMedia( @JsonProperty("id") val id: Int, @JsonProperty("idMal") val idMal: Int?, @JsonProperty("seasonYear") val seasonYear: Int, @JsonProperty("title") val title: GetSearchTitle, @JsonProperty("startDate") val startDate: StartedAt, @JsonProperty("averageScore") val averageScore: Int?, @JsonProperty("meanScore") val meanScore: Int?, @JsonProperty("bannerImage") val bannerImage: String?, @JsonProperty("trailer") val trailer: TrailerObject?, @JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?, @JsonProperty("recommendations") val recommendations: Recommendations?, @JsonProperty("relations") val relations: SeasonEdges? ) data class GetSearchPage( @JsonProperty("Page") val page: GetSearchData?, ) data class GetSearchData( @JsonProperty("media") val media: List?, ) data class GetSearchRoot( @JsonProperty("data") val data: GetSearchPage?, ) } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/KitsuApi.kt ================================================ package com.lagradost.cloudstream3.syncproviders.providers import androidx.annotation.StringRes import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.ShowStatus import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse import com.lagradost.cloudstream3.syncproviders.AuthToken import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.txt import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.withIndex import okhttp3.RequestBody.Companion.toRequestBody import java.text.SimpleDateFormat import java.time.Instant import java.time.LocalDate import java.time.format.DateTimeFormatter import java.util.Date import java.util.Locale import kotlin.collections.set const val KITSU_MAX_SEARCH_LIMIT = 20 class KitsuApi: SyncAPI() { override var name = "Kitsu" override val idPrefix = "kitsu" private val apiUrl = "https://kitsu.io/api/edge" private val oauthUrl = "https://kitsu.io/api/oauth" override val hasInApp = true override val mainUrl = "https://kitsu.app" override val icon = R.drawable.kitsu_icon override val syncIdName = SyncIdName.Kitsu override val createAccountUrl = mainUrl override val supportedWatchTypes = setOf( SyncWatchType.WATCHING, SyncWatchType.COMPLETED, SyncWatchType.PLANTOWATCH, SyncWatchType.DROPPED, SyncWatchType.ONHOLD, SyncWatchType.NONE ) override val inAppLoginRequirement = AuthLoginRequirement( password = true, email = true ) override suspend fun login(form: AuthLoginResponse): AuthToken? { val username = form.email ?: return null val password = form.password ?: return null val grantType = "password" val token = app.post( "$oauthUrl/token", data = mapOf( "grant_type" to grantType, "username" to username, "password" to password ) ).parsed() return AuthToken( accessTokenLifetime = unixTime + token.expiresIn.toLong(), refreshToken = token.refreshToken, accessToken = token.accessToken, ) } override suspend fun refreshToken(token: AuthToken): AuthToken { val res = app.post( "$oauthUrl/token", data = mapOf( "grant_type" to "refresh_token", "refresh_token" to token.refreshToken!! ) ).parsed() return AuthToken( accessToken = res.accessToken, refreshToken = res.refreshToken, accessTokenLifetime = unixTime + res.expiresIn.toLong() ) } override suspend fun user(token: AuthToken?): AuthUser? { val user = app.get( "$apiUrl/users?filter[self]=true", headers = mapOf( "Authorization" to "Bearer ${token?.accessToken ?: return null}" ), cacheTime = 0 ).parsed() if (user.data.isEmpty()) { return null } return AuthUser( id = user.data[0].id.toInt(), name = user.data[0].attributes.name, profilePicture = user.data[0].attributes.avatar?.original ) } override suspend fun search(auth: AuthData?, query: String): List? { val auth = auth?.token?.accessToken ?: return null val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","episodeCount") val url = "$apiUrl/anime?filter[text]=$query&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}" val res = app.get( url, headers = mapOf( "Authorization" to "Bearer $auth", ), cacheTime = 0 ).parsed() return res.data.map { val attributes = it.attributes val title = attributes.canonicalTitle ?: attributes.titles?.enJp ?: attributes.titles?.jaJp ?: "No title" SyncSearchResult( title, this.name, it.id, "$mainUrl/anime/${it.id}/", attributes.posterImage?.large ?: attributes.posterImage?.medium ) } } override suspend fun load(auth : AuthData?, id: String): SyncResult? { val auth = auth?.token?.accessToken ?: return null if (id.toIntOrNull() == null) { return null } data class KitsuResponse( @field:JsonProperty(value = "data") val data: KitsuNode, ) val url = "$apiUrl/anime/$id" val anime = app.get( url, headers = mapOf( "Authorization" to "Bearer $auth" ) ).parsed().data.attributes return SyncResult( id = id, totalEpisodes = anime.episodeCount, title = anime.canonicalTitle ?: anime.titles?.enJp ?: anime.titles?.jaJp.orEmpty(), publicScore = Score.from(anime.ratingTwenty.toString(), 20), duration = anime.episodeLength, synopsis = anime.synopsis, airStatus = when(anime.status) { "finished" -> ShowStatus.Completed "current" -> ShowStatus.Ongoing else -> null }, nextAiring = null, studio = null, genres = null, trailers = null, startDate = LocalDate.parse(anime.startDate).toEpochDay(), endDate = LocalDate.parse(anime.endDate).toEpochDay(), recommendations = null, nextSeason =null, prevSeason = null, actors = null, ) } override suspend fun status(auth : AuthData?, id: String): AbstractSyncStatus? { val accessToken = auth?.token?.accessToken ?: return null val userId = auth.user.id val selectedFields = arrayOf("status","ratingTwenty", "progress") val url = "$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id&fields[libraryEntries]=${selectedFields.joinToString(",")}" val anime = app.get( url, headers = mapOf( "Authorization" to "Bearer $accessToken" ) ).parsed().data.firstOrNull()?.attributes if (anime == null) { return SyncStatus( score = null, status = SyncWatchType.NONE, isFavorite = null, watchedEpisodes = null ) } return SyncStatus( score = Score.from(anime.ratingTwenty.toString(), 20), status = SyncWatchType.fromInternalId(kitsuStatusAsString.indexOf(anime.status)), isFavorite = null, watchedEpisodes = anime.progress, ) } suspend fun getAnimeIdByTitle(title: String): String? { val animeSelectedFields = arrayOf("titles","canonicalTitle") val url = "$apiUrl/anime?filter[text]=$title&page[limit]=$KITSU_MAX_SEARCH_LIMIT&fields[anime]=${animeSelectedFields.joinToString(",")}" val res = app.get(url).parsed() return res.data.firstOrNull()?.id } override fun urlToId(url: String): String? = Regex("""/anime/((.*)/|(.*))""").find(url)?.groupValues?.first() override suspend fun updateStatus( auth : AuthData?, id: String, newStatus: AbstractSyncStatus ): Boolean { return setScoreRequest( auth ?: return false, id.toIntOrNull() ?: return false, fromIntToAnimeStatus(newStatus.status), newStatus.score?.toInt(20), newStatus.watchedEpisodes ) } private suspend fun setScoreRequest( auth : AuthData, id: Int, status: KitsuStatusType? = null, score: Int? = null, numWatchedEpisodes: Int? = null, ): Boolean { val libraryEntryId = getAnimeLibraryEntryId(auth, id) // Exists entry for anime in library if (libraryEntryId != null) { // Delete anime from library if (status == null || status == KitsuStatusType.None) { val res = app.delete( "$apiUrl/library-entries/$libraryEntryId", headers = mapOf( "Authorization" to "Bearer ${auth.token.accessToken}" ), ) return res.isSuccessful } return setScoreRequest( auth, libraryEntryId, kitsuStatusAsString[maxOf(0, status.value)], score, numWatchedEpisodes ) } val data = mapOf( "data" to mapOf( "type" to "libraryEntries", "attributes" to mapOf( "ratingTwenty" to score, "progress" to numWatchedEpisodes, "status" to if (status == null) null else kitsuStatusAsString[maxOf(0, status.value)], ), "relationships" to mapOf( "anime" to mapOf( "data" to mapOf( "type" to "anime", "id" to id.toString() ) ), "user" to mapOf( "data" to mapOf( "type" to "users", "id" to auth.user.id ) ) ) ) ) val res = app.post( "$apiUrl/library-entries", headers = mapOf( "content-type" to "application/vnd.api+json", "Authorization" to "Bearer ${auth.token.accessToken}" ), requestBody = data.toJson().toRequestBody() ) return res.isSuccessful } @Suppress("UNCHECKED_CAST") private suspend fun setScoreRequest( auth : AuthData, id: Int, status: String? = null, score: Int? = null, numWatchedEpisodes: Int? = null, ): Boolean { val data = mapOf( "data" to mapOf( "type" to "libraryEntries", "id" to id.toString(), "attributes" to mapOf( "ratingTwenty" to score, "progress" to numWatchedEpisodes, "status" to status ) ) ) val res = app.patch( "$apiUrl/library-entries/$id", headers = mapOf( "content-type" to "application/vnd.api+json", "Authorization" to "Bearer ${auth.token.accessToken}" ), requestBody = data.toJson().toRequestBody() ) return res.isSuccessful } private suspend fun getAnimeLibraryEntryId(auth: AuthData, id: Int): Int? { val userId = auth.user.id val res = app.get( "$apiUrl/library-entries?filter[userId]=$userId&filter[animeId]=$id", headers = mapOf( "Authorization" to "Bearer ${auth.token.accessToken}" ), ).parsed().data.firstOrNull() ?: return null return res.id.toInt() } override suspend fun library(auth : AuthData?): LibraryMetadata? { val list = getKitsuAnimeListSmart(auth ?: return null)?.groupBy { convertToStatus(it.attributes.status ?: "").stringRes }?.mapValues { group -> group.value.map { it.toLibraryItem() } } ?: emptyMap() // To fill empty lists when Kitsu does not return them val baseMap = KitsuStatusType.entries.filter { it.value >= 0 }.associate { it.stringRes to emptyList() } return LibraryMetadata( (baseMap + list).map { LibraryList(txt(it.key), it.value) }, setOf( ListSorting.AlphabeticalA, ListSorting.AlphabeticalZ, ListSorting.UpdatedNew, ListSorting.UpdatedOld, ListSorting.ReleaseDateNew, ListSorting.ReleaseDateOld, ListSorting.RatingHigh, ListSorting.RatingLow, ) ) } private suspend fun getKitsuAnimeListSmart(auth : AuthData): Array? { return if (requireLibraryRefresh) { val list = getKitsuAnimeList(auth.token, auth.user.id) setKey(KITSU_CACHED_LIST, auth.user.id.toString(), list) list } else { getKey>(KITSU_CACHED_LIST, auth.user.id.toString()) as? Array } } private suspend fun getKitsuAnimeList(token: AuthToken, userId: Int): Array { val animeSelectedFields = arrayOf("titles","canonicalTitle","posterImage","synopsis","startDate","episodeCount") val libraryEntriesSelectedFields = arrayOf("progress","rating","updatedAt", "status") val limit = 500 var url = "$apiUrl/library-entries?filter[userId]=$userId&filter[kind]=anime&include=anime&page[limit]=$limit&page[offset]=0&fields[anime]=${animeSelectedFields.joinToString(",")}&fields[libraryEntries]=${libraryEntriesSelectedFields.joinToString(",")}" val fullList = mutableListOf() while (true) { val data: KitsuResponse = getKitsuAnimeListSlice(token, url) data.data.forEachIndexed { index, value -> value.anime = data.included?.get(index) } fullList.addAll(data.data) url = data.links?.next ?: break } return fullList.toTypedArray() } private suspend fun getKitsuAnimeListSlice(token: AuthToken, url: String): KitsuResponse { val res = app.get( url, headers = mapOf( "Authorization" to "Bearer ${token.accessToken}", ) ).parsed() return res } data class ResponseToken( @JsonProperty("token_type") val tokenType: String, @JsonProperty("expires_in") val expiresIn: Int, @JsonProperty("access_token") val accessToken: String, @JsonProperty("refresh_token") val refreshToken: String, ) data class KitsuNode( @JsonProperty("id") val id: String, @JsonProperty("attributes") val attributes: KitsuNodeAttributes, /* User list anime node */ @JsonProperty("relationships") val relationships: KitsuRelationships?, var anime: KitsuAnimeData? ) { fun toLibraryItem(): LibraryItem { val animeItem = this.anime val numEpisodes = animeItem?.attributes?.episodeCount val startDate = animeItem?.attributes?.startDate val posterImage = animeItem?.attributes?.posterImage val canonicalTitle = animeItem?.attributes?.canonicalTitle val titles = animeItem?.attributes?.titles val animeId = animeItem?.id val description: String? = animeItem?.attributes?.synopsis return LibraryItem( canonicalTitle ?: titles?.enJp ?: titles?.jaJp.orEmpty(), "https://kitsu.app/anime/${animeId}/", this.id, this.attributes.progress, numEpisodes, Score.from(this.attributes.ratingTwenty.toString(), 20), parseDateLong(this.attributes.updatedAt), "Kitsu", TvType.Anime, posterImage?.large ?: posterImage?.medium, null, null, plot = description, releaseDate = if (startDate == null) null else try { Date.from( Instant.from( DateTimeFormatter.ofPattern(if (startDate.length == 4) "yyyy" else if (startDate.length == 7) "yyyy-MM" else "yyyy-MM-dd") .parse(startDate) ) ) } catch (_: RuntimeException) { null } ) } } data class KitsuAnimeAttributes( @JsonProperty("titles") val titles: KitsuTitles?, @JsonProperty("canonicalTitle") val canonicalTitle: String?, @JsonProperty("posterImage") val posterImage: KitsuPosterImage?, @JsonProperty("synopsis") val synopsis: String?, @JsonProperty("startDate") val startDate: String?, @JsonProperty("endDate") val endDate: String?, @JsonProperty("episodeCount") val episodeCount: Int?, @JsonProperty("episodeLength") val episodeLength: Int?, ) data class KitsuAnimeData( @JsonProperty("id") val id: String, @JsonProperty("attributes") val attributes: KitsuAnimeAttributes, ) data class KitsuNodeAttributes( /* General attributes */ @JsonProperty("titles") val titles: KitsuTitles?, @JsonProperty("canonicalTitle") val canonicalTitle: String?, @JsonProperty("posterImage") val posterImage: KitsuPosterImage?, @JsonProperty("synopsis") val synopsis: String?, @JsonProperty("startDate") val startDate: String?, @JsonProperty("endDate") val endDate: String?, @JsonProperty("episodeCount") val episodeCount: Int?, @JsonProperty("episodeLength") val episodeLength: Int?, /* User attributes */ @JsonProperty("name") val name: String?, @JsonProperty("location") val location: String?, @JsonProperty("createdAt") val createdAt: String?, @JsonProperty("avatar") val avatar: KitsuUserAvatar?, /* User list anime attributes */ @JsonProperty("progress") val progress: Int?, @JsonProperty("ratingTwenty") val ratingTwenty: Float?, @JsonProperty("updatedAt") val updatedAt: String?, @JsonProperty("status") val status: String?, ) data class KitsuRelationships( @JsonProperty("anime") val anime: KitsuRelationshipsAnime? ) data class KitsuRelationshipsAnime( @JsonProperty("links") val links: KitsuLinks? ) data class KitsuPosterImage( @JsonProperty("large") val large: String?, @JsonProperty("medium") val medium: String?, ) data class KitsuTitles( @JsonProperty("en_jp") val enJp: String?, @JsonProperty("ja_jp") val jaJp: String? ) data class KitsuUserAvatar( @JsonProperty("original") val original: String? ) data class KitsuLinks( /* Pagination */ @JsonProperty("first") val first: String?, @JsonProperty("next") val next: String?, @JsonProperty("last") val last: String?, /* Relationships */ @JsonProperty("related") val related: String? ) data class KitsuResponse( @JsonProperty("links") val links: KitsuLinks?, @JsonProperty("data") val data: List, /* When requesting related info (User library entry -> anime) */ @JsonProperty("included") val included: List?, ) companion object { const val KITSU_CACHED_LIST: String = "kitsu_cached_list" private fun parseDateLong(string: String?): Long? { return try { SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()).parse( string ?: return null )?.time?.div(1000) } catch (e: Exception) { null } } private val kitsuStatusAsString = arrayOf("current", "completed", "on_hold", "dropped", "planned") private fun fromIntToAnimeStatus(inp: SyncWatchType): KitsuStatusType { return when (inp) { SyncWatchType.NONE -> KitsuStatusType.None SyncWatchType.WATCHING -> KitsuStatusType.Watching SyncWatchType.COMPLETED -> KitsuStatusType.Completed SyncWatchType.ONHOLD -> KitsuStatusType.OnHold SyncWatchType.DROPPED -> KitsuStatusType.Dropped SyncWatchType.PLANTOWATCH -> KitsuStatusType.PlanToWatch SyncWatchType.REWATCHING -> KitsuStatusType.Watching } } enum class KitsuStatusType(var value: Int, @StringRes val stringRes: Int) { Watching(0, R.string.type_watching), Completed(1, R.string.type_completed), OnHold(2, R.string.type_on_hold), Dropped(3, R.string.type_dropped), PlanToWatch(4, R.string.type_plan_to_watch), None(-1, R.string.type_none) } private fun convertToStatus(string: String): KitsuStatusType { return when (string) { "current" -> KitsuStatusType.Watching "completed" -> KitsuStatusType.Completed "on_hold" -> KitsuStatusType.OnHold "dropped" -> KitsuStatusType.Dropped "planned" -> KitsuStatusType.PlanToWatch else -> KitsuStatusType.None } } } } // modified code from from https://github.com/saikou-app/saikou/blob/main/app/src/main/java/ani/saikou/others/Kitsu.kt // GNU General Public License v3.0 https://github.com/saikou-app/saikou/blob/main/LICENSE.md object Kitsu { private suspend fun getKitsuData(query: String): KitsuResponse { val headers = mapOf( "Content-Type" to "application/json", "Accept" to "application/json", "Connection" to "keep-alive", "DNT" to "1", "Origin" to "https://kitsu.io" ) return app.post( "https://kitsu.io/api/graphql", headers = headers, data = mapOf("query" to query) ).parsed() } private val cache: MutableMap, Map> = mutableMapOf() var isEnabled = true suspend fun getEpisodesDetails( malId: String?, anilistId: String?, isResponseRequired: Boolean = true, // overrides isEnabled ): Map? { if (!isResponseRequired && !isEnabled) return null if (anilistId != null) { try { val map = getKitsuEpisodesDetails(anilistId, "ANILIST_ANIME") if (!map.isNullOrEmpty()) return map } catch (e: Exception) { logError(e) } } if (malId != null) { try { val map = getKitsuEpisodesDetails(malId, "MYANIMELIST_ANIME") if (!map.isNullOrEmpty()) return map } catch (e: Exception) { logError(e) } } return null } @Throws suspend fun getKitsuEpisodesDetails(id: String, site: String): Map? { require(id.isNotBlank()) { "Black id" } require(site.isNotBlank()) { "invalid site" } if (cache.containsKey(id to site)) { return cache[id to site] } val query = """ query { lookupMapping(externalId: $id, externalSite: $site) { __typename ... on Anime { id episodes(first: 2000) { nodes { number titles { canonical } description thumbnail { original { url } } } } } } }""" val result = getKitsuData(query) val map = (result.data?.lookupMapping?.episodes?.nodes ?: return null).mapNotNull { ep -> val num = ep?.num ?: return@mapNotNull null num to ep }.toMap() if (map.isNotEmpty()) { cache[id to site] = map } return map } data class KitsuResponse( val data: Data? = null ) { data class Data( val lookupMapping: LookupMapping? = null ) data class LookupMapping( val id: String? = null, val episodes: Episodes? = null ) data class Episodes( val nodes: List? = null ) data class Node( @JsonProperty("number") val num: Int? = null, val titles: Titles? = null, val description: Description? = null, val thumbnail: Thumbnail? = null ) data class Description( val en: String? = null ) data class Thumbnail( val original: Original? = null ) data class Original( val url: String? = null ) data class Titles( val canonical: String? = null ) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/LocalList.kt ================================================ package com.lagradost.cloudstream3.syncproviders.providers import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllFavorites import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState import com.lagradost.cloudstream3.utils.txt class LocalList : SyncAPI() { override val name = "Local" override val idPrefix = "local" override val icon: Int = R.drawable.ic_baseline_storage_24 override val requiresLogin = false override val createAccountUrl = null override var requireLibraryRefresh = true override val syncIdName = SyncIdName.LocalList override suspend fun library(auth : AuthData?): SyncAPI.LibraryMetadata? { val watchStatusIds = ioWork { getAllWatchStateIds()?.map { id -> Pair(id, getResultWatchState(id)) } }?.distinctBy { it.first } ?: return null val list = ioWork { val isTrueTv = isLayout(TV) val baseMap = WatchType.entries.filter { it != WatchType.NONE }.associate { // None is not something to display it.stringRes to emptyList() } + mapOf( R.string.favorites_list_name to emptyList() ) + if (!isTrueTv) { mapOf( R.string.subscription_list_name to emptyList() ) } else { emptyMap() } val watchStatusMap = watchStatusIds.groupBy { it.second.stringRes }.mapValues { group -> group.value.mapNotNull { getBookmarkedData(it.first)?.toLibraryItem(it.first.toString()) } } val favoritesMap = mapOf(R.string.favorites_list_name to getAllFavorites().mapNotNull { it.toLibraryItem() }) // Don't show subscriptions on TV val result = if (isTrueTv) { baseMap + watchStatusMap + favoritesMap } else { val subscriptionsMap = mapOf(R.string.subscription_list_name to getAllSubscriptions().mapNotNull { it.toLibraryItem() }) baseMap + watchStatusMap + subscriptionsMap + favoritesMap } result } return LibraryMetadata( list.map { LibraryList(txt(it.key), it.value) }, setOf( ListSorting.AlphabeticalA, ListSorting.AlphabeticalZ, ListSorting.UpdatedNew, ListSorting.UpdatedOld, ListSorting.ReleaseDateNew, ListSorting.ReleaseDateOld, // ListSorting.RatingHigh, // ListSorting.RatingLow, ) ) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt ================================================ package com.lagradost.cloudstream3.syncproviders.providers import androidx.annotation.StringRes import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.ShowStatus import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.AuthLoginPage import com.lagradost.cloudstream3.syncproviders.AuthToken import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject import com.lagradost.cloudstream3.utils.txt import java.text.SimpleDateFormat import java.time.Instant import java.time.format.DateTimeFormatter import java.util.Date import java.util.Locale /** max 100 via https://myanimelist.net/apiconfig/references/api/v2#tag/anime */ const val MAL_MAX_SEARCH_LIMIT = 25 class MALApi : SyncAPI() { override var name = "MAL" override val idPrefix = "mal" val key = "1714d6f2f4f7cc19644384f8c4629910" private val apiUrl = "https://api.myanimelist.net" override val hasOAuth2 = true override val redirectUrlIdentifier: String? = "mallogin" override val mainUrl = "https://myanimelist.net" override val icon = R.drawable.mal_logo override val syncIdName = SyncIdName.MyAnimeList override val createAccountUrl = "$mainUrl/register.php" override val supportedWatchTypes = setOf( SyncWatchType.WATCHING, SyncWatchType.COMPLETED, SyncWatchType.PLANTOWATCH, SyncWatchType.DROPPED, SyncWatchType.ONHOLD, SyncWatchType.NONE ) data class PayLoad( val requestId: Int, val codeVerifier: String ) override suspend fun login(redirectUrl: String, payload: String?): AuthToken? { val payloadData = parseJson(payload!!) val sanitizer = splitRedirectUrl(redirectUrl) val state = sanitizer["state"]!! if (state != "RequestID${payloadData.requestId}") { return null } val currentCode = sanitizer["code"]!! val token = app.post( "$mainUrl/v1/oauth2/token", data = mapOf( "client_id" to key, "code" to currentCode, "code_verifier" to payloadData.codeVerifier, "grant_type" to "authorization_code" ) ).parsed() return AuthToken( accessTokenLifetime = unixTime + token.expiresIn.toLong(), refreshToken = token.refreshToken, accessToken = token.accessToken ) } override suspend fun user(token: AuthToken?): AuthUser? { val user = app.get( "$apiUrl/v2/users/@me", headers = mapOf( "Authorization" to "Bearer ${token?.accessToken ?: return null}" ), cacheTime = 0 ).parsed() return AuthUser( id = user.id, name = user.name, profilePicture = user.picture ) } override suspend fun search(auth : AuthData?, query: String): List? { val auth = auth?.token?.accessToken ?: return null val url = "$apiUrl/v2/anime?q=$name&limit=$MAL_MAX_SEARCH_LIMIT" val res = app.get( url, headers = mapOf( "Authorization" to "Bearer $auth", ), cacheTime = 0 ).parsed() return res.data.map { val node = it.node SyncAPI.SyncSearchResult( node.title, this.name, node.id.toString(), "$mainUrl/anime/${node.id}/", node.mainPicture?.large ?: node.mainPicture?.medium ) } } override fun urlToId(url: String): String? = Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first() override suspend fun updateStatus( auth : AuthData?, id: String, newStatus: SyncAPI.AbstractSyncStatus ): Boolean { return setScoreRequest( auth?.token ?: return false, id.toIntOrNull() ?: return false, fromIntToAnimeStatus(newStatus.status), newStatus.score?.toInt(10), newStatus.watchedEpisodes ) } data class MalAnime( @JsonProperty("id") val id: Int?, @JsonProperty("title") val title: String?, @JsonProperty("main_picture") val mainPicture: MainPicture?, @JsonProperty("alternative_titles") val alternativeTitles: AlternativeTitles?, @JsonProperty("start_date") val startDate: String?, @JsonProperty("end_date") val endDate: String?, @JsonProperty("synopsis") val synopsis: String?, @JsonProperty("mean") val mean: Double?, @JsonProperty("rank") val rank: Int?, @JsonProperty("popularity") val popularity: Int?, @JsonProperty("num_list_users") val numListUsers: Int?, @JsonProperty("num_scoring_users") val numScoringUsers: Int?, @JsonProperty("nsfw") val nsfw: String?, @JsonProperty("created_at") val createdAt: String?, @JsonProperty("updated_at") val updatedAt: String?, @JsonProperty("media_type") val mediaType: String?, @JsonProperty("status") val status: String?, @JsonProperty("genres") val genres: ArrayList?, @JsonProperty("my_list_status") val myListStatus: MyListStatus?, @JsonProperty("num_episodes") val numEpisodes: Int?, @JsonProperty("start_season") val startSeason: StartSeason?, @JsonProperty("broadcast") val broadcast: Broadcast?, @JsonProperty("source") val source: String?, @JsonProperty("average_episode_duration") val averageEpisodeDuration: Int?, @JsonProperty("rating") val rating: String?, @JsonProperty("pictures") val pictures: ArrayList?, @JsonProperty("background") val background: String?, @JsonProperty("related_anime") val relatedAnime: ArrayList?, @JsonProperty("related_manga") val relatedManga: ArrayList?, @JsonProperty("recommendations") val recommendations: ArrayList?, @JsonProperty("studios") val studios: ArrayList?, @JsonProperty("statistics") val statistics: Statistics?, ) data class Recommendations( @JsonProperty("node") val node: Node? = null, @JsonProperty("num_recommendations") val numRecommendations: Int? = null ) data class Studios( @JsonProperty("id") val id: Int? = null, @JsonProperty("name") val name: String? = null ) data class MyListStatus( @JsonProperty("status") val status: String? = null, @JsonProperty("score") val score: Int? = null, @JsonProperty("num_episodes_watched") val numEpisodesWatched: Int? = null, @JsonProperty("is_rewatching") val isRewatching: Boolean? = null, @JsonProperty("updated_at") val updatedAt: String? = null ) data class RelatedAnime( @JsonProperty("node") val node: Node? = null, @JsonProperty("relation_type") val relationType: String? = null, @JsonProperty("relation_type_formatted") val relationTypeFormatted: String? = null ) data class Status( @JsonProperty("watching") val watching: String? = null, @JsonProperty("completed") val completed: String? = null, @JsonProperty("on_hold") val onHold: String? = null, @JsonProperty("dropped") val dropped: String? = null, @JsonProperty("plan_to_watch") val planToWatch: String? = null ) data class Statistics( @JsonProperty("status") val status: Status? = null, @JsonProperty("num_list_users") val numListUsers: Int? = null ) private fun parseDate(string: String?): Long? { return try { SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).parse(string ?: return null)?.time } catch (e: Exception) { null } } private fun toSearchResult(node: Node?): SyncAPI.SyncSearchResult? { return SyncAPI.SyncSearchResult( name = node?.title ?: return null, apiName = this.name, syncId = node.id.toString(), url = "$mainUrl/anime/${node.id}", posterUrl = node.mainPicture?.large ) } override suspend fun load(auth : AuthData?, id: String): SyncAPI.SyncResult? { val auth = auth?.token?.accessToken ?: return null val internalId = id.toIntOrNull() ?: return null val url = "$apiUrl/v2/anime/$internalId?fields=id,title,main_picture,alternative_titles,start_date,end_date,synopsis,mean,rank,popularity,num_list_users,num_scoring_users,nsfw,created_at,updated_at,media_type,status,genres,my_list_status,num_episodes,start_season,broadcast,source,average_episode_duration,rating,pictures,background,related_anime,related_manga,recommendations,studios,statistics" val res = app.get( url, headers = mapOf( "Authorization" to "Bearer $auth" ) ).text return parseJson(res).let { malAnime -> SyncAPI.SyncResult( id = internalId.toString(), totalEpisodes = malAnime.numEpisodes, title = malAnime.title, publicScore = Score.from10(malAnime.mean), duration = malAnime.averageEpisodeDuration, synopsis = malAnime.synopsis, airStatus = when (malAnime.status) { "finished_airing" -> ShowStatus.Completed "currently_airing" -> ShowStatus.Ongoing //"not_yet_aired" else -> null }, nextAiring = null, studio = malAnime.studios?.mapNotNull { it.name }, genres = malAnime.genres?.map { it.name }, trailers = null, startDate = parseDate(malAnime.startDate), endDate = parseDate(malAnime.endDate), recommendations = malAnime.recommendations?.mapNotNull { rec -> val node = rec.node ?: return@mapNotNull null toSearchResult(node) }, nextSeason = malAnime.relatedAnime?.firstOrNull { return@firstOrNull it.relationType == "sequel" }?.let { toSearchResult(it.node) }, prevSeason = malAnime.relatedAnime?.firstOrNull { return@firstOrNull it.relationType == "prequel" }?.let { toSearchResult(it.node) }, actors = null, ) } } override suspend fun status(auth : AuthData?, id: String): SyncAPI.AbstractSyncStatus? { val auth = auth?.token?.accessToken ?: return null // https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get val url = "$apiUrl/v2/anime/$id?fields=id,title,num_episodes,my_list_status" val data = app.get( url, headers = mapOf( "Authorization" to "Bearer $auth" ), cacheTime = 0 ).parsed().myListStatus return SyncAPI.SyncStatus( score = Score.from10(data?.score), status = SyncWatchType.fromInternalId(malStatusAsString.indexOf(data?.status)), isFavorite = null, watchedEpisodes = data?.numEpisodesWatched, ) } companion object { private val malStatusAsString = arrayOf("watching", "completed", "on_hold", "dropped", "plan_to_watch") const val MAL_CACHED_LIST: String = "mal_cached_list" fun convertToStatus(string: String): MalStatusType { return when (string) { "watching" -> MalStatusType.Watching "completed" -> MalStatusType.Completed "on_hold" -> MalStatusType.OnHold "dropped" -> MalStatusType.Dropped "plan_to_watch" -> MalStatusType.PlanToWatch else -> MalStatusType.None } } enum class MalStatusType(var value: Int, @StringRes val stringRes: Int) { Watching(0, R.string.type_watching), Completed(1, R.string.type_completed), OnHold(2, R.string.type_on_hold), Dropped(3, R.string.type_dropped), PlanToWatch(4, R.string.type_plan_to_watch), None(-1, R.string.type_none) } private fun fromIntToAnimeStatus(inp: SyncWatchType): MalStatusType {//= AniListStatusType.values().first { it.value == inp } return when (inp) { SyncWatchType.NONE -> MalStatusType.None SyncWatchType.WATCHING -> MalStatusType.Watching SyncWatchType.COMPLETED -> MalStatusType.Completed SyncWatchType.ONHOLD -> MalStatusType.OnHold SyncWatchType.DROPPED -> MalStatusType.Dropped SyncWatchType.PLANTOWATCH -> MalStatusType.PlanToWatch SyncWatchType.REWATCHING -> MalStatusType.Watching } } private fun parseDateLong(string: String?): Long? { return try { SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.getDefault()).parse( string ?: return null )?.time?.div(1000) } catch (e: Exception) { null } } } override fun loginRequest(): AuthLoginPage? { val codeVerifier = generateCodeVerifier() val requestId = ++requestIdCounter val codeChallenge = codeVerifier val request = "$mainUrl/v1/oauth2/authorize?response_type=code&client_id=$key&code_challenge=$codeChallenge&state=RequestID$requestId" return AuthLoginPage( url = request, payload = PayLoad(requestId, codeVerifier).toJson() ) } override suspend fun refreshToken(token: AuthToken): AuthToken? { val res = app.post( "$mainUrl/v1/oauth2/token", data = mapOf( "client_id" to key, "grant_type" to "refresh_token", "refresh_token" to token.refreshToken!! ) ).parsed() return AuthToken( accessToken = res.accessToken, refreshToken = res.refreshToken, accessTokenLifetime = unixTime + res.expiresIn.toLong() ) } private var requestIdCounter = 0 private val allTitles = hashMapOf() data class MalList( @JsonProperty("data") val data: List, @JsonProperty("paging") val paging: Paging ) data class MainPicture( @JsonProperty("medium") val medium: String, @JsonProperty("large") val large: String ) data class Node( @JsonProperty("id") val id: Int, @JsonProperty("title") val title: String, @JsonProperty("main_picture") val mainPicture: MainPicture?, @JsonProperty("alternative_titles") val alternativeTitles: AlternativeTitles?, @JsonProperty("media_type") val mediaType: String?, @JsonProperty("num_episodes") val numEpisodes: Int?, @JsonProperty("status") val status: String?, @JsonProperty("start_date") val startDate: String?, @JsonProperty("end_date") val endDate: String?, @JsonProperty("average_episode_duration") val averageEpisodeDuration: Int?, @JsonProperty("synopsis") val synopsis: String?, @JsonProperty("mean") val mean: Double?, @JsonProperty("genres") val genres: List?, @JsonProperty("rank") val rank: Int?, @JsonProperty("popularity") val popularity: Int?, @JsonProperty("num_list_users") val numListUsers: Int?, @JsonProperty("num_favorites") val numFavorites: Int?, @JsonProperty("num_scoring_users") val numScoringUsers: Int?, @JsonProperty("start_season") val startSeason: StartSeason?, @JsonProperty("broadcast") val broadcast: Broadcast?, @JsonProperty("nsfw") val nsfw: String?, @JsonProperty("created_at") val createdAt: String?, @JsonProperty("updated_at") val updatedAt: String? ) data class ListStatus( @JsonProperty("status") val status: String?, @JsonProperty("score") val score: Int, @JsonProperty("num_episodes_watched") val numEpisodesWatched: Int, @JsonProperty("is_rewatching") val isRewatching: Boolean, @JsonProperty("updated_at") val updatedAt: String, ) data class Data( @JsonProperty("node") val node: Node, @JsonProperty("list_status") val listStatus: ListStatus?, ) { fun toLibraryItem(): SyncAPI.LibraryItem { return SyncAPI.LibraryItem( this.node.title, "https://myanimelist.net/anime/${this.node.id}/", this.node.id.toString(), this.listStatus?.numEpisodesWatched, this.node.numEpisodes, Score.from10(this.listStatus?.score), parseDateLong(this.listStatus?.updatedAt), "MAL", TvType.Anime, this.node.mainPicture?.large ?: this.node.mainPicture?.medium, null, null, plot = this.node.synopsis, releaseDate = if (this.node.startDate == null) null else try { Date.from( Instant.from( DateTimeFormatter.ofPattern(if (this.node.startDate.length == 4) "yyyy" else if (this.node.startDate.length == 7) "yyyy-MM" else "yyyy-MM-dd") .parse(this.node.startDate) ) ) } catch (_: RuntimeException) { null } ) } } data class Paging( @JsonProperty("next") val next: String? ) data class AlternativeTitles( @JsonProperty("synonyms") val synonyms: List, @JsonProperty("en") val en: String, @JsonProperty("ja") val ja: String ) data class Genres( @JsonProperty("id") val id: Int, @JsonProperty("name") val name: String ) data class StartSeason( @JsonProperty("year") val year: Int, @JsonProperty("season") val season: String ) data class Broadcast( @JsonProperty("day_of_the_week") val dayOfTheWeek: String?, @JsonProperty("start_time") val startTime: String? ) override suspend fun library(auth : AuthData?): LibraryMetadata? { val list = getMalAnimeListSmart(auth ?: return null)?.groupBy { convertToStatus(it.listStatus?.status ?: "").stringRes }?.mapValues { group -> group.value.map { it.toLibraryItem() } } ?: emptyMap() // To fill empty lists when MAL does not return them val baseMap = MalStatusType.entries.filter { it.value >= 0 }.associate { it.stringRes to emptyList() } return SyncAPI.LibraryMetadata( (baseMap + list).map { SyncAPI.LibraryList(txt(it.key), it.value) }, setOf( ListSorting.AlphabeticalA, ListSorting.AlphabeticalZ, ListSorting.UpdatedNew, ListSorting.UpdatedOld, ListSorting.ReleaseDateNew, ListSorting.ReleaseDateOld, ListSorting.RatingHigh, ListSorting.RatingLow, ) ) } private suspend fun getMalAnimeListSmart(auth : AuthData): Array? { return if (requireLibraryRefresh) { val list = getMalAnimeList(auth.token) setKey(MAL_CACHED_LIST, auth.user.id.toString(), list) list } else { getKey>(MAL_CACHED_LIST, auth.user.id.toString()) as? Array } } private suspend fun getMalAnimeList(token: AuthToken): Array { var offset = 0 val fullList = mutableListOf() val offsetRegex = Regex("""offset=(\d+)""") while (true) { val data: MalList = getMalAnimeListSlice(token, offset) ?: break fullList.addAll(data.data) offset = data.paging.next?.let { offsetRegex.find(it)?.groupValues?.get(1)?.toInt() } ?: break } return fullList.toTypedArray() } private suspend fun getMalAnimeListSlice(token: AuthToken, offset: Int = 0): MalList? { val user = "@me" // Very lackluster docs // https://myanimelist.net/apiconfig/references/api/v2#operation/users_user_id_animelist_get val url = "$apiUrl/v2/users/$user/animelist?fields=list_status,num_episodes,media_type,status,start_date,end_date,synopsis,alternative_titles,mean,genres,rank,num_list_users,nsfw,average_episode_duration,num_favorites,popularity,num_scoring_users,start_season,favorites_info,broadcast,created_at,updated_at&nsfw=1&limit=100&offset=$offset" val res = app.get( url, headers = mapOf( "Authorization" to "Bearer ${token.accessToken}", ), cacheTime = 0 ).text return res.toKotlinObject() } private suspend fun setScoreRequest( token: AuthToken, id: Int, status: MalStatusType? = null, score: Int? = null, numWatchedEpisodes: Int? = null, ): Boolean { val res = setScoreRequest( token, id, if (status == null) null else malStatusAsString[maxOf(0, status.value)], score, numWatchedEpisodes ) return if (res.isNullOrBlank()) { false } else { val malStatus = parseJson(res) if (allTitles.containsKey(id)) { val currentTitle = allTitles[id]!! allTitles[id] = MalTitleHolder(malStatus, id, currentTitle.name) } else { allTitles[id] = MalTitleHolder(malStatus, id, "") } true } } @Suppress("UNCHECKED_CAST") private suspend fun setScoreRequest( token: AuthToken, id: Int, status: String? = null, score: Int? = null, numWatchedEpisodes: Int? = null, ): String? { val data = mapOf( "status" to status, "score" to score?.toString(), "num_watched_episodes" to numWatchedEpisodes?.toString() ).filterValues { it != null } as Map return app.put( "$apiUrl/v2/anime/$id/my_list_status", headers = mapOf( "Authorization" to "Bearer ${token.accessToken}" ), data = data ).text } data class ResponseToken( @JsonProperty("token_type") val tokenType: String, @JsonProperty("expires_in") val expiresIn: Int, @JsonProperty("access_token") val accessToken: String, @JsonProperty("refresh_token") val refreshToken: String, ) data class MalRoot( @JsonProperty("data") val data: List, ) data class MalDatum( @JsonProperty("node") val node: MalNode, @JsonProperty("list_status") val listStatus: MalStatus, ) data class MalNode( @JsonProperty("id") val id: Int, @JsonProperty("title") val title: String, /* also, but not used main_picture -> public string medium; public string large; */ ) data class MalStatus( @JsonProperty("status") val status: String, @JsonProperty("score") val score: Int, @JsonProperty("num_episodes_watched") val numEpisodesWatched: Int, @JsonProperty("is_rewatching") val isRewatching: Boolean, @JsonProperty("updated_at") val updatedAt: String, ) data class MalUser( @JsonProperty("id") val id: Int, @JsonProperty("name") val name: String, @JsonProperty("location") val location: String, @JsonProperty("joined_at") val joinedAt: String, @JsonProperty("picture") val picture: String?, ) data class MalMainPicture( @JsonProperty("large") val large: String?, @JsonProperty("medium") val medium: String?, ) // Used for getDataAboutId() data class SmallMalAnime( @JsonProperty("id") val id: Int, @JsonProperty("title") val title: String?, @JsonProperty("num_episodes") val numEpisodes: Int, @JsonProperty("my_list_status") val myListStatus: MalStatus?, @JsonProperty("main_picture") val mainPicture: MalMainPicture?, ) data class MalSearchNode( @JsonProperty("node") val node: Node, ) data class MalSearch( @JsonProperty("data") val data: List, //paging ) data class MalTitleHolder( val status: MalStatus, val id: Int, val name: String, ) } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/OpenSubtitlesApi.kt ================================================ package com.lagradost.cloudstream3.syncproviders.providers import android.util.Log import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse import com.lagradost.cloudstream3.syncproviders.AuthToken import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.SubtitleAPI import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.utils.AppUtils import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToLangTagIETF import com.lagradost.cloudstream3.utils.SubtitleHelper.fromCodeToOpenSubtitlesTag class OpenSubtitlesApi : SubtitleAPI() { override val name = "OpenSubtitles" override val idPrefix = "opensubtitles" override val icon = R.drawable.open_subtitles_icon override val hasInApp = true override val inAppLoginRequirement = AuthLoginRequirement( password = true, username = true, ) override val createAccountUrl = "https://www.opensubtitles.com/en/users/sign_up" companion object { const val API_KEY = "uyBLgFD17MgrYmA0gSXoKllMJBelOYj2" const val HOST = "https://api.opensubtitles.com/api/v1" const val TAG = "OPENSUBS" const val COOLDOWN_DURATION: Long = 1000L * 30L // CoolDown if 429 error code in ms var currentCoolDown: Long = 0L const val userAgent = "Cloudstream3 v0.2" val headers = mapOf("user-agent" to userAgent, "Api-Key" to API_KEY) } private fun canDoRequest(): Boolean { return unixTimeMs > currentCoolDown } private fun throwIfCantDoRequest() { if (!canDoRequest()) { throw ErrorLoadingException("Too many requests wait for ${(currentCoolDown - unixTimeMs) / 1000L}s") } } private fun throwGotTooManyRequests() { currentCoolDown = unixTimeMs + COOLDOWN_DURATION throw ErrorLoadingException("Too many requests") } override suspend fun refreshToken(token: AuthToken): AuthToken? { return login(parseJson(token.payload ?: return null)) } override suspend fun user(token: AuthToken?): AuthUser? { val user = parseJson(token?.payload ?: return null) val username = user.username ?: return null return AuthUser( id = username.hashCode(), name = username ) } override suspend fun login(form: AuthLoginResponse): AuthToken? { val username = form.username ?: return null val password = form.password ?: return null val response = app.post( url = "$HOST/login", headers = mapOf( "Content-Type" to "application/json", ) + headers, json = mapOf( "username" to username, "password" to password ), ).parsed() return AuthToken( accessToken = response.token ?: throw ErrorLoadingException("Invalid password or username"), /// JWT token is valid 24 hours after successfully authentication of user accessTokenLifetime = unixTime + 60 * 60 * 24, payload = form.toJson() ) } /** * Fetch subtitles using token authenticated on previous method (see authorize). * Returns list of Subtitles which user can select to download (see load). * */ override suspend fun search( auth : AuthData?, query: AbstractSubtitleEntities.SubtitleSearch ): List? { throwIfCantDoRequest() val langOpenSubTag = fromCodeToOpenSubtitlesTag(query.lang) ?: query.lang ?: "" val imdbId = query.imdbId?.replace("tt", "")?.toInt() ?: 0 val queryText = query.query val epNum = query.epNumber ?: 0 val seasonNum = query.seasonNumber ?: 0 val yearNum = query.year ?: 0 val epQuery = if (epNum > 0) "&episode_number=$epNum" else "" val seasonQuery = if (seasonNum > 0) "&season_number=$seasonNum" else "" val yearQuery = if (yearNum > 0) "&year=$yearNum" else "" val searchQueryUrl = when (imdbId > 0) { //Use imdb_id to search if its valid true -> "$HOST/subtitles?imdb_id=$imdbId&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery" false -> "$HOST/subtitles?query=${queryText}&languages=${langOpenSubTag}$yearQuery$epQuery$seasonQuery" } val req = app.get( url = searchQueryUrl, headers = mapOf( Pair("Content-Type", "application/json") ) + headers, ) Log.i(TAG, "searchQueryUrl => ${searchQueryUrl}") Log.i(TAG, "Search Req => ${req.text}") if (!req.isSuccessful) { if (req.code == 429) throwGotTooManyRequests() return null } val results = mutableListOf() AppUtils.tryParseJson(req.text)?.let { it.data?.forEach { item -> val attr = item.attributes ?: return@forEach val featureDetails = attr.featDetails //Use filename as name, if its valid val filename = attr.files?.firstNotNullOfOrNull { subfile -> subfile.fileName } //Use any valid name/title in hierarchy val name = filename ?: featureDetails?.movieName ?: featureDetails?.title ?: featureDetails?.parentTitle ?: attr.release ?: query.query val langTagIETF = fromCodeToLangTagIETF(attr.language) ?: "" val resEpNum = featureDetails?.episodeNumber ?: query.epNumber val resSeasonNum = featureDetails?.seasonNumber ?: query.seasonNumber val year = featureDetails?.year ?: query.year val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie val isHearingImpaired = attr.hearingImpaired ?: false //Log.i(TAG, "Result id/name => ${item.id} / $name") item.attributes?.files?.forEach { file -> val resultData = file.fileId?.toString() ?: "" //Log.i(TAG, "Result file => ${file.fileId} / ${file.fileName}") results.add( AbstractSubtitleEntities.SubtitleEntity( idPrefix = this.idPrefix, name = name, lang = langTagIETF, data = resultData, type = type, source = this.name, epNumber = resEpNum, seasonNumber = resSeasonNum, year = year, isHearingImpaired = isHearingImpaired ) ) } } } return results } /* Process data returned from search. Returns string url for the subtitle file. */ override suspend fun load( auth : AuthData?, subtitle: AbstractSubtitleEntities.SubtitleEntity ): String? { if(auth == null) return null throwIfCantDoRequest() val req = app.post( url = "$HOST/download", headers = mapOf( Pair( "Authorization", "Bearer ${auth.token.accessToken ?: throw ErrorLoadingException("No access token active in current session")}" ), Pair("Content-Type", "application/json"), Pair("Accept", "*/*") ) + headers, data = mapOf( Pair("file_id", subtitle.data) ) ) Log.i(TAG, "Request result => (${req.code}) ${req.text}") //Log.i(TAG, "Request headers => ${req.headers}") if (req.isSuccessful) { AppUtils.tryParseJson(req.text)?.let { val link = it.link ?: "" Log.i(TAG, "Request load link => $link") return link } } else { if (req.code == 429) throwGotTooManyRequests() } return null } data class OAuthToken( @JsonProperty("token") var token: String? = null, @JsonProperty("status") var status: Int? = null ) data class Results( @JsonProperty("data") var data: List? = listOf() ) data class ResultData( @JsonProperty("id") var id: String? = null, @JsonProperty("type") var type: String? = null, @JsonProperty("attributes") var attributes: ResultAttributes? = ResultAttributes() ) data class ResultAttributes( @JsonProperty("subtitle_id") var subtitleId: String? = null, @JsonProperty("language") var language: String? = null, @JsonProperty("release") var release: String? = null, @JsonProperty("url") var url: String? = null, @JsonProperty("files") var files: List? = listOf(), @JsonProperty("feature_details") var featDetails: ResultFeatureDetails? = ResultFeatureDetails(), @JsonProperty("hearing_impaired") var hearingImpaired: Boolean? = null, ) data class ResultFiles( @JsonProperty("file_id") var fileId: Int? = null, @JsonProperty("file_name") var fileName: String? = null ) data class ResultDownloadLink( @JsonProperty("link") var link: String? = null, @JsonProperty("file_name") var fileName: String? = null, @JsonProperty("requests") var requests: Int? = null, @JsonProperty("remaining") var remaining: Int? = null, @JsonProperty("message") var message: String? = null, @JsonProperty("reset_time") var resetTime: String? = null, @JsonProperty("reset_time_utc") var resetTimeUtc: String? = null ) data class ResultFeatureDetails( @JsonProperty("year") var year: Int? = null, @JsonProperty("title") var title: String? = null, @JsonProperty("movie_name") var movieName: String? = null, @JsonProperty("imdb_id") var imdbId: Int? = null, @JsonProperty("tmdb_id") var tmdbId: Int? = null, @JsonProperty("season_number") var seasonNumber: Int? = null, @JsonProperty("episode_number") var episodeNumber: Int? = null, @JsonProperty("parent_imdb_id") var parentImdbId: Int? = null, @JsonProperty("parent_title") var parentTitle: String? = null, @JsonProperty("parent_tmdb_id") var parentTmdbId: Int? = null, @JsonProperty("parent_feature_id") var parentFeatureId: Int? = null ) } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SimklApi.kt ================================================ package com.lagradost.cloudstream3.syncproviders.providers import androidx.annotation.StringRes import androidx.core.net.toUri import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SimklSyncServices import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mapper import com.lagradost.cloudstream3.mvvm.debugPrint import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.AuthLoginPage import com.lagradost.cloudstream3.syncproviders.AuthPinData import com.lagradost.cloudstream3.syncproviders.AuthToken import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.DataStoreHelper.toYear import com.lagradost.cloudstream3.utils.txt import java.math.BigInteger import java.security.SecureRandom import java.text.SimpleDateFormat import java.time.Instant import java.util.Date import java.util.Locale import java.util.TimeZone import kotlin.time.Duration import kotlin.time.DurationUnit import kotlin.time.toDuration class SimklApi : SyncAPI() { override var name = "Simkl" override val idPrefix = "simkl" val key = "simkl-key" override val redirectUrlIdentifier = "simkl" override val hasOAuth2 = true override val hasPin = true override var requireLibraryRefresh = true override var mainUrl = "https://api.simkl.com" override val icon = R.drawable.simkl_logo override val createAccountUrl = "$mainUrl/signup" override val syncIdName = SyncIdName.Simkl /** Automatically adds simkl auth headers */ // private val interceptor = HeaderInterceptor() /** * This is required to override the reported last activity as simkl activites * may not always update based on testing. */ private var lastScoreTime = -1L private object SimklCache { private const val SIMKL_CACHE_KEY = "SIMKL_API_CACHE" enum class CacheTimes(val value: String) { OneMonth("30d"), ThirtyMinutes("30m") } private class SimklCacheWrapper( @JsonProperty("obj") val obj: T?, @JsonProperty("validUntil") val validUntil: Long, @JsonProperty("cacheTime") val cacheTime: Long = unixTime, ) { /** Returns true if cache is newer than cacheDays */ fun isFresh(): Boolean { return validUntil > unixTime } fun remainingTime(): Duration { val unixTime = unixTime return if (validUntil > unixTime) { (validUntil - unixTime).toDuration(DurationUnit.SECONDS) } else { Duration.ZERO } } } fun cleanOldCache() { getKeys(SIMKL_CACHE_KEY)?.forEach { val isOld = CloudStreamApp.getKey>(it)?.isFresh() == false if (isOld) { removeKey(it) } } } fun setKey(path: String, value: T, cacheTime: Duration) { debugPrint { "Set cache: $SIMKL_CACHE_KEY/$path for ${cacheTime.inWholeDays} days or ${cacheTime.inWholeSeconds} seconds." } setKey( SIMKL_CACHE_KEY, path, // Storing as plain sting is required to make generics work. SimklCacheWrapper(value, unixTime + cacheTime.inWholeSeconds).toJson() ) } /** * Gets cached object, if object is not fresh returns null and removes it from cache */ inline fun getKey(path: String): T? { // Required for generic otherwise "LinkedHashMap cannot be cast to MediaObject" val type = mapper.typeFactory.constructParametricType( SimklCacheWrapper::class.java, T::class.java ) val cache = getKey(SIMKL_CACHE_KEY, path)?.let { mapper.readValue>(it, type) } return if (cache?.isFresh() == true) { debugPrint { "Cache hit at: $SIMKL_CACHE_KEY/$path. " + "Remains fresh for ${cache.remainingTime().inWholeDays} days or ${cache.remainingTime().inWholeSeconds} seconds." } cache.obj } else { debugPrint { "Cache miss at: $SIMKL_CACHE_KEY/$path" } removeKey(SIMKL_CACHE_KEY, path) null } } } companion object { private const val CLIENT_ID: String = BuildConfig.SIMKL_CLIENT_ID private const val CLIENT_SECRET: String = BuildConfig.SIMKL_CLIENT_SECRET const val SIMKL_CACHED_LIST: String = "simkl_cached_list" const val SIMKL_CACHED_LIST_TIME: String = "simkl_cached_time" /** 2014-09-01T09:10:11Z -> 1409562611 */ private const val SIMKL_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss'Z'" fun getUnixTime(string: String?): Long? { return try { SimpleDateFormat(SIMKL_DATE_FORMAT, Locale.getDefault()).apply { this.timeZone = TimeZone.getTimeZone("UTC") }.parse( string ?: return null )?.toInstant()?.epochSecond } catch (e: Exception) { logError(e) return null } } /** 1409562611 -> 2014-09-01T09:10:11Z */ fun getDateTime(unixTime: Long?): String? { return try { SimpleDateFormat(SIMKL_DATE_FORMAT, Locale.getDefault()).apply { this.timeZone = TimeZone.getTimeZone("UTC") }.format( Date.from( Instant.ofEpochSecond( unixTime ?: return null ) ) ) } catch (e: Exception) { null } } fun getPosterUrl(poster: String): String { return "https://wsrv.nl/?url=https://simkl.in/posters/${poster}_m.webp" } private fun getUrlFromId(id: Int): String { return "https://simkl.com/shows/$id" } enum class SimklListStatusType( var value: Int, @StringRes val stringRes: Int, val originalName: String? ) { Watching(0, R.string.type_watching, "watching"), Completed(1, R.string.type_completed, "completed"), Paused(2, R.string.type_on_hold, "hold"), Dropped(3, R.string.type_dropped, "dropped"), Planning(4, R.string.type_plan_to_watch, "plantowatch"), ReWatching(5, R.string.type_re_watching, "watching"), None(-1, R.string.none, null); companion object { fun fromString(string: String): SimklListStatusType? { return SimklListStatusType.entries.firstOrNull { it.originalName == string } } } } // ------------------- @JsonInclude(JsonInclude.Include.NON_EMPTY) data class TokenRequest( @JsonProperty("code") val code: String, @JsonProperty("client_id") val clientId: String = CLIENT_ID, @JsonProperty("client_secret") val clientSecret: String = CLIENT_SECRET, @JsonProperty("redirect_uri") val redirectUri: String = "$APP_STRING://simkl", @JsonProperty("grant_type") val grantType: String = "authorization_code" ) data class TokenResponse( /** No expiration date */ @JsonProperty("access_token") val accessToken: String, @JsonProperty("token_type") val tokenType: String, @JsonProperty("scope") val scope: String ) // ------------------- /** https://simkl.docs.apiary.io/#reference/users/settings/receive-settings */ data class SettingsResponse( @JsonProperty("user") val user: User, @JsonProperty("account") val account: Account, ) { data class User( @JsonProperty("name") val name: String, /** Url */ @JsonProperty("avatar") val avatar: String ) data class Account( @JsonProperty("id") val id: Int, ) } data class PinAuthResponse( @JsonProperty("result") val result: String, @JsonProperty("device_code") val deviceCode: String, @JsonProperty("user_code") val userCode: String, @JsonProperty("verification_url") val verificationUrl: String, @JsonProperty("expires_in") val expiresIn: Int, @JsonProperty("interval") val interval: Int, ) data class PinExchangeResponse( @JsonProperty("result") val result: String, @JsonProperty("message") val message: String? = null, @JsonProperty("access_token") val accessToken: String? = null, ) // ------------------- data class ActivitiesResponse( @JsonProperty("all") val all: String?, @JsonProperty("tv_shows") val tvShows: UpdatedAt, @JsonProperty("anime") val anime: UpdatedAt, @JsonProperty("movies") val movies: UpdatedAt, ) { data class UpdatedAt( @JsonProperty("all") val all: String?, @JsonProperty("removed_from_list") val removedFromList: String?, @JsonProperty("rated_at") val ratedAt: String?, ) } /** https://simkl.docs.apiary.io/#reference/tv/episodes/get-tv-show-episodes */ @JsonInclude(JsonInclude.Include.NON_EMPTY) data class EpisodeMetadata( @JsonProperty("title") val title: String?, @JsonProperty("description") val description: String?, @JsonProperty("season") val season: Int?, @JsonProperty("episode") val episode: Int, @JsonProperty("img") val img: String? ) { companion object { fun convertToEpisodes(list: List?): List? { return list?.map { MediaObject.Season.Episode(it.episode) } } fun convertToSeasons(list: List?): List? { return list?.filter { it.season != null }?.groupBy { it.season }?.mapNotNull { (season, episodes) -> convertToEpisodes(episodes)?.let { MediaObject.Season(season!!, it) } }?.ifEmpty { null } } } } /** * https://simkl.docs.apiary.io/#introduction/about-simkl-api/standard-media-objects * Useful for finding shows from metadata */ @JsonInclude(JsonInclude.Include.NON_EMPTY) open class MediaObject( @JsonProperty("title") val title: String?, @JsonProperty("year") val year: Int?, @JsonProperty("ids") val ids: Ids?, @JsonProperty("total_episodes") val totalEpisodes: Int? = null, @JsonProperty("status") val status: String? = null, @JsonProperty("poster") val poster: String? = null, @JsonProperty("type") val type: String? = null, @JsonProperty("seasons") val seasons: List? = null, @JsonProperty("episodes") val episodes: List? = null ) { fun hasEnded(): Boolean { return status == "released" || status == "ended" } @JsonInclude(JsonInclude.Include.NON_EMPTY) data class Season( @JsonProperty("number") val number: Int, @JsonProperty("episodes") val episodes: List ) { data class Episode(@JsonProperty("number") val number: Int) } @JsonInclude(JsonInclude.Include.NON_EMPTY) data class Ids( @JsonProperty("simkl") val simkl: Int?, @JsonProperty("imdb") val imdb: String? = null, @JsonProperty("tmdb") val tmdb: String? = null, @JsonProperty("mal") val mal: String? = null, @JsonProperty("anilist") val anilist: String? = null, ) { companion object { fun fromMap(map: Map): Ids { return Ids( simkl = map[SimklSyncServices.Simkl]?.toIntOrNull(), imdb = map[SimklSyncServices.Imdb], tmdb = map[SimklSyncServices.Tmdb], mal = map[SimklSyncServices.Mal], anilist = map[SimklSyncServices.AniList] ) } } } fun toSyncSearchResult(): SyncAPI.SyncSearchResult? { return SyncAPI.SyncSearchResult( this.title ?: return null, "Simkl", this.ids?.simkl?.toString() ?: return null, getUrlFromId(this.ids.simkl), this.poster?.let { getPosterUrl(it) }, if (this.type == "movie") TvType.Movie else TvType.TvSeries ) } } class SimklScoreBuilder private constructor() { data class Builder( private var url: String? = null, private var headers: Map? = null, private var ids: MediaObject.Ids? = null, private var score: Int? = null, private var status: Int? = null, private var addEpisodes: Pair?, List?>? = null, private var removeEpisodes: Pair?, List?>? = null, // Required for knowing if the status should be overwritten private var onList: Boolean = false ) { fun token(token: AuthToken) = apply { this.headers = getHeaders(token) } fun apiUrl(url: String) = apply { this.url = url } fun ids(ids: MediaObject.Ids) = apply { this.ids = ids } fun score(score: Int?, oldScore: Int?) = apply { if (score != oldScore) { this.score = score } } fun status(newStatus: Int?, oldStatus: Int?) = apply { onList = oldStatus != null // Only set status if its new if (newStatus != oldStatus) { this.status = newStatus } else { this.status = null } } fun episodes( allEpisodes: List?, newEpisodes: Int?, oldEpisodes: Int?, ) = apply { if (allEpisodes == null || newEpisodes == null) return@apply fun getEpisodes(rawEpisodes: List) = if (rawEpisodes.any { it.season != null }) { EpisodeMetadata.convertToSeasons(rawEpisodes) to null } else { null to EpisodeMetadata.convertToEpisodes(rawEpisodes) } // Do not add episodes if there is no change if (newEpisodes > (oldEpisodes ?: 0)) { this.addEpisodes = getEpisodes(allEpisodes.take(newEpisodes)) // Set to watching if episodes are added and there is no current status if (!onList) { status = SimklListStatusType.Watching.value } } if ((oldEpisodes ?: 0) > newEpisodes) { this.removeEpisodes = getEpisodes(allEpisodes.drop(newEpisodes)) } } suspend fun execute(): Boolean { val time = getDateTime(unixTime) val headers = this.headers ?: emptyMap() return if (this.status == SimklListStatusType.None.value) { app.post( "$url/sync/history/remove", json = StatusRequest( shows = listOf(HistoryMediaObject(ids = ids)), movies = emptyList() ), headers = headers ).isSuccessful } else { val statusResponse = this.status?.let { setStatus -> val newStatus = SimklListStatusType.entries .firstOrNull { it.value == setStatus }?.originalName ?: SimklListStatusType.Watching.originalName!! app.post( "${this.url}/sync/add-to-list", json = StatusRequest( shows = listOf( StatusMediaObject( null, null, ids, newStatus, ) ), movies = emptyList() ), headers = headers ).isSuccessful } ?: true val episodeRemovalResponse = removeEpisodes?.let { (seasons, episodes) -> app.post( "${this.url}/sync/history/remove", json = StatusRequest( shows = listOf( HistoryMediaObject( ids = ids, seasons = seasons, episodes = episodes ) ), movies = emptyList() ), headers = headers ).isSuccessful } ?: true // You cannot rate if you are planning to watch it. val shouldRate = score != null && status != SimklListStatusType.Planning.value val realScore = if (shouldRate) score else null val historyResponse = // Only post if there are episodes or score to upload if (addEpisodes != null || shouldRate) { app.post( "${this.url}/sync/history", json = StatusRequest( shows = listOf( HistoryMediaObject( null, null, ids, addEpisodes?.first, addEpisodes?.second, realScore, realScore?.let { time }, ) ), movies = emptyList() ), headers = headers ).isSuccessful } else { true } statusResponse && episodeRemovalResponse && historyResponse } } } } fun getHeaders(token: AuthToken): Map = mapOf("Authorization" to "Bearer ${token.accessToken}", "simkl-api-key" to CLIENT_ID) suspend fun getEpisodes( simklId: Int?, type: String?, episodes: Int?, hasEnded: Boolean? ): Array? { if (simklId == null) return null val cacheKey = "Episodes/$simklId" val cache = SimklCache.getKey>(cacheKey) // Return cached result if its higher or equal the amount of episodes. if (cache != null && cache.size >= (episodes ?: 0)) { return cache } // There is always one season in Anime -> no request necessary if (type == "anime" && episodes != null) { return episodes.takeIf { it > 0 }?.let { (1..it).map { episode -> EpisodeMetadata( null, null, null, episode, null ) }.toTypedArray() } } val url = when (type) { "anime" -> "https://api.simkl.com/anime/episodes/$simklId" "tv" -> "https://api.simkl.com/tv/episodes/$simklId" "movie" -> return null else -> return null } debugPrint { "Requesting episodes from $url" } return app.get(url, params = mapOf("client_id" to CLIENT_ID)) .parsedSafe>()?.also { val cacheTime = if (hasEnded == true) SimklCache.CacheTimes.OneMonth.value else SimklCache.CacheTimes.ThirtyMinutes.value // 1 Month cache SimklCache.setKey(cacheKey, it, Duration.parse(cacheTime)) } } @JsonInclude(JsonInclude.Include.NON_EMPTY) class HistoryMediaObject( @JsonProperty("title") title: String? = null, @JsonProperty("year") year: Int? = null, @JsonProperty("ids") ids: Ids? = null, @JsonProperty("seasons") seasons: List? = null, @JsonProperty("episodes") episodes: List? = null, @JsonProperty("rating") val rating: Int? = null, @JsonProperty("rated_at") val ratedAt: String? = null, ) : MediaObject(title, year, ids, seasons = seasons, episodes = episodes) @JsonInclude(JsonInclude.Include.NON_EMPTY) class RatingMediaObject( @JsonProperty("title") title: String?, @JsonProperty("year") year: Int?, @JsonProperty("ids") ids: Ids?, @JsonProperty("rating") val rating: Int, @JsonProperty("rated_at") val ratedAt: String? = getDateTime(unixTime) ) : MediaObject(title, year, ids) @JsonInclude(JsonInclude.Include.NON_EMPTY) class StatusMediaObject( @JsonProperty("title") title: String?, @JsonProperty("year") year: Int?, @JsonProperty("ids") ids: Ids?, @JsonProperty("to") val to: String, @JsonProperty("watched_at") val watchedAt: String? = getDateTime(unixTime) ) : MediaObject(title, year, ids) @JsonInclude(JsonInclude.Include.NON_EMPTY) data class StatusRequest( @JsonProperty("movies") val movies: List, @JsonProperty("shows") val shows: List ) /** https://simkl.docs.apiary.io/#reference/sync/get-all-items/get-all-items-in-the-user's-watchlist */ data class AllItemsResponse( @JsonProperty("shows") val shows: List = emptyList(), @JsonProperty("anime") val anime: List = emptyList(), @JsonProperty("movies") val movies: List = emptyList(), ) { companion object { fun merge(first: AllItemsResponse?, second: AllItemsResponse?): AllItemsResponse { // Replace the first item with the same id, or add the new item fun MutableList.replaceOrAddItem(newItem: T, predicate: (T) -> Boolean) { for (i in this.indices) { if (predicate(this[i])) { this[i] = newItem return } } this.add(newItem) } // fun merge( first: List?, second: List? ): List { return (first?.toMutableList() ?: mutableListOf()).apply { second?.forEach { secondShow -> this.replaceOrAddItem(secondShow) { it.getIds().simkl == secondShow.getIds().simkl } } } } return AllItemsResponse( merge(first?.shows, second?.shows), merge(first?.anime, second?.anime), merge(first?.movies, second?.movies), ) } } interface Metadata { val lastWatchedAt: String? val status: String? val userRating: Int? val lastWatched: String? val watchedEpisodesCount: Int? val totalEpisodesCount: Int? fun getIds(): ShowMetadata.Show.Ids fun toLibraryItem(): SyncAPI.LibraryItem } data class MovieMetadata( @JsonProperty("last_watched_at") override val lastWatchedAt: String?, @JsonProperty("status") override val status: String, @JsonProperty("user_rating") override val userRating: Int?, @JsonProperty("last_watched") override val lastWatched: String?, @JsonProperty("watched_episodes_count") override val watchedEpisodesCount: Int?, @JsonProperty("total_episodes_count") override val totalEpisodesCount: Int?, val movie: ShowMetadata.Show ) : Metadata { override fun getIds(): ShowMetadata.Show.Ids { return this.movie.ids } override fun toLibraryItem(): SyncAPI.LibraryItem { return SyncAPI.LibraryItem( this.movie.title, "https://simkl.com/tv/${movie.ids.simkl}", movie.ids.simkl.toString(), this.watchedEpisodesCount, this.totalEpisodesCount, Score.from10(this.userRating), getUnixTime(lastWatchedAt) ?: 0, "Simkl", TvType.Movie, this.movie.poster?.let { getPosterUrl(it) }, null, null, this.movie.year?.toYear(), movie.ids.simkl ) } } data class ShowMetadata( @JsonProperty("last_watched_at") override val lastWatchedAt: String?, @JsonProperty("status") override val status: String, @JsonProperty("user_rating") override val userRating: Int?, @JsonProperty("last_watched") override val lastWatched: String?, @JsonProperty("watched_episodes_count") override val watchedEpisodesCount: Int?, @JsonProperty("total_episodes_count") override val totalEpisodesCount: Int?, @JsonProperty("show") val show: Show ) : Metadata { override fun getIds(): Show.Ids { return this.show.ids } override fun toLibraryItem(): SyncAPI.LibraryItem { return SyncAPI.LibraryItem( this.show.title, "https://simkl.com/tv/${show.ids.simkl}", show.ids.simkl.toString(), this.watchedEpisodesCount, this.totalEpisodesCount, Score.from10(this.userRating), getUnixTime(lastWatchedAt) ?: 0, "Simkl", TvType.Anime, this.show.poster?.let { getPosterUrl(it) }, null, null, this.show.year?.toYear(), show.ids.simkl ) } data class Show( @JsonProperty("title") val title: String, @JsonProperty("poster") val poster: String?, @JsonProperty("year") val year: Int?, @JsonProperty("ids") val ids: Ids, ) { data class Ids( @JsonProperty("simkl") val simkl: Int, @JsonProperty("slug") val slug: String?, @JsonProperty("imdb") val imdb: String?, @JsonProperty("zap2it") val zap2it: String?, @JsonProperty("tmdb") val tmdb: String?, @JsonProperty("offen") val offen: String?, @JsonProperty("tvdb") val tvdb: String?, @JsonProperty("mal") val mal: String?, @JsonProperty("anidb") val anidb: String?, @JsonProperty("anilist") val anilist: String?, @JsonProperty("traktslug") val traktslug: String? ) { fun matchesId(database: SimklSyncServices, id: String): Boolean { return when (database) { SimklSyncServices.Simkl -> this.simkl == id.toIntOrNull() SimklSyncServices.AniList -> this.anilist == id SimklSyncServices.Mal -> this.mal == id SimklSyncServices.Tmdb -> this.tmdb == id SimklSyncServices.Imdb -> this.imdb == id } } } } } } } /** * Appends api keys to the requests **/ /*private inner class HeaderInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { debugPrint { "${this@SimklApi.name} made request to ${chain.request().url}" } return chain.proceed( chain.request() .newBuilder() .addHeader("Authorization", "Bearer $token") .addHeader("simkl-api-key", CLIENT_ID) .build() ) } }*/ private suspend fun getUser(token: AuthToken): SettingsResponse = app.post("$mainUrl/users/settings", headers = getHeaders(token)) .parsed() /** * Useful to get episodes on demand to prevent unnecessary requests. */ class SimklEpisodeConstructor( private val simklId: Int?, private val type: String?, private val totalEpisodeCount: Int?, private val hasEnded: Boolean? ) { suspend fun getEpisodes(): Array? { return getEpisodes(simklId, type, totalEpisodeCount, hasEnded) } } class SimklSyncStatus( override var status: SyncWatchType, override var score: Score?, val oldScore: Int?, override var watchedEpisodes: Int?, val episodeConstructor: SimklEpisodeConstructor, override var isFavorite: Boolean? = null, override var maxEpisodes: Int? = null, /** Save seen episodes separately to know the change from old to new. * Required to remove seen episodes if count decreases */ val oldEpisodes: Int, val oldStatus: String? ) : SyncAPI.AbstractSyncStatus() override suspend fun status(auth: AuthData?, id: String): SyncAPI.AbstractSyncStatus? { if (auth == null) return null val realIds = readIdFromString(id) // Key which assumes all ids are the same each time :/ // This could be some sort of reference system to make multiple IDs // point to the same key. val idKey = realIds.toList().map { "${it.first.originalName}=${it.second}" }.sorted().joinToString() val cachedObject = SimklCache.getKey(idKey) val searchResult: MediaObject = cachedObject ?: (searchByIds(realIds)?.firstOrNull()?.also { result -> val cacheTime = if (result.hasEnded()) SimklCache.CacheTimes.OneMonth.value else SimklCache.CacheTimes.ThirtyMinutes.value SimklCache.setKey(idKey, result, Duration.parse(cacheTime)) }) ?: return null val episodeConstructor = SimklEpisodeConstructor( searchResult.ids?.simkl, searchResult.type, searchResult.totalEpisodes, searchResult.hasEnded() ) val foundItem = getSyncListSmart(auth)?.let { list -> listOf(list.shows, list.anime, list.movies).flatten().firstOrNull { show -> realIds.any { (database, id) -> show.getIds().matchesId(database, id) } } } if (foundItem != null) { return SimklSyncStatus( status = foundItem.status?.let { SyncWatchType.fromInternalId( SimklListStatusType.fromString( it )?.value ) } ?: return null, score = Score.from10(foundItem.userRating), watchedEpisodes = foundItem.watchedEpisodesCount, maxEpisodes = searchResult.totalEpisodes, episodeConstructor = episodeConstructor, oldEpisodes = foundItem.watchedEpisodesCount ?: 0, oldScore = foundItem.userRating, oldStatus = foundItem.status ) } else { return SimklSyncStatus( status = SyncWatchType.fromInternalId(SimklListStatusType.None.value), score = null, watchedEpisodes = 0, maxEpisodes = if (searchResult.type == "movie") 0 else searchResult.totalEpisodes, episodeConstructor = episodeConstructor, oldEpisodes = 0, oldStatus = null, oldScore = null ) } } override suspend fun updateStatus( auth: AuthData?, id: String, newStatus: AbstractSyncStatus ): Boolean { val parsedId = readIdFromString(id) lastScoreTime = unixTime val simklStatus = newStatus as? SimklSyncStatus val builder = SimklScoreBuilder.Builder() .apiUrl(this.mainUrl) .score(newStatus.score?.toInt(10), simklStatus?.oldScore) .status( newStatus.status.internalId, (newStatus as? SimklSyncStatus)?.oldStatus?.let { oldStatus -> SimklListStatusType.entries.firstOrNull { it.originalName == oldStatus }?.value }) .token(auth?.token ?: return false) .ids(MediaObject.Ids.fromMap(parsedId)) // Get episodes only when required val episodes = simklStatus?.episodeConstructor?.getEpisodes() // All episodes if marked as completed val watchedEpisodes = if (newStatus.status.internalId == SimklListStatusType.Completed.value) { episodes?.size } else { newStatus.watchedEpisodes } builder.episodes(episodes?.toList(), watchedEpisodes, simklStatus?.oldEpisodes) requireLibraryRefresh = true return builder.execute() } /** See https://simkl.docs.apiary.io/#reference/search/id-lookup/get-items-by-id */ private suspend fun searchByIds(serviceMap: Map): Array? { if (serviceMap.isEmpty()) return emptyArray() return app.get( "$mainUrl/search/id", params = mapOf("client_id" to CLIENT_ID) + serviceMap.map { (service, id) -> service.originalName to id } ).parsedSafe() } override suspend fun search(auth: AuthData?, query: String): List? { return app.get( "$mainUrl/search/", params = mapOf("client_id" to CLIENT_ID, "q" to name) ).parsedSafe>()?.mapNotNull { it.toSyncSearchResult() } } override fun loginRequest(): AuthLoginPage? { val lastLoginState = BigInteger(130, SecureRandom()).toString(32) val url = "https://simkl.com/oauth/authorize?response_type=code&client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrlIdentifier}&state=$lastLoginState" return AuthLoginPage( url = url, payload = lastLoginState ) } override suspend fun load(auth: AuthData?, id: String): SyncResult? = null private suspend fun getSyncListSince(auth: AuthData, since: Long?): AllItemsResponse? { val params = getDateTime(since)?.let { mapOf("date_from" to it) } ?: emptyMap() // Can return null on no change. return app.get( "$mainUrl/sync/all-items/", params = params, headers = getHeaders(auth.token) ).parsedSafe() } private suspend fun getActivities(token: AuthToken): ActivitiesResponse? { return app.post("$mainUrl/sync/activities", headers = getHeaders(token)).parsedSafe() } private fun getSyncListCached(auth: AuthData): AllItemsResponse? { return getKey(SIMKL_CACHED_LIST, auth.user.id.toString()) } private suspend fun getSyncListSmart(auth: AuthData): AllItemsResponse? { val activities = getActivities(auth.token) val userId = auth.user.id.toString() val lastCacheUpdate = getKey(SIMKL_CACHED_LIST_TIME, auth.user.id.toString()) val lastRemoval = listOf( activities?.tvShows?.removedFromList, activities?.anime?.removedFromList, activities?.movies?.removedFromList ).maxOf { getUnixTime(it) ?: -1 } val lastRealUpdate = listOf( activities?.tvShows?.all, activities?.anime?.all, activities?.movies?.all, ).maxOf { getUnixTime(it) ?: -1 } debugPrint { "Cache times: lastCacheUpdate=$lastCacheUpdate, lastRemoval=$lastRemoval, lastRealUpdate=$lastRealUpdate" } val list = if (lastCacheUpdate == null || lastCacheUpdate < lastRemoval) { debugPrint { "Full list update in ${this.name}." } setKey(SIMKL_CACHED_LIST_TIME, userId, lastRemoval) getSyncListSince(auth, null) } else if (lastCacheUpdate < lastRealUpdate || lastCacheUpdate < lastScoreTime) { debugPrint { "Partial list update in ${this.name}." } setKey(SIMKL_CACHED_LIST_TIME, userId, lastCacheUpdate) AllItemsResponse.merge( getSyncListCached(auth), getSyncListSince(auth, lastCacheUpdate) ) } else { debugPrint { "Cached list update in ${this.name}." } getSyncListCached(auth) } debugPrint { "List sizes: movies=${list?.movies?.size}, shows=${list?.shows?.size}, anime=${list?.anime?.size}" } setKey(SIMKL_CACHED_LIST, userId, list) return list } override suspend fun library(auth: AuthData?): SyncAPI.LibraryMetadata? { val list = getSyncListSmart(auth ?: return null) ?: return null val baseMap = SimklListStatusType.entries .filter { it.value >= 0 && it.value != SimklListStatusType.ReWatching.value } .associate { it.stringRes to emptyList() } val syncMap = listOf(list.anime, list.movies, list.shows) .flatten() .groupBy { it.status } .mapNotNull { (status, list) -> val stringRes = status?.let { SimklListStatusType.fromString(it)?.stringRes } ?: return@mapNotNull null val libraryList = list.map { it.toLibraryItem() } stringRes to libraryList }.toMap() return SyncAPI.LibraryMetadata( (baseMap + syncMap).map { SyncAPI.LibraryList(txt(it.key), it.value) }, setOf( ListSorting.AlphabeticalA, ListSorting.AlphabeticalZ, ListSorting.UpdatedNew, ListSorting.UpdatedOld, ListSorting.ReleaseDateNew, ListSorting.ReleaseDateOld, ListSorting.RatingHigh, ListSorting.RatingLow, ) ) } override fun urlToId(url: String): String? { val simklUrlRegex = Regex("""https://simkl\.com/[^/]*/(\d+).*""") return simklUrlRegex.find(url)?.groupValues?.get(1) ?: "" } override suspend fun pinRequest(): AuthPinData? { val pinAuthResp = app.get( "$mainUrl/oauth/pin?client_id=$CLIENT_ID&redirect_uri=$APP_STRING://${redirectUrlIdentifier}" ).parsedSafe() ?: return null return AuthPinData( deviceCode = pinAuthResp.deviceCode, userCode = pinAuthResp.userCode, verificationUrl = pinAuthResp.verificationUrl, expiresIn = pinAuthResp.expiresIn, interval = pinAuthResp.interval ) } override suspend fun login(payload: AuthPinData): AuthToken? { val pinAuthResp = app.get( "$mainUrl/oauth/pin/${payload.userCode}?client_id=$CLIENT_ID" ).parsedSafe() ?: return null return AuthToken( accessToken = pinAuthResp.accessToken ?: return null, ) } override suspend fun login(redirectUrl: String, payload: String?): AuthToken? { val uri = redirectUrl.toUri() val state = uri.getQueryParameter("state") // Ensure consistent state if (state != payload) return null val code = uri.getQueryParameter("code") ?: return null val tokenResponse = app.post( "$mainUrl/oauth/token", json = TokenRequest(code) ).parsedSafe() ?: return null return AuthToken( accessToken = tokenResponse.accessToken, ) } override suspend fun user(token: AuthToken?): AuthUser? { val user = getUser(token ?: return null) return AuthUser( id = user.account.id, name = user.user.name, profilePicture = user.user.avatar ) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/SubSource.kt ================================================ package com.lagradost.cloudstream3.syncproviders.providers import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.SubtitleResource import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.SubtitleAPI import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.SubtitleHelper class SubSourceApi : SubtitleAPI() { override val name = "SubSource" override val idPrefix = "subsource" override val requiresLogin = false companion object { const val APIURL = "https://api.subsource.net/api" const val DOWNLOADENDPOINT = "https://api.subsource.net/api/downloadSub" } override suspend fun search( auth: AuthData?, query: AbstractSubtitleEntities.SubtitleSearch ): List? { //Only supports Imdb Id search for now if (query.imdbId == null) return null val queryLang = SubtitleHelper.fromTagToEnglishLanguageName(query.lang) val type = if ((query.seasonNumber ?: 0) > 0) TvType.TvSeries else TvType.Movie val searchRes = app.post( url = "$APIURL/searchMovie", data = mapOf( "query" to query.imdbId!! ) ).parsedSafe() ?: return null val postData = if (type == TvType.TvSeries) { mapOf( "langs" to "[]", "movieName" to searchRes.found.first().linkName, "season" to "season-${query.seasonNumber}" ) } else { mapOf( "langs" to "[]", "movieName" to searchRes.found.first().linkName, ) } val getMovieRes = app.post( url = "$APIURL/getMovie", data = postData ).parsedSafe().let { // api doesn't has episode number or lang filtering if (type == TvType.Movie) { it?.subs?.filter { sub -> sub.lang == queryLang } } else { it?.subs?.filter { sub -> sub.releaseName!!.contains( String.format( null, "E%02d", query.epNumber ) ) && sub.lang == queryLang } } } ?: return null return getMovieRes.map { subtitle -> AbstractSubtitleEntities.SubtitleEntity( idPrefix = this.idPrefix, name = subtitle.releaseName!!, lang = subtitle.lang!!, data = SubData( movie = subtitle.linkName!!, lang = subtitle.lang, id = subtitle.subId.toString(), ).toJson(), type = type, source = this.name, epNumber = query.epNumber, seasonNumber = query.seasonNumber, isHearingImpaired = subtitle.hi == 1, ) } } override suspend fun SubtitleResource.getResources( auth: AuthData?, subtitle: AbstractSubtitleEntities.SubtitleEntity ) { val parsedSub = parseJson(subtitle.data) val subRes = app.post( url = "$APIURL/getSub", data = mapOf( "movie" to parsedSub.movie, "lang" to subtitle.lang, "id" to parsedSub.id ) ).parsedSafe() ?: return this.addZipUrl( "$DOWNLOADENDPOINT/${subRes.sub.downloadToken}" ) { name, _ -> name } } data class ApiSearch( @JsonProperty("success") val success: Boolean, @JsonProperty("found") val found: List, ) data class Found( @JsonProperty("id") val id: Long, @JsonProperty("title") val title: String, @JsonProperty("seasons") val seasons: Long, @JsonProperty("type") val type: String, @JsonProperty("releaseYear") val releaseYear: Long, @JsonProperty("linkName") val linkName: String, ) data class ApiResponse( @JsonProperty("success") val success: Boolean, @JsonProperty("movie") val movie: Movie, @JsonProperty("subs") val subs: List, ) data class Movie( @JsonProperty("id") val id: Long? = null, @JsonProperty("type") val type: String? = null, @JsonProperty("year") val year: Long? = null, @JsonProperty("fullName") val fullName: String? = null, ) data class Sub( @JsonProperty("hi") val hi: Int? = null, @JsonProperty("fullLink") val fullLink: String? = null, @JsonProperty("linkName") val linkName: String? = null, @JsonProperty("lang") val lang: String? = null, @JsonProperty("releaseName") val releaseName: String? = null, @JsonProperty("subId") val subId: Long? = null, ) data class SubData( @JsonProperty("movie") val movie: String, @JsonProperty("lang") val lang: String, @JsonProperty("id") val id: String, ) data class SubTitleLink( @JsonProperty("sub") val sub: SubToken, ) data class SubToken( @JsonProperty("downloadToken") val downloadToken: String, ) } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/Subdl.kt ================================================ package com.lagradost.cloudstream3.syncproviders.providers import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.SubtitleResource import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.syncproviders.AuthLoginRequirement import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse import com.lagradost.cloudstream3.syncproviders.AuthToken import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.SubtitleAPI import com.lagradost.cloudstream3.TvType class SubDlApi : SubtitleAPI() { override val name = "SubDL" override val idPrefix = "subdl" override val icon = R.drawable.subdl_logo_big override val hasInApp = true override val inAppLoginRequirement = AuthLoginRequirement(password = true, email = true) override val requiresLogin = true override val createAccountUrl = "https://subdl.com/panel/register" companion object { const val APIURL = "https://apiold.subdl.com" const val APIENDPOINT = "$APIURL/api/v1/subtitles" const val DOWNLOADENDPOINT = "https://dl.subdl.com" } override suspend fun login(form: AuthLoginResponse): AuthToken? { val email = form.email ?: return null val password = form.password ?: return null val tokenResponse = app.post( url = "$APIURL/login", json = mapOf( "email" to email, "password" to password ) ).parsed() val apiResponse = app.get( url = "$APIURL/user/userApi", headers = mapOf( "Authorization" to "Bearer ${tokenResponse.token}" ) ).parsed() return AuthToken(accessToken = apiResponse.apiKey, payload = email) } override suspend fun user(token: AuthToken?): AuthUser? { val name = token?.payload ?: return null return AuthUser(id = name.hashCode(), name = name) } override suspend fun search( auth : AuthData?, query: AbstractSubtitleEntities.SubtitleSearch ): List? { if (auth == null) return null val apiKey = auth.token.accessToken ?: return null val queryText = query.query val epNum = query.epNumber ?: 0 val seasonNum = query.seasonNumber ?: 0 val yearNum = query.year ?: 0 val langSubdlCode = langTagIETF2subdl[query.lang.toString()] ?: query.lang val idQuery = when { query.imdbId != null -> "&imdb_id=${query.imdbId}" query.tmdbId != null -> "&tmdb_id=${query.tmdbId}" else -> null } val epQuery = if (epNum > 0) "&episode_number=$epNum" else "" val seasonQuery = if (seasonNum > 0) "&season_number=$seasonNum" else "" val yearQuery = if (yearNum > 0) "&year=$yearNum" else "" val searchQueryUrl = when (idQuery) { //Use imdb/tmdb id to search if its valid null -> "$APIENDPOINT?api_key=${apiKey}&film_name=$queryText&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery" else -> "$APIENDPOINT?api_key=${apiKey}$idQuery&languages=$langSubdlCode$epQuery$seasonQuery$yearQuery" } val req = app.get( url = searchQueryUrl, headers = mapOf( "Accept" to "application/json" ) ) return req.parsedSafe()?.subtitles?.map { subtitle -> val langTagIETF = langTagIETF2subdl.entries.find { it.value == subtitle.lang }?.key ?: subtitle.lang val resEpNum = subtitle.episode ?: query.epNumber val resSeasonNum = subtitle.season ?: query.seasonNumber val type = if ((resSeasonNum ?: 0) > 0) TvType.TvSeries else TvType.Movie AbstractSubtitleEntities.SubtitleEntity( idPrefix = this.idPrefix, name = subtitle.releaseName, lang = langTagIETF, data = "${DOWNLOADENDPOINT}${subtitle.url}", type = type, source = this.name, epNumber = resEpNum, seasonNumber = resSeasonNum, isHearingImpaired = subtitle.hearingImpaired ?: false, ) } } override suspend fun SubtitleResource.getResources( auth: AuthData?, subtitle: AbstractSubtitleEntities.SubtitleEntity ) { this.addZipUrl(subtitle.data) { name, _ -> name } } data class SubtitleOAuthEntity( @JsonProperty("userEmail") var userEmail: String, @JsonProperty("pass") var pass: String, @JsonProperty("name") var name: String? = null, @JsonProperty("accessToken") var accessToken: String? = null, @JsonProperty("apiKey") var apiKey: String? = null, ) data class OAuthTokenResponse( @JsonProperty("token") val token: String, @JsonProperty("userData") val userData: UserData? = null, @JsonProperty("status") val status: Boolean? = null, @JsonProperty("message") val message: String? = null, ) data class UserData( @JsonProperty("email") val email: String, @JsonProperty("name") val name: String, @JsonProperty("country") val country: String, @JsonProperty("scStepCode") val scStepCode: String, @JsonProperty("scVerified") val scVerified: Boolean, @JsonProperty("username") val username: String? = null, @JsonProperty("scUsername") val scUsername: String, ) data class ApiKeyResponse( @JsonProperty("ok") val ok: Boolean? = false, @JsonProperty("api_key") val apiKey: String, @JsonProperty("usage") val usage: Usage? = null, ) data class Usage( @JsonProperty("total") val total: Long? = 0, @JsonProperty("today") val today: Long? = 0, ) data class ApiResponse( @JsonProperty("status") val status: Boolean? = null, @JsonProperty("results") val results: List? = null, @JsonProperty("subtitles") val subtitles: List? = null, ) data class Result( @JsonProperty("sd_id") val sdId: Int? = null, @JsonProperty("type") val type: String? = null, @JsonProperty("name") val name: String? = null, @JsonProperty("imdb_id") val imdbId: String? = null, @JsonProperty("tmdb_id") val tmdbId: Long? = null, @JsonProperty("first_air_date") val firstAirDate: String? = null, @JsonProperty("year") val year: Int? = null, ) data class Subtitle( @JsonProperty("release_name") val releaseName: String, @JsonProperty("name") val name: String, @JsonProperty("lang") val lang: String, // subdl language code @JsonProperty("author") val author: String? = null, @JsonProperty("url") val url: String? = null, @JsonProperty("subtitlePage") val subtitlePage: String? = null, @JsonProperty("season") val season: Int? = null, @JsonProperty("episode") val episode: Int? = null, @JsonProperty("language") val language: String? = null, // full language name @JsonProperty("hi") val hearingImpaired: Boolean? = null, ) // https://subdl.com/api-files/language_list.json // most of it is IETF BPC 47 conformant tag // but there are some exceptions private val langTagIETF2subdl = mapOf( "en-bg" to "BG_EN", // "Bulgarian_English" "en-de" to "EN_DE", // "English_German" "en-hu" to "HU_EN", // "Hungarian_English" "en-nl" to "NL_EN", // "Dutch_English" "pt-br" to "BR_PT", // "Brazillian Portuguese" "zh-hant" to "ZH_BG", // "Big 5 code" -> traditional Chinese (?_?) // "ar" to "AR", // "Arabic" // "az" to "AZ", // "Azerbaijani" // "be" to "BE", // "Belarusian" // "bg" to "BG", // "Bulgarian" // "bn" to "BN", // "Bengali" // "bs" to "BS", // "Bosnian" // "ca" to "CA", // "Catalan" // "cs" to "CS", // "Czech" // "da" to "DA", // "Danish" // "de" to "DE", // "German" // "el" to "EL", // "Greek" // "en" to "EN", // "English" // "eo" to "EO", // "Esperanto" // "es" to "ES", // "Spanish" // "et" to "ET", // "Estonian" // "fa" to "FA", // "Farsi_Persian" // "fi" to "FI", // "Finnish" // "fr" to "FR", // "French" // "he" to "HE", // "Hebrew" // "hi" to "HI", // "Hindi" // "hr" to "HR", // "Croatian" // "hu" to "HU", // "Hungarian" // "id" to "ID", // "Indonesian" // "is" to "IS", // "Icelandic" // "it" to "IT", // "Italian" // "ja" to "JA", // "Japanese" // "ka" to "KA", // "Georgian" // "kl" to "KL", // "Greenlandic" // "ko" to "KO", // "Korean" // "ku" to "KU", // "Kurdish" // "lt" to "LT", // "Lithuanian" // "lv" to "LV", // "Latvian" // "mk" to "MK", // "Macedonian" // "ml" to "ML", // "Malayalam" // "mni" to "MNI", // "Manipuri" // "ms" to "MS", // "Malay" // "my" to "MY", // "Burmese" // "nl" to "NL", // "Dutch" // "no" to "NO", // "Norwegian" // "pl" to "PL", // "Polish" // "pt" to "PT", // "Portuguese" // "ro" to "RO", // "Romanian" // "ru" to "RU", // "Russian" // "si" to "SI", // "Sinhala" // "sk" to "SK", // "Slovak" // "sl" to "SL", // "Slovenian" // "sq" to "SQ", // "Albanian" // "sr" to "SR", // "Serbian" // "sv" to "SV", // "Swedish" // "ta" to "TA", // "Tamil" // "te" to "TE", // "Telugu" // "th" to "TH", // "Thai" // "tl" to "TL", // "Tagalog" // "tr" to "TR", // "Turkish" // "uk" to "UK", // "Ukranian" // "ur" to "UR", // "Urdu" // "vi" to "VI", // "Vietnamese" // "zh" to "ZH", // "Chinese BG code" ) } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/APIRepository.kt ================================================ package com.lagradost.cloudstream3.ui import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.HomePageResponse import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.MainPageRequest import com.lagradost.cloudstream3.SearchResponseList import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.fixUrl import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.newSearchResponseList import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.ExtractorLink import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.withTimeout class APIRepository(val api: MainAPI) { companion object { // 2 minute timeout to prevent bad extensions/extractors from hogging the resources // No real provider should take longer, so we hard kill them. private const val DEFAULT_TIMEOUT = 120_000L private const val MAX_TIMEOUT = 4 * DEFAULT_TIMEOUT private const val MIN_TIMEOUT = 5_000L var dubStatusActive = HashSet() val noneApi = object : MainAPI() { override var name = "None" override val supportedTypes = emptySet() override var lang = "" } val randomApi = object : MainAPI() { override var name = "Random" override val supportedTypes = emptySet() override var lang = "" } fun isInvalidData(data: String): Boolean { return data.isEmpty() || data == "[]" || data == "about:blank" } data class SavedLoadResponse( val unixTime: Long, val response: LoadResponse, val hash: Pair ) private val cache = threadSafeListOf() private var cacheIndex: Int = 0 const val CACHE_SIZE = 20 fun getTimeout(desired: Long?): Long { return (desired ?: DEFAULT_TIMEOUT).coerceIn(MIN_TIMEOUT, MAX_TIMEOUT) } } private fun afterPluginsLoaded(forceReload: Boolean) { if (forceReload) { synchronized(cache) { cache.clear() } } } init { afterPluginsLoadedEvent += ::afterPluginsLoaded } val hasMainPage = api.hasMainPage val providerType = api.providerType val name = api.name val mainUrl = api.mainUrl val mainPage = api.mainPage val hasQuickSearch = api.hasQuickSearch val vpnStatus = api.vpnStatus suspend fun load(url: String): Resource { return safeApiCall { withTimeout(getTimeout(api.loadTimeoutMs)) { if (isInvalidData(url)) throw ErrorLoadingException() val fixedUrl = api.fixUrl(url) val lookingForHash = Pair(api.name, fixedUrl) synchronized(cache) { for (item in cache) { // 10 min save if (item.hash == lookingForHash && (unixTime - item.unixTime) < 60 * 10) { return@withTimeout item.response } } } api.load(fixedUrl)?.also { response -> // Remove all blank tags as early as possible response.tags = response.tags?.filter { it.isNotBlank() } val add = SavedLoadResponse(unixTime, response, lookingForHash) synchronized(cache) { if (cache.size > CACHE_SIZE) { cache[cacheIndex] = add // rolling cache cacheIndex = (cacheIndex + 1) % CACHE_SIZE } else { cache.add(add) } } } ?: throw ErrorLoadingException() } } } suspend fun search(query: String, page: Int): Resource { if (query.isEmpty()) return Resource.Success(newSearchResponseList(emptyList())) return safeApiCall { withTimeout(getTimeout(api.searchTimeoutMs)) { (api.search(query, page) ?: throw ErrorLoadingException()) // .filter { typesActive.contains(it.type) } } } } suspend fun quickSearch(query: String): Resource { if (query.isEmpty()) return Resource.Success(newSearchResponseList(emptyList())) return safeApiCall { withTimeout(getTimeout(api.quickSearchTimeoutMs)) { newSearchResponseList( api.quickSearch(query) ?: throw ErrorLoadingException(), false ) } } } suspend fun waitForHomeDelay() { val delta = api.sequentialMainPageScrollDelay + api.lastHomepageRequest - unixTimeMS if (delta < 0) return delay(delta) } suspend fun getMainPage(page: Int, nameIndex: Int? = null): Resource> { return safeApiCall { withTimeout(getTimeout(api.getMainPageTimeoutMs)) { api.lastHomepageRequest = unixTimeMS nameIndex?.let { api.mainPage.getOrNull(it) }?.let { data -> listOf( api.getMainPage( page, MainPageRequest(data.name, data.data, data.horizontalImages) ) ) } ?: run { if (api.sequentialMainPage) { var first = true api.mainPage.map { data -> if (!first) // dont want to sleep on first request delay(api.sequentialMainPageDelay) first = false api.getMainPage( page, MainPageRequest(data.name, data.data, data.horizontalImages) ) } } else { with(CoroutineScope(coroutineContext)) { api.mainPage.map { data -> async { api.getMainPage( page, MainPageRequest(data.name, data.data, data.horizontalImages) ) } }.map { it.await() } } } } } } } suspend fun extractorVerifierJob(extractorData: String?) { safeApiCall { api.extractorVerifierJob(extractorData) } } suspend fun loadLinks( data: String, isCasting: Boolean, subtitleCallback: (SubtitleFile) -> Unit, callback: (ExtractorLink) -> Unit, ): Boolean { if (isInvalidData(data)) return false // this makes providers cleaner return try { withTimeout(getTimeout(api.loadLinksTimeoutMs)) { api.loadLinks(data, isCasting, subtitleCallback, callback) } } catch (throwable: Throwable) { logError(throwable) return false } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/BaseAdapter.kt ================================================ package com.lagradost.cloudstream3.ui import android.content.Context import android.view.View import android.view.ViewGroup import android.widget.ImageView import androidx.core.view.children import androidx.recyclerview.widget.AsyncDifferConfig import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.viewbinding.ViewBinding import coil3.dispose import java.util.WeakHashMap import java.util.concurrent.CopyOnWriteArrayList open class ViewHolderState(val view: ViewBinding) : ViewHolder(view.root) { open fun save(): T? = null open fun restore(state: T) = Unit } abstract class NoStateAdapter( diffCallback: DiffUtil.ItemCallback = BaseDiffCallback() ) : BaseAdapter(0, diffCallback) /** Creates a new shared pool, using the supplied lambda as a constructor. * * The reason for this complicated structure is that a pool should not be shared between contexts * as it makes coil fuck up, and theming. * */ fun newSharedPool(lambda: RecyclerView.RecycledViewPool.() -> Unit = { }): Pair, RecyclerView.RecycledViewPool.() -> Unit> = WeakHashMap() to lambda /** Sets the shared pool of the recyclerview */ fun RecyclerView.setRecycledViewPool(pool: Pair, RecyclerView.RecycledViewPool.() -> Unit>) { val ctx = context ?: return synchronized(pool.first) { this.setRecycledViewPool(pool.first.getOrPut(ctx) { RecyclerView.RecycledViewPool().apply(pool.second) }) } } /** Clears the shared pool of views */ fun Pair, RecyclerView.RecycledViewPool.() -> Unit>.clear() { synchronized(this.first) { for (pool in this.first.values) { pool?.clear() } } } /** * BaseAdapter is a persistent state stored adapter that supports headers and footers. * This should be used for restoring eg scroll or focus related to a view when it is recreated. * * Id is a per fragment based unique id used to store the underlying data done in an internal ViewModel. * * diffCallback is how the view should be handled when updating, override onUpdateContent for updates * * NOTE: * * By default it should save automatically, but you can also call save(recycle) * * By default no state is stored, but doing an id != 0 will store * * By default no headers or footers exist, override footers and headers count */ abstract class BaseAdapter< T : Any, S : Any>( val id: Int = 0, diffCallback: DiffUtil.ItemCallback = BaseDiffCallback() ) : RecyclerView.Adapter>() { open val footers: Int = 0 open val headers: Int = 0 val immutableCurrentList: List get() = mDiffer.currentList fun getItem(position: Int): T { return mDiffer.currentList[position] } fun getItemOrNull(position: Int): T? { return mDiffer.currentList.getOrNull(position) } private val mDiffer: AsyncListDiffer = AsyncListDiffer( object : NonFinalAdapterListUpdateCallback(this) { override fun onMoved(fromPosition: Int, toPosition: Int) { super.onMoved(fromPosition + headers, toPosition + headers) } override fun onRemoved(position: Int, count: Int) { super.onRemoved(position + headers, count) } override fun onChanged(position: Int, count: Int, payload: Any?) { super.onChanged(position + headers, count, payload) } override fun onInserted(position: Int, count: Int) { super.onInserted(position + headers, count) } }, AsyncDifferConfig.Builder(diffCallback).build() ) /** * Instantly submits a **new and fresh** list. This means that no changes like moves are done as * we assume the new list is not the same thing as the old list, nothing is shared. * * The views are rendered instantly as a result, so no fade/pop-ins or similar. * * Use `submitList` for general use, as that can reuse old views. * */ open fun submitIncomparableList(list: List?, commitCallback : Runnable? = null) { // This leverages a quirk in the submitList function that has a fast case for null arrays // What this implies is that as long as we do a double submit we can ensure no pop-ins, // as the changes are the entire list instead of calculating deltas submitList(null) submitList(list, commitCallback) } /** * @param commitCallback Optional runnable that is executed when the List is committed, if it is committed. * This is needed for some tasks as submitList will use a background thread for diff * */ open fun submitList(list: Collection?, commitCallback : Runnable? = null) { // deep copy at least the top list, because otherwise adapter can go crazy if (list.isNullOrEmpty()) { mDiffer.submitList(null, commitCallback) // It is "faster" to submit null than emptyList() } else { mDiffer.submitList(CopyOnWriteArrayList(list), commitCallback) } } override fun getItemCount(): Int { return mDiffer.currentList.size + footers + headers } open fun onUpdateContent(holder: ViewHolderState, item: T, position: Int) = onBindContent(holder, item, position) open fun onBindContent(holder: ViewHolderState, item: T, position: Int) = Unit open fun onBindFooter(holder: ViewHolderState) = Unit open fun onBindHeader(holder: ViewHolderState) = Unit open fun onCreateContent(parent: ViewGroup): ViewHolderState = throw NotImplementedError() open fun onCreateCustomContent( parent: ViewGroup, viewType: Int ) = onCreateContent(parent) open fun onCreateFooter(parent: ViewGroup): ViewHolderState = throw NotImplementedError() open fun onCreateCustomFooter( parent: ViewGroup, viewType: Int ) = onCreateFooter(parent) open fun onCreateHeader(parent: ViewGroup): ViewHolderState = throw NotImplementedError() open fun onCreateCustomHeader( parent: ViewGroup, viewType: Int ) = onCreateHeader(parent) override fun onViewAttachedToWindow(holder: ViewHolderState) {} override fun onViewDetachedFromWindow(holder: ViewHolderState) {} @Suppress("UNCHECKED_CAST") fun save(recyclerView: RecyclerView) { for (child in recyclerView.children) { val holder = recyclerView.findContainingViewHolder(child) as? ViewHolderState ?: continue setState(holder) } } fun clearState() { layoutManagerStates[id]?.clear() } @Suppress("UNCHECKED_CAST") private fun getState(holder: ViewHolderState): S? = layoutManagerStates[id]?.get(holder.absoluteAdapterPosition) as? S private fun setState(holder: ViewHolderState) { if (id == 0) return if (!layoutManagerStates.contains(id)) { layoutManagerStates[id] = HashMap() } layoutManagerStates[id]?.let { map -> map[holder.absoluteAdapterPosition] = holder.save() } } private val attachListener = object : View.OnAttachStateChangeListener { override fun onViewAttachedToWindow(v: View) = Unit override fun onViewDetachedFromWindow(v: View) { if (v !is RecyclerView) return save(v) } } final override fun onAttachedToRecyclerView(recyclerView: RecyclerView) { recyclerView.addOnAttachStateChangeListener(attachListener) super.onAttachedToRecyclerView(recyclerView) } final override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) { recyclerView.removeOnAttachStateChangeListener(attachListener) super.onDetachedFromRecyclerView(recyclerView) } open fun customContentViewType(item: T): Int = 0 open fun customFooterViewType(): Int = 0 open fun customHeaderViewType(): Int = 0 final override fun getItemViewType(position: Int): Int { if (position < headers) { return HEADER or customHeaderViewType() } val realPosition = position - headers if (realPosition >= mDiffer.currentList.size) { return FOOTER or customFooterViewType() } return CONTENT or customContentViewType(getItem(realPosition)) } final override fun onViewRecycled(holder: ViewHolderState) { setState(holder) onClearView(holder) super.onViewRecycled(holder) } /** Same as onViewRecycled, but for the purpose of cleaning the view of any relevant data. * * If an item view has large or expensive data bound to it such as large bitmaps, this may be a good place to release those resources. * * Use this with `clearImage` * */ open fun onClearView(holder: ViewHolderState) {} final override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolderState { return when (viewType and TYPE_MASK) { CONTENT -> onCreateCustomContent(parent, viewType and CUSTOM_MASK) HEADER -> onCreateCustomHeader(parent, viewType and CUSTOM_MASK) FOOTER -> onCreateCustomFooter(parent, viewType and CUSTOM_MASK) else -> throw NotImplementedError() } } // https://medium.com/@domen.lanisnik/efficiently-updating-recyclerview-items-using-payloads-1305f65f3068 override fun onBindViewHolder( holder: ViewHolderState, position: Int, payloads: MutableList ) { if (payloads.isEmpty()) { super.onBindViewHolder(holder, position, payloads) return } when (getItemViewType(position) and TYPE_MASK) { CONTENT -> { val realPosition = position - headers val item = getItem(realPosition) onUpdateContent(holder, item, realPosition) } FOOTER -> { onBindFooter(holder) } HEADER -> { onBindHeader(holder) } } } final override fun onBindViewHolder(holder: ViewHolderState, position: Int) { when (getItemViewType(position) and TYPE_MASK) { CONTENT -> { val realPosition = position - headers val item = getItem(realPosition) onBindContent(holder, item, realPosition) } FOOTER -> { onBindFooter(holder) } HEADER -> { onBindHeader(holder) } } getState(holder)?.let { state -> holder.restore(state) } } companion object { val layoutManagerStates = hashMapOf>() fun clearImage(image: ImageView?) { image?.dispose() } // Use the lowermost MASK_SIZE bits for the custom content, // use the uppermost 32 - MASK_SIZE to the type private const val MASK_SIZE = 28 private const val CUSTOM_MASK = (1 shl MASK_SIZE) - 1 private const val TYPE_MASK = CUSTOM_MASK.inv() const val HEADER: Int = 3 shl MASK_SIZE const val FOOTER: Int = 2 shl MASK_SIZE /** For custom content, write `CONTENT or X` when calling setMaxRecycledViews */ const val CONTENT: Int = 1 shl MASK_SIZE } } class BaseDiffCallback( val itemSame: (T, T) -> Boolean = { a, b -> a.hashCode() == b.hashCode() }, val contentSame: (T, T) -> Boolean = { a, b -> a.hashCode() == b.hashCode() } ) : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = itemSame(oldItem, newItem) override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = contentSame(oldItem, newItem) override fun getChangePayload(oldItem: T, newItem: T): Any? = Any() } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/BaseFragment.kt ================================================ package com.lagradost.cloudstream3.ui import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.annotation.LayoutRes import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment import androidx.preference.PreferenceFragmentCompat import androidx.viewbinding.ViewBinding import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding import com.lagradost.cloudstream3.utils.txt /** * A base Fragment class that simplifies ViewBinding usage and handles view inflation safely. * * This class allows two modes of creating ViewBinding: * 1. Inflate: Using the standard `inflate()` method provided by generated ViewBinding classes. * 2. Bind: Using `bind()` on an existing root view. * * It also provides hooks for: * - Safe initialization of the binding (`onBindingCreated`) * - Automatic padding adjustment for system bars (`fixPadding`) * - Optional layout resource selection via `pickLayout()` * * @param T The type of ViewBinding for this Fragment. * @param bindingCreator The strategy used to create the binding instance. */ private interface BaseFragmentHelper { val bindingCreator: BaseFragment.BindingCreator var _binding: T? val binding: T? get() = _binding fun createBinding( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { val layoutId = pickLayout() val root: View? = layoutId?.let { inflater.inflate(it, container, false) } _binding = try { when (val creator = bindingCreator) { is BaseFragment.BindingCreator.Inflate -> creator.fn(inflater, container, false) is BaseFragment.BindingCreator.Bind -> { if (root != null) creator.fn(root) else throw IllegalStateException("Root view is null for bind()") } } } catch (t: Throwable) { showToast( txt(R.string.unable_to_inflate, t.message ?: ""), Toast.LENGTH_LONG ) logError(t) null } return _binding?.root ?: root } /** * Called after the fragment's view has been created. * * This method is `final` to ensure that the binding is properly initialized and * system bar padding adjustments are applied before any subclass logic runs. * Subclasses should use [onBindingCreated] instead of overriding this method directly. */ fun onViewReady(view: View, savedInstanceState: Bundle?) { fixLayout(view) binding?.let { onBindingCreated(it, savedInstanceState) } } /** * Called when the binding is safely created and view is ready. * Can be overridden to provide fragment-specific initialization. * * @param binding The safely created ViewBinding. * @param savedInstanceState Saved state bundle or null. */ fun onBindingCreated(binding: T, savedInstanceState: Bundle?) { onBindingCreated(binding) } /** * Called when the binding is safely created and view is ready. * Overload without savedInstanceState for convenience. * * @param binding The safely created ViewBinding. */ fun onBindingCreated(binding: T) {} /** * Pick a layout resource ID for the fragment. * * Return `null` by default. Override to provide a layout resource when using * `BindingCreator.Bind`. Not needed if using `BindingCreator.Inflate`. * * @return Layout resource ID or null. */ @LayoutRes fun pickLayout(): Int? = null /** * Ensures the layout of the root view is correctly adjusted for the current configuration. * * This may include applying padding for system bars, adjusting insets, or performing other * layout updates. `fixLayout` should remain idempotent, as it can be called multiple * times on the same view, such as during configuration changes (e.g. device rotation) or when * the view is recreated. * * @param view The root view to adjust. */ fun fixLayout(view: View) } abstract class BaseFragment( override val bindingCreator: BindingCreator ) : Fragment(), BaseFragmentHelper { override var _binding: T? = null /** Safer activity?.onBackPressedDispatcher?.onBackPressed() with fallback behavior instead of app crash */ fun dispatchBackPressed() { try { activity?.onBackPressedDispatcher?.onBackPressed() } catch (_: IllegalStateException) { // FragmentManager is already executing transactions, so try again delayedDispatchBackPressed(5) } catch (t: Throwable) { logError(t) } } /** Recursive back press when available */ private fun delayedDispatchBackPressed(remaining: Int) { if (remaining <= 0) return binding?.root?.postDelayed({ try { activity?.onBackPressedDispatcher?.onBackPressed() } catch (_: IllegalStateException) { // FragmentManager is already executing transactions, so try again delayedDispatchBackPressed(remaining - 1) } catch (t: Throwable) { logError(t) } }, 200) } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? = createBinding(inflater, container, savedInstanceState) final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) onViewReady(view, savedInstanceState) } /** * Called when the device configuration changes (e.g., orientation). * Re-applies system bar padding fixes to the root view to ensure it * readjusts for orientation changes. */ override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) view?.let { fixLayout(it) } } /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ override fun onDestroyView() { super.onDestroyView() _binding = null } /** * Sealed class representing the two strategies for creating a ViewBinding instance. */ sealed class BindingCreator { /** * Use the standard inflate() method for creating the binding. * * @param fn Lambda that inflates the binding. */ class Inflate( val fn: (LayoutInflater, ViewGroup?, Boolean) -> T ) : BindingCreator() /** * Use bind() on an existing root view to create the binding. This should * be used if you are differing per device layouts, such as different * layouts for TV and Phone. * * @param fn Lambda that binds the root view. */ class Bind( val fn: (View) -> T ) : BindingCreator() } } abstract class BaseDialogFragment( override val bindingCreator: BaseFragment.BindingCreator ) : DialogFragment(), BaseFragmentHelper { override var _binding: T? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? = createBinding(inflater, container, savedInstanceState) final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) onViewReady(view, savedInstanceState) } /** @see [BaseFragment.onConfigurationChanged] for documentation. */ override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) view?.let { fixLayout(it) } } /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ override fun onDestroyView() { super.onDestroyView() _binding = null } } abstract class BaseBottomSheetDialogFragment( override val bindingCreator: BaseFragment.BindingCreator ) : BottomSheetDialogFragment(), BaseFragmentHelper { override var _binding: T? = null override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? = createBinding(inflater, container, savedInstanceState) final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) onViewReady(view, savedInstanceState) } /** @see [BaseFragment.onConfigurationChanged] for documentation. */ override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) view?.let { fixLayout(it) } } /** Cleans up the binding reference when the view is destroyed to avoid memory leaks. */ override fun onDestroyView() { super.onDestroyView() _binding = null } } abstract class BasePreferenceFragmentCompat() : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setSystemBarsPadding() } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) setSystemBarsPadding() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/ControllerActivity.kt ================================================ package com.lagradost.cloudstream3.ui import android.os.Bundle import android.util.Log import android.view.Menu import android.view.View.GONE import android.view.View.INVISIBLE import android.view.View.VISIBLE import android.widget.AbsListView import android.widget.ArrayAdapter import android.widget.ImageView import android.widget.LinearLayout import android.widget.ListView import androidx.appcompat.app.AlertDialog import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.json.JsonMapper import com.fasterxml.jackson.module.kotlin.kotlinModule import com.google.android.gms.cast.MediaLoadOptions import com.google.android.gms.cast.MediaQueueItem import com.google.android.gms.cast.MediaSeekOptions import com.google.android.gms.cast.MediaStatus.REPEAT_MODE_REPEAT_OFF import com.google.android.gms.cast.MediaTrack import com.google.android.gms.cast.TextTrackStyle import com.google.android.gms.cast.framework.CastButtonFactory import com.google.android.gms.cast.framework.CastSession import com.google.android.gms.cast.framework.media.RemoteMediaClient import com.google.android.gms.cast.framework.media.uicontroller.UIController import com.google.android.gms.cast.framework.media.widget.ExpandedControllerActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.sortUrls import com.lagradost.cloudstream3.ui.player.LOADTYPE_CHROMECAST import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.CastHelper.awaitLinks import com.lagradost.cloudstream3.utils.CastHelper.getMediaInfo import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import org.json.JSONObject /*class SkipOpController(val view: ImageView) : UIController() { init { view.setImageResource(R.drawable.exo_controls_fastforward) view.setOnClickListener { remoteMediaClient?.let { val options = MediaSeekOptions.Builder() .setPosition(it.approximateStreamPosition + 85000) it.seek(options.build()) } } } }*/ private fun RemoteMediaClient.getItemIndex(): Int? { return try { val index = this.mediaQueue.itemIds.indexOf(this.currentItem?.itemId ?: 0) if (index < 0) null else index } catch (e: Exception) { null } } class SkipNextEpisodeController(val view: ImageView) : UIController() { init { view.setImageResource(R.drawable.ic_baseline_skip_next_24) view.setOnClickListener { remoteMediaClient?.let { it.queueNext(JSONObject()) view.visibility = GONE // TO PREVENT MULTI CLICK } } } override fun onMediaStatusUpdated() { super.onMediaStatusUpdated() view.visibility = GONE val currentIdIndex = remoteMediaClient?.getItemIndex() ?: return val itemCount = remoteMediaClient?.mediaQueue?.itemCount ?: return if (itemCount - currentIdIndex > 1 && remoteMediaClient?.isLoadingNextItem == false) { view.visibility = VISIBLE } } } data class MetadataHolder( val apiName: String, val isMovie: Boolean, val title: String?, val poster: String?, val currentEpisodeIndex: Int, val episodes: List, val currentLinks: List, val currentSubtitles: List ) class SelectSourceController(val view: ImageView, val activity: ControllerActivity) : UIController() { private val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() init { view.setImageResource(R.drawable.ic_baseline_playlist_play_24) view.setOnClickListener { // lateinit var dialog: AlertDialog val holder = getCurrentMetaData() if (holder != null) { val items = holder.currentLinks if (items.isNotEmpty() && remoteMediaClient?.currentItem != null) { val subTracks = remoteMediaClient?.mediaInfo?.mediaTracks?.filter { it.type == MediaTrack.TYPE_TEXT } ?: ArrayList() val bottomSheetDialogBuilder = AlertDialog.Builder(view.context, R.style.AlertDialogCustomBlack) bottomSheetDialogBuilder.setView(R.layout.sort_bottom_sheet) val bottomSheetDialog = bottomSheetDialogBuilder.create() bottomSheetDialog.show() // bottomSheetDialog.setContentView(R.layout.sort_bottom_sheet) val providerList = bottomSheetDialog.findViewById(R.id.sort_providers)!! val subtitleList = bottomSheetDialog.findViewById(R.id.sort_subtitles)!! if (subTracks.isEmpty()) { bottomSheetDialog.findViewById(R.id.sort_subtitles_holder)?.visibility = GONE } else { val arrayAdapter = ArrayAdapter(view.context, R.layout.sort_bottom_single_choice) arrayAdapter.add(view.context.getString(R.string.no_subtitles)) arrayAdapter.addAll(subTracks.mapNotNull { it.name }) subtitleList.choiceMode = AbsListView.CHOICE_MODE_SINGLE subtitleList.adapter = arrayAdapter val currentTracks = remoteMediaClient?.mediaStatus?.activeTrackIds val subtitleIndex = if (currentTracks == null) 0 else subTracks.map { it.id } .indexOfFirst { currentTracks.contains(it) } + 1 subtitleList.setSelection(subtitleIndex) subtitleList.setItemChecked(subtitleIndex, true) subtitleList.setOnItemClickListener { _, _, which, _ -> if (which == 0) { remoteMediaClient?.setActiveMediaTracks(longArrayOf()) // NO SUBS } else { ChromecastSubtitlesFragment.getCurrentSavedStyle().apply { val font = TextTrackStyle() font.setFontFamily(fontFamily ?: "Google Sans") fontGenericFamily?.let { font.fontGenericFamily = it } font.windowColor = windowColor font.backgroundColor = backgroundColor font.edgeColor = edgeColor font.edgeType = edgeType font.foregroundColor = foregroundColor font.fontScale = fontScale remoteMediaClient?.setTextTrackStyle(font) } remoteMediaClient?.setActiveMediaTracks(longArrayOf(subTracks[which - 1].id)) ?.setResultCallback { if (!it.status.isSuccess) { Log.e( "CHROMECAST", "Failed with status code:" + it.status.statusCode + " > " + it.status.statusMessage ) } } } bottomSheetDialog.dismissSafe(activity) } } //https://developers.google.com/cast/docs/reference/web_receiver/cast.framework.messages.MediaInformation val contentUrl = (remoteMediaClient?.currentItem?.media?.contentUrl ?: remoteMediaClient?.currentItem?.media?.contentId) val sortingMethods = items.map { "${it.name} ${Qualities.getStringByInt(it.quality)}" } .toTypedArray() val sotringIndex = items.indexOfFirst { it.url == contentUrl } val arrayAdapter = ArrayAdapter(view.context, R.layout.sort_bottom_single_choice) arrayAdapter.addAll(sortingMethods.toMutableList()) providerList.choiceMode = AbsListView.CHOICE_MODE_SINGLE providerList.adapter = arrayAdapter providerList.setSelection(sotringIndex) providerList.setItemChecked(sotringIndex, true) providerList.setOnItemClickListener { _, _, which, _ -> val epData = holder.episodes[holder.currentEpisodeIndex] fun loadMirror(index: Int) { if (holder.currentLinks.size <= index) return val mediaItem = getMediaInfo( epData, holder, index, remoteMediaClient?.mediaInfo?.customData, holder.currentSubtitles, ) val startAt = remoteMediaClient?.approximateStreamPosition ?: 0 //remoteMediaClient.load(mediaItem, true, startAt) try { // THIS IS VERY IMPORTANT BECAUSE WE NEVER WANT TO AUTOLOAD THE NEXT EPISODE val currentIdIndex = remoteMediaClient?.getItemIndex() val nextId = remoteMediaClient?.mediaQueue?.itemIds?.get( currentIdIndex?.plus(1) ?: 0 ) if (currentIdIndex == null && nextId != null) { awaitLinks( remoteMediaClient?.queueInsertAndPlayItem( MediaQueueItem.Builder(mediaItem).build(), nextId, startAt, JSONObject() ) ) { loadMirror(index + 1) } } else { val mediaLoadOptions = MediaLoadOptions.Builder() .setPlayPosition(startAt) .setAutoplay(true) .build() awaitLinks( remoteMediaClient?.load( mediaItem, mediaLoadOptions ) ) { loadMirror(index + 1) } } } catch (e: Exception) { val mediaLoadOptions = MediaLoadOptions.Builder() .setPlayPosition(startAt) .setAutoplay(true) .build() awaitLinks(remoteMediaClient?.load(mediaItem, mediaLoadOptions)) { loadMirror(index + 1) } } } loadMirror(which) bottomSheetDialog.dismissSafe(activity) } } } } } private fun getCurrentMetaData(): MetadataHolder? { return try { val data = remoteMediaClient?.mediaInfo?.customData?.toString() data?.toKotlinObject() } catch (e: Exception) { null } } var isLoadingMore = false override fun onMediaStatusUpdated() { super.onMediaStatusUpdated() val meta = getCurrentMetaData() view.visibility = if ((meta?.currentLinks?.size ?: 0) > 1 ) VISIBLE else INVISIBLE try { if (meta != null && meta.episodes.size > meta.currentEpisodeIndex + 1) { val currentIdIndex = remoteMediaClient?.getItemIndex() ?: return val itemCount = remoteMediaClient?.mediaQueue?.itemCount val index = meta.currentEpisodeIndex + 1 val epData = meta.episodes[index] try { val currentDuration = remoteMediaClient?.streamDuration val currentPosition = remoteMediaClient?.approximateStreamPosition if (currentDuration != null && currentPosition != null) DataStoreHelper.setViewPosAndResume( epData.id, currentPosition, currentDuration, epData, meta.episodes.getOrNull(index + 1) ) } catch (t: Throwable) { logError(t) } if (itemCount != null && itemCount - currentIdIndex == 1 && !isLoadingMore) { isLoadingMore = true ioSafe { val currentLinks = mutableSetOf() val currentSubs = mutableSetOf() val generator = RepoLinkGenerator(listOf(epData)) val isSuccessful = safeApiCall { generator.generateLinks( clearCache = false, sourceTypes = LOADTYPE_CHROMECAST, callback = { it.first?.let { link -> currentLinks.add(link) } }, subtitleCallback = { currentSubs.add(it) }, isCasting = true ) } val sortedLinks = sortUrls(currentLinks) val sortedSubs = sortSubs(currentSubs) if (isSuccessful == Resource.Success(true)) { if (currentLinks.isNotEmpty()) { val jsonCopy = meta.copy( currentLinks = sortedLinks, currentSubtitles = sortedSubs, currentEpisodeIndex = index ) val done = JSONObject(jsonCopy.toJson()) val mediaInfo = getMediaInfo( epData, jsonCopy, 0, done, sortedSubs ) /*fun loadIndex(index: Int) { println("LOAD INDEX::::: $index") if (meta.currentLinks.size <= index) return val info = getMediaInfo( epData, meta, index, done) awaitLinks(remoteMediaClient?.load(info, true, 0)) { loadIndex(index + 1) } }*/ activity.runOnUiThread { awaitLinks( remoteMediaClient?.queueAppendItem( MediaQueueItem.Builder(mediaInfo).build(), JSONObject() ) ) { println("FAILED TO LOAD NEXT ITEM") // loadIndex(1) } isLoadingMore = false } } } } } } } catch (e: Exception) { println(e) } } override fun onSessionConnected(castSession: CastSession) { super.onSessionConnected(castSession) remoteMediaClient?.queueSetRepeatMode(REPEAT_MODE_REPEAT_OFF, JSONObject()) } } class SkipTimeController(val view: ImageView, forwards: Boolean) : UIController() { init { //val settingsManager = PreferenceManager.getDefaultSharedPreferences() //val time = settingsManager?.getInt("chromecast_tap_time", 30) ?: 30 val time = 30 //view.setImageResource(if (forwards) R.drawable.netflix_skip_forward else R.drawable.netflix_skip_back) view.setImageResource(if (forwards) R.drawable.go_forward_30 else R.drawable.go_back_30) view.setOnClickListener { remoteMediaClient?.let { val options = MediaSeekOptions.Builder() .setPosition(it.approximateStreamPosition + time * 1000 * if (forwards) 1 else -1) it.seek(options.build()) } } } } class ControllerActivity : ExpandedControllerActivity() { override fun onCreateOptionsMenu(menu: Menu): Boolean { super.onCreateOptionsMenu(menu) menuInflater.inflate(R.menu.cast_expanded_controller_menu, menu) CastButtonFactory.setUpMediaRouteButton(this, menu, R.id.media_route_menu_item) return true } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val sourcesButton: ImageView = getButtonImageViewAt(0) val skipBackButton: ImageView = getButtonImageViewAt(1) val skipForwardButton: ImageView = getButtonImageViewAt(2) val skipOpButton: ImageView = getButtonImageViewAt(3) uiMediaController.bindViewToUIController( sourcesButton, SelectSourceController(sourcesButton, this) ) uiMediaController.bindViewToUIController( skipBackButton, SkipTimeController(skipBackButton, false) ) uiMediaController.bindViewToUIController( skipForwardButton, SkipTimeController(skipForwardButton, true) ) uiMediaController.bindViewToUIController( skipOpButton, SkipNextEpisodeController(skipOpButton) ) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/CustomRecyclerViews.kt ================================================ package com.lagradost.cloudstream3.ui import android.content.Context import android.util.AttributeSet import android.view.View import androidx.core.content.withStyledAttributes import androidx.core.view.children import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import kotlin.math.abs class GrdLayoutManager(val context: Context, spanCount: Int) : GridLayoutManager(context, spanCount) { override fun onFocusSearchFailed( focused: View, focusDirection: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State ): View? { return try { val fromPos = getPosition(focused) val nextPos = getNextViewPos(fromPos, focusDirection) findViewByPosition(nextPos) } catch (e: Exception) { null } } /*override fun onRequestChildFocus( parent: RecyclerView, state: RecyclerView.State, child: View, focused: View? ): Boolean { // android.widget.FrameLayout$LayoutParams cannot be cast to androidx.recyclerview.widget.RecyclerView$LayoutParams return try { if(focused != null) { // val pos = maxOf(0, getPosition(focused) - 2) // IDK WHY val pos = getPosition(focused) if(pos >= 0) parent.scrollToPosition(pos) } super.onRequestChildFocus(parent, state, child, focused) } catch (e: Exception) { false } }*/ // Allows moving right and left with focus https://gist.github.com/vganin/8930b41f55820ec49e4d override fun onInterceptFocusSearch(focused: View, direction: Int): View? { return try { val fromPos = getPosition(focused) val nextPos = getNextViewPos(fromPos, direction) findViewByPosition(nextPos) } catch (e: Exception) { null } } private fun getNextViewPos(fromPos: Int, direction: Int): Int { val offset = calcOffsetToNextView(direction) if (hitBorder(fromPos, offset)) { return fromPos } return fromPos + offset } private fun calcOffsetToNextView(direction: Int): Int { val spanCount = this.spanCount val orientation = this.orientation // fixes arabic by inverting left and right layout focus val correctDirection = if (this.isLayoutRTL) { when (direction) { View.FOCUS_RIGHT -> View.FOCUS_LEFT View.FOCUS_LEFT -> View.FOCUS_RIGHT else -> direction } } else direction if (orientation == VERTICAL) { when (correctDirection) { View.FOCUS_DOWN -> { return spanCount } View.FOCUS_UP -> { return -spanCount } View.FOCUS_RIGHT -> { return 1 } View.FOCUS_LEFT -> { return -1 } } } else if (orientation == HORIZONTAL) { when (correctDirection) { View.FOCUS_DOWN -> { return 1 } View.FOCUS_UP -> { return -1 } View.FOCUS_RIGHT -> { return spanCount } View.FOCUS_LEFT -> { return -spanCount } } } return 0 } private fun hitBorder(from: Int, offset: Int): Boolean { val spanCount = spanCount return if (abs(offset) == 1) { val spanIndex = from % spanCount val newSpanIndex = spanIndex + offset newSpanIndex < 0 || newSpanIndex >= spanCount } else { val newPos = from + offset newPos in spanCount..-1 } } } // https://riptutorial.com/android/example/4810/gridlayoutmanager-with-dynamic-span-count class AutofitRecyclerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null) : RecyclerView(context, attrs) { private val manager = GrdLayoutManager(context, 2) // THIS CONTROLS SPANS private var columnWidth = -1 var spanCount = 0 set(value) { field = value if (value > 0) { manager.spanCount = value } } val itemWidth: Int get() = measuredWidth / manager.spanCount init { if (attrs != null) { context.withStyledAttributes(attrs, intArrayOf(android.R.attr.columnWidth)) { columnWidth = getDimensionPixelSize(0, -1) } } layoutManager = manager } } /** * Recyclerview wherein the max item width or height is set by the biggest view to prevent inconsistent view sizes. */ class MaxRecyclerView(ctx: Context, attrs: AttributeSet) : RecyclerView(ctx, attrs) { private var biggestObserved: Int = 0 private val orientation = LayoutManager.getProperties(context, attrs, 0, 0).orientation private val isHorizontal = orientation == HORIZONTAL private fun View.updateMaxSize() { if (isHorizontal) { this.minimumHeight = biggestObserved } else { this.minimumWidth = biggestObserved } } override fun onChildAttachedToWindow(child: View) { child.measure(MeasureSpec.UNSPECIFIED, MeasureSpec.UNSPECIFIED) val observed = if (isHorizontal) child.measuredHeight else child.measuredWidth if (observed > biggestObserved) { biggestObserved = observed children.forEach { it.updateMaxSize() } } else { child.updateMaxSize() } super.onChildAttachedToWindow(child) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/EasterEggMonkeFragment.kt ================================================ package com.lagradost.cloudstream3.ui import android.animation.Animator import android.animation.AnimatorListenerAdapter import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.view.MotionEvent import android.view.View import android.view.animation.AccelerateInterpolator import android.view.animation.LinearInterpolator import android.widget.FrameLayout import android.widget.ImageView import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentEasterEggMonkeBinding import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlin.random.Random class EasterEggMonkeFragment : BaseFragment( BaseFragment.BindingCreator.Inflate(FragmentEasterEggMonkeBinding::inflate) ) { // planet of monks private val monkeys: List = listOf( R.drawable.monke_benene, R.drawable.monke_burrito, R.drawable.monke_coco, R.drawable.monke_cookie, R.drawable.monke_flusdered, R.drawable.monke_funny, R.drawable.monke_like, R.drawable.monke_party, R.drawable.monke_sob, R.drawable.monke_drink, R.drawable.benene, R.drawable.ic_launcher_foreground, R.drawable.quick_novel_icon, ) private val activeMonkeys = mutableListOf() private var spawningJob: Job? = null override fun fixLayout(view: View) = Unit override fun onBindingCreated(binding: FragmentEasterEggMonkeBinding) { activity?.hideSystemUI() spawningJob = lifecycleScope.launch { delay(1000) while (isActive) { spawnMonkey(binding) delay(500) } } } private fun spawnMonkey(binding: FragmentEasterEggMonkeBinding) { val newMonkey = ImageView(context ?: return).apply { setImageResource(monkeys.random()) isVisible = true } val initialScale = Random.nextFloat() * 1.5f + 0.5f newMonkey.scaleX = initialScale newMonkey.scaleY = initialScale newMonkey.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) val monkeyW = newMonkey.measuredWidth * initialScale val monkeyH = newMonkey.measuredHeight * initialScale newMonkey.x = Random.nextFloat() * (binding.frame.width.toFloat() - monkeyW) newMonkey.y = Random.nextFloat() * (binding.frame.height.toFloat() - monkeyH) binding.frame.addView(newMonkey, FrameLayout.LayoutParams( FrameLayout.LayoutParams.WRAP_CONTENT, FrameLayout.LayoutParams.WRAP_CONTENT )) activeMonkeys.add(newMonkey) newMonkey.alpha = 0f ObjectAnimator.ofFloat(newMonkey, View.ALPHA, 0f, 1f).apply { duration = Random.nextLong(1000, 2500) interpolator = AccelerateInterpolator() start() } @SuppressLint("ClickableViewAccessibility") newMonkey.setOnTouchListener { view, event -> handleTouch(view, event, binding) } startFloatingAnimation(newMonkey, binding) } private fun startFloatingAnimation(monkey: ImageView, binding: FragmentEasterEggMonkeBinding) { val floatUpAnimator = ObjectAnimator.ofFloat( monkey, View.TRANSLATION_Y, monkey.y, -monkey.height.toFloat() ).apply { duration = Random.nextLong(8000, 15000) interpolator = LinearInterpolator() } floatUpAnimator.addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { binding.frame.removeView(monkey) activeMonkeys.remove(monkey) } }) floatUpAnimator.start() monkey.tag = floatUpAnimator } private fun handleTouch( view: View, event: MotionEvent, binding: FragmentEasterEggMonkeBinding ): Boolean { val monkey = view as ImageView when (event.action) { MotionEvent.ACTION_DOWN -> { (monkey.tag as? ObjectAnimator)?.pause() return true } MotionEvent.ACTION_MOVE -> { // Update both X and Y positions properly monkey.x = event.rawX - monkey.width / 2 monkey.y = event.rawY - monkey.height / 2 // Check if monkey touches the screen edge if (isTouchingEdge(monkey, binding)) { removeMonkey(monkey, binding) } return true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { if (isTouchingEdge(monkey, binding)) { removeMonkey(monkey, binding) } else { startFloatingAnimation(monkey, binding) } return true } } return false } private fun isTouchingEdge(monkey: ImageView, binding: FragmentEasterEggMonkeBinding): Boolean { return monkey.x <= 0 || monkey.x + monkey.width >= binding.frame.width || monkey.y <= 0 || monkey.y + monkey.height >= binding.frame.height } private fun removeMonkey(monkey: ImageView, binding: FragmentEasterEggMonkeBinding) { // Fade out and remove the monkey ObjectAnimator.ofFloat(monkey, View.ALPHA, 1f, 0f).apply { duration = 300 addListener(object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { binding.frame.removeView(monkey) activeMonkeys.remove(monkey) } }) start() } } override fun onDestroyView() { super.onDestroyView() activity?.showSystemUI() spawningJob?.cancel() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/MiniControllerFragment.kt ================================================ package com.lagradost.cloudstream3.ui import android.content.Context import android.os.Bundle import android.util.AttributeSet import android.view.View import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.RelativeLayout import androidx.core.content.withStyledAttributes import com.google.android.gms.cast.framework.media.widget.MiniControllerFragment import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.utils.UIHelper.adjustAlpha import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.toPx class MyMiniControllerFragment : MiniControllerFragment() { var currentColor: Int = 0 override fun onDestroy() { currentColor = 0 super.onDestroy() } // I KNOW, KINDA SPAGHETTI SOLUTION, BUT IT WORKS override fun onInflate(context: Context, attributeSet: AttributeSet, bundle: Bundle?) { if (currentColor == 0) { context.withStyledAttributes(attributeSet, R.styleable.CustomCast, 0, 0) { if (hasValue(R.styleable.CustomCast_customCastBackgroundColor)) { currentColor = getColor(R.styleable.CustomCast_customCastBackgroundColor, 0) } } } super.onInflate(context, attributeSet, bundle) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // SEE https://github.com/dandar3/android-google-play-services-cast-framework/blob/master/res/layout/cast_mini_controller.xml try { val progressBar: ProgressBar? = view.findViewById(R.id.progressBar) val containerAll: LinearLayout? = view.findViewById(com.google.android.gms.cast.framework.R.id.container_all) val containerCurrent: RelativeLayout? = view.findViewById(com.google.android.gms.cast.framework.R.id.container_current) context?.let { ctx -> progressBar?.setBackgroundColor( adjustAlpha( ctx.colorFromAttribute(R.attr.colorPrimary), 0.35f ) ) val params = RelativeLayout.LayoutParams( RelativeLayout.LayoutParams.MATCH_PARENT, 2.toPx ) progressBar?.layoutParams = params val color = currentColor if (color != 0) containerCurrent?.setBackgroundColor(color) } val child = containerAll?.getChildAt(0) child?.alpha = 0f // REMOVE GRADIENT } catch (e: Exception) { // JUST IN CASE } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/NonFinalAdapterListUpdateCallback.kt ================================================ package com.lagradost.cloudstream3.ui import android.annotation.SuppressLint import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListUpdateCallback import androidx.recyclerview.widget.RecyclerView /** * ListUpdateCallback that dispatches update events to the given adapter. * * @see DiffUtil.DiffResult.dispatchUpdatesTo */ open class NonFinalAdapterListUpdateCallback /** * Creates an AdapterListUpdateCallback that will dispatch update events to the given adapter. * * @param mAdapter The Adapter to send updates to. */(private var mAdapter: RecyclerView.Adapter<*>) : ListUpdateCallback { override fun onInserted(position: Int, count: Int) { mAdapter.notifyItemRangeInserted(position, count) } override fun onRemoved(position: Int, count: Int) { mAdapter.notifyItemRangeRemoved(position, count) } override fun onMoved(fromPosition: Int, toPosition: Int) { mAdapter.notifyItemMoved(fromPosition, toPosition) } @SuppressLint("UnknownNullness") // b/240775049: Cannot annotate properly override fun onChanged(position: Int, count: Int, payload: Any?) { mAdapter.notifyItemRangeChanged(position, count, payload) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/WatchType.kt ================================================ package com.lagradost.cloudstream3.ui import androidx.annotation.DrawableRes import androidx.annotation.StringRes import com.lagradost.cloudstream3.R enum class WatchType(val internalId: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int) { WATCHING(0, R.string.type_watching, R.drawable.ic_baseline_bookmark_24), COMPLETED(1, R.string.type_completed, R.drawable.ic_baseline_bookmark_24), ONHOLD(2, R.string.type_on_hold, R.drawable.ic_baseline_bookmark_24), DROPPED(3, R.string.type_dropped, R.drawable.ic_baseline_bookmark_24), PLANTOWATCH(4, R.string.type_plan_to_watch, R.drawable.ic_baseline_bookmark_24), NONE(5, R.string.type_none, R.drawable.ic_baseline_add_24); companion object { fun fromInternalId(id: Int?) = entries.find { value -> value.internalId == id } ?: NONE } } enum class SyncWatchType(val internalId: Int, @StringRes val stringRes: Int, @DrawableRes val iconRes: Int) { NONE(-1, R.string.type_none, R.drawable.ic_baseline_add_24), WATCHING(0, R.string.type_watching, R.drawable.ic_baseline_bookmark_24), COMPLETED(1, R.string.type_completed, R.drawable.ic_baseline_bookmark_24), ONHOLD(2, R.string.type_on_hold, R.drawable.ic_baseline_bookmark_24), DROPPED(3, R.string.type_dropped, R.drawable.ic_baseline_bookmark_24), PLANTOWATCH(4, R.string.type_plan_to_watch, R.drawable.ic_baseline_bookmark_24), REWATCHING(5, R.string.type_re_watching, R.drawable.ic_baseline_bookmark_24); companion object { fun fromInternalId(id: Int?) = entries.find { value -> value.internalId == id } ?: NONE } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/WebviewFragment.kt ================================================ package com.lagradost.cloudstream3.ui import android.os.Bundle import android.view.View import android.webkit.JavascriptInterface import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import androidx.fragment.app.FragmentActivity import androidx.navigation.fragment.findNavController import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.databinding.FragmentWebviewBinding import com.lagradost.cloudstream3.network.WebViewResolver import com.lagradost.cloudstream3.utils.AppContextUtils.loadRepository class WebviewFragment : BaseFragment( BaseFragment.BindingCreator.Inflate(FragmentWebviewBinding::inflate) ) { override fun fixLayout(view: View) = Unit override fun onBindingCreated(binding: FragmentWebviewBinding) { val url = arguments?.getString(WEBVIEW_URL) ?: "".also { findNavController().popBackStack() } binding.webView.webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading( view: WebView?, request: WebResourceRequest? ): Boolean { val requestUrl = request?.url.toString() val performedAction = MainActivity.handleAppIntentUrl(activity, requestUrl, true) if (performedAction) { findNavController().popBackStack() return true } return super.shouldOverrideUrlLoading(view, request) } } binding.webView.apply { WebViewResolver.webViewUserAgent = settings.userAgentString addJavascriptInterface(RepoApi(activity), "RepoApi") settings.javaScriptEnabled = true settings.userAgentString = USER_AGENT settings.domStorageEnabled = true loadUrl(url) } } companion object { private const val WEBVIEW_URL = "webview_url" fun newInstance(webViewUrl: String) = Bundle().apply { putString(WEBVIEW_URL, webViewUrl) } } private class RepoApi(val activity: FragmentActivity?) { @JavascriptInterface fun installRepo(repoUrl: String) { activity?.loadRepository(repoUrl) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountAdapter.kt ================================================ package com.lagradost.cloudstream3.ui.account import android.os.Build import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.content.ContextCompat import androidx.core.view.isVisible import coil3.transform.RoundedCornersTransformation import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.AccountListItemAddBinding import com.lagradost.cloudstream3.databinding.AccountListItemBinding import com.lagradost.cloudstream3.databinding.AccountListItemEditBinding import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.ImageLoader.loadImage class AccountAdapter( private val accountSelectCallback: (DataStoreHelper.Account) -> Unit, private val accountCreateCallback: (DataStoreHelper.Account) -> Unit, private val accountEditCallback: (DataStoreHelper.Account) -> Unit, private val accountDeleteCallback: (DataStoreHelper.Account) -> Unit ) : NoStateAdapter() { companion object { const val VIEW_TYPE_SELECT_ACCOUNT = 0 const val VIEW_TYPE_EDIT_ACCOUNT = 2 } override val footers: Int = 1 var viewType = VIEW_TYPE_SELECT_ACCOUNT override fun customContentViewType(item: DataStoreHelper.Account): Int { return viewType } override fun onBindContent( holder: ViewHolderState, item: DataStoreHelper.Account, position: Int ) { when (val binding = holder.view) { is AccountListItemBinding -> binding.apply { val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex accountName.text = item.name accountImage.loadImage(item.image) lockIcon.isVisible = item.lockPin != null outline.isVisible = !isTv && isLastUsedAccount if (isTv) { // For emulator but this is fine on TV also root.isFocusableInTouchMode = true if (isLastUsedAccount) { root.requestFocus() } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { root.foreground = ContextCompat.getDrawable( root.context, R.drawable.outline_drawable ) } } else { root.setOnLongClickListener { showAccountEditDialog( context = root.context, account = item, isNewAccount = false, accountEditCallback = { account -> accountEditCallback.invoke( account ) }, accountDeleteCallback = { account -> accountDeleteCallback.invoke( account ) } ) true } } root.setOnClickListener { accountSelectCallback.invoke(item) } } is AccountListItemEditBinding -> binding.apply { val isTv = isLayout(TV or EMULATOR) || !root.isInTouchMode val isLastUsedAccount = item.keyIndex == DataStoreHelper.selectedKeyIndex accountName.text = item.name accountImage.loadImage(item.image) { RoundedCornersTransformation(10f) } lockIcon.isVisible = item.lockPin != null outline.isVisible = !isTv && isLastUsedAccount if (isTv) { // For emulator but this is fine on TV also root.isFocusableInTouchMode = true if (isLastUsedAccount) { root.requestFocus() } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { root.foreground = ContextCompat.getDrawable( root.context, R.drawable.outline_drawable ) } } root.setOnClickListener { showAccountEditDialog( context = root.context, account = item, isNewAccount = false, accountEditCallback = { account -> accountEditCallback.invoke(account) }, accountDeleteCallback = { account -> accountDeleteCallback.invoke( account ) } ) } } } } override fun onBindFooter(holder: ViewHolderState) { val binding = holder.view as? AccountListItemAddBinding ?: return binding.apply { root.setOnClickListener { val accounts = this@AccountAdapter.immutableCurrentList val remainingImages = DataStoreHelper.profileImages.toSet() - accounts.filter { it.customImage == null } .mapNotNull { DataStoreHelper.profileImages.getOrNull(it.defaultImageIndex) } .toSet() val image = DataStoreHelper.profileImages.indexOf( remainingImages.randomOrNull() ?: DataStoreHelper.profileImages.random() ) val keyIndex = (accounts.maxOfOrNull { it.keyIndex } ?: 0) + 1 val accountName = root.context.getString(R.string.account) showAccountEditDialog( root.context, DataStoreHelper.Account( keyIndex = keyIndex, name = "$accountName $keyIndex", customImage = null, defaultImageIndex = image ), isNewAccount = true, accountEditCallback = { account -> accountCreateCallback.invoke(account) }, accountDeleteCallback = {} ) } } } override fun onCreateFooter(parent: ViewGroup): ViewHolderState { return ViewHolderState( AccountListItemAddBinding.inflate( LayoutInflater.from(parent.context), parent, false ) ) } override fun onCreateContent(parent: ViewGroup): ViewHolderState { return ViewHolderState( when (viewType) { VIEW_TYPE_SELECT_ACCOUNT -> { AccountListItemBinding.inflate( LayoutInflater.from(parent.context), parent, false ) } VIEW_TYPE_EDIT_ACCOUNT -> { AccountListItemEditBinding.inflate( LayoutInflater.from(parent.context), parent, false ) } else -> throw IllegalArgumentException("Invalid view type") } ) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountHelper.kt ================================================ package com.lagradost.cloudstream3.ui.account import android.app.Activity import android.content.Context import android.content.DialogInterface import android.os.Bundle import android.text.Editable import android.view.LayoutInflater import android.view.inputmethod.EditorInfo import android.widget.TextView import android.widget.Toast import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import coil3.ImageLoader import coil3.request.ImageRequest import coil3.request.allowHardware import com.google.android.material.bottomsheet.BottomSheetDialog import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.AccountEditDialogBinding import com.lagradost.cloudstream3.databinding.AccountSelectLinearBinding import com.lagradost.cloudstream3.databinding.BottomInputDialogBinding import com.lagradost.cloudstream3.databinding.LockPinDialogBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.showInputMethod object AccountHelper { fun showAccountEditDialog( context: Context, account: DataStoreHelper.Account, isNewAccount: Boolean, accountEditCallback: (DataStoreHelper.Account) -> Unit, accountDeleteCallback: (DataStoreHelper.Account) -> Unit ) { val binding = AccountEditDialogBinding.inflate(LayoutInflater.from(context), null, false) val builder = AlertDialog.Builder(context, R.style.AlertDialogCustom) .setView(binding.root) var currentEditAccount = account val dialog = builder.show() if (!isNewAccount) binding.title.setText(R.string.edit_account) // Set up the dialog content binding.accountName.text = Editable.Factory.getInstance()?.newEditable(account.name) binding.accountName.doOnTextChanged { text, _, _, _ -> currentEditAccount = currentEditAccount.copy(name = text?.toString() ?: "") } binding.deleteBtt.isGone = isNewAccount binding.deleteBtt.setOnClickListener { val dialogClickListener = DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { accountDeleteCallback.invoke(account) dialog?.dismissSafe() } DialogInterface.BUTTON_NEGATIVE -> { dialog?.dismissSafe() } } } try { AlertDialog.Builder(context).setTitle(R.string.delete).setMessage( context.getString(R.string.delete_message).format( currentEditAccount.name ) ) .setPositiveButton(R.string.delete, dialogClickListener) .setNegativeButton(R.string.cancel, dialogClickListener) .show().setDefaultFocus() } catch (t: Throwable) { logError(t) } } binding.cancelBtt.setOnClickListener { dialog?.dismissSafe() } // Handle the profile picture and its interactions binding.accountImage.loadImage(account.image) binding.accountImage.setOnClickListener { // Roll the image forwards once currentEditAccount = currentEditAccount.copy(customImage = null) currentEditAccount = currentEditAccount.copy(defaultImageIndex = (currentEditAccount.defaultImageIndex + 1) % DataStoreHelper.profileImages.size) binding.accountImage.loadImage(currentEditAccount.image) } // Handle applying changes binding.applyBtt.setOnClickListener { if (currentEditAccount.lockPin != null) { // Ask for the current PIN showPinInputDialog(context, currentEditAccount.lockPin, false) { pin -> if (pin == null) return@showPinInputDialog // PIN is correct, proceed to update the account accountEditCallback.invoke(currentEditAccount) dialog.dismissSafe() } } else { // No lock PIN set, proceed to update the account accountEditCallback.invoke(currentEditAccount) dialog.dismissSafe() } } // Handle setting or changing the PIN if (currentEditAccount.keyIndex == getDefaultAccount(context).keyIndex) { binding.lockProfileCheckbox.isVisible = false if (currentEditAccount.lockPin != null) { currentEditAccount = currentEditAccount.copy(lockPin = null) } } var canSetPin = true binding.lockProfileCheckbox.isChecked = currentEditAccount.lockPin != null binding.lockProfileCheckbox.setOnCheckedChangeListener { _, isChecked -> if (isChecked) { if (canSetPin) { showPinInputDialog(context, null, true) { pin -> if (pin == null) { binding.lockProfileCheckbox.isChecked = false return@showPinInputDialog } currentEditAccount = currentEditAccount.copy(lockPin = pin) } } } else { if (currentEditAccount.lockPin != null) { // Ask for the current PIN showPinInputDialog(context, currentEditAccount.lockPin, true) { pin -> if (pin == null || pin != currentEditAccount.lockPin) { canSetPin = false binding.lockProfileCheckbox.isChecked = true } else { currentEditAccount = currentEditAccount.copy(lockPin = null) } } } } } canSetPin = true binding.editProfilePhotoButton.setOnClickListener({ val bottomSheetDialog = BottomSheetDialog(context) val sheetBinding = BottomInputDialogBinding.inflate(LayoutInflater.from(context)) bottomSheetDialog.setContentView(sheetBinding.root) bottomSheetDialog.show() sheetBinding.apply { text1.text = context.getString(R.string.edit_profile_image_title) nginxTextInput.hint = context.getString(R.string.edit_profile_image_hint) applyBtt.setOnClickListener({ val url = sheetBinding.nginxTextInput.text.toString() if (url.isNotEmpty()) { val imageLoader = ImageLoader(context) val request = ImageRequest.Builder(context) .data(url) .allowHardware(false) .listener( onSuccess = { _, _ -> currentEditAccount = currentEditAccount.copy(customImage = url) binding.accountImage.loadImage(url) showToast( R.string.edit_profile_image_success, Toast.LENGTH_SHORT ) bottomSheetDialog.dismiss() }, onError = { _, _ -> showToast( R.string.edit_profile_image_error_invalid, Toast.LENGTH_SHORT ) } ) .build() imageLoader.enqueue(request) } else { showToast(R.string.edit_profile_image_error_empty, Toast.LENGTH_SHORT) } bottomSheetDialog.dismissSafe() }) sheetBinding.cancelBtt.setOnClickListener({ bottomSheetDialog.dismissSafe() }) } }) } fun showPinInputDialog( context: Context, currentPin: String?, editAccount: Boolean, forStartup: Boolean = false, errorText: String? = null, callback: (String?) -> Unit ) { fun TextView.visibleWithText(@StringRes textRes: Int) { isVisible = true setText(textRes) } fun TextView.visibleWithText(text: String?) { isVisible = true setText(text) } val binding = LockPinDialogBinding.inflate(LayoutInflater.from(context)) val isPinSet = currentPin != null val isNewPin = editAccount && !isPinSet val isEditPin = editAccount && isPinSet val titleRes = if (isEditPin) R.string.enter_current_pin else R.string.enter_pin var isPinValid = false val builder = AlertDialog.Builder(context, R.style.AlertDialogCustom) .setView(binding.root) .setTitle(titleRes) .setNegativeButton(R.string.cancel) { _, _ -> callback.invoke(null) } .setOnCancelListener { callback.invoke(null) } .setOnDismissListener { if (!isPinValid) { callback.invoke(null) } } if (forStartup) { val currentAccount = DataStoreHelper.accounts.firstOrNull { it.keyIndex == DataStoreHelper.selectedKeyIndex } builder.setTitle(context.getString(R.string.enter_pin_with_name, currentAccount?.name)) builder.setOnDismissListener { if (!isPinValid) { context.getActivity()?.finish() } } // So that if they don't know the PIN for the current account, // they don't get completely locked out builder.setNeutralButton(R.string.use_default_account) { _, _ -> val activity = context.getActivity() if (activity is AccountSelectActivity) { isPinValid = true activity.accountViewModel.handleAccountSelect(getDefaultAccount(context), activity) } } } if (isNewPin) { if (errorText != null) binding.pinEditTextError.visibleWithText(errorText) builder.setPositiveButton(R.string.setup_done) { _, _ -> if (!isPinValid) { // If the done button is pressed and there is an error, // ask again, and mention the error that caused this. showPinInputDialog( context = binding.root.context, currentPin = null, editAccount = true, errorText = binding.pinEditTextError.text.toString(), callback = callback ) } else { val enteredPin = binding.pinEditText.text.toString() callback.invoke(enteredPin) } } } val dialog = builder.create() binding.pinEditText.doOnTextChanged { text, _, _, _ -> val enteredPin = text.toString() val isEnteredPinValid = enteredPin.length == 4 if (isEnteredPinValid) { if (isPinSet) { if (enteredPin != currentPin) { binding.pinEditTextError.visibleWithText(R.string.pin_error_incorrect) binding.pinEditText.text = null isPinValid = false } else { binding.pinEditTextError.isVisible = false isPinValid = true callback.invoke(enteredPin) dialog.dismissSafe() } } else { binding.pinEditTextError.isVisible = false isPinValid = true } } else if (isNewPin) { binding.pinEditTextError.visibleWithText(R.string.pin_error_length) isPinValid = false } } // Detect IME_ACTION_DONE binding.pinEditText.setOnEditorActionListener { _, actionId, _ -> if (actionId == EditorInfo.IME_ACTION_DONE && isPinValid) { val enteredPin = binding.pinEditText.text.toString() callback.invoke(enteredPin) dialog.dismissSafe() } true } // We don't want to accidentally have the dialog dismiss when clicking outside of it. // That is what the cancel button is for. dialog.setCanceledOnTouchOutside(false) dialog.show() // Auto focus on PIN input and show keyboard binding.pinEditText.requestFocus() binding.pinEditText.postDelayed({ showInputMethod(binding.pinEditText) }, 200) } fun Activity?.showAccountSelectLinear() { val activity = this as? MainActivity ?: return val viewModel = ViewModelProvider(activity)[AccountViewModel::class.java] val binding: AccountSelectLinearBinding = AccountSelectLinearBinding.inflate( LayoutInflater.from(activity) ) val builder = BottomSheetDialog(activity) builder.setContentView(binding.root) builder.show() binding.manageAccountsButton.setOnClickListener { activity.navigate( R.id.accountSelectActivity, Bundle().apply { putBoolean("isEditingFromMainActivity", true) } ) builder.dismissSafe() } val recyclerView: RecyclerView = binding.accountRecyclerView val itemSize = recyclerView.resources.getDimensionPixelSize( R.dimen.account_select_linear_item_size ) recyclerView.addItemDecoration(AccountSelectLinearItemDecoration(itemSize)) recyclerView.setLinearListLayout(isHorizontal = true) val currentAccount = DataStoreHelper.accounts.firstOrNull { it.keyIndex == DataStoreHelper.selectedKeyIndex } ?: getDefaultAccount(activity) // We want to make sure the accounts are up-to-date viewModel.handleAccountSelect( currentAccount, activity, reloadForActivity = true ) activity.observe(viewModel.accounts) { liveAccounts -> recyclerView.adapter = AccountAdapter( accountSelectCallback = { account -> viewModel.handleAccountSelect(account, activity) builder.dismissSafe() }, accountCreateCallback = { viewModel.handleAccountUpdate(it, activity) }, accountEditCallback = { viewModel.handleAccountUpdate(it, activity) }, accountDeleteCallback = { viewModel.handleAccountDelete(it, activity) } ).apply { submitList(liveAccounts) } activity.observe(viewModel.selectedKeyIndex) { selectedKeyIndex -> // Scroll to current account (which is focused by default) val layoutManager = recyclerView.layoutManager as LinearLayoutManager layoutManager.scrollToPositionWithOffset(selectedKeyIndex, 0) } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectActivity.kt ================================================ package com.lagradost.cloudstream3.ui.account import android.annotation.SuppressLint import android.os.Bundle import android.util.Log import androidx.fragment.app.FragmentActivity import androidx.activity.viewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.CommonActivity.loadThemes import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.ActivityAccountSelectBinding import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.ui.AutofitRecyclerView import com.lagradost.cloudstream3.ui.account.AccountAdapter.Companion.VIEW_TYPE_EDIT_ACCOUNT import com.lagradost.cloudstream3.ui.account.AccountAdapter.Companion.VIEW_TYPE_SELECT_ACCOUNT import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.BiometricAuthenticator import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback import com.lagradost.cloudstream3.utils.BiometricAuthenticator.biometricPrompt import com.lagradost.cloudstream3.utils.BiometricAuthenticator.deviceHasPasswordPinLock import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication import com.lagradost.cloudstream3.utils.DataStoreHelper.accounts import com.lagradost.cloudstream3.utils.DataStoreHelper.selectedKeyIndex import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.openActivity import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat class AccountSelectActivity : FragmentActivity(), BiometricCallback { val accountViewModel: AccountViewModel by viewModels() @SuppressLint("NotifyDataSetChanged") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) loadThemes(this) enableEdgeToEdgeCompat() setNavigationBarColorCompat(R.attr.primaryBlackBackground) // Are we editing and coming from MainActivity? val isEditingFromMainActivity = intent.getBooleanExtra( "isEditingFromMainActivity", false ) val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val skipStartup = settingsManager.getBoolean( getString(R.string.skip_startup_account_select_key), false ) || accounts.count() <= 1 fun askBiometricAuth() { if (isLayout(PHONE) && isAuthEnabled(this)) { if (deviceHasPasswordPinLock(this)) { startBiometricAuthentication( this, R.string.biometric_authentication_title, false ) promptInfo?.let { prompt -> biometricPrompt?.authenticate(prompt) } } } } observe(accountViewModel.isAllowedLogin) { isAllowedLogin -> if (isAllowedLogin) { // We are allowed to continue to MainActivity navigateToMainActivity() } } // Don't show account selection if there is only // one account that exists if (!isEditingFromMainActivity && skipStartup) { val currentAccount = accounts.firstOrNull { it.keyIndex == selectedKeyIndex } if (currentAccount?.lockPin != null) { CommonActivity.init(this) accountViewModel.handleAccountSelect(currentAccount, this, true) } else { if (accounts.count() > 1) { showToast( this, getString( R.string.logged_account, currentAccount?.name ) ) } navigateToMainActivity() } return } CommonActivity.init(this) val binding = ActivityAccountSelectBinding.inflate(layoutInflater) setContentView(binding.root) fixSystemBarsPadding(binding.root, padTop = false) val recyclerView: AutofitRecyclerView = binding.accountRecyclerView observe(accountViewModel.accounts) { liveAccounts -> val adapter = AccountAdapter( // Handle the selected account accountSelectCallback = { accountViewModel.handleAccountSelect(it, this) }, accountCreateCallback = { accountViewModel.handleAccountUpdate(it, this) }, accountEditCallback = { accountViewModel.handleAccountUpdate(it, this) // We came from MainActivity, return there // and switch to the edited account if (isEditingFromMainActivity) { setAccount(it) navigateToMainActivity() } }, accountDeleteCallback = { accountViewModel.handleAccountDelete(it, this) } ).apply { submitList(liveAccounts) } recyclerView.adapter = adapter if (isLayout(TV or EMULATOR)) { binding.editAccountButton.setBackgroundResource( R.drawable.player_button_tv_attr_no_bg ) } observe(accountViewModel.selectedKeyIndex) { selectedKeyIndex -> // Scroll to current account (which is focused by default) val layoutManager = recyclerView.layoutManager as GridLayoutManager layoutManager.scrollToPositionWithOffset(selectedKeyIndex, 0) } observe(accountViewModel.isEditing) { isEditing -> if (isEditing) { binding.editAccountButton.setImageResource(R.drawable.ic_baseline_close_24) binding.title.setText(R.string.manage_accounts) adapter.viewType = VIEW_TYPE_EDIT_ACCOUNT } else { binding.editAccountButton.setImageResource(R.drawable.ic_baseline_edit_24) binding.title.setText(R.string.select_an_account) adapter.viewType = VIEW_TYPE_SELECT_ACCOUNT } adapter.notifyDataSetChanged() } if (isEditingFromMainActivity) { accountViewModel.setIsEditing(true) } binding.editAccountButton.setOnClickListener { // We came from MainActivity, return there // and resume its state if (isEditingFromMainActivity) { navigateToMainActivity() return@setOnClickListener } accountViewModel.toggleIsEditing() } if (isLayout(TV or EMULATOR)) { recyclerView.spanCount = if (liveAccounts.count() + 1 <= 6) { liveAccounts.count() + 1 } else 6 } } askBiometricAuth() } private fun navigateToMainActivity() { openActivity(MainActivity::class.java) finish() // Finish the account selection activity } override fun onAuthenticationSuccess() { Log.i(BiometricAuthenticator.TAG, "Authentication successful in AccountSelectActivity") } override fun onAuthenticationError() { finish() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountSelectLinearItemDecoration.kt ================================================ package com.lagradost.cloudstream3.ui.account import android.graphics.Rect import android.view.View import androidx.recyclerview.widget.RecyclerView class AccountSelectLinearItemDecoration(private val size: Int) : RecyclerView.ItemDecoration() { override fun getItemOffsets(outRect: Rect, view: View, parent: RecyclerView, state: RecyclerView.State) { val layoutParams = view.layoutParams as RecyclerView.LayoutParams layoutParams.width = size layoutParams.height = size view.layoutParams = layoutParams } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/account/AccountViewModel.kt ================================================ package com.lagradost.cloudstream3.ui.account import android.content.Context import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.ui.account.AccountHelper.showPinInputDialog import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getAccounts import com.lagradost.cloudstream3.utils.DataStoreHelper.getDefaultAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.setAccount class AccountViewModel : ViewModel() { private fun getAllAccounts(): List { return context?.let { getAccounts(it) } ?: DataStoreHelper.accounts.toList() } private val _accounts: MutableLiveData> = MutableLiveData(getAllAccounts()) val accounts: LiveData> = _accounts private val _isEditing = MutableLiveData(false) val isEditing: LiveData = _isEditing private val _isAllowedLogin = MutableLiveData(false) val isAllowedLogin: LiveData = _isAllowedLogin private val _selectedKeyIndex = MutableLiveData( getAllAccounts().indexOfFirst { it.keyIndex == DataStoreHelper.selectedKeyIndex } ) val selectedKeyIndex: LiveData = _selectedKeyIndex fun setIsEditing(value: Boolean) { _isEditing.postValue(value) } fun toggleIsEditing() { _isEditing.postValue(!(_isEditing.value ?: false)) } fun handleAccountUpdate( account: DataStoreHelper.Account, context: Context ) { val currentAccounts = getAccounts(context).toMutableList() val overrideIndex = currentAccounts.indexOfFirst { it.keyIndex == account.keyIndex } if (overrideIndex != -1) { currentAccounts[overrideIndex] = account } else currentAccounts.add(account) val currentHomePage = DataStoreHelper.currentHomePage setAccount(account) DataStoreHelper.currentHomePage = currentHomePage DataStoreHelper.accounts = currentAccounts.toTypedArray() _accounts.postValue(getAccounts(context)) _selectedKeyIndex.postValue(getAccounts(context).indexOf(account)) MainActivity.reloadAccountEvent(true) } fun handleAccountDelete( account: DataStoreHelper.Account, context: Context ) { removeKeys(account.keyIndex.toString()) val currentAccounts = getAccounts(context).toMutableList() currentAccounts.removeIf { it.keyIndex == account.keyIndex } DataStoreHelper.accounts = currentAccounts.toTypedArray() if (account.keyIndex == DataStoreHelper.selectedKeyIndex) { setAccount(getDefaultAccount(context)) } _accounts.postValue(getAccounts(context)) _selectedKeyIndex.postValue(getAllAccounts().indexOfFirst { it.keyIndex == DataStoreHelper.selectedKeyIndex }) MainActivity.reloadAccountEvent(true) } fun handleAccountSelect( account: DataStoreHelper.Account, context: Context, forStartup: Boolean = false, reloadForActivity: Boolean = false ) { if (reloadForActivity) { _accounts.postValue(getAccounts(context)) _selectedKeyIndex.postValue(getAccounts(context).indexOf(account)) MainActivity.reloadAccountEvent(true) return } // Check if the selected account has a lock PIN set if (account.lockPin != null) { // The selected account has a PIN set, prompt the user to enter the PIN showPinInputDialog( context, account.lockPin, false, forStartup ) { pin -> if (pin == null) return@showPinInputDialog // Pin is correct, proceed _isAllowedLogin.postValue(true) _selectedKeyIndex.postValue(getAccounts(context).indexOf(account)) setAccount(account) MainActivity.reloadAccountEvent(true) } } else { // No PIN set for the selected account, proceed _isAllowedLogin.postValue(true) _selectedKeyIndex.postValue(getAccounts(context).indexOf(account)) setAccount(account) MainActivity.reloadAccountEvent(true) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadAdapter.kt ================================================ package com.lagradost.cloudstream3.ui.download import android.annotation.SuppressLint import android.text.format.Formatter.formatShortFileSize import android.view.LayoutInflater import android.view.ViewGroup import android.widget.CheckBox import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.recyclerview.widget.DiffUtil import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.DownloadChildEpisodeBinding import com.lagradost.cloudstream3.databinding.DownloadHeaderEpisodeBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.download.button.DownloadStatusTell import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.downloader.DownloadObjects const val DOWNLOAD_ACTION_PLAY_FILE = 0 const val DOWNLOAD_ACTION_DELETE_FILE = 1 const val DOWNLOAD_ACTION_RESUME_DOWNLOAD = 2 const val DOWNLOAD_ACTION_PAUSE_DOWNLOAD = 3 const val DOWNLOAD_ACTION_DOWNLOAD = 4 const val DOWNLOAD_ACTION_LONG_CLICK = 5 const val DOWNLOAD_ACTION_CANCEL_PENDING = 6 const val DOWNLOAD_ACTION_GO_TO_CHILD = 0 const val DOWNLOAD_ACTION_LOAD_RESULT = 1 sealed class VisualDownloadCached { abstract val currentBytes: Long abstract val totalBytes: Long abstract val data: DownloadObjects.DownloadCached abstract var isSelected: Boolean data class Child( override val currentBytes: Long, override val totalBytes: Long, override val data: DownloadObjects.DownloadEpisodeCached, override var isSelected: Boolean, ) : VisualDownloadCached() data class Header( override val currentBytes: Long, override val totalBytes: Long, override val data: DownloadObjects.DownloadHeaderCached, override var isSelected: Boolean, val child: DownloadObjects.DownloadEpisodeCached?, val currentOngoingDownloads: Int, val totalDownloads: Int, ) : VisualDownloadCached() } data class DownloadClickEvent( val action: Int, val data: DownloadObjects.DownloadEpisodeCached ) data class DownloadHeaderClickEvent( val action: Int, val data: DownloadObjects.DownloadHeaderCached ) class DownloadAdapter( private val onHeaderClickEvent: (DownloadHeaderClickEvent) -> Unit, private val onItemClickEvent: (DownloadClickEvent) -> Unit, private val onItemSelectionChanged: (Int, Boolean) -> Unit, ) : NoStateAdapter(DiffCallback()) { private var isMultiDeleteState: Boolean = false companion object { private const val VIEW_TYPE_HEADER = 0 private const val VIEW_TYPE_CHILD = 1 } private fun bindHeader(binding: ViewBinding, card: VisualDownloadCached.Header?) { if (binding !is DownloadHeaderEpisodeBinding || card == null) return val data = card.data binding.apply { episodeHolder.apply { if (isMultiDeleteState) { setOnClickListener { toggleIsChecked(deleteCheckbox, data.id) } setOnLongClickListener { toggleIsChecked(deleteCheckbox, data.id) true } } else { setOnLongClickListener { onItemSelectionChanged.invoke(data.id, true) true } } } downloadHeaderPoster.apply { loadImage(data.poster) if (isMultiDeleteState) { setOnClickListener { toggleIsChecked(deleteCheckbox, data.id) } } else { setOnClickListener { onHeaderClickEvent.invoke( DownloadHeaderClickEvent( DOWNLOAD_ACTION_LOAD_RESULT, data ) ) } } setOnLongClickListener { toggleIsChecked(deleteCheckbox, data.id) true } } downloadHeaderTitle.text = data.name val formattedSize = formatShortFileSize(binding.root.context, card.totalBytes) if (card.child != null) { handleChildDownload(card, formattedSize) } else handleParentDownload(card, formattedSize) if (isMultiDeleteState) { deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> onItemSelectionChanged.invoke(data.id, isChecked) } } else deleteCheckbox.setOnCheckedChangeListener(null) deleteCheckbox.apply { isVisible = isMultiDeleteState isChecked = card.isSelected } } } private fun DownloadHeaderEpisodeBinding.handleChildDownload( card: VisualDownloadCached.Header, formattedSize: String ) { card.child ?: return downloadHeaderGotoChild.isVisible = false val posDur = getViewPos(card.data.id) watchProgressContainer.isVisible = true downloadHeaderEpisodeProgress.apply { isVisible = posDur != null posDur?.let { val max = (it.duration / 1000).toInt() val progress = (it.position / 1000).toInt() if (max > 0 && progress >= (0.95 * max).toInt()) { playIcon.setImageResource(R.drawable.ic_baseline_check_24) isVisible = false } else { playIcon.setImageResource(R.drawable.netflix_play) this.max = max this.progress = progress isVisible = true } } } downloadButton.resetView() val status = downloadButton.getStatus(card.child.id, card.currentBytes, card.totalBytes) if (status == DownloadStatusTell.IsDone) { // We do this here instead if we are finished downloading // so that we can use the value from the view model // rather than extra unneeded disk operations and to prevent a // delay in updating download icon state. downloadButton.setProgress(card.currentBytes, card.totalBytes) downloadButton.applyMetaData(card.child.id, card.currentBytes, card.totalBytes) // We will let the view model handle this downloadButton.doSetProgress = false downloadButton.progressBar.progressDrawable = downloadButton.getDrawableFromStatus(status) ?.let { ContextCompat.getDrawable(downloadButton.context, it) } downloadHeaderInfo.text = formattedSize } else { // We need to make sure we restore the correct progress // when we refresh data in the adapter. val drawable = downloadButton.getDrawableFromStatus(status)?.let { ContextCompat.getDrawable(downloadButton.context, it) } downloadButton.statusView.setImageDrawable(drawable) downloadButton.progressBar.progressDrawable = ContextCompat.getDrawable( downloadButton.context, downloadButton.progressDrawable ) } downloadHeaderInfo.isVisible = true downloadButton.setDefaultClickListener(card.child, downloadHeaderInfo, onItemClickEvent) downloadButton.isVisible = !isMultiDeleteState if (!isMultiDeleteState) { episodeHolder.setOnClickListener { onItemClickEvent.invoke( DownloadClickEvent( DOWNLOAD_ACTION_PLAY_FILE, card.child ) ) } } } private fun DownloadHeaderEpisodeBinding.handleParentDownload( card: VisualDownloadCached.Header, formattedSize: String ) { downloadButton.resetViewData() watchProgressContainer.isVisible = false downloadButton.isVisible = false downloadHeaderEpisodeProgress.isVisible = false downloadHeaderGotoChild.isVisible = !isMultiDeleteState try { downloadHeaderInfo.isVisible = true downloadHeaderInfo.text = downloadHeaderInfo.context.getString(R.string.extra_info_format).format( card.totalDownloads, downloadHeaderInfo.context.resources.getQuantityString( R.plurals.episodes, card.totalDownloads ), formattedSize ) } catch (e: Exception) { downloadHeaderInfo.text = null logError(e) } if (!isMultiDeleteState) { episodeHolder.setOnClickListener { onHeaderClickEvent.invoke( DownloadHeaderClickEvent( DOWNLOAD_ACTION_GO_TO_CHILD, card.data ) ) } } } private fun bindChild(binding: ViewBinding, card: VisualDownloadCached.Child?) { if (binding !is DownloadChildEpisodeBinding || card == null) return val data = card.data binding.apply { val posDur = getViewPos(data.id) downloadChildEpisodeProgress.apply { isVisible = posDur != null posDur?.let { val max = (it.duration / 1000).toInt() val progress = (it.position / 1000).toInt() if (max > 0 && progress >= (0.95 * max).toInt()) { downloadChildEpisodePlay.setImageResource(R.drawable.ic_baseline_check_24) isVisible = false } else { downloadChildEpisodePlay.setImageResource(R.drawable.play_button_transparent) this.max = max this.progress = progress isVisible = true } } } downloadButton.resetView() val status = downloadButton.getStatus(data.id, card.currentBytes, card.totalBytes) if (status == DownloadStatusTell.IsDone) { // We do this here instead if we are finished downloading // so that we can use the value from the view model // rather than extra unneeded disk operations and to prevent a // delay in updating download icon state. downloadButton.setProgress(card.currentBytes, card.totalBytes) downloadButton.applyMetaData(data.id, card.currentBytes, card.totalBytes) // We will let the view model handle this downloadButton.doSetProgress = false downloadButton.progressBar.progressDrawable = downloadButton.getDrawableFromStatus(status) ?.let { ContextCompat.getDrawable(downloadButton.context, it) } downloadChildEpisodeTextExtra.text = formatShortFileSize(downloadChildEpisodeTextExtra.context, card.totalBytes) } else { // We need to make sure we restore the correct progress // when we refresh data in the adapter. val drawable = downloadButton.getDrawableFromStatus(status)?.let { ContextCompat.getDrawable(downloadButton.context, it) } downloadButton.statusView.setImageDrawable(drawable) downloadButton.progressBar.progressDrawable = ContextCompat.getDrawable( downloadButton.context, downloadButton.progressDrawable ) } downloadButton.setDefaultClickListener( data, downloadChildEpisodeTextExtra, onItemClickEvent ) downloadButton.isVisible = !isMultiDeleteState downloadChildEpisodeText.apply { text = context.getNameFull(data.name, data.episode, data.season) isSelected = true // Needed for text repeating } downloadChildEpisodeHolder.setOnClickListener { onItemClickEvent.invoke(DownloadClickEvent(DOWNLOAD_ACTION_PLAY_FILE, data)) } downloadChildEpisodeHolder.apply { when { isMultiDeleteState -> { setOnClickListener { toggleIsChecked(deleteCheckbox, data.id) } setOnLongClickListener { toggleIsChecked(deleteCheckbox, data.id) true } } else -> { setOnClickListener { onItemClickEvent.invoke( DownloadClickEvent( DOWNLOAD_ACTION_PLAY_FILE, data ) ) } setOnLongClickListener { onItemSelectionChanged.invoke(data.id, true) true } } } } if (isMultiDeleteState) { deleteCheckbox.setOnCheckedChangeListener { _, isChecked -> onItemSelectionChanged.invoke(data.id, isChecked) } } else deleteCheckbox.setOnCheckedChangeListener(null) deleteCheckbox.apply { isVisible = isMultiDeleteState isChecked = card.isSelected } } } override fun onCreateCustomContent(parent: ViewGroup, viewType: Int): ViewHolderState { val inflater = LayoutInflater.from(parent.context) val binding = when (viewType) { VIEW_TYPE_HEADER -> DownloadHeaderEpisodeBinding.inflate(inflater, parent, false) VIEW_TYPE_CHILD -> DownloadChildEpisodeBinding.inflate(inflater, parent, false) else -> throw IllegalArgumentException("Invalid view type") } return ViewHolderState(binding) } override fun onBindContent( holder: ViewHolderState, item: VisualDownloadCached, position: Int ) { when (val binding = holder.view) { is DownloadHeaderEpisodeBinding -> bindHeader( binding, item as? VisualDownloadCached.Header ) is DownloadChildEpisodeBinding -> bindChild( binding, item as? VisualDownloadCached.Child ) } } override fun customContentViewType(item: VisualDownloadCached): Int { return when (item) { is VisualDownloadCached.Child -> VIEW_TYPE_CHILD is VisualDownloadCached.Header -> VIEW_TYPE_HEADER } } @SuppressLint("NotifyDataSetChanged") fun setIsMultiDeleteState(value: Boolean) { if (isMultiDeleteState == value) return isMultiDeleteState = value notifyDataSetChanged() // This is shit, but what can you do? } private fun toggleIsChecked(checkbox: CheckBox, itemId: Int) { val isChecked = !checkbox.isChecked checkbox.isChecked = isChecked onItemSelectionChanged.invoke(itemId, isChecked) } class DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame( oldItem: VisualDownloadCached, newItem: VisualDownloadCached ): Boolean { return oldItem.data.id == newItem.data.id } override fun areContentsTheSame( oldItem: VisualDownloadCached, newItem: VisualDownloadCached ): Boolean { return oldItem == newItem } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadButtonSetup.kt ================================================ package com.lagradost.cloudstream3.ui.download import android.content.DialogInterface import android.net.Uri import androidx.appcompat.app.AlertDialog import com.google.android.material.snackbar.Snackbar import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.player.DownloadFileGenerator import com.lagradost.cloudstream3.ui.player.ExtractorUri import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.SnackbarHelper.showSnackbar import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import kotlinx.coroutines.MainScope object DownloadButtonSetup { fun handleDownloadClick(click: DownloadClickEvent) { val id = click.data.id when (click.action) { DOWNLOAD_ACTION_DELETE_FILE -> { activity?.let { ctx -> val builder: AlertDialog.Builder = AlertDialog.Builder(ctx) val dialogClickListener = DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { VideoDownloadManager.deleteFilesAndUpdateSettings( ctx, setOf(id), MainScope() ) } DialogInterface.BUTTON_NEGATIVE -> { // Do nothing on cancel } } } try { builder.setTitle(R.string.delete_file) .setMessage( ctx.getString(R.string.delete_message).format( ctx.getNameFull( click.data.name, click.data.episode, click.data.season ) ) ) .setPositiveButton(R.string.delete, dialogClickListener) .setNegativeButton(R.string.cancel, dialogClickListener) .show().setDefaultFocus() } catch (e: Exception) { logError(e) // ye you somehow fucked up formatting did you? } } } DOWNLOAD_ACTION_PAUSE_DOWNLOAD -> { VideoDownloadManager.downloadEvent.invoke( Pair(click.data.id, VideoDownloadManager.DownloadActionType.Pause) ) } DOWNLOAD_ACTION_RESUME_DOWNLOAD -> { activity?.let { ctx -> if (VideoDownloadManager.downloadStatus.containsKey(id) && VideoDownloadManager.downloadStatus[id] == VideoDownloadManager.DownloadType.IsPaused) { VideoDownloadManager.downloadEvent.invoke( Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume) ) } else { val pkg = VideoDownloadManager.getDownloadResumePackage(ctx, id) if (pkg != null) { DownloadQueueManager.addToQueue(pkg.toWrapper()) } else { VideoDownloadManager.downloadEvent.invoke( Pair(click.data.id, VideoDownloadManager.DownloadActionType.Resume) ) } } } } DOWNLOAD_ACTION_LONG_CLICK -> { activity?.let { act -> val length = VideoDownloadManager.getDownloadFileInfo( act, click.data.id )?.fileLength ?: 0 if (length > 0) { showSnackbar( act, R.string.offline_file, Snackbar.LENGTH_LONG ) } } } DOWNLOAD_ACTION_CANCEL_PENDING -> { DownloadQueueManager.cancelDownload(id) } DOWNLOAD_ACTION_PLAY_FILE -> { activity?.let { act -> val parent = getKey( DOWNLOAD_HEADER_CACHE, click.data.parentId.toString() ) ?: return val episodes = getKeys(DOWNLOAD_EPISODE_CACHE) ?.mapNotNull { getKey(it) } ?.filter { it.parentId == click.data.parentId } val items = mutableListOf() val allRelevantEpisodes = episodes?.sortedWith(compareBy { it.season ?: 0 }.thenBy { it.episode }) allRelevantEpisodes?.forEach { val keyInfo = getKey( VideoDownloadManager.KEY_DOWNLOAD_INFO, it.id.toString() ) ?: return@forEach items.add( ExtractorUri( // We just use a temporary placeholder for the URI, // it will be updated in generateLinks(). // We just do this for performance since getting // all paths at once can be quite expensive. uri = Uri.EMPTY, id = it.id, parentId = it.parentId, name = it.name ?: act.getString(R.string.downloaded_file), season = it.season, episode = it.episode, headerName = parent.name, tvType = parent.type, basePath = keyInfo.basePath, displayName = keyInfo.displayName, relativePath = keyInfo.relativePath, ) ) } act.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( DownloadFileGenerator(items).apply { goto(items.indexOfFirst { it.id == click.data.id }) } ) ) } } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadChildFragment.kt ================================================ package com.lagradost.cloudstream3.ui.download import android.os.Bundle import android.text.format.Formatter.formatShortFileSize import android.view.View import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentChildDownloadsBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV class DownloadChildFragment : BaseFragment( BaseFragment.BindingCreator.Inflate(FragmentChildDownloadsBinding::inflate) ) { private val downloadViewModel: DownloadViewModel by activityViewModels() companion object { fun newInstance(headerName: String, folder: String): Bundle { return Bundle().apply { putString("folder", folder) putString("name", headerName) } } } override fun onDestroyView() { activity?.detachBackPressedCallback("Downloads") downloadViewModel.clearChildren() super.onDestroyView() } override fun fixLayout(view: View) { fixSystemBarsPadding( view, padBottom = isLandscape(), padLeft = isLayout(TV or EMULATOR) ) } override fun onBindingCreated(binding: FragmentChildDownloadsBinding) { val folder = arguments?.getString("folder") val name = arguments?.getString("name") if (folder == null) { dispatchBackPressed() return } context?.let { downloadViewModel.updateChildList(it, folder) } binding.downloadChildToolbar.apply { title = name if (isLayout(PHONE or EMULATOR)) { setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) setNavigationOnClickListener { dispatchBackPressed() } } setAppBarNoScrollFlagsOnTV() } binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV() observe(downloadViewModel.childCards) { cards -> when (cards) { is Resource.Success -> { if (cards.value.isEmpty()) { dispatchBackPressed() } (binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(cards.value) } else -> { (binding.downloadChildList.adapter as? DownloadAdapter)?.submitList(null) } } } observe(downloadViewModel.selectedBytes) { updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) } binding.apply { btnDelete.setOnClickListener { view -> downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener) } btnCancel.setOnClickListener { downloadViewModel.cancelSelection() } btnToggleAll.setOnClickListener { val allSelected = downloadViewModel.isAllChildrenSelected() if (allSelected) { downloadViewModel.clearSelectedItems() } else { downloadViewModel.selectAllChildren() } } } observeNullable(downloadViewModel.selectedItemIds) { selection -> val isMultiDeleteState = selection != null val adapter = binding.downloadChildList.adapter as? DownloadAdapter adapter?.setIsMultiDeleteState(isMultiDeleteState) binding.downloadDeleteAppbar.isVisible = isMultiDeleteState binding.downloadChildToolbar.isGone = isMultiDeleteState if (selection == null) { activity?.detachBackPressedCallback("Downloads") return@observeNullable } activity?.attachBackPressedCallback("Downloads") { downloadViewModel.cancelSelection() } updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L) binding.btnDelete.isVisible = selection.isNotEmpty() binding.selectItemsText.isVisible = selection.isEmpty() val allSelected = downloadViewModel.isAllChildrenSelected() if (allSelected) { binding.btnToggleAll.setText(R.string.deselect_all) } else binding.btnToggleAll.setText(R.string.select_all) } val adapter = DownloadAdapter( {}, { click -> if (click.action == DOWNLOAD_ACTION_DELETE_FILE) { context?.let { ctx -> downloadViewModel.handleSingleDelete(ctx, click.data.id) } } else handleDownloadClick(click) }, { itemId, isChecked -> if (isChecked) { downloadViewModel.addSelected(itemId) } else downloadViewModel.removeSelected(itemId) } ) binding.downloadChildList.apply { setHasFixedSize(true) setItemViewCacheSize(20) this.adapter = adapter setLinearListLayout( isHorizontal = false, nextRight = FOCUS_SELF, nextDown = FOCUS_SELF, ) } } private fun updateDeleteButton(count: Int, selectedBytes: Long) { val formattedSize = formatShortFileSize(context, selectedBytes) binding?.btnDelete?.text = getString(R.string.delete_format).format(count, formattedSize) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadFragment.kt ================================================ package com.lagradost.cloudstream3.ui.download import android.app.Activity import android.app.Dialog import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION import android.os.Build import android.text.format.Formatter.formatShortFileSize import android.view.View import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.StringRes import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged import androidx.fragment.app.activityViewModels import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentDownloadsBinding import com.lagradost.cloudstream3.databinding.StreamInputBinding import com.lagradost.cloudstream3.isEpisodeBased import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueViewModel import com.lagradost.cloudstream3.ui.player.BasicLink import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.ui.player.LinkGenerator import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV import java.net.URI const val DOWNLOAD_NAVIGATE_TO = "downloadpage" class DownloadFragment : BaseFragment( BaseFragment.BindingCreator.Inflate(FragmentDownloadsBinding::inflate) ) { private val downloadViewModel: DownloadViewModel by activityViewModels() private val downloadQueueViewModel: DownloadQueueViewModel by activityViewModels() private fun View.setLayoutWidth(weight: Long) { val param = LinearLayout.LayoutParams( 0, LinearLayout.LayoutParams.MATCH_PARENT, maxOf((weight / 1000000000f), 0.1f) // 100mb ) this.layoutParams = param } override fun onDestroyView() { activity?.detachBackPressedCallback("Downloads") super.onDestroyView() } override fun fixLayout(view: View) { fixSystemBarsPadding( view, padBottom = isLandscape(), padLeft = isLayout(TV or EMULATOR) ) } override fun onBindingCreated(binding: FragmentDownloadsBinding) { hideKeyboard() binding.downloadAppbar.setAppBarNoScrollFlagsOnTV() binding.downloadDeleteAppbar.setAppBarNoScrollFlagsOnTV() observe(downloadViewModel.headerCards) { cards -> when (cards) { is Resource.Success -> { (binding.downloadList.adapter as? DownloadAdapter)?.submitList(cards.value) binding.textNoDownloads.isVisible = cards.value.isEmpty() binding.downloadLoading.isVisible = false binding.downloadList.isVisible = true } is Resource.Loading -> { binding.downloadList.isVisible = false binding.downloadLoading.isVisible = true } is Resource.Failure -> { binding.downloadList.isVisible = true binding.downloadLoading.isVisible = false } } } observe(downloadViewModel.availableBytes) { updateStorageInfo( binding.root.context, it, R.string.free_storage, binding.downloadFreeTxt, binding.downloadFree ) } observe(downloadViewModel.usedBytes) { updateStorageInfo( binding.root.context, it, R.string.used_storage, binding.downloadUsedTxt, binding.downloadUsed ) val hasBytes = it > 0 if (hasBytes) { binding.downloadLoadingBytes.stopShimmer() } else binding.downloadLoadingBytes.startShimmer() binding.downloadBytesBar.isVisible = hasBytes binding.downloadLoadingBytes.isGone = hasBytes } observe(downloadViewModel.downloadBytes) { updateStorageInfo( binding.root.context, it, R.string.app_storage, binding.downloadAppTxt, binding.downloadApp ) } observe(downloadQueueViewModel.childCards) { cards -> val size = cards.currentDownloads.size + cards.queue.size val context = binding.root.context val baseText = context.getString(R.string.download_queue) binding.downloadQueueText.text = if (size > 0) { "$baseText (${cards.currentDownloads.size}/$size)" } else { baseText } } observe(downloadViewModel.selectedBytes) { updateDeleteButton(downloadViewModel.selectedItemIds.value?.count() ?: 0, it) } binding.apply { btnDelete.setOnClickListener { view -> downloadViewModel.handleMultiDelete(view.context ?: return@setOnClickListener) } btnCancel.setOnClickListener { downloadViewModel.cancelSelection() } btnToggleAll.setOnClickListener { val allSelected = downloadViewModel.isAllHeadersSelected() if (allSelected) { downloadViewModel.clearSelectedItems() } else { downloadViewModel.selectAllHeaders() } } } observeNullable(downloadViewModel.selectedItemIds) { selection -> val isMultiDeleteState = selection != null val adapter = binding.downloadList.adapter as? DownloadAdapter adapter?.setIsMultiDeleteState(isMultiDeleteState) binding.downloadDeleteAppbar.isVisible = isMultiDeleteState binding.downloadAppbar.isGone = isMultiDeleteState if (selection == null) { activity?.detachBackPressedCallback("Downloads") return@observeNullable } activity?.attachBackPressedCallback("Downloads") { downloadViewModel.cancelSelection() } updateDeleteButton(selection.count(), downloadViewModel.selectedBytes.value ?: 0L) binding.btnDelete.isVisible = selection.isNotEmpty() binding.selectItemsText.isVisible = selection.isEmpty() val allSelected = downloadViewModel.isAllHeadersSelected() if (allSelected) { binding.btnToggleAll.setText(R.string.deselect_all) } else binding.btnToggleAll.setText(R.string.select_all) } val adapter = DownloadAdapter( { click -> handleItemClick(click) }, { click -> if (click.action == DOWNLOAD_ACTION_DELETE_FILE) { context?.let { ctx -> downloadViewModel.handleSingleDelete(ctx, click.data.id) } } else handleDownloadClick(click) }, { itemId, isChecked -> if (isChecked) { downloadViewModel.addSelected(itemId) } else downloadViewModel.removeSelected(itemId) } ) binding.downloadList.apply { setHasFixedSize(true) setItemViewCacheSize(20) this.adapter = adapter setLinearListLayout( isHorizontal = false, nextRight = FOCUS_SELF, nextDown = R.id.download_queue_button, ) } binding.apply { openLocalVideoButton.apply { isGone = isLayout(TV) setOnClickListener { openLocalVideo() } } downloadStreamButton.apply { isGone = isLayout(TV) setOnClickListener { showStreamInputDialog(it.context) } } downloadQueueButton.setOnClickListener { activity?.navigate(R.id.action_navigation_global_to_navigation_download_queue) } downloadStreamButtonTv.isFocusableInTouchMode = isLayout(TV) downloadAppbar.isFocusableInTouchMode = isLayout(TV) downloadStreamButtonTv.setOnClickListener { showStreamInputDialog(it.context) } steamImageviewHolder.isVisible = isLayout(TV) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { binding.downloadList.setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> handleScroll(scrollY - oldScrollY) } } context?.let { downloadViewModel.updateHeaderList(it) } } private fun handleItemClick(click: DownloadHeaderClickEvent) { when (click.action) { DOWNLOAD_ACTION_GO_TO_CHILD -> { if (click.data.type.isEpisodeBased()) { val folder = getFolderName(DOWNLOAD_EPISODE_CACHE, click.data.id.toString()) activity?.navigate( R.id.action_navigation_downloads_to_navigation_download_child, DownloadChildFragment.newInstance(click.data.name, folder) ) } } DOWNLOAD_ACTION_LOAD_RESULT -> { activity?.loadResult(click.data.url, click.data.apiName, click.data.name) } } } private fun updateDeleteButton(count: Int, selectedBytes: Long) { val formattedSize = formatShortFileSize(context, selectedBytes) binding?.btnDelete?.text = getString(R.string.delete_format).format(count, formattedSize) } private fun updateStorageInfo( context: Context, bytes: Long, @StringRes stringRes: Int, textView: TextView?, view: View? ) { textView?.text = getString(R.string.storage_size_format).format( getString(stringRes), formatShortFileSize(context, bytes) ) view?.setLayoutWidth(bytes) } private fun openLocalVideo() { val intent = Intent() .setAction(Intent.ACTION_GET_CONTENT) .setType("video/*") .addCategory(Intent.CATEGORY_OPENABLE) .addFlags(FLAG_GRANT_READ_URI_PERMISSION) // Request temporary access safe { videoResultLauncher.launch( Intent.createChooser( intent, getString(R.string.open_local_video) ) ) } } private fun showStreamInputDialog(context: Context) { val dialog = Dialog(context, R.style.AlertDialogCustom) val binding = StreamInputBinding.inflate(dialog.layoutInflater) dialog.setContentView(binding.root) dialog.show() var preventAutoSwitching = false binding.hlsSwitch.setOnClickListener { preventAutoSwitching = true } binding.streamReferer.doOnTextChanged { text, _, _, _ -> if (!preventAutoSwitching) activateSwitchOnHls(text?.toString(), binding) } (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt( 0 )?.text?.toString()?.let { copy -> val fixedText = copy.trim() binding.streamUrl.setText(fixedText) activateSwitchOnHls(fixedText, binding) } binding.applyBtt.setOnClickListener { val url = binding.streamUrl.text?.toString() if (url.isNullOrEmpty()) { showToast(R.string.error_invalid_url, Toast.LENGTH_SHORT) } else { val referer = binding.streamReferer.text?.toString() activity?.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( LinkGenerator( listOf(BasicLink(url)), extract = true, refererUrl = referer, ) ) ) dialog.dismissSafe(activity) } } binding.cancelBtt.setOnClickListener { dialog.dismissSafe(activity) } } private fun activateSwitchOnHls(text: String?, binding: StreamInputBinding) { binding.hlsSwitch.isChecked = safe { URI(text).path?.substringAfterLast(".")?.contains("m3u") } == true } private fun handleScroll(dy: Int) { if (dy > 0) { binding?.downloadStreamButton?.shrink() } else if (dy < -5) { binding?.downloadStreamButton?.extend() } } // Open local video from files using content provider x safeFile private val videoResultLauncher = registerForActivityResult( ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult val selectedVideoUri = result.data?.data ?: return@registerForActivityResult playUri(activity ?: return@registerForActivityResult, selectedVideoUri) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/download/DownloadViewModel.kt ================================================ package com.lagradost.cloudstream3.ui.download import android.content.Context import android.content.DialogInterface import android.os.Environment import android.os.StatFs import androidx.appcompat.app.AlertDialog import androidx.core.content.edit import androidx.lifecycle.LiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lagradost.api.Log import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.isEpisodeBased import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.services.DownloadQueueService import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.ConsistentLiveData import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE_BACKUP import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.getKeys import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.ResourceLiveData import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.deleteFilesAndUpdateSettings import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext class DownloadViewModel : ViewModel() { companion object { const val TAG = "DownloadViewModel" } private val _headerCards = ResourceLiveData>(Resource.Loading()) val headerCards: LiveData>> = _headerCards private val _childCards = ResourceLiveData>(Resource.Loading()) val childCards: LiveData>> = _childCards private val _usedBytes = ConsistentLiveData() val usedBytes: LiveData = _usedBytes private val _availableBytes = ConsistentLiveData() val availableBytes: LiveData = _availableBytes private val _downloadBytes = ConsistentLiveData() val downloadBytes: LiveData = _downloadBytes private val _selectedBytes = ConsistentLiveData(0) val selectedBytes: LiveData = _selectedBytes private val _selectedItemIds = ConsistentLiveData?>(null) val selectedItemIds: LiveData?> = _selectedItemIds fun cancelSelection() { updateSelectedItems { null } } fun addSelected(itemId: Int) { updateSelectedItems { it?.plus(itemId) ?: setOf(itemId) } } fun removeSelected(itemId: Int) { updateSelectedItems { it?.minus(itemId) ?: emptySet() } } fun selectAllHeaders() { updateSelectedItems { _headerCards.success.orEmpty() .map { item -> item.data.id }.toSet() } } fun selectAllChildren() { updateSelectedItems { _childCards.success.orEmpty() .map { item -> item.data.id }.toSet() } } fun clearSelectedItems() { // We need this to be done immediately // so we can't use postValue updateSelectedItems { emptySet() } } fun isAllChildrenSelected(): Boolean { val currentSelected = selectedItemIds.value ?: return false val children = _childCards.success.orEmpty() return currentSelected.size == children.size && children.all { it.data.id in currentSelected } } fun isAllHeadersSelected(): Boolean { val currentSelected = selectedItemIds.value ?: return false val headers = _headerCards.success.orEmpty() return currentSelected.size == headers.size && headers.all { it.data.id in currentSelected } } private fun updateSelectedItems(action: (Set?) -> Set?) { val currentSelected = action(selectedItemIds.value) _selectedItemIds.postValue(currentSelected) postHeaders() postChildren() updateSelectedBytes() } private fun updateSelectedBytes() = viewModelScope.launchSafe { val selectedItemsList = getSelectedItemsData() ?: return@launchSafe val totalSelectedBytes = selectedItemsList.sumOf { it.totalBytes } _selectedBytes.postValue(totalSelectedBytes) } fun removeRedundantEpisodeKeys(context: Context, keys: List>) { val settingsManager = context.getSharedPrefs() ioSafe { settingsManager.edit { keys.forEach { (parentId, childId) -> Log.i(TAG, "Removing download episode key: ${parentId}/${childId}") val oldPath = getFolderName( getFolderName( DOWNLOAD_EPISODE_CACHE, parentId.toString() ), childId.toString() ) val newPath = getFolderName( getFolderName( DOWNLOAD_EPISODE_CACHE_BACKUP, parentId.toString() ), childId.toString() ) val oldPref = settingsManager.getString(oldPath, null) // Cowardly future backup solution in case the key removal fails in some edge case. // This and all backup keys may be removed in a future update if the key removal is proven to be robust. this.putString(newPath, oldPref) this.remove(oldPath) } } } } fun removeRedundantHeaderKeys( context: Context, cached: List, totalBytesUsedByChild: Map, totalDownloads: Map ) { val settingsManager = context.getSharedPrefs() ioSafe { // Do not remove headers used by resume watching val resumeWatchingIds = getAllResumeStateIds()?.mapNotNull { id -> getLastWatched(id)?.parentId }?.toSet() ?: emptySet() settingsManager.edit { cached.forEach { header -> val downloads = totalDownloads[header.id] ?: 0 val bytes = totalBytesUsedByChild[header.id] ?: 0 if ( (downloads <= 0 || bytes <= 0) && !resumeWatchingIds.contains(header.id) ) { Log.i(TAG, "Removing download header key: ${header.id}") val oldPAth = getFolderName(DOWNLOAD_HEADER_CACHE, header.id.toString()) val newPath = getFolderName(DOWNLOAD_HEADER_CACHE_BACKUP, header.id.toString()) val oldPref = settingsManager.getString(oldPAth, null) // Cowardly future backup solution in case the key removal fails in some edge case. // This and all backup keys may be removed in a future update if the key removal is proven to be robust. this.putString(newPath, oldPref) this.remove(oldPAth) } } } } } fun updateHeaderList(context: Context) = viewModelScope.launchSafe { // Do not push loading as it interrupts the UI //_headerCards.postValue(Resource.Loading()) val visual = ioWork { val children = context.getKeys(DOWNLOAD_EPISODE_CACHE) .mapNotNull { context.getKey(it) } .distinctBy { it.id } // Remove duplicates val isCurrentlyDownloading = DownloadQueueService.isRunning || downloadInstances.value.isNotEmpty() || DownloadQueueManager.queue.value.isNotEmpty() val downloadStats = calculateDownloadStats(context, children) val cached = context.getKeys(DOWNLOAD_HEADER_CACHE) .mapNotNull { context.getKey(it) } // Download stats and header keys may change when downloading. // To prevent the downloader and key removal from colliding, simply do not prune keys when downloading. if (!isCurrentlyDownloading) { removeRedundantHeaderKeys( context, cached, downloadStats.totalBytesUsedByChild, downloadStats.totalDownloads ) } // calculateDownloadStats already performed checks when creating redundantDownloads, no extra check required removeRedundantEpisodeKeys(context, downloadStats.redundantDownloads) createVisualDownloadList( context, cached, downloadStats.totalBytesUsedByChild, downloadStats.currentBytesUsedByChild, downloadStats.totalDownloads ) } updateStorageStats(visual) postHeaders(visual) } fun postHeaders(newValue: List? = null) { val newValue = newValue ?: _headerCards.success ?: return val selection = selectedItemIds.value ?: emptySet() _headerCards.postValue(Resource.Success(newValue.map { it.copy( isSelected = selection.contains( it.data.id ) ) })) } fun postChildren(newValue: List? = null) { val newValue = newValue ?: _childCards.success ?: return val selection = selectedItemIds.value ?: emptySet() _childCards.postValue(Resource.Success(newValue.map { it.copy( isSelected = selection.contains( it.data.id ) ) })) } private data class DownloadStats( val totalBytesUsedByChild: Map, val currentBytesUsedByChild: Map, val totalDownloads: Map, /** Parent ID to child ID. Keys to be removed. */ val redundantDownloads: List> ) private fun calculateDownloadStats( context: Context, children: List ): DownloadStats { // parentId : bytes val totalBytesUsedByChild = mutableMapOf() // parentId : bytes val currentBytesUsedByChild = mutableMapOf() // parentId : downloadsCount val totalDownloads = mutableMapOf() val redundantDownloads = mutableListOf>() children.forEach { child -> val childFile = getDownloadFileInfo(context, child.id) if (childFile == null) { // It may not be a redundant child if something is currently downloading. // DOWNLOAD_EPISODE_CACHE gets created before KEY_DOWNLOAD_INFO in the downloader // leading to valid situations where getDownloadFileInfo is null, but we do not want to remove DOWNLOAD_EPISODE_CACHE if (!DownloadQueueService.isRunning && downloadInstances.value.isEmpty() && DownloadQueueManager.queue.value.isEmpty()) { redundantDownloads.add(child.parentId to child.id) } return@forEach } if (childFile.fileLength <= 1) return@forEach val len = childFile.totalBytes val flen = childFile.fileLength totalBytesUsedByChild.merge(child.parentId, len, Long::plus) currentBytesUsedByChild.merge(child.parentId, flen, Long::plus) totalDownloads.merge(child.parentId, 1, Int::plus) } return DownloadStats( totalBytesUsedByChild, currentBytesUsedByChild, totalDownloads, redundantDownloads ) } private fun createVisualDownloadList( context: Context, cached: List, totalBytesUsedByChild: Map, currentBytesUsedByChild: Map, totalDownloads: Map ): List { return cached.mapNotNull { val downloads = totalDownloads[it.id] ?: 0 val bytes = totalBytesUsedByChild[it.id] ?: 0 val currentBytes = currentBytesUsedByChild[it.id] ?: 0 if (bytes <= 0 || downloads <= 0) { return@mapNotNull null } val isSelected = selectedItemIds.value?.contains(it.id) ?: false val movieEpisode = if (it.type.isEpisodeBased()) null else context.getKey( DOWNLOAD_EPISODE_CACHE, getFolderName(it.id.toString(), it.id.toString()) ) VisualDownloadCached.Header( currentBytes = currentBytes, totalBytes = bytes, data = it, child = movieEpisode, currentOngoingDownloads = 0, totalDownloads = downloads, isSelected = isSelected, ) // Prevent order being almost completely random, // making things difficult to find. }.sortedWith(compareBy { // Sort by isEpisodeBased() ascending. We put those that // are episode based at the bottom for UI purposes and to // make it easier to find by grouping them together. it.data.type.isEpisodeBased() }.thenBy { // Then we sort alphabetically by name (case-insensitive). // Again, we do this to make things easier to find. it.data.name.lowercase() }) } fun updateChildList(context: Context, folder: String) = viewModelScope.launchSafe { _childCards.postValue(Resource.Loading()) // always push loading val visual = withContext(Dispatchers.IO) { context.getKeys(folder).mapNotNull { key -> context.getKey(key) }.mapNotNull { val isSelected = selectedItemIds.value?.contains(it.id) ?: false val info = getDownloadFileInfo(context, it.id) ?: return@mapNotNull null VisualDownloadCached.Child( currentBytes = info.fileLength, totalBytes = info.totalBytes, isSelected = isSelected, data = it, ) } }.sortedWith( compareBy( // Sort by season first, and then by episode number, // to ensure sorting is consistent. { it.data.season ?: 0 }, { it.data.episode } )) postChildren(visual) } private fun removeItems(idsToRemove: Set) = viewModelScope.launchSafe { _selectedItemIds.postValue(null) postHeaders(_headerCards.success?.filter { it.data.id !in idsToRemove }) postChildren(_childCards.success?.filter { it.data.id !in idsToRemove }) } private fun updateStorageStats(visual: List) { try { val stat = StatFs(Environment.getExternalStorageDirectory().path) val localBytesAvailable = stat.availableBytes val localTotalBytes = stat.blockSizeLong * stat.blockCountLong val localDownloadedBytes = visual.sumOf { it.totalBytes } val localUsedBytes = localTotalBytes - localBytesAvailable _usedBytes.postValue(localUsedBytes) _availableBytes.postValue(localBytesAvailable) _downloadBytes.postValue(localDownloadedBytes) } catch (t: Throwable) { _downloadBytes.postValue(0) logError(t) } } fun handleMultiDelete(context: Context) = viewModelScope.launchSafe { val selectedItemsList = getSelectedItemsData().orEmpty() val deleteData = processSelectedItems(context, selectedItemsList) val message = buildDeleteMessage(context, deleteData) showDeleteConfirmationDialog(context, message, deleteData.ids, deleteData.parentIds) } fun handleSingleDelete( context: Context, itemId: Int ) = viewModelScope.launchSafe { val itemData = getItemDataFromId(itemId) val deleteData = processSelectedItems(context, itemData) val message = buildDeleteMessage(context, deleteData) showDeleteConfirmationDialog(context, message, deleteData.ids, deleteData.parentIds) } private fun processSelectedItems( context: Context, selectedItemsList: List ): DeleteData { val names = mutableListOf() val seriesNames = mutableListOf() val ids = mutableSetOf() val parentIds = mutableSetOf() var parentName: String? = null selectedItemsList.forEach { item -> when (item) { is VisualDownloadCached.Header -> { if (item.data.type.isEpisodeBased()) { val episodes = context.getKeys(DOWNLOAD_EPISODE_CACHE) .mapNotNull { context.getKey( it ) } .filter { it.parentId == item.data.id } .map { it.id } ids.addAll(episodes) parentIds.add(item.data.id) val episodeInfo = "${item.data.name} (${item.totalDownloads} ${ context.resources.getQuantityString( R.plurals.episodes, item.totalDownloads ).lowercase() })" seriesNames.add(episodeInfo) } else { ids.add(item.data.id) names.add(item.data.name) } } is VisualDownloadCached.Child -> { ids.add(item.data.id) val parent = context.getKey( DOWNLOAD_HEADER_CACHE, item.data.parentId.toString() ) parentName = parent?.name names.add( context.getNameFull( item.data.name, item.data.episode, item.data.season ) ) } } } return DeleteData(ids, parentIds, seriesNames, names, parentName) } private fun buildDeleteMessage( context: Context, data: DeleteData ): String { val formattedNames = data.names.sortedBy { it.lowercase() } .joinToString(separator = "\n") { "• $it" } val formattedSeriesNames = data.seriesNames.sortedBy { it.lowercase() } .joinToString(separator = "\n") { "• $it" } return when { data.seriesNames.isNotEmpty() && data.names.isEmpty() -> { context.getString(R.string.delete_message_series_only).format(formattedSeriesNames) } data.ids.count() == 1 -> { context.getString(R.string.delete_message).format( data.names.firstOrNull() ) } data.parentName != null && data.names.isNotEmpty() -> { context.getString(R.string.delete_message_series_episodes) .format(data.parentName, formattedNames) } data.seriesNames.isNotEmpty() -> { val seriesSection = context.getString(R.string.delete_message_series_section) .format(formattedSeriesNames) context.getString(R.string.delete_message_multiple) .format(formattedNames) + "\n\n" + seriesSection } else -> context.getString(R.string.delete_message_multiple).format(formattedNames) } } private fun showDeleteConfirmationDialog( context: Context, message: String, ids: Set, parentIds: Set ) { val builder = AlertDialog.Builder(context) val dialogClickListener = DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { viewModelScope.launchSafe { deleteFilesAndUpdateSettings(context, ids, this) { successfulIds -> // We always remove parent because if we are deleting from here // and we have it as non-empty, it was triggered on // parent header card removeItems(successfulIds + parentIds) } } } DialogInterface.BUTTON_NEGATIVE -> { // Do nothing on cancel } } } try { val title = if (ids.count() == 1) { R.string.delete_file } else R.string.delete_files builder.setTitle(title) .setMessage(message) .setPositiveButton(R.string.delete, dialogClickListener) .setNegativeButton(R.string.cancel, dialogClickListener) .show().setDefaultFocus() } catch (e: Exception) { logError(e) } } private fun getSelectedItemsData(): List? { val headers = _headerCards.success.orEmpty() val children = _childCards.success.orEmpty() return selectedItemIds.value?.mapNotNull { id -> headers.find { it.data.id == id } ?: children.find { it.data.id == id } } } private fun getItemDataFromId(itemId: Int): List { return (_headerCards.success.orEmpty() + _childCards.success.orEmpty()).filter { it.data.id == itemId } } fun clearChildren() { _childCards.postValue(Resource.Loading()) } private data class DeleteData( val ids: Set, val parentIds: Set, val seriesNames: List, val names: List, val parentName: String? ) } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/download/button/BaseFetchButton.kt ================================================ package com.lagradost.cloudstream3.ui.download.button import android.content.Context import android.text.format.Formatter.formatShortFileSize import android.util.AttributeSet import android.widget.FrameLayout import android.widget.TextView import androidx.annotation.LayoutRes import androidx.core.view.isVisible import androidx.core.widget.ContentLoadingProgressBar import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.mainWork import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager typealias DownloadStatusTell = VideoDownloadManager.DownloadType data class DownloadMetadata( var id: Int, var downloadedLength: Long, var totalLength: Long, var status: DownloadStatusTell? = null ) { val progressPercentage: Long get() = if (downloadedLength < 1024) 0 else maxOf( 0, minOf(100, (downloadedLength * 100L) / (totalLength + 1)) ) } abstract class BaseFetchButton(context: Context, attributeSet: AttributeSet) : FrameLayout(context, attributeSet) { var persistentId: Int? = null // used to save sessions lateinit var progressBar: ContentLoadingProgressBar var progressText: TextView? = null /* val gid: String? get() = sessionIdToGid[persistentId] // used for resuming data var _lastRequestOverride: UriRequest? = null var lastRequest: UriRequest? get() = _lastRequestOverride ?: sessionIdToLastRequest[persistentId] set(value) { _lastRequestOverride = value } var files: List = emptyList() */ protected var isZeroBytes: Boolean = true fun inflate(@LayoutRes layout: Int) { inflate(context, layout, this) } init { @Suppress("LeakingThis") resetViewData() } var doSetProgress = true open fun resetViewData() { // lastRequest = null progressText = null isZeroBytes = true doSetProgress = true persistentId = null } var currentMetaData: DownloadMetadata = DownloadMetadata(0, 0, 0, null) fun setPersistentId(id: Int) { persistentId = id currentMetaData.id = id if (!doSetProgress) return ioSafe { val savedData = VideoDownloadManager.getDownloadFileInfo(context, id) mainWork { if (savedData != null) { val downloadedBytes = savedData.fileLength val totalBytes = savedData.totalBytes setProgress(downloadedBytes, totalBytes) applyMetaData(id, downloadedBytes, totalBytes) } } } } abstract fun setStatus(status: VideoDownloadManager.DownloadType?) fun getStatus(id: Int, downloadedBytes: Long, totalBytes: Long): DownloadStatusTell { // some extra padding for just in case return VideoDownloadManager.downloadStatus[id] ?: if (downloadedBytes > 1024L && downloadedBytes + 1024L >= totalBytes) { DownloadStatusTell.IsDone } else DownloadStatusTell.IsPaused } fun applyMetaData(id: Int, downloadedBytes: Long, totalBytes: Long) { val status = getStatus(id, downloadedBytes, totalBytes) currentMetaData.apply { this.id = id this.downloadedLength = downloadedBytes this.totalLength = totalBytes this.status = status } setStatus(status) } open fun setProgress(downloadedBytes: Long, totalBytes: Long) { isZeroBytes = downloadedBytes == 0L progressBar.post { val steps = 10000L progressBar.max = steps.toInt() // div by zero error and 1 byte off is ok impo val progress = (downloadedBytes * steps / (totalBytes + 1L)).toInt() val animation = ProgressBarAnimation( progressBar, progressBar.progress.toFloat(), progress.toFloat() ).apply { fillAfter = true duration = if (progress > progressBar.progress) // we don't want to animate backward changes in progress 100 else 0L } if (isZeroBytes) { progressText?.isVisible = false } else { if (doSetProgress) { progressText?.apply { val currentFormattedSizeString = formatShortFileSize(context, downloadedBytes) val totalFormattedSizeString = formatShortFileSize(context, totalBytes) text = // if (isTextPercentage) "%d%%".format(setCurrentBytes * 100L / setTotalBytes) else context?.getString(R.string.download_size_format) ?.format(currentFormattedSizeString, totalFormattedSizeString) } } } progressBar.startAnimation(animation) } } fun downloadStatusEvent(data: Pair) { val (id, status) = data if (id == persistentId) { currentMetaData.status = status setStatus(status) } } /*fun downloadDeleteEvent(data: Int) { }*/ /*fun downloadEvent(data: Pair) { val (id, action) = data }*/ fun downloadProgressEvent(data: Triple) { val (id, bytesDownloaded, bytesTotal) = data if (id == persistentId) { currentMetaData.downloadedLength = bytesDownloaded currentMetaData.totalLength = bytesTotal setProgress(bytesDownloaded, bytesTotal) } } override fun onAttachedToWindow() { VideoDownloadManager.downloadStatusEvent += ::downloadStatusEvent // VideoDownloadManager.downloadDeleteEvent += ::downloadDeleteEvent // VideoDownloadManager.downloadEvent += ::downloadEvent VideoDownloadManager.downloadProgressEvent += ::downloadProgressEvent val pid = persistentId if (pid != null) { // refresh in case of onDetachedFromWindow -> onAttachedToWindow while still being ??????? setPersistentId(pid) } super.onAttachedToWindow() } override fun onDetachedFromWindow() { VideoDownloadManager.downloadStatusEvent -= ::downloadStatusEvent // VideoDownloadManager.downloadDeleteEvent -= ::downloadDeleteEvent // VideoDownloadManager.downloadEvent -= ::downloadEvent VideoDownloadManager.downloadProgressEvent -= ::downloadProgressEvent super.onDetachedFromWindow() } /** * No checks required. Arg will always include a download with current id * */ abstract fun updateViewOnDownload(metadata: DownloadMetadata) /** * Get a clean slate again, might be useful in recyclerview? * */ abstract fun resetView() } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/download/button/DownloadButton.kt ================================================ package com.lagradost.cloudstream3.ui.download.button import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.widget.TextView import androidx.core.view.isVisible import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.utils.downloader.DownloadObjects class DownloadButton(context: Context, attributeSet: AttributeSet) : PieFetchButton(context, attributeSet) { private var mainText: TextView? = null override fun onAttachedToWindow() { super.onAttachedToWindow() progressText = findViewById(R.id.result_movie_download_text_precentage) mainText = findViewById(R.id.result_movie_download_text) setStatus(null) } override fun setStatus(status: DownloadStatusTell?) { mainText?.post { val txt = when (status) { DownloadStatusTell.IsPaused -> R.string.download_paused DownloadStatusTell.IsDownloading -> R.string.downloading DownloadStatusTell.IsDone -> R.string.downloaded else -> R.string.download } mainText?.setText(txt) } super.setStatus(status) } override fun setDefaultClickListener( card: DownloadObjects.DownloadEpisodeCached, textView: TextView?, callback: (DownloadClickEvent) -> Unit ) { this.setDefaultClickListener( this.findViewById(R.id.download_movie_button), textView, card, callback ) } @SuppressLint("SetTextI18n") override fun updateViewOnDownload(metadata: DownloadMetadata) { super.updateViewOnDownload(metadata) val isVis = metadata.progressPercentage > 0 progressText?.isVisible = isVis if (isVis) progressText?.text = "${metadata.progressPercentage}%" } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/download/button/PieFetchButton.kt ================================================ package com.lagradost.cloudstream3.ui.download.button import android.content.Context import android.os.Looper import android.util.AttributeSet import android.util.Log import android.view.View import android.view.animation.AnimationUtils import android.widget.ImageView import android.widget.TextView import androidx.annotation.MainThread import androidx.core.content.ContextCompat import androidx.core.content.withStyledAttributes import androidx.core.view.isGone import androidx.core.view.isVisible import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PAUSE_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PLAY_FILE import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.queue import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES open class PieFetchButton(context: Context, attributeSet: AttributeSet) : BaseFetchButton(context, attributeSet) { private var waitingAnimation: Int = 0 private var animateWaiting: Boolean = false private var activeOutline: Int = 0 private var nonActiveOutline: Int = 0 private var iconInit: Int = 0 private var iconError: Int = 0 private var iconComplete: Int = 0 private var iconActive: Int = 0 private var iconWaiting: Int = 0 private var iconRemoved: Int = 0 private var iconPaused: Int = 0 private var hideWhenIcon: Boolean = true var progressDrawable: Int = 0 var overrideLayout: Int? = null companion object { val fillArray = arrayOf( R.drawable.circular_progress_bar_clockwise, R.drawable.circular_progress_bar_counter_clockwise, R.drawable.circular_progress_bar_small_to_large, R.drawable.circular_progress_bar_top_to_bottom, ) } private var progressBarBackground: View var statusView: ImageView open fun onInflate() {} init { context.withStyledAttributes(attributeSet, R.styleable.PieFetchButton, 0, 0) { try { inflate( overrideLayout ?: getResourceId( R.styleable.PieFetchButton_download_layout, R.layout.download_button_view ) ) } catch (e: Exception) { recycle() // Manually call recycle first to avoid memory leaks Log.e( "PieFetchButton", "Error inflating PieFetchButton, " + "check that you have declared the required aria2c attrs: aria2c_icon_scale aria2c_icon_color aria2c_outline_color aria2c_fill_color" ) throw e } animateWaiting = getBoolean( R.styleable.PieFetchButton_download_animate_waiting, true ) hideWhenIcon = getBoolean( R.styleable.PieFetchButton_download_hide_when_icon, true ) waitingAnimation = getResourceId( R.styleable.PieFetchButton_download_waiting_animation, R.anim.rotate_around_center_point ) activeOutline = getResourceId( R.styleable.PieFetchButton_download_outline_active, R.drawable.circle_shape ) nonActiveOutline = getResourceId( R.styleable.PieFetchButton_download_outline_non_active, R.drawable.circle_shape_dotted ) iconInit = getResourceId( R.styleable.PieFetchButton_download_icon_init, R.drawable.netflix_download ) iconError = getResourceId( R.styleable.PieFetchButton_download_icon_paused, R.drawable.download_icon_error ) iconComplete = getResourceId( R.styleable.PieFetchButton_download_icon_complete, R.drawable.download_icon_done ) iconPaused = getResourceId( R.styleable.PieFetchButton_download_icon_paused, 0 // R.drawable.download_icon_pause ) iconActive = getResourceId( R.styleable.PieFetchButton_download_icon_active, 0 // R.drawable.download_icon_load ) iconWaiting = getResourceId( R.styleable.PieFetchButton_download_icon_waiting, 0 ) iconRemoved = getResourceId( R.styleable.PieFetchButton_download_icon_removed, R.drawable.netflix_download ) val fillIndex = getInt(R.styleable.PieFetchButton_download_fill, 0) progressDrawable = getResourceId( R.styleable.PieFetchButton_download_fill_override, fillArray[fillIndex] ) } progressBar = findViewById(R.id.progress_downloaded) progressBarBackground = findViewById(R.id.progress_downloaded_background) statusView = findViewById(R.id.image_download_status) progressBar.progressDrawable = ContextCompat.getDrawable(context, progressDrawable) // resetView() onInflate() } override fun onAttachedToWindow() { super.onAttachedToWindow() // Re-run all animations when the view gets visible. // Otherwise views may run without animations after recycled setStatusInternal(currentStatus) } private var currentStatus: DownloadStatusTell? = null /*private fun getActivity(): Activity? { var context = context while (context is ContextWrapper) { if (context is Activity) { return context } context = context.baseContext } return null } fun callback(event : DownloadClickEvent) { handleDownloadClick( getActivity(), event ) }*/ protected fun setDefaultClickListener( view: View, textView: TextView?, card: DownloadObjects.DownloadEpisodeCached, callback: (DownloadClickEvent) -> Unit ) { this.progressText = textView this.setPersistentId(card.id) view.setOnClickListener { if (isZeroBytes) { val localQueue = queue.value val localInstances = downloadInstances.value val id = card.id // If the download is already in queue or active downloads, provide an option to cancel it if (localQueue.any { q -> q.id == id } || localInstances.any { i -> i.downloadQueueWrapper.id == id }) { it.popupMenuNoIcons( arrayListOf( Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel), ) ) { callback(DownloadClickEvent(itemId, card)) } } else { // Otherwise just start a download instantly removeKey(KEY_RESUME_PACKAGES, card.id.toString()) callback(DownloadClickEvent(DOWNLOAD_ACTION_DOWNLOAD, card)) } } else { val list = arrayListOf( Pair(DOWNLOAD_ACTION_PLAY_FILE, R.string.popup_play_file), Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file), ) currentMetaData.apply { // DON'T RESUME A DOWNLOADED FILE lastState != VideoDownloadManager.DownloadType.IsDone && if (progressPercentage < 98) { list.add( if (status == VideoDownloadManager.DownloadType.IsDownloading) Pair(DOWNLOAD_ACTION_PAUSE_DOWNLOAD, R.string.popup_pause_download) else Pair( DOWNLOAD_ACTION_RESUME_DOWNLOAD, R.string.popup_resume_download ) ) } } it.popupMenuNoIcons( list ) { callback(DownloadClickEvent(itemId, card)) // callback.invoke(DownloadClickEvent(itemId, data)) } } } view.setOnLongClickListener { callback(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, card)) // clickCallback.invoke(DownloadClickEvent(DOWNLOAD_ACTION_LONG_CLICK, data)) return@setOnLongClickListener true } } open fun setDefaultClickListener( card: DownloadObjects.DownloadEpisodeCached, textView: TextView?, callback: (DownloadClickEvent) -> Unit ) { setDefaultClickListener(this, textView, card, callback) } /* open fun setDefaultClickListener(requestGetter: suspend BaseFetchButton.() -> List) { this.setOnClickListener { when (this.currentStatus) { null -> { setStatus(DownloadStatusTell.IsPending) ioThread { val request = requestGetter.invoke(this) if (request.size == 1) { performDownload(request.first()) } else if (request.isNotEmpty()) { performFailQueueDownload(request) } } } DownloadStatusTell.Paused -> { resumeDownload() } DownloadStatusTell.Active -> { pauseDownload() } DownloadStatusTell.Error -> { redownload() } else -> {} } } } */ @MainThread private fun setStatusInternal(status: DownloadStatusTell?) { val isPreActive = isZeroBytes && status == DownloadStatusTell.IsDownloading if (animateWaiting && (status == DownloadStatusTell.IsPending || isPreActive)) { val animation = AnimationUtils.loadAnimation(context, waitingAnimation) progressBarBackground.startAnimation(animation) } else { progressBarBackground.clearAnimation() } val progressDrawable = if (status == DownloadStatusTell.IsDownloading && !isPreActive) activeOutline else nonActiveOutline progressBarBackground.background = ContextCompat.getDrawable(context, progressDrawable) val drawable = getDrawableFromStatus(status)?.let { ContextCompat.getDrawable(this.context, it) } statusView.setImageDrawable(drawable) val isDrawable = drawable != null statusView.isVisible = isDrawable val hide = hideWhenIcon && isDrawable if (hide) { progressBar.clearAnimation() progressBarBackground.clearAnimation() } progressBarBackground.isGone = hide progressBar.isGone = hide } /** Also sets currentStatus */ override fun setStatus(status: DownloadStatusTell?) { currentStatus = status // Runs on the main thread, but also instant if it already is if (Looper.myLooper() == Looper.getMainLooper()) { try { setStatusInternal(status) } catch (t: Throwable) { logError(t) // Just in case setStatusInternal throws because thread progressBarBackground.post { setStatusInternal(status) } } } else { progressBarBackground.post { setStatusInternal(status) } } } override fun resetView() { setStatus(null) currentMetaData = DownloadMetadata(0, 0, 0, null) isZeroBytes = true doSetProgress = true progressBar.progress = 0 } override fun updateViewOnDownload(metadata: DownloadMetadata) { val newStatus = metadata.status if (newStatus == null) { resetView() return } val isDone = newStatus == DownloadStatusTell.IsDone || (metadata.downloadedLength > 1024 && metadata.downloadedLength + 1024 >= metadata.totalLength) if (isDone) setStatus(DownloadStatusTell.IsDone) else { setProgress(metadata.downloadedLength, metadata.totalLength) setStatus(newStatus) } } open fun getDrawableFromStatus(status: DownloadStatusTell?): Int? = when (status) { DownloadStatusTell.IsPaused -> iconPaused DownloadStatusTell.IsPending -> iconWaiting DownloadStatusTell.IsDownloading -> iconActive DownloadStatusTell.IsFailed -> iconError DownloadStatusTell.IsDone -> iconComplete DownloadStatusTell.IsStopped -> iconRemoved else -> iconInit }.takeIf { it != 0 } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/download/button/ProgressBarAnimation.kt ================================================ package com.lagradost.cloudstream3.ui.download.button import android.view.animation.Animation import android.view.animation.Transformation import android.widget.ProgressBar class ProgressBarAnimation( private val progressBar: ProgressBar, private val from: Float, private val to: Float ) : Animation() { override fun applyTransformation(interpolatedTime: Float, t: Transformation?) { super.applyTransformation(interpolatedTime, t) val value = from + (to - from) * interpolatedTime progressBar.progress = value.toInt() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueAdapter.kt ================================================ package com.lagradost.cloudstream3.ui.download.queue import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.isGone import androidx.fragment.app.Fragment import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.DownloadQueueItemBinding import com.lagradost.cloudstream3.ui.BaseAdapter import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_CANCEL_PENDING import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DELETE_FILE import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PAUSE_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_RESUME_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.ui.download.queue.DownloadQueueAdapter.Companion.DOWNLOAD_SEPARATOR_TAG import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_DOWNLOAD_INFO /** An item in the adapter can either be a separator or a real item. * isCurrentlyDownloading is used to fully update items as opposed to just moving them. */ class DownloadAdapterItem(val item: DownloadQueueWrapper?) { val isSeparator = item == null } class DownloadQueueAdapter(val fragment: Fragment) : BaseAdapter( diffCallback = BaseDiffCallback( itemSame = { a, b -> a.item?.id == b.item?.id }, contentSame = { a, b -> a.item == b.item }) ) { var currentDownloads = 0 companion object { val DOWNLOAD_SEPARATOR_TAG = "DOWNLOAD_SEPARATOR_TAG" } override fun onCreateContent(parent: ViewGroup): ViewHolderState { val inflater = LayoutInflater.from(parent.context) val binding = DownloadQueueItemBinding.inflate(inflater, parent, false) return ViewHolderState(binding) } override fun onBindContent( holder: ViewHolderState, item: DownloadAdapterItem, position: Int ) { when (val binding = holder.view) { is DownloadQueueItemBinding -> { if (item.item == null) { holder.itemView.tag = DOWNLOAD_SEPARATOR_TAG bindSeparator(binding) } else { holder.itemView.tag = null bind(binding, item.item) } } } } fun submitQueue(newQueue: DownloadAdapterQueue) { val index = newQueue.currentDownloads.size val current = newQueue.currentDownloads val queue = newQueue.queue currentDownloads = current.size val newList = (current + queue).distinctBy { it.id }.map { DownloadAdapterItem(it) }.toMutableList() .apply { // Only add the separator if it actually separates something if (index < this.size) { add(index, DownloadAdapterItem(null)) } } submitList(newList) } fun bindSeparator(binding: DownloadQueueItemBinding) { binding.apply { separatorHolder.isGone = false downloadChildEpisodeHolder.isGone = true } } fun bind( binding: DownloadQueueItemBinding, queueWrapper: DownloadQueueWrapper, ) { val context = binding.root.context binding.apply { separatorHolder.isGone = true downloadChildEpisodeHolder.isGone = false // Only set the child-text if child and parent are not the same // This prevents setting movie titles twice if (queueWrapper.id != queueWrapper.parentId) { val mainName = queueWrapper.downloadItem?.resultName ?: queueWrapper.resumePackage?.item?.ep?.mainName downloadChildEpisodeTextExtra.text = mainName } else { downloadChildEpisodeTextExtra.text = null } downloadChildEpisodeTextExtra.isGone = downloadChildEpisodeTextExtra.text.isNullOrBlank() val status = VideoDownloadManager.downloadStatus[queueWrapper.id] downloadButton.setOnClickListener { view -> val episodeCached = getKey( DOWNLOAD_EPISODE_CACHE, getFolderName(queueWrapper.parentId.toString(), queueWrapper.id.toString()) ) val downloadInfo = context.getKey( KEY_DOWNLOAD_INFO, queueWrapper.id.toString() ) val isCurrentlyDownloading = queueWrapper.isCurrentlyDownloading() val actionList = arrayListOf>() if (isCurrentlyDownloading && episodeCached != null) { // KEY_DOWNLOAD_INFO is used in the file deletion, and is required to exist to delete anything if (downloadInfo != null) { actionList.add(Pair(DOWNLOAD_ACTION_DELETE_FILE, R.string.popup_delete_file)) } else { actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel)) } val currentStatus = VideoDownloadManager.downloadStatus[queueWrapper.id] when (currentStatus) { VideoDownloadManager.DownloadType.IsDownloading -> { actionList.add( Pair( DOWNLOAD_ACTION_PAUSE_DOWNLOAD, R.string.popup_pause_download ) ) } VideoDownloadManager.DownloadType.IsPaused -> { actionList.add( Pair( DOWNLOAD_ACTION_RESUME_DOWNLOAD, R.string.popup_resume_download ) ) } else -> {} } view.popupMenuNoIcons( actionList ) { handleDownloadClick(DownloadClickEvent(itemId, episodeCached)) } } else { actionList.add(Pair(DOWNLOAD_ACTION_CANCEL_PENDING, R.string.cancel)) view.popupMenuNoIcons( actionList ) { when (itemId) { DOWNLOAD_ACTION_CANCEL_PENDING -> { DownloadQueueManager.cancelDownload(queueWrapper.id) } } } } } downloadButton.resetView() downloadButton.setStatus(status) downloadButton.setPersistentId(queueWrapper.id) downloadChildEpisodeText.apply { val name = queueWrapper.downloadItem?.episode?.name ?: queueWrapper.resumePackage?.item?.ep?.name val episode = queueWrapper.downloadItem?.episode?.episode ?: queueWrapper.resumePackage?.item?.ep?.episode val season = queueWrapper.downloadItem?.episode?.season ?: queueWrapper.resumePackage?.item?.ep?.season text = context.getNameFull(name, episode, season) isSelected = true // Needed for text repeating } } } } class DragAndDropTouchHelper(adapter: DownloadQueueAdapter) : ItemTouchHelper( DragAndDropTouchHelperCallback(adapter) ) private class DragAndDropTouchHelperCallback(private val adapter: DownloadQueueAdapter) : ItemTouchHelper.Callback() { override fun getMovementFlags( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder ): Int { val item = adapter.getItem(viewHolder.absoluteAdapterPosition) val isDownloading = item.item?.isCurrentlyDownloading() == true val dragFlags = if (item.isSeparator || isDownloading) { 0 } else { ItemTouchHelper.UP or ItemTouchHelper.DOWN // Allow drag up/down } val swipeFlags = 0 // Disable swipe functionality return makeMovementFlags(dragFlags, swipeFlags) } override fun onMove( recyclerView: RecyclerView, source: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean { val fromPosition = source.absoluteAdapterPosition val toPosition = target.absoluteAdapterPosition val separatorPosition = adapter.currentDownloads val toPositionNoSeparator = if (separatorPosition < toPosition) toPosition - separatorPosition else toPosition if (source.itemView.tag == DOWNLOAD_SEPARATOR_TAG) { return false } else { adapter.getItem(fromPosition).item?.let { downloadQueueInfo -> DownloadQueueManager.reorderItem( downloadQueueInfo, toPositionNoSeparator - 1 ) } } return true } override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { } override fun isLongPressDragEnabled(): Boolean { return true // Enable drag with long press } override fun isItemViewSwipeEnabled(): Boolean { return false // Disable swipe by default } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueFragment.kt ================================================ package com.lagradost.cloudstream3.ui.download.queue import android.view.View import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone import androidx.fragment.app.activityViewModels import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentDownloadQueueBinding import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.setAppBarNoScrollFlagsOnTV import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager import com.lagradost.cloudstream3.utils.txt class DownloadQueueFragment : BaseFragment(BindingCreator.Inflate(FragmentDownloadQueueBinding::inflate)) { private val queueViewModel: DownloadQueueViewModel by activityViewModels() override fun onBindingCreated(binding: FragmentDownloadQueueBinding) { val adapter = DownloadQueueAdapter(this@DownloadQueueFragment) val clearQueueItem = binding.downloadQueueToolbar.menu?.findItem(R.id.cancel_all) observe(queueViewModel.childCards) { cards -> val size = cards.queue.size + cards.currentDownloads.size val isEmptyQueue = size == 0 binding.downloadQueueList.isGone = isEmptyQueue binding.textNoQueue.isGone = !isEmptyQueue clearQueueItem?.isVisible = !isEmptyQueue adapter.submitQueue(cards) } binding.apply { downloadQueueToolbar.apply { title = txt(R.string.download_queue).asString(context) if (isLayout(PHONE or EMULATOR)) { setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) setNavigationOnClickListener { dispatchBackPressed() } } setAppBarNoScrollFlagsOnTV() clearQueueItem?.setOnMenuItemClickListener { AlertDialog.Builder(context, R.style.AlertDialogCustom) .setTitle(R.string.cancel_all) .setMessage(R.string.cancel_queue_message) .setPositiveButton(R.string.yes) { _, _ -> DownloadQueueManager.removeAllFromQueue() } .setNegativeButton(R.string.no) { _, _ -> }.show() true } } downloadQueueList.adapter = adapter // Drag and drop val helper = DragAndDropTouchHelper(adapter) helper.attachToRecyclerView(downloadQueueList) } } override fun fixLayout(view: View) { fixSystemBarsPadding( view, padBottom = isLandscape(), padLeft = isLayout(TV or EMULATOR) ) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/download/queue/DownloadQueueViewModel.kt ================================================ package com.lagradost.cloudstream3.ui.download.queue import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch data class DownloadAdapterQueue( val currentDownloads: List, val queue: List, ) class DownloadQueueViewModel : ViewModel() { private val _childCards = MutableLiveData() val childCards: LiveData = _childCards private val totalDownloadFlow = downloadInstances.combine(DownloadQueueManager.queue) { instances, queue -> val current = instances.map { it.downloadQueueWrapper } DownloadAdapterQueue(current, queue.toList()) }.combine(VideoDownloadManager.currentDownloads) { total, _ -> // We want to update the flow when currentDownloads updates, but we do not care about its value total } init { viewModelScope.launch { totalDownloadFlow.collect { queue -> updateChildList(queue) } } } fun updateChildList(downloads: DownloadAdapterQueue) { _childCards.postValue(downloads) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeChildItemAdapter.kt ================================================ package com.lagradost.cloudstream3.ui.home import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import androidx.preference.PreferenceManager import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.databinding.HomeRemoveGridBinding import com.lagradost.cloudstream3.databinding.HomeRemoveGridExpandedBinding import com.lagradost.cloudstream3.databinding.HomeResultGridBinding import com.lagradost.cloudstream3.databinding.HomeResultGridExpandedBinding import com.lagradost.cloudstream3.ui.BaseAdapter import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.UIHelper.isBottomLayout import com.lagradost.cloudstream3.utils.UIHelper.toPx class HomeScrollViewHolderState(view: ViewBinding) : ViewHolderState(view) { // very shitty that we cant store the state when the view clears, // but this is because the focus clears before the view is removed // so we have to manually store it var wasFocused: Boolean = false override fun save(): Boolean = wasFocused override fun restore(state: Boolean) { if (state) { wasFocused = false // only refocus if tv if (isLayout(TV)) { itemView.requestFocus() } } } } class ResumeItemAdapter( nextFocusUp: Int? = null, nextFocusDown: Int? = null, clickCallback: (SearchClickCallback) -> Unit, private val removeCallback: (View) -> Unit, ) : HomeChildItemAdapter( id = "resumeAdapter".hashCode(), nextFocusUp = nextFocusUp, nextFocusDown = nextFocusDown, clickCallback = clickCallback ) { // As there is no popup on TV we instead use the footer to clear override val footers = if (isLayout(TV or EMULATOR)) 1 else 0 override fun onCreateFooter(parent: ViewGroup): ViewHolderState { val expanded = parent.context.isBottomLayout() val inflater = LayoutInflater.from(parent.context) val binding = if (expanded) HomeRemoveGridExpandedBinding.inflate( inflater, parent, false ) else HomeRemoveGridBinding.inflate(inflater, parent, false) return HomeScrollViewHolderState(binding) } override fun onClearView(holder: ViewHolderState) { // Clear the image, idk if this saves ram or not, but I guess? clearImage(holder.view.root.findViewById(R.id.imageView)) } override fun onBindFooter(holder: ViewHolderState) { this.applyBinding(holder, false) when (val binding = holder.view) { is HomeRemoveGridBinding -> { updateLayoutParms(binding.backgroundCard, setWidth, setHeight) } is HomeRemoveGridExpandedBinding -> { updateLayoutParms(binding.backgroundCard, setWidth, setHeight) } } holder.itemView.apply { if (isLayout(TV)) { isFocusableInTouchMode = true isFocusable = true } nextFocusUp?.let { nextFocusUpId = it } nextFocusDown?.let { nextFocusDownId = it } setOnClickListener { v -> removeCallback.invoke(v ?: return@setOnClickListener) } } } } /** Remember to set `updatePosterSize` to cache the poster size, * otherwise the width and height is unset */ open class HomeChildItemAdapter( id: Int, var nextFocusUp: Int? = null, var nextFocusDown: Int? = null, var clickCallback: (SearchClickCallback) -> Unit, ) : BaseAdapter( id, diffCallback = BaseDiffCallback( itemSame = { a, b -> a.url == b.url && a.name == b.name }, contentSame = { a, b -> a == b }) ) { var hasNext: Boolean = false var isHorizontal: Boolean = false set(value) { field = value updateCachedPosterSize() } private fun updateCachedPosterSize() { setWidth = if (!isHorizontal) { minPosterSize } else { maxPosterSize } setHeight = if (!isHorizontal) { maxPosterSize } else { minPosterSize } } init { updateCachedPosterSize() } protected var setWidth = 0 protected var setHeight = 0 override fun onCreateContent(parent: ViewGroup): ViewHolderState { val expanded = parent.context.isBottomLayout() val inflater = LayoutInflater.from(parent.context) val binding = if (expanded) HomeResultGridExpandedBinding.inflate( inflater, parent, false ) else HomeResultGridBinding.inflate(inflater, parent, false) return HomeScrollViewHolderState(binding) } companion object { // The vast majority of the lag comes from creating the view // This simply shares the views between all HomeChildItemAdapter val sharedPool = newSharedPool { setMaxRecycledViews(CONTENT, 20) } var minPosterSize: Int = 0 var maxPosterSize: Int = 0 fun updatePosterSize(context: Context, value: Int? = null) { val scale = value ?: PreferenceManager.getDefaultSharedPreferences(context) ?.getInt(context.getString(R.string.poster_size_key), 0) ?: 0 // Scale by +10% per step val mul = 1.0f + scale * 0.1f minPosterSize = (114.toPx.toFloat() * mul).toInt() maxPosterSize = (180.toPx.toFloat() * mul).toInt() } fun updateLayoutParms(layout: FrameLayout, width: Int, height: Int) { val params = layout.layoutParams if (params.height == height && params.width == width) return params.width = width params.height = height layout.layoutParams = params } } protected fun applyBinding(holder: ViewHolderState, isFirstItem: Boolean) { when (val binding = holder.view) { is HomeResultGridBinding -> { updateLayoutParms(binding.backgroundCard, setWidth, setHeight) } is HomeResultGridExpandedBinding -> { updateLayoutParms(binding.backgroundCard, setWidth, setHeight) if (isFirstItem) { // to fix tv binding.backgroundCard.nextFocusLeftId = R.id.nav_rail_view } } } } override fun onBindContent( holder: ViewHolderState, item: SearchResponse, position: Int ) { applyBinding(holder, position == 0) SearchResultBuilder.bind( clickCallback = { click -> // ok, so here we hijack the callback to fix the focus when (click.action) { SEARCH_ACTION_LOAD -> (holder as? HomeScrollViewHolderState)?.wasFocused = true } clickCallback(click) }, item, position, holder.itemView, nextFocusUp, nextFocusDown ) holder.itemView.tag = position } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeFragment.kt ================================================ package com.lagradost.cloudstream3.ui.home import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.DialogInterface import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.AbsListView import android.widget.ArrayAdapter import android.widget.ImageView import android.widget.ListView import android.widget.TextView import android.widget.Toast import androidx.activity.ComponentActivity import androidx.appcompat.app.AlertDialog import androidx.core.net.toUri import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.chip.Chip import com.lagradost.api.Log import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.FragmentHomeBinding import com.lagradost.cloudstream3.databinding.HomeEpisodesExpandedBinding import com.lagradost.cloudstream3.databinding.HomeSelectMainpageBinding import com.lagradost.cloudstream3.databinding.TvtypesChipsBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear import com.lagradost.cloudstream3.ui.account.AccountViewModel import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_PLAY_FILE import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchHelper.handleSearchClickCallback import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.AppContextUtils.isNetworkAvailable import com.lagradost.cloudstream3.utils.AppContextUtils.isRecyclerScrollable import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.EmptyEvent import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso import com.lagradost.cloudstream3.utils.TvChannelUtils import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes import com.lagradost.cloudstream3.utils.UIHelper.toPx private const val TAG = "HomeFragment" class HomeFragment : BaseFragment( BindingCreator.Bind(FragmentHomeBinding::bind) ) { companion object { // Used for configuration changed events to fix any popups that are not attached to a fragment val configEvent = EmptyEvent() var currentSpan = 1 private val errorProfilePics = listOf( R.drawable.monke_benene, R.drawable.monke_burrito, R.drawable.monke_coco, R.drawable.monke_cookie, R.drawable.monke_flusdered, R.drawable.monke_funny, R.drawable.monke_like, R.drawable.monke_party, R.drawable.monke_sob, R.drawable.monke_drink, ) val errorProfilePic = errorProfilePics.random() //fun Activity.loadHomepageList( // item: HomePageList, // deleteCallback: (() -> Unit)? = null, //) { // loadHomepageList( // expand = HomeViewModel.ExpandableHomepageList(item, 1, false), // deleteCallback = deleteCallback, // expandCallback = null // ) //} // returns a BottomSheetDialog that will be hidden with OwnHidden upon hide, and must be saved to be able call ownShow in onCreateView fun Activity.loadHomepageList( expand: HomeViewModel.ExpandableHomepageList, deleteCallback: (() -> Unit)? = null, expandCallback: (suspend (String) -> HomeViewModel.ExpandableHomepageList?)? = null, dismissCallback: (() -> Unit), ): BottomSheetDialog { val context = this val bottomSheetDialogBuilder = BottomSheetDialog(context) val binding: HomeEpisodesExpandedBinding = HomeEpisodesExpandedBinding.inflate( bottomSheetDialogBuilder.layoutInflater, null, false ) bottomSheetDialogBuilder.setContentView(binding.root) //val title = bottomSheetDialogBuilder.findViewById(R.id.home_expanded_text)!! //title.findViewTreeLifecycleOwner().lifecycle.addObserver() val item = expand.list binding.homeExpandedText.text = item.name // val recycle = // bottomSheetDialogBuilder.findViewById(R.id.home_expanded_recycler)!! //val titleHolder = // bottomSheetDialogBuilder.findViewById(R.id.home_expanded_drag_down)!! // main { //(bottomSheetDialogBuilder.ownerActivity as androidx.fragment.app.FragmentActivity?)?.supportFragmentManager?.fragments?.lastOrNull()?.viewLifecycleOwner?.apply { // println("GOT LIFE: lifecycle $this") // this.lifecycle.addObserver(object : DefaultLifecycleObserver { // override fun onResume(owner: LifecycleOwner) { // super.onResume(owner) // println("onResume!!!!") // bottomSheetDialogBuilder?.ownShow() // } // override fun onStop(owner: LifecycleOwner) { // super.onStop(owner) // bottomSheetDialogBuilder?.ownHide() // } // }) //} // } //val delete = bottomSheetDialogBuilder.home_expanded_delete binding.homeExpandedDelete.isGone = deleteCallback == null if (deleteCallback != null) { binding.homeExpandedDelete.setOnClickListener { try { val builder: AlertDialog.Builder = AlertDialog.Builder(context) val dialogClickListener = DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { deleteCallback.invoke() bottomSheetDialogBuilder.dismissSafe(this) } DialogInterface.BUTTON_NEGATIVE -> {} } } builder.setTitle(R.string.clear_history) .setMessage( context.getString(R.string.delete_message).format( item.name ) ) .setPositiveButton(R.string.delete, dialogClickListener) .setNegativeButton(R.string.cancel, dialogClickListener) .show().setDefaultFocus() } catch (e: Exception) { logError(e) // ye you somehow fucked up formatting did you? } } } binding.homeExpandedDragDown.setOnClickListener { bottomSheetDialogBuilder.dismissSafe(this) } // Span settings binding.homeExpandedRecycler.spanCount = context.getSpanCount(item.isHorizontalImages) binding.homeExpandedRecycler.setRecycledViewPool(SearchAdapter.sharedPool) binding.homeExpandedRecycler.adapter = SearchAdapter(binding.homeExpandedRecycler,item.isHorizontalImages) { callback -> handleSearchClickCallback(callback) if (callback.action == SEARCH_ACTION_LOAD || callback.action == SEARCH_ACTION_PLAY_FILE) { bottomSheetDialogBuilder.ownHide() // we hide here because we want to resume it later //bottomSheetDialogBuilder.dismissSafe(this) } }.apply { submitList(item.list) hasNext = expand.hasNext } binding.homeExpandedRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { var expandCount = 0 val name = expand.list.name override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { super.onScrollStateChanged(recyclerView, newState) val adapter = recyclerView.adapter if (adapter !is SearchAdapter) return val count = adapter.itemCount val currentHasNext = adapter.hasNext //!recyclerView.canScrollVertically(1) if (!recyclerView.isRecyclerScrollable() && currentHasNext && expandCount != count) { expandCount = count ioSafe { expandCallback?.invoke(name)?.let { newExpand -> (recyclerView.adapter as? SearchAdapter?)?.apply { hasNext = newExpand.hasNext submitList(newExpand.list.list) } } } } } }) val spanListener = Runnable { binding.homeExpandedRecycler.spanCount = context.getSpanCount(item.isHorizontalImages) // We want to rebind everything to update the UI, however we also want to avoid // any animations ect, this is the easiest way to do this, and the most correct @SuppressLint("NotifyDataSetChanged") binding.homeExpandedRecycler.adapter?.notifyDataSetChanged() } configEvent += spanListener bottomSheetDialogBuilder.setOnDismissListener { dismissCallback.invoke() configEvent -= spanListener } //(recycle.adapter as SearchAdapter).notifyDataSetChanged() bottomSheetDialogBuilder.show() return bottomSheetDialogBuilder } private fun getPairList( anime: Chip?, cartoons: Chip?, tvs: Chip?, docs: Chip?, movies: Chip?, asian: Chip?, livestream: Chip?, torrent: Chip?, nsfw: Chip?, others: Chip?, ): List>> { // This list should be same order as home screen to aid navigation return listOf( Pair(movies, listOf(TvType.Movie)), Pair(tvs, listOf(TvType.TvSeries)), Pair(anime, listOf(TvType.Anime, TvType.OVA, TvType.AnimeMovie)), Pair(asian, listOf(TvType.AsianDrama)), Pair(cartoons, listOf(TvType.Cartoon)), Pair(docs, listOf(TvType.Documentary)), Pair(livestream, listOf(TvType.Live)), Pair(torrent, listOf(TvType.Torrent)), Pair(nsfw, listOf(TvType.NSFW)), Pair(others, listOf(TvType.Others)), ) } private fun getPairList(header: TvtypesChipsBinding) = getPairList( header.homeSelectAnime, header.homeSelectCartoons, header.homeSelectTvSeries, header.homeSelectDocumentaries, header.homeSelectMovies, header.homeSelectAsian, header.homeSelectLivestreams, header.homeSelectTorrents, header.homeSelectNsfw, header.homeSelectOthers ) fun validateChips(header: TvtypesChipsBinding?, validTypes: List) { if (header == null) return val pairList = getPairList(header) for ((button, types) in pairList) { val isValid = validTypes.any { types.contains(it) } button?.isVisible = isValid } } fun updateChips(header: TvtypesChipsBinding?, selectedTypes: List) { if (header == null) return val pairList = getPairList(header) for ((button, types) in pairList) { button?.isChecked = button.isVisible && selectedTypes.any { types.contains(it) } } } fun bindChips( header: TvtypesChipsBinding?, selectedTypes: List, validTypes: List, callback: (List) -> Unit ) { bindChips(header, selectedTypes, validTypes, callback, null, null) } fun bindChips( header: TvtypesChipsBinding?, selectedTypes: List, validTypes: List, callback: (List) -> Unit, nextFocusDown: Int?, nextFocusUp: Int? ) { if (header == null) return val pairList = getPairList(header) for ((button, types) in pairList) { val isValid = validTypes.any { types.contains(it) } button?.isVisible = isValid button?.isChecked = isValid && selectedTypes.any { types.contains(it) } button?.isFocusable = true if (isLayout(TV)) { button?.isFocusableInTouchMode = true } if (nextFocusDown != null) button?.nextFocusDownId = nextFocusDown if (nextFocusUp != null) button?.nextFocusUpId = nextFocusUp button?.setOnCheckedChangeListener { _, _ -> val list = ArrayList() for ((sbutton, vvalidTypes) in pairList) { if (sbutton?.isChecked == true) list.addAll(vvalidTypes) } callback(list) } } } fun Context.selectHomepage(selectedApiName: String?, callback: (String) -> Unit) { val validAPIs = filterProviderByPreferredMedia().toMutableList() validAPIs.add(0, randomApi) validAPIs.add(0, noneApi) //val builder: AlertDialog.Builder = AlertDialog.Builder(this) //builder.setView(R.layout.home_select_mainpage) val builder = BottomSheetDialog(this) builder.behavior.state = BottomSheetBehavior.STATE_EXPANDED val binding: HomeSelectMainpageBinding = HomeSelectMainpageBinding.inflate( builder.layoutInflater, null, false ) builder.setContentView(binding.root) builder.show() builder.let { dialog -> val isMultiLang = getApiProviderLangSettings().let { set -> set.size > 1 || set.contains(AllLanguagesName) } //dialog.window?.setGravity(Gravity.BOTTOM) var currentApiName = selectedApiName var currentValidApis: MutableList = mutableListOf() val preSelectedTypes = DataStoreHelper.homePreference.toMutableList() binding.cancelBtt.setOnClickListener { dialog.dismissSafe() } binding.applyBtt.setOnClickListener { if (currentApiName != selectedApiName) { currentApiName?.let(callback) } dialog.dismissSafe() } var pinnedphashset = DataStoreHelper.pinnedProviders.toHashSet() val listView = dialog.findViewById(R.id.listview1) val arrayAdapter = object : ArrayAdapter( this, R.layout.sort_bottom_single_provider_choice, mutableListOf() ) { override fun getView( position: Int, convertView: View?, parent: ViewGroup ): View { val view = convertView ?: LayoutInflater.from(context) .inflate(R.layout.sort_bottom_single_provider_choice, parent, false) val titleText = view.findViewById(R.id.text1) val pinIcon = view.findViewById(R.id.pinicon) val name = getItem(position) titleText?.text = name val isPinned = pinnedphashset.contains(currentValidApis[position].name) pinIcon.visibility = if (isPinned) View.VISIBLE else View.GONE return view } } listView?.adapter = arrayAdapter listView?.choiceMode = AbsListView.CHOICE_MODE_SINGLE listView?.setOnItemClickListener { _, _, i, _ -> if (currentValidApis.isNotEmpty()) { currentApiName = currentValidApis[i].name //to switch to apply simply remove this currentApiName.let(callback) dialog.dismissSafe() } } fun updateList() { DataStoreHelper.homePreference = preSelectedTypes val pinnedp = DataStoreHelper.pinnedProviders.toList() pinnedphashset = pinnedp.toHashSet() arrayAdapter.clear() val sortedApis = validAPIs .filter { it.hasMainPage && (pinnedphashset.contains(it.name) || it.supportedTypes.any( preSelectedTypes::contains )) } .sortedBy { it.name.lowercase() } val sortedApiMap = LinkedHashMap().apply { sortedApis.forEach { put(it.name, it) } } val pinnedApis = pinnedp.asReversed().mapNotNull { name -> sortedApiMap[name] } val remainingApis = sortedApis.filterNot { pinnedphashset.contains(it.name) } currentValidApis = mutableListOf().apply { addAll(validAPIs.take(2)) addAll(pinnedApis) addAll(remainingApis) } val names = currentValidApis.map { if (isMultiLang) "${getFlagFromIso(it.lang)?.plus(" ") ?: ""}${it.name}" else it.name } val index = currentValidApis.map { it.name }.indexOf(currentApiName) listView?.setItemChecked(index, true) arrayAdapter.addAll(names) arrayAdapter.notifyDataSetChanged() } // pin provider on hold listView?.setOnItemLongClickListener { _, _, i, _ -> if (currentValidApis.isNotEmpty() && i > 1) { val pinnedp = DataStoreHelper.pinnedProviders.toMutableList() val thisapi = currentValidApis[i].name if (pinnedp.contains(thisapi)) { pinnedp.remove(thisapi) } else { pinnedp.add(thisapi) } DataStoreHelper.pinnedProviders = pinnedp.toTypedArray() updateList() } true } bindChips( binding.tvtypesChipsScroll.tvtypesChips, preSelectedTypes, validAPIs.flatMap { it.supportedTypes }.distinct() ) { list -> preSelectedTypes.clear() preSelectedTypes.addAll(list) updateList() } updateList() } } } private val homeViewModel: HomeViewModel by activityViewModels() private val accountViewModel: AccountViewModel by activityViewModels() fun addMovies(cards: List) { val ctx = context ?: run { Log.e(TAG, "Context is null, aborting addMovies") return } try { val existingId = TvChannelUtils.getChannelId(ctx, getString(R.string.app_name)) if (existingId != null) { Log.d(TAG, "Channel ID: $existingId") val programCards = cards TvChannelUtils.addPrograms( context = ctx, channelId = existingId, items = programCards ) } else { Log.d(TAG, "Channel does not exist") } } catch (e: Exception) { Log.e(TAG, "Error adding movies: $e") } } private fun deleteAll() { val ctx = context ?: run { Log.e(TAG, "Context is null, aborting deleteAll") return } try { val existingId = TvChannelUtils.getChannelId(ctx, getString(R.string.app_name)) if (existingId != null) { Log.d(TAG, "Channel ID: $existingId") TvChannelUtils.deleteStoredPrograms(ctx) } else { Log.d(TAG, "Channel does not exist") } } catch (e: Exception) { Log.e(TAG, "Error deleting programs: ${e.message}") } } override fun pickLayout(): Int? = if (isLayout(PHONE)) R.layout.fragment_home else R.layout.fragment_home_tv override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { bottomSheetDialog?.ownShow() return super.onCreateView(inflater, container, savedInstanceState) } override fun onDestroyView() { (activity as? ComponentActivity)?.detachBackPressedCallback("HomeFragment_BackPress") bottomSheetDialog?.ownHide() super.onDestroyView() } private val apiChangeClickListener = View.OnClickListener { view -> view.context.selectHomepage(currentApiName) { api -> homeViewModel.loadAndCancel(api, forceReload = true, fromUI = true) } /*val validAPIs = view.context?.filterProviderByPreferredMedia()?.toMutableList() ?: mutableListOf() validAPIs.add(0, randomApi) validAPIs.add(0, noneApi) view.popupMenuNoIconsAndNoStringRes(validAPIs.mapIndexed { index, api -> Pair(index, api.name) }) { homeViewModel.loadAndCancel(validAPIs[itemId].name) }*/ } private var currentApiName: String? = null private var toggleRandomButton = false private var bottomSheetDialog: BottomSheetDialog? = null private var homeMasterAdapter: HomeParentItemAdapterPreview? = null var lastSavedHomepage: String? = null fun saveHomepageToTV(page: Map) { // No need to update for phone if (isLayout(PHONE)) { return } val (name, data) = page.entries.firstOrNull() ?: return // Modifying homepage is an expensive operation, and therefore we avoid it at all cost if (name == lastSavedHomepage) { return } Log.i(TAG, "Adding programs $name to TV") lastSavedHomepage = name ioSafe { // empty the channel deleteAll() // insert the program from first array addMovies(data.list.list) } } override fun fixLayout(view: View) { fixSystemBarsPadding( view, padTop = false, padBottom = isLandscape(), padLeft = isLayout(TV or EMULATOR) ) // Fix grid configEvent.invoke() } @SuppressLint("SetTextI18n") override fun onBindingCreated(binding: FragmentHomeBinding) { context?.let { HomeChildItemAdapter.updatePosterSize(it) } (activity as? ComponentActivity)?.attachBackPressedCallback("HomeFragment_BackPress") { handleTvBackPress(this) } binding.apply { //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) //homeChangeApiLoading.setOnClickListener(apiChangeClickListener) homeApiFab.setOnClickListener(apiChangeClickListener) homeApiFab.setOnLongClickListener { if (currentApiName == noneApi.name) return@setOnLongClickListener false homeViewModel.loadAndCancel(currentApiName, forceReload = true, fromUI = true) showToast(R.string.action_reload, Toast.LENGTH_SHORT) true } homeChangeApi.setOnClickListener(apiChangeClickListener) homeSwitchAccount.setOnClickListener { activity?.showAccountSelectLinear() } homeMasterAdapter = HomeParentItemAdapterPreview( fragment = this@HomeFragment, homeViewModel, accountViewModel ) homeMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) homeMasterRecycler.adapter = homeMasterAdapter homeApiFab.isVisible = isLayout(PHONE) homePreviewReloadProvider.setOnClickListener { homeViewModel.loadAndCancel( homeViewModel.apiName.value ?: noneApi.name, forceReload = true, fromUI = true ) showToast(R.string.action_reload, Toast.LENGTH_SHORT) true } homePreviewSearchButton.setOnClickListener { _ -> // Open blank screen. homeViewModel.queryTextSubmit("") } homeMasterRecycler.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { if (isLayout(PHONE)) { // Fab is only relevant to Phone if (dy > 0) { //check for scroll down homeApiFab.shrink() // hide homeRandom.shrink() } else if (dy < -5) { if (isLayout(PHONE)) { homeApiFab.extend() // show homeRandom.extend() } } } else { // Header scrolling is only relevant to TV/Emulator val view = recyclerView.findViewHolderForAdapterPosition(0)?.itemView val scrollParent = binding.homeApiHolder if (view == null) { // The first view is not visible, so we can assume we have scrolled past it scrollParent.isVisible = false } else { // A bit weird, but this is a major limitation we are working around here // 1. We cant have a real parent to the recyclerview as android cant layout that without lagging // 2. We cant put the view in the recyclerview, as it should always be shown // 3. We cant mirror the view in the recyclerview as then it causes focus issues when swaping out the mirror view // // This means that if we want to have a parent view to the recyclerview we are out of luck // Instead this uses getLocationInWindow to calculate how much the view should be scrolled // as recyclerView has no scrollY (always 0) // // Then it manually "scrolls" it to the correct position // // Hopefully getLocationInWindow acts correctly on all devices val rect = IntArray(2) view.getLocationInWindow(rect) scrollParent.isVisible = true scrollParent.translationY = rect[1].toFloat() - 60.toPx } } super.onScrolled(recyclerView, dx, dy) } }) } //Load value for toggling Random button. Hide at startup context?.let { val settingsManager = PreferenceManager.getDefaultSharedPreferences(it) toggleRandomButton = settingsManager.getBoolean( getString(R.string.random_button_key), false ) binding.homeRandom.visibility = View.GONE binding.homeRandomButtonTv.visibility = View.GONE } observe(homeViewModel.apiName) { apiName -> currentApiName = apiName binding.apply { homeApiFab.text = apiName homeChangeApi.text = apiName homePreviewReloadProvider.isGone = (apiName == noneApi.name) homePreviewSearchButton.isGone = (apiName == noneApi.name) } } observe(homeViewModel.page) { data -> binding.apply { when (data) { is Resource.Success -> { val d = data.value (homeMasterRecycler.adapter as? ParentItemAdapter)?.submitList(d.values.map { it.copy( list = it.list.copy(list = it.list.list.toMutableList()) ) }) saveHomepageToTV(d) homeLoading.isVisible = false homeLoadingError.isVisible = false homeMasterRecycler.isVisible = true homeLoadingShimmer.stopShimmer() //home_loaded?.isVisible = true if (toggleRandomButton) { val distinct = d.values .flatMap { it.list.list } .distinctBy { it.url } val hasItems = distinct.isNotEmpty() val isPhone = isLayout(PHONE) val randomClickListener = View.OnClickListener { distinct.randomOrNull()?.let { activity.loadSearchResult(it) } } homeRandom.isVisible = isPhone && hasItems homeRandom.setOnClickListener(randomClickListener) homeRandomButtonTv.isVisible = !isPhone && hasItems homeRandomButtonTv.setOnClickListener(randomClickListener) } else { homeRandom.isGone = true homeRandomButtonTv.isGone = true } } is Resource.Failure -> { homeLoadingShimmer.stopShimmer() homeReloadConnectionerror.setOnClickListener(apiChangeClickListener) homeReloadConnectionOpenInBrowser.setOnClickListener { view -> val validAPIs = apis//.filter { api -> api.hasMainPage } view.popupMenuNoIconsAndNoStringRes(validAPIs.mapIndexed { index, api -> Pair( index, api.name ) }) { try { val i = Intent(Intent.ACTION_VIEW) i.data = validAPIs[itemId].mainUrl.toUri() startActivity(i) } catch (e: Exception) { logError(e) } } } homeLoading.isVisible = false homeLoadingError.isVisible = true homeMasterRecycler.isInvisible = true // Based on https://github.com/recloudstream/cloudstream/pull/1438 val hasNoNetworkConnection = context?.isNetworkAvailable() == false val isNetworkError = data.isNetworkError // Show the downloads button if we have any sort of network shenanigans homeReloadConnectionGoToDownloads.isVisible = hasNoNetworkConnection || isNetworkError // Only hide the open in browser button if we know this is not network shenanigans related to cs3 homeReloadConnectionOpenInBrowser.isGone = hasNoNetworkConnection resultErrorText.text = if (hasNoNetworkConnection) { getString(R.string.no_internet_connection) } else { data.errorString } homeReloadConnectionGoToDownloads.setOnClickListener { activity.navigate(R.id.navigation_downloads) } (homeMasterRecycler.adapter as? ParentItemAdapter)?.apply { submitList(null) clearState() } } is Resource.Loading -> { homeLoadingShimmer.startShimmer() homeLoading.isVisible = true homeLoadingError.isVisible = false homeMasterRecycler.isInvisible = true (homeMasterRecycler.adapter as? ParentItemAdapter)?.apply { submitList(null) clearState() } //home_loaded?.isVisible = false } } } } observeNullable(homeViewModel.popup) { item -> if (item == null) { bottomSheetDialog?.dismissSafe() bottomSheetDialog = null return@observeNullable } // don't recreate if (bottomSheetDialog != null) { return@observeNullable } val (items, delete) = item bottomSheetDialog = activity?.loadHomepageList(items, expandCallback = { homeViewModel.expandAndReturn(it) }, dismissCallback = { homeViewModel.popup(null) bottomSheetDialog = null }, deleteCallback = delete) } homeViewModel.reloadStored() homeViewModel.loadAndCancel(DataStoreHelper.currentHomePage, false) //loadHomePage(false) // nice profile pic on homepage //home_profile_picture_holder?.isVisible = false // just in case //TODO READD THIS /*for (syncApi in OAuth2Apis) { val login = SyncAPI2.loginInfo() val pic = login?.profilePicture if (home_profile_picture?.setImage( pic, errorImageDrawable = errorProfilePic ) == true ) { home_profile_picture_holder?.isVisible = true break } }*/ } private fun handleTvBackPress(helper: BackPressedCallbackHelper.CallbackHelper) { // Only apply custom behavior on TV interface if (!isLayout(TV)) { helper.runDefault() return } val currentFocus = activity?.currentFocus ?: run { helper.runDefault() return } // isInsideRecycle is true when focus is inside home_master_recycler var parent = currentFocus.parent var isInsideRecycler = false while (parent != null) { if (parent is View && parent.id == R.id.home_master_recycler) { isInsideRecycler = true break } parent = parent.parent } when { // Case 1: Focus is within plugin content -> Move to plugin selector isInsideRecycler -> { binding?.homeMasterRecycler?.scrollToPosition(0) // Defer focus request until after scroll ends binding?.homeChangeApi?.post { binding?.homeChangeApi?.requestFocus() } } // Case 2: Focus is on plugin selector or nearby buttons -> Move to home navigation currentFocus.id == R.id.home_change_api || currentFocus.id == R.id.home_preview_reload_provider || currentFocus.id == R.id.home_preview_search_button -> { activity?.findViewById(R.id.navigation_home)?.requestFocus() } // Case 3: Any other location -> Use default back behavior else -> helper.runDefault() } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapter.kt ================================================ package com.lagradost.cloudstream3.ui.home import android.os.Build import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.HomepageParentBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.BaseAdapter import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.isRecyclerScrollable class LoadClickCallback( val action: Int = 0, val view: View, val position: Int, val response: LoadResponse ) open class ParentItemAdapter( id: Int, private val clickCallback: (SearchClickCallback) -> Unit, private val moreInfoClickCallback: (HomeViewModel.ExpandableHomepageList) -> Unit, private val expandCallback: ((String) -> Unit)? = null, ) : BaseAdapter( id, diffCallback = BaseDiffCallback( itemSame = { a, b -> a.list.name == b.list.name }, contentSame = { a, b -> a.list.list == b.list.list }) ) { companion object { val sharedPool = newSharedPool { setMaxRecycledViews(CONTENT, 4) } } data class ParentItemHolder(val binding: ViewBinding) : ViewHolderState(binding) { override fun save(): Bundle = Bundle().apply { val recyclerView = (binding as? HomepageParentBinding)?.homeChildRecyclerview putParcelable( "value", recyclerView?.layoutManager?.onSaveInstanceState() ) (recyclerView?.adapter as? BaseAdapter<*, *>)?.save(recyclerView) } override fun restore(state: Bundle) { (binding as? HomepageParentBinding)?.homeChildRecyclerview?.layoutManager?.onRestoreInstanceState( state.getSafeParcelable("value") ) } } override fun submitList( list: Collection?, commitCallback: Runnable? ) { super.submitList(list?.sortedBy { it.list.list.isEmpty() }, commitCallback) } override fun onUpdateContent( holder: ViewHolderState, item: HomeViewModel.ExpandableHomepageList, position: Int ) { val binding = holder.view if (binding !is HomepageParentBinding) return (binding.homeChildRecyclerview.adapter as? HomeChildItemAdapter)?.submitList(item.list.list) } override fun onBindContent( holder: ViewHolderState, item: HomeViewModel.ExpandableHomepageList, position: Int ) { val startFocus = R.id.nav_rail_view val endFocus = FOCUS_SELF val binding = holder.view if (binding !is HomepageParentBinding) return val info = item.list binding.apply { val currentAdapter = homeChildRecyclerview.adapter as? HomeChildItemAdapter if (currentAdapter == null) { homeChildRecyclerview.setRecycledViewPool(HomeChildItemAdapter.sharedPool) homeChildRecyclerview.adapter = HomeChildItemAdapter( id = id + position + 100, clickCallback = clickCallback, nextFocusUp = homeChildRecyclerview.nextFocusUpId, nextFocusDown = homeChildRecyclerview.nextFocusDownId, ).apply { isHorizontal = info.isHorizontalImages hasNext = item.hasNext submitList(item.list.list) } } else { currentAdapter.apply { isHorizontal = info.isHorizontalImages hasNext = item.hasNext this.clickCallback = this@ParentItemAdapter.clickCallback nextFocusUp = homeChildRecyclerview.nextFocusUpId nextFocusDown = homeChildRecyclerview.nextFocusDownId submitIncomparableList(item.list.list) } } homeChildRecyclerview.setLinearListLayout( isHorizontal = true, nextLeft = startFocus, nextRight = endFocus, ) homeChildMoreInfo.text = info.name homeChildRecyclerview.addOnScrollListener(object : RecyclerView.OnScrollListener() { var expandCount = 0 val name = item.list.name override fun onScrollStateChanged( recyclerView: RecyclerView, newState: Int ) { super.onScrollStateChanged(recyclerView, newState) val adapter = recyclerView.adapter if (adapter !is HomeChildItemAdapter) return val count = adapter.itemCount val hasNext = adapter.hasNext /*println( "scolling ${recyclerView.isRecyclerScrollable()} ${ recyclerView.canScrollHorizontally( 1 ) }" )*/ //!recyclerView.canScrollHorizontally(1) if (!recyclerView.isRecyclerScrollable() && hasNext && expandCount != count) { expandCount = count expandCallback?.invoke(name) } } }) //(recyclerView.adapter as HomeChildItemAdapter).notifyDataSetChanged() if (isLayout(PHONE)) { homeChildMoreInfo.setOnClickListener { moreInfoClickCallback.invoke(item) } } } } override fun onCreateContent(parent: ViewGroup): ParentItemHolder { val layoutResId = when { isLayout(TV) -> R.layout.homepage_parent_tv isLayout(EMULATOR) -> R.layout.homepage_parent_emulator else -> R.layout.homepage_parent } val inflater = LayoutInflater.from(parent.context) val binding = try { HomepageParentBinding.bind(inflater.inflate(layoutResId, parent, false)) } catch (t: Throwable) { logError(t) // just in case someone forgot we don't want to crash HomepageParentBinding.inflate(inflater) } return ParentItemHolder(binding) } } @Suppress("DEPRECATION") inline fun Bundle.getSafeParcelable(key: String): T? = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getParcelable(key) else getParcelable(key, T::class.java) ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeParentItemAdapterPreview.kt ================================================ package com.lagradost.cloudstream3.ui.home import android.content.Context import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ImageView import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import androidx.viewpager2.widget.ViewPager2 import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipGroup import com.google.android.material.navigation.NavigationBarItemView import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.databinding.FragmentHomeHeadBinding import com.lagradost.cloudstream3.databinding.FragmentHomeHeadTvBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.debugException import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountEditDialog import com.lagradost.cloudstream3.ui.account.AccountHelper.showAccountSelectLinear import com.lagradost.cloudstream3.ui.account.AccountViewModel import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo import com.lagradost.cloudstream3.ui.result.ResultViewModel2 import com.lagradost.cloudstream3.ui.result.START_ACTION_RESUME_LATEST import com.lagradost.cloudstream3.ui.result.getId import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showOptionSelectStringRes import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarMargin import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView import com.lagradost.cloudstream3.utils.UIHelper.populateChips import androidx.core.graphics.toColorInt import com.lagradost.cloudstream3.ui.setRecycledViewPool class HomeParentItemAdapterPreview( val fragment: LifecycleOwner, private val viewModel: HomeViewModel, private val accountViewModel: AccountViewModel ) : ParentItemAdapter( id = "HomeParentItemAdapterPreview".hashCode(), clickCallback = { viewModel.click(it) }, moreInfoClickCallback = { viewModel.popup(it) }, expandCallback = { viewModel.expand(it) }) { override val headers = 1 override fun onCreateHeader(parent: ViewGroup): ViewHolderState { val inflater = LayoutInflater.from(parent.context) val binding = if (isLayout(TV or EMULATOR)) FragmentHomeHeadTvBinding.inflate( inflater, parent, false ) else FragmentHomeHeadBinding.inflate(inflater, parent, false) if (binding is FragmentHomeHeadTvBinding && isLayout(EMULATOR)) { binding.homeBookmarkParentItemMoreInfo.isVisible = true val marginInDp = 50 val density = binding.horizontalScrollChips.context.resources.displayMetrics.density val marginInPixels = (marginInDp * density).toInt() val params = binding.horizontalScrollChips.layoutParams as ViewGroup.MarginLayoutParams params.marginEnd = marginInPixels binding.horizontalScrollChips.layoutParams = params binding.homeWatchParentItemTitle.setCompoundDrawablesWithIntrinsicBounds( null, null, ContextCompat.getDrawable( parent.context, R.drawable.ic_baseline_arrow_forward_24 ), null ) } return HeaderViewHolder(binding, viewModel, accountViewModel, fragment) } override fun onBindHeader(holder: ViewHolderState) { (holder as? HeaderViewHolder)?.bind() } override fun onViewDetachedFromWindow(holder: ViewHolderState) { when (holder) { is HeaderViewHolder -> { holder.onViewDetachedFromWindow() } } } override fun onViewAttachedToWindow(holder: ViewHolderState) { when (holder) { is HeaderViewHolder -> { holder.onViewAttachedToWindow() } } } private class HeaderViewHolder( val binding: ViewBinding, val viewModel: HomeViewModel, accountViewModel: AccountViewModel, fragment: LifecycleOwner, ) : ViewHolderState(binding) { override fun save(): Bundle = Bundle().apply { putParcelable( "resumeRecyclerView", resumeRecyclerView.layoutManager?.onSaveInstanceState() ) putParcelable( "bookmarkRecyclerView", bookmarkRecyclerView.layoutManager?.onSaveInstanceState() ) //putInt("previewViewpager", previewViewpager.currentItem) } override fun restore(state: Bundle) { state.getSafeParcelable("resumeRecyclerView")?.let { recycle -> resumeRecyclerView.layoutManager?.onRestoreInstanceState(recycle) } state.getSafeParcelable("bookmarkRecyclerView")?.let { recycle -> bookmarkRecyclerView.layoutManager?.onRestoreInstanceState(recycle) } } val previewAdapter = HomeScrollAdapter { view, position, item -> viewModel.click( LoadClickCallback(0, view, position, item) ) } private val resumeAdapter = ResumeItemAdapter( nextFocusUp = itemView.nextFocusUpId, nextFocusDown = itemView.nextFocusDownId, removeCallback = { v -> try { val context = v.context ?: return@ResumeItemAdapter val builder: AlertDialog.Builder = AlertDialog.Builder(context) // Copy pasted from https://github.com/recloudstream/cloudstream/pull/1658/files builder.apply { setTitle(R.string.clear_history) setMessage( context.getString(R.string.delete_message).format( context.getString( R.string.continue_watching ) ) ) setNegativeButton(R.string.cancel) { _, _ -> /*NO-OP*/ } setPositiveButton(R.string.delete) { _, _ -> DataStoreHelper.deleteAllResumeStateIds() viewModel.reloadStored() } show().setDefaultFocus() } } catch (t: Throwable) { // This may throw a formatting error logError(t) } }, clickCallback = { callback -> if (callback.action != SEARCH_ACTION_SHOW_METADATA) { viewModel.click(callback) return@ResumeItemAdapter } callback.view.context?.getActivity()?.showOptionSelectStringRes( callback.view, callback.card.posterUrl, listOf( R.string.action_open_watching, R.string.action_remove_watching ), listOf( R.string.action_open_play, R.string.action_open_watching, R.string.action_remove_watching ) ) { (isTv, actionId) -> when (actionId + if (isTv) 0 else 1) { // play 0 -> { viewModel.click( SearchClickCallback( START_ACTION_RESUME_LATEST, callback.view, -1, callback.card ) ) } //info 1 -> { viewModel.click( SearchClickCallback( SEARCH_ACTION_LOAD, callback.view, -1, callback.card ) ) } // remove 2 -> { val card = callback.card if (card is DataStoreHelper.ResumeWatchingResult) { DataStoreHelper.removeLastWatched(card.parentId) viewModel.reloadStored() } } } } }) private val bookmarkAdapter = HomeChildItemAdapter( id = "bookmarkAdapter".hashCode(), nextFocusUp = itemView.nextFocusUpId, nextFocusDown = itemView.nextFocusDownId ) { callback -> if (callback.action != SEARCH_ACTION_SHOW_METADATA) { viewModel.click(callback) return@HomeChildItemAdapter } (callback.view.context?.getActivity() as? MainActivity)?.loadPopup( callback.card, load = false ) /* callback.view.context?.getActivity()?.showOptionSelectStringRes( callback.view, callback.card.posterUrl, listOf( R.string.action_open_watching, R.string.action_remove_from_bookmarks, ), listOf( R.string.action_open_play, R.string.action_open_watching, R.string.action_remove_from_bookmarks ) ) { (isTv, actionId) -> when (actionId + if (isTv) 0 else 1) { // play 0 -> { viewModel.click( SearchClickCallback( START_ACTION_RESUME_LATEST, callback.view, -1, callback.card ) ) } 1 -> { // info viewModel.click( SearchClickCallback( SEARCH_ACTION_LOAD, callback.view, -1, callback.card ) ) } 2 -> { // remove DataStoreHelper.setResultWatchState( callback.card.id, WatchType.NONE.internalId ) viewModel.reloadStored() } } } */ } private val previewViewpager: ViewPager2 = itemView.findViewById(R.id.home_preview_viewpager) private val previewViewpagerText: ViewGroup = itemView.findViewById(R.id.home_preview_viewpager_text) // private val previewHeader: FrameLayout = itemView.findViewById(R.id.home_preview) private val resumeHolder: View = itemView.findViewById(R.id.home_watch_holder) private val resumeRecyclerView: RecyclerView = itemView.findViewById(R.id.home_watch_child_recyclerview) private val bookmarkHolder: View = itemView.findViewById(R.id.home_bookmarked_holder) private val bookmarkRecyclerView: RecyclerView = itemView.findViewById(R.id.home_bookmarked_child_recyclerview) private val headProfilePic: ImageView? = itemView.findViewById(R.id.home_head_profile_pic) private val headProfilePicCard: View? = itemView.findViewById(R.id.home_head_profile_padding) private val alternateHeadProfilePic: ImageView? = itemView.findViewById(R.id.alternate_home_head_profile_pic) private val alternateHeadProfilePicCard: View? = itemView.findViewById(R.id.alternate_home_head_profile_padding) private val topPadding: View? = itemView.findViewById(R.id.home_padding) private val alternativeAccountPadding: View? = itemView.findViewById(R.id.alternative_account_padding) private val homeNonePadding: View = itemView.findViewById(R.id.home_none_padding) fun onSelect(item: LoadResponse, position: Int) { (binding as? FragmentHomeHeadTvBinding)?.apply { homePreviewDescription.isGone = item.plot.isNullOrBlank() homePreviewDescription.text = item.plot?.html() ?: "" val scoreText = item.score?.toStringNull(0.1, 10, 1, false) scoreText?.let { score -> homePreviewScore.text = homePreviewScore.context.getString(R.string.extension_rating, score) // while it should never fail, we do this just in case val rating = score.toDoubleOrNull() ?: item.score?.toDouble() ?: 0.0 val color = when { rating < 5.0 -> "#eb2f2f".toColorInt() // Red rating < 8.0 -> "#eda009".toColorInt() // Yellow else -> "#3bb33b".toColorInt() // Green } homePreviewScore.backgroundTintList = android.content.res.ColorStateList.valueOf(color) } homePreviewScore.isGone = scoreText == null item.year?.let { year -> homePreviewYear.text = year.toString() } homePreviewYear.isGone = item.year == null val duration = item.duration duration?.let { min -> homePreviewDuration.text = homePreviewDuration.context.getString(R.string.duration_format, min) } homePreviewDuration.isGone = duration == null || duration <= 0 val castText = item.actors?.take(3)?.joinToString(", ") { it.actor.name } if (!castText.isNullOrBlank()) { homePreviewCast.text = homePreviewCast.context.getString(R.string.cast_format, castText) homePreviewCast.isVisible = true } else { homePreviewCast.isVisible = false } homePreviewText.text = item.name.html() populateChips( homePreviewTags, item.tags?.take(6) ?: emptyList(), R.style.ChipFilledSemiTransparent, null ) bindLogo( url = item.logoUrl, headers = item.posterHeaders, titleView = homePreviewText, logoView = homeBackgroundPosterWatermarkBadgeHolder ) homePreviewTags.isGone = item.tags.isNullOrEmpty() homePreviewInfoBtt.setOnClickListener { view -> viewModel.click( LoadClickCallback(0, view, position, item) ) } } (binding as? FragmentHomeHeadBinding)?.apply { //homePreviewImage.setImage(item.posterUrl, item.posterHeaders) homePreviewPlay.setOnClickListener { view -> viewModel.click( LoadClickCallback( START_ACTION_RESUME_LATEST, view, position, item ) ) } homePreviewInfo.setOnClickListener { view -> viewModel.click( LoadClickCallback(0, view, position, item) ) } // very ugly code, but I don't care val id = item.getId() val watchType = DataStoreHelper.getResultWatchState(id) homePreviewBookmark.setText(watchType.stringRes) homePreviewBookmark.setCompoundDrawablesWithIntrinsicBounds( null, ContextCompat.getDrawable( homePreviewBookmark.context, watchType.iconRes ), null, null ) homePreviewBookmark.setOnClickListener { fab -> fab.context.getActivity()?.showBottomDialog( WatchType.entries .map { fab.context.getString(it.stringRes) } .toList(), DataStoreHelper.getResultWatchState(id).ordinal, fab.context.getString(R.string.action_add_to_bookmarks), showApply = false, {}) { val newValue = WatchType.entries[it] ResultViewModel2().updateWatchStatus( newValue, fab.context, item ) { statusChanged: Boolean -> if (!statusChanged) return@updateWatchStatus homePreviewBookmark.setCompoundDrawablesWithIntrinsicBounds( null, ContextCompat.getDrawable( homePreviewBookmark.context, newValue.iconRes ), null, null ) homePreviewBookmark.setText(newValue.stringRes) } } } } } private val previewCallback: ViewPager2.OnPageChangeCallback = object : ViewPager2.OnPageChangeCallback() { override fun onPageSelected(position: Int) { previewAdapter.apply { if (position >= itemCount - 1 && hasMoreItems) { hasMoreItems = false // don't make two requests viewModel.loadMoreHomeScrollResponses() } } val item = previewAdapter.getItemOrNull(position) ?: return onSelect(item, position) } } fun onViewDetachedFromWindow() { previewViewpager.unregisterOnPageChangeCallback(previewCallback) } private val toggleList = listOf>( Pair(itemView.findViewById(R.id.home_type_watching_btt), WatchType.WATCHING), Pair(itemView.findViewById(R.id.home_type_completed_btt), WatchType.COMPLETED), Pair(itemView.findViewById(R.id.home_type_dropped_btt), WatchType.DROPPED), Pair(itemView.findViewById(R.id.home_type_on_hold_btt), WatchType.ONHOLD), Pair(itemView.findViewById(R.id.home_plan_to_watch_btt), WatchType.PLANTOWATCH), ) private val toggleListHolder: ChipGroup? = itemView.findViewById(R.id.home_type_holder) fun bind() = Unit init { previewViewpager.setPageTransformer(HomeScrollTransformer()) previewViewpager.adapter = previewAdapter resumeRecyclerView.adapter = resumeAdapter bookmarkRecyclerView.setRecycledViewPool(HomeChildItemAdapter.sharedPool) bookmarkRecyclerView.adapter = bookmarkAdapter resumeRecyclerView.setLinearListLayout( nextLeft = R.id.nav_rail_view, nextRight = FOCUS_SELF ) bookmarkRecyclerView.setLinearListLayout( nextLeft = R.id.nav_rail_view, nextRight = FOCUS_SELF ) fixPaddingStatusbarMargin(topPadding) for ((chip, watch) in toggleList) { chip.isChecked = false chip.setOnCheckedChangeListener { _, isChecked -> if (isChecked) { viewModel.loadStoredData(setOf(watch)) } // Else if all are unchecked -> Do not load data else if (toggleList.all { !it.first.isChecked }) { viewModel.loadStoredData(emptySet()) } } } headProfilePicCard?.isGone = isLayout(TV or EMULATOR) alternateHeadProfilePicCard?.isGone = isLayout(TV or EMULATOR) fragment.observe(viewModel.currentAccount) { currentAccount -> headProfilePic?.loadImage(currentAccount?.image) alternateHeadProfilePic?.loadImage(currentAccount?.image) } headProfilePicCard?.setOnClickListener { activity?.showAccountSelectLinear() } fun showAccountEditBox(context: Context): Boolean { val currentAccount = DataStoreHelper.getCurrentAccount() return if (currentAccount != null) { showAccountEditDialog( context = context, account = currentAccount, isNewAccount = false, accountEditCallback = { accountViewModel.handleAccountUpdate(it, context) }, accountDeleteCallback = { accountViewModel.handleAccountDelete( it, context ) } ) true } else false } alternateHeadProfilePicCard?.setOnLongClickListener { showAccountEditBox(it.context) } headProfilePicCard?.setOnLongClickListener { showAccountEditBox(it.context) } alternateHeadProfilePicCard?.setOnClickListener { activity?.showAccountSelectLinear() } (binding as? FragmentHomeHeadTvBinding)?.apply { /*homePreviewChangeApi.setOnClickListener { view -> view.context.selectHomepage(viewModel.repo?.name) { api -> viewModel.loadAndCancel(api, forceReload = true, fromUI = true) } } homePreviewReloadProvider.setOnClickListener { viewModel.loadAndCancel( viewModel.apiName.value ?: noneApi.name, forceReload = true, fromUI = true ) showToast(R.string.action_reload, Toast.LENGTH_SHORT) true } homePreviewSearchButton.setOnClickListener { _ -> // Open blank screen. viewModel.queryTextSubmit("") }*/ // A workaround to the focus problem of always centering the view on focus // as that causes higher android versions to stretch the ui when switching between shows var lastFocusTimeoutMs = 0L homePreviewInfoBtt.setOnFocusChangeListener { view, hasFocus -> val lastFocusMs = lastFocusTimeoutMs // Always reset timer, as we only want to update // it if we have not interacted in half a second lastFocusTimeoutMs = System.currentTimeMillis() if (!hasFocus) return@setOnFocusChangeListener if (lastFocusMs + 500L < System.currentTimeMillis()) { MainActivity.centerView(view) } } homePreviewHiddenNextFocus.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) return@setOnFocusChangeListener previewViewpager.setCurrentItem(previewViewpager.currentItem + 1, true) homePreviewInfoBtt.requestFocus() } homePreviewHiddenPrevFocus.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) return@setOnFocusChangeListener if (previewViewpager.currentItem <= 0) { //Focus the Home item as the default focus will be the header item (activity as? MainActivity)?.binding?.navRailView?.findViewById( R.id.navigation_home )?.requestFocus() } else { previewViewpager.setCurrentItem(previewViewpager.currentItem - 1, true) binding.homePreviewInfoBtt.requestFocus() //binding.homePreviewPlayBtt.requestFocus() } } } (binding as? FragmentHomeHeadBinding)?.apply { homeSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { viewModel.queryTextSubmit(query) return true } override fun onQueryTextChange(newText: String): Boolean { viewModel.queryTextChange(newText) return true } }) } } private fun updatePreview(preview: Resource>>) { if (preview is Resource.Success) { homeNonePadding.apply { val params = layoutParams params.height = 0 layoutParams = params } } else fixPaddingStatusbarView(homeNonePadding) when (preview) { is Resource.Success -> { previewAdapter.submitList(preview.value.second) previewAdapter.hasMoreItems = preview.value.first /*if (!.setItems( preview.value.second, preview.value.first ) ) { // this might seam weird and useless, however this prevents a very weird andrid bug were the viewpager is not rendered properly // I have no idea why that happens, but this is my ducktape solution previewViewpager.setCurrentItem(0, false) previewViewpager.beginFakeDrag() previewViewpager.fakeDragBy(1f) previewViewpager.endFakeDrag() previewCallback.onPageSelected(0) //previewHeader.isVisible = true }*/ previewViewpager.isVisible = true previewViewpagerText.isVisible = true alternativeAccountPadding?.isVisible = false (binding as? FragmentHomeHeadTvBinding)?.apply { homePreviewInfoBtt.isVisible = true } // Explicitly bind the current item to ensure instant loading val currentPos = previewViewpager.currentItem val item = preview.value.second.getOrNull(currentPos) if (item != null) { onSelect(item, currentPos) } } else -> { previewAdapter.submitList(listOf()) previewViewpager.setCurrentItem(0, false) previewViewpager.isVisible = false previewViewpagerText.isVisible = false alternativeAccountPadding?.isVisible = true (binding as? FragmentHomeHeadTvBinding)?.apply { homePreviewInfoBtt.isVisible = false } //previewHeader.isVisible = false } } } private fun updateResume(resumeWatching: List) { resumeHolder.isVisible = resumeWatching.isNotEmpty() resumeAdapter.submitList(resumeWatching) if ( binding is FragmentHomeHeadBinding || binding is FragmentHomeHeadTvBinding && isLayout(EMULATOR) ) { val title = (binding as? FragmentHomeHeadBinding)?.homeWatchParentItemTitle ?: (binding as? FragmentHomeHeadTvBinding)?.homeWatchParentItemTitle title?.setOnClickListener { viewModel.popup( HomeViewModel.ExpandableHomepageList( HomePageList( title.text.toString(), resumeWatching, false ), 1, false ), deleteCallback = { viewModel.deleteResumeWatching() } ) } } } private fun updateBookmarks(data: Pair>) { val (visible, list) = data bookmarkHolder.isVisible = visible bookmarkAdapter.submitList(list) if ( binding is FragmentHomeHeadBinding || binding is FragmentHomeHeadTvBinding && isLayout(EMULATOR) ) { val title = (binding as? FragmentHomeHeadBinding)?.homeBookmarkParentItemTitle ?: (binding as? FragmentHomeHeadTvBinding)?.homeBookmarkParentItemTitle title?.setOnClickListener { val items = toggleList.map { it.first }.filter { it.isChecked } if (items.isEmpty()) return@setOnClickListener // we don't want to show an empty dialog val textSum = items .mapNotNull { it.text }.joinToString() viewModel.popup( HomeViewModel.ExpandableHomepageList( HomePageList( textSum, list, false ), 1, false ), deleteCallback = { viewModel.deleteBookmarks(list) } ) } } } fun onViewAttachedToWindow() { previewViewpager.registerOnPageChangeCallback(previewCallback) binding.root.findViewTreeLifecycleOwner()?.apply { observe(viewModel.preview) { updatePreview(it) } /*if (binding is FragmentHomeHeadTvBinding) { observe(viewModel.apiName) { name -> binding.homePreviewChangeApi.text = name binding.homePreviewReloadProvider.isGone = (name == noneApi.name) } }*/ observe(viewModel.resumeWatching) { updateResume(it) } observe(viewModel.bookmarks) { updateBookmarks(it) } observe(viewModel.availableWatchStatusTypes) { (checked, visible) -> for ((chip, watch) in toggleList) { chip.apply { isVisible = visible.contains(watch) isChecked = checked.contains(watch) } } toggleListHolder?.isGone = visible.isEmpty() } } ?: debugException { "Expected findViewTreeLifecycleOwner" } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollAdapter.kt ================================================ package com.lagradost.cloudstream3.ui.home import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isGone import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.databinding.HomeScrollViewBinding import com.lagradost.cloudstream3.databinding.HomeScrollViewTvBinding import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.ImageLoader.loadImage class HomeScrollAdapter( val callback: ((View, Int, LoadResponse) -> Unit) ) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> a.uniqueUrl == b.uniqueUrl && a.name == b.name })) { var hasMoreItems: Boolean = false override fun onCreateContent(parent: ViewGroup): ViewHolderState { val inflater = LayoutInflater.from(parent.context) val binding = if (isLayout(TV or EMULATOR)) { HomeScrollViewTvBinding.inflate(inflater, parent, false) } else { HomeScrollViewBinding.inflate(inflater, parent, false) } return ViewHolderState(binding) } override fun onClearView(holder: ViewHolderState) { when (val binding = holder.view) { is HomeScrollViewBinding -> { clearImage(binding.homeScrollPreview) } is HomeScrollViewTvBinding -> { clearImage(binding.homeScrollPreview) } } } override fun onBindContent( holder: ViewHolderState, item: LoadResponse, position: Int, ) { val binding = holder.view val posterUrl = item.backgroundPosterUrl ?: item.posterUrl when (binding) { is HomeScrollViewBinding -> { binding.homeScrollPreview.loadImage(posterUrl) binding.homeScrollPreviewTags.apply { text = item.tags?.joinToString(" • ") ?: "" isGone = item.tags.isNullOrEmpty() maxLines = 2 } binding.homeScrollPreviewTitle.text = item.name.html() bindLogo( url = item.logoUrl, headers = item.posterHeaders, titleView = binding.homeScrollPreviewTitle, logoView = binding.homePreviewLogo ) } is HomeScrollViewTvBinding -> { binding.homeScrollPreview.isFocusable = false binding.homeScrollPreview.setOnClickListener { view -> callback.invoke(view ?: return@setOnClickListener, position, item) } binding.homeScrollPreview.loadImage(posterUrl) } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeScrollTransformer.kt ================================================ package com.lagradost.cloudstream3.ui.home import android.view.View import androidx.viewpager2.widget.ViewPager2 class HomeScrollTransformer : ViewPager2.PageTransformer { override fun transformPage(page: View, position: Float) { //page.translationX = -position * page.width / 2.0f //val params = RecyclerView.LayoutParams( // RecyclerView.LayoutParams.MATCH_PARENT, // 0 //) //page.layoutParams = params //progressBar?.layoutParams = params val padding = (-position * page.width / 2).toInt() page.setPadding( padding, 0, -padding, 0 ) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/home/HomeViewModel.kt ================================================ package com.lagradost.cloudstream3.ui.home import android.os.Build import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.debugWarning import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_FOCUSED import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchHelper import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.addProgramsToContinueWatching import com.lagradost.cloudstream3.utils.AppContextUtils.filterHomePageListByFilmQuality import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilmQuality import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE_BACKUP import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteAllResumeStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllWatchStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getCurrentAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.withContext import java.util.EnumSet import java.util.concurrent.CopyOnWriteArrayList class HomeViewModel : ViewModel() { companion object { suspend fun getResumeWatching(): List? { val resumeWatching = withContext(Dispatchers.IO) { getAllResumeStateIds()?.mapNotNull { id -> getLastWatched(id) }?.sortedBy { -it.updateTime } } val resumeWatchingResult = withContext(Dispatchers.IO) { resumeWatching?.mapNotNull { resume -> val headerCache = getKey( DOWNLOAD_HEADER_CACHE, resume.parentId.toString() ) val data = if (headerCache == null) { // We store resume watching data in download header cache // Because downloads automatically pruned outdated download headers we // removed resume watching data. We should restore the data for affected users. val oldData = getKey( DOWNLOAD_HEADER_CACHE_BACKUP, resume.parentId.toString() ) ?: return@mapNotNull null // Restore data setKey(DOWNLOAD_HEADER_CACHE, resume.parentId.toString(), oldData) oldData } else { headerCache } val watchPos = getViewPos(resume.episodeId) DataStoreHelper.ResumeWatchingResult( data.name, data.url, data.apiName, data.type, data.poster, watchPos, resume.episodeId, resume.parentId, resume.episode, resume.season, resume.isFromDownload ) } } return resumeWatchingResult } } fun deleteResumeWatching() { deleteAllResumeStateIds() loadResumeWatching() } fun deleteBookmarks(list: List) { list.forEach { DataStoreHelper.deleteBookmarkedData(it.id) } loadStoredData() } var repo: APIRepository? = null private val _apiName = MutableLiveData() val apiName: LiveData = _apiName private val _currentAccount = MutableLiveData() val currentAccount: MutableLiveData = _currentAccount private val _randomItems = MutableLiveData?>(null) val randomItems: LiveData?> = _randomItems private var currentShuffledList: List = listOf() private fun autoloadRepo(): APIRepository { return APIRepository(synchronized(apis) { apis.first { it.hasMainPage } }) } private val _availableWatchStatusTypes = MutableLiveData, Set>>() val availableWatchStatusTypes: LiveData, Set>> = _availableWatchStatusTypes private val _bookmarks = MutableLiveData>>() val bookmarks: LiveData>> = _bookmarks private val _resumeWatching = MutableLiveData>() private val _preview = MutableLiveData>>>() private val previewResponses = CopyOnWriteArrayList() private val previewResponsesAdded = mutableSetOf() val resumeWatching: LiveData> = _resumeWatching val preview: LiveData>>> = _preview private fun loadResumeWatching() = viewModelScope.launchSafe { val resumeWatchingResult = getResumeWatching() if (isLayout(TV) && resumeWatchingResult != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { ioSafe { // this WILL crash on non tvs, so keep this inside a try catch activity?.addProgramsToContinueWatching(resumeWatchingResult) } } resumeWatchingResult?.let { _resumeWatching.postValue(it) } } fun loadStoredData(preferredWatchStatus: Set?) = viewModelScope.launchSafe { val watchStatusIds = withContext(Dispatchers.IO) { getAllWatchStateIds()?.map { id -> Pair(id, getResultWatchState(id)) } }?.distinctBy { it.first } ?: return@launchSafe val length = WatchType.entries.size val currentWatchTypes = mutableSetOf() for (watch in watchStatusIds) { currentWatchTypes.add(watch.second) if (currentWatchTypes.size >= length) { break } } currentWatchTypes.remove(WatchType.NONE) if (currentWatchTypes.size <= 0) { DataStoreHelper.homeBookmarkedList = intArrayOf() _availableWatchStatusTypes.postValue(setOf() to setOf()) _bookmarks.postValue(Pair(false, ArrayList())) return@launchSafe } val watchPrefNotNull = preferredWatchStatus ?: EnumSet.of(currentWatchTypes.first()) //if (currentWatchTypes.any { watchPrefNotNull.contains(it) }) watchPrefNotNull else listOf(currentWatchTypes.first()) DataStoreHelper.homeBookmarkedList = watchPrefNotNull.map { it.internalId }.toIntArray() _availableWatchStatusTypes.postValue( watchPrefNotNull to currentWatchTypes, ) val list = withContext(Dispatchers.IO) { watchStatusIds.filter { watchPrefNotNull.contains(it.second) } .mapNotNull { getBookmarkedData(it.first) } .sortedBy { -it.latestUpdatedTime } } _bookmarks.postValue(Pair(true, list)) } private var onGoingLoad: Job? = null private var isCurrentlyLoadingName: String? = null private fun loadAndCancel(api: MainAPI) { //println("loaded ${api.name}") onGoingLoad?.cancel() isCurrentlyLoadingName = api.name onGoingLoad = load(api) } data class ExpandableHomepageList( var list: HomePageList, var currentPage: Int, var hasNext: Boolean, ) private val expandable: MutableMap = mutableMapOf() private val _page = MutableLiveData>>(Resource.Loading()) val page: LiveData>> = _page val lock: MutableSet = mutableSetOf() suspend fun expandAndReturn(name: String): ExpandableHomepageList? { if (lock.contains(name)) return null lock += name repo?.apply { waitForHomeDelay() expandable[name]?.let { current -> debugAssert({ !current.hasNext }) { "Expand called when not needed" } val nextPage = current.currentPage + 1 val next = getMainPage(nextPage, mainPage.indexOfFirst { it.name == name }) if (next is Resource.Success) { next.value.filterNotNull().forEach { main -> main.items.forEach { newList -> val key = newList.name expandable[key]?.apply { hasNext = main.hasNext currentPage = nextPage debugWarning({ newList.list.any { outer -> this.list.list.any { it.url == outer.url } } }) { "Expanded contained an item that was previously already in the list\n${list.name} = ${this.list.list}\n${newList.name} = ${newList.list}" } this.list.list += newList.list this.list.list.distinctBy { it.url } // just to be sure we are not adding the same shit for some reason } ?: debugWarning { "Expanded an item not in main load named $key, current list is ${expandable.keys}" } } } } else { current.hasNext = false } } _page.postValue(Resource.Success(expandable)) } lock -= name return expandable[name] } // this is soo over engineered, but idk how I can make it clean without making the main api harder to use :pensive: fun expand(name: String) = viewModelScope.launchSafe { expandAndReturn(name) } // returns the amount of items added and modifies current private suspend fun updatePreviewResponses( current: MutableList, alreadyAdded: MutableSet, shuffledList: List, size: Int ): Int { var count = 0 val addItems = arrayListOf() for (searchResponse in shuffledList) { if (!alreadyAdded.contains(searchResponse.url)) { addItems.add(searchResponse) previewResponsesAdded.add(searchResponse.url) if (++count >= size) { break } } } val add = addItems.amap { searchResponse -> repo?.load(searchResponse.url) }.mapNotNull { if (it != null && it is Resource.Success) it.value else null } current.addAll(add) return add.size } private var addJob: Job? = null fun loadMoreHomeScrollResponses() { addJob = ioSafe { updatePreviewResponses(previewResponses, previewResponsesAdded, currentShuffledList, 1) _preview.postValue(Resource.Success((previewResponsesAdded.size < currentShuffledList.size) to previewResponses)) } } private fun load(api: MainAPI): Job = ioSafe { repo = //if (api != null) { APIRepository(api) //} else { // autoloadRepo() //} _apiName.postValue(repo?.name) _randomItems.postValue(listOf()) if (repo?.hasMainPage != true) { _page.postValue(Resource.Success(emptyMap())) _preview.postValue(Resource.Failure(false, "No homepage")) return@ioSafe } _page.postValue(Resource.Loading()) _preview.postValue(Resource.Loading()) // cancel the current preview expand as that is no longer relevant addJob?.cancel() when (val data = repo?.getMainPage(1, null)) { is Resource.Success -> { try { expandable.clear() data.value.forEach { home -> home?.items?.forEach { list -> val filteredList = context?.filterHomePageListByFilmQuality(list) ?: list expandable[list.name] = ExpandableHomepageList( filteredList.copy( list = CopyOnWriteArrayList( filteredList.list ) ), 1, home.hasNext ) } } val items = data.value.mapNotNull { it?.items }.flatten() previewResponses.clear() previewResponsesAdded.clear() //val home = data.value if (items.isNotEmpty()) { val currentList = items.shuffled().filter { it.list.isNotEmpty() } .flatMap { it.list } .distinctBy { it.url }.toList() if (currentList.isNotEmpty()) { val randomItems = context?.filterSearchResultByFilmQuality(currentList.shuffled()) ?: currentList.shuffled() updatePreviewResponses( previewResponses, previewResponsesAdded, randomItems, 3 ) _randomItems.postValue(randomItems) currentShuffledList = randomItems } } if (previewResponses.isEmpty()) { _preview.postValue( Resource.Failure( false, "No homepage responses" ) ) } else { _preview.postValue(Resource.Success((previewResponsesAdded.size < currentShuffledList.size) to previewResponses)) } _page.postValue(Resource.Success(expandable)) } catch (e: Exception) { _randomItems.postValue(emptyList()) logError(e) } } is Resource.Failure -> { @Suppress("UNNECESSARY_NOT_NULL_ASSERTION") _page.postValue(data!!) @Suppress("UNNECESSARY_NOT_NULL_ASSERTION") _preview.postValue(data!!) } else -> Unit } isCurrentlyLoadingName = null } fun click(callback: SearchClickCallback) { if (callback.action != SEARCH_ACTION_FOCUSED) { SearchHelper.handleSearchClickCallback(callback) } } private val _popup = MutableLiveData Unit)?>?>(null) val popup: LiveData Unit)?>?> = _popup fun popup(list: ExpandableHomepageList?, deleteCallback: (() -> Unit)? = null) { if (list == null) _popup.postValue(null) else _popup.postValue(list to deleteCallback) } private fun bookmarksUpdated(unused: Boolean) { reloadStored() } private fun afterPluginsLoaded(forceReload: Boolean) { loadAndCancel(DataStoreHelper.currentHomePage, forceReload) } private fun afterMainPluginsLoaded(unused: Boolean = false) { loadAndCancel(DataStoreHelper.currentHomePage, false) } private fun reloadHome(unused: Boolean = false) { loadAndCancel(DataStoreHelper.currentHomePage, true) } private fun reloadAccount(unused: Boolean = false) { _currentAccount.postValue( getCurrentAccount() ) } init { MainActivity.bookmarksUpdatedEvent += ::bookmarksUpdated MainActivity.afterPluginsLoadedEvent += ::afterPluginsLoaded MainActivity.mainPluginsLoadedEvent += ::afterMainPluginsLoaded MainActivity.reloadHomeEvent += ::reloadHome MainActivity.reloadAccountEvent += ::reloadAccount } override fun onCleared() { MainActivity.bookmarksUpdatedEvent -= ::bookmarksUpdated MainActivity.afterPluginsLoadedEvent -= ::afterPluginsLoaded MainActivity.mainPluginsLoadedEvent -= ::afterMainPluginsLoaded MainActivity.reloadHomeEvent -= ::reloadHome MainActivity.reloadAccountEvent -= ::reloadAccount super.onCleared() } fun queryTextSubmit(query: String) { QuickSearchFragment.pushSearch( query, repo?.name?.let { arrayOf(it) }) } fun queryTextChange(newText: String) { // do nothing } fun loadStoredData() { val list = EnumSet.noneOf(WatchType::class.java) DataStoreHelper.homeBookmarkedList.map { WatchType.fromInternalId(it) }.let { list.addAll(it) } loadStoredData(list) } fun reloadStored() { loadResumeWatching() loadStoredData() } fun click(load: LoadClickCallback) { loadResult(load.response.url, load.response.apiName, load.response.name, load.action) } // only save the key if it is from UI, as we don't want internal functions changing the setting fun loadAndCancel( preferredApiName: String?, forceReload: Boolean = true, fromUI: Boolean = false ) = ioSafe { //println("trying to load $preferredApiName") // Since plugins are loaded in stages this function can get called multiple times. // The issue with this is that the homepage may be fetched multiple times while the first request is loading // api?.let { expandable[it.name]?.list?.list?.isNotEmpty() } == true val currentPage = page.value // if we don't need to reload and we have a valid homepage or currently loading the same thing then return val currentLoading = isCurrentlyLoadingName if (!forceReload && (currentPage is Resource.Success && currentPage.value.isNotEmpty() || (currentLoading != null && currentLoading == preferredApiName))) { return@ioSafe } val api = getApiFromNameNull(preferredApiName) if (preferredApiName == noneApi.name) { // just set to random if (fromUI) DataStoreHelper.currentHomePage = noneApi.name loadAndCancel(noneApi) } else if (preferredApiName == randomApi.name) { // randomize the api, if none exist like if not loaded or not installed // then use nothing val validAPIs = context?.filterProviderByPreferredMedia() if (validAPIs.isNullOrEmpty()) { loadAndCancel(noneApi) } else { val apiRandom = validAPIs.random() loadAndCancel(apiRandom) if (fromUI) DataStoreHelper.currentHomePage = apiRandom.name } } else if (api == null) { // API is not found aka not loaded or removed, post the loading // progress if waiting for plugins, otherwise nothing if (PluginManager.loadedOnlinePlugins || PluginManager.isSafeMode()) { loadAndCancel(noneApi) } else { _page.postValue(Resource.Loading()) if (preferredApiName != null) _apiName.postValue(preferredApiName) } } else { // if the api is found, then set it to it and save key if (fromUI) DataStoreHelper.currentHomePage = api.name loadAndCancel(api) } reloadAccount() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryFragment.kt ================================================ package com.lagradost.cloudstream3.ui.library import android.annotation.SuppressLint import android.app.Activity import android.content.Context import android.content.res.Configuration import android.os.Bundle import android.os.Handler import android.os.Looper import android.view.View import android.view.ViewGroup.FOCUS_AFTER_DESCENDANTS import android.view.ViewGroup.FOCUS_BLOCK_DESCENDANTS import android.view.animation.AlphaAnimation import android.widget.TextView import androidx.annotation.StringRes import androidx.appcompat.widget.SearchView import androidx.core.view.allViews import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.databinding.FragmentLibraryBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.SyncIdName import com.lagradost.cloudstream3.ui.AutofitRecyclerView import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_LOAD import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_SHOW_METADATA import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.loadResult import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.AppContextUtils.reduceDragSensitivity import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import java.util.concurrent.CopyOnWriteArrayList import kotlin.math.abs const val LIBRARY_FOLDER = "library_folder" enum class LibraryOpenerType(@StringRes val stringRes: Int) { Default(R.string.action_default), Provider(R.string.none), Browser(R.string.browser), Search(R.string.search), None(R.string.none), } /** Used to store how the user wants to open said poster */ data class LibraryOpener( val openType: LibraryOpenerType, val providerData: ProviderLibraryData?, ) data class ProviderLibraryData( val apiName: String ) class LibraryFragment : BaseFragment( BaseFragment.BindingCreator.Bind(FragmentLibraryBinding::bind) ) { companion object { fun newInstance() = LibraryFragment() /** * Store which page was last seen when exiting the fragment and returning **/ const val VIEWPAGER_ITEM_KEY = "viewpager_item" } private val libraryViewModel: LibraryViewModel by activityViewModels() private var toggleRandomButton = false override fun pickLayout(): Int? = if (isLayout(PHONE)) R.layout.fragment_library else R.layout.fragment_library_tv override fun onSaveInstanceState(outState: Bundle) { binding?.viewpager?.currentItem?.let { currentItem -> outState.putInt(VIEWPAGER_ITEM_KEY, currentItem) } super.onSaveInstanceState(outState) } private fun updateRandomVisibility(binding: FragmentLibraryBinding) { if (!toggleRandomButton) { binding.libraryRandom.isGone = true binding.libraryRandomButtonTv.isGone = true return } val position = libraryViewModel.currentPage.value ?: 0 val pages = (libraryViewModel.pages.value as? Resource.Success)?.value ?: return val hasItems = pages[position].items.isNotEmpty() val isPhone = isLayout(PHONE) binding.libraryRandom.isVisible = isPhone && hasItems binding.libraryRandomButtonTv.isVisible = !isPhone && hasItems } override fun fixLayout(view: View) { fixSystemBarsPadding( view, padBottom = isLandscape(), padLeft = !isLayout(PHONE) ) } @SuppressLint("ResourceType", "CutPasteId") override fun onBindingCreated( binding: FragmentLibraryBinding, savedInstanceState: Bundle? ) { binding.sortFab.setOnClickListener(sortChangeClickListener) binding.librarySort.setOnClickListener(sortChangeClickListener) binding.libraryRoot.findViewById(androidx.appcompat.R.id.search_src_text) ?.apply { tag = "tv_no_focus_tag" // Expand the Appbar when search bar is focused, fixing scroll up issue setOnFocusChangeListener { _, _ -> binding.searchBar.setExpanded(true) } } val searchCallback = Runnable { val newText = binding.mainSearch.query.toString() libraryViewModel.sort(ListSorting.Query, newText) } binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { libraryViewModel.sort(ListSorting.Query, query) return true } // This is required to prevent the first text change // When this is attached it'll immediately send a onQueryTextChange("") // Which we do not want var hasInitialized = false override fun onQueryTextChange(newText: String?): Boolean { if (!hasInitialized) { hasInitialized = true return true } binding.mainSearch.removeCallbacks(searchCallback) // Delay the execution of the search operation by 1 second (adjust as needed) // this prevents running search when the user is typing binding.mainSearch.postDelayed(searchCallback, 1000) return true } }) libraryViewModel.reloadPages(false) binding.listSelector.setOnClickListener { val items = libraryViewModel.availableApiNames val currentItem = libraryViewModel.currentApiName.value activity?.showBottomDialog( items, items.indexOf(currentItem), txt(R.string.select_library).asString(it.context), false, {}) { index -> val selectedItem = items.getOrNull(index) ?: return@showBottomDialog libraryViewModel.switchList(selectedItem) } } //Load value for toggling Random button. Hide at startup context?.let { val settingsManager = PreferenceManager.getDefaultSharedPreferences(it) toggleRandomButton = settingsManager.getBoolean( getString(R.string.random_button_key), false ) binding.libraryRandom.visibility = View.GONE binding.libraryRandomButtonTv.visibility = View.GONE } /** * Shows a plugin selection dialogue and saves the response **/ fun Activity.showPluginSelectionDialog( key: String, syncId: SyncIdName, apiName: String? = null, ) { val availableProviders = synchronized(allProviders) { allProviders.filter { it.supportedSyncNames.contains(syncId) }.map { it.name } + // Add the api if it exists (APIHolder.getApiFromNameNull(apiName)?.let { listOf(it.name) } ?: emptyList()) } val baseOptions = listOf( LibraryOpenerType.Default, LibraryOpenerType.None, LibraryOpenerType.Browser, LibraryOpenerType.Search ) val items = baseOptions.map { txt(it.stringRes).asString(this) } + availableProviders val savedSelection = getKey("$currentAccount/$LIBRARY_FOLDER", key) val selectedIndex = when { savedSelection == null -> 0 // If provider savedSelection.openType == LibraryOpenerType.Provider && savedSelection.providerData?.apiName != null -> { availableProviders.indexOf(savedSelection.providerData.apiName) .takeIf { it != -1 } ?.plus(baseOptions.size) ?: 0 } // Else base option else -> baseOptions.indexOf(savedSelection.openType) } this.showBottomDialog( items, selectedIndex, txt(R.string.open_with).asString(this), false, {}, ) { val savedData = if (it < baseOptions.size) { LibraryOpener( baseOptions[it], null ) } else { LibraryOpener( LibraryOpenerType.Provider, ProviderLibraryData(items[it]) ) } setKey( "$currentAccount/$LIBRARY_FOLDER", key, savedData, ) } } binding.providerSelector.setOnClickListener { val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@setOnClickListener activity?.showPluginSelectionDialog(syncName.name, syncName) } binding.viewpager.setPageTransformer(LibraryScrollTransformer()) binding.viewpager.adapter = ViewpagerAdapter( { isScrollingDown: Boolean -> if (isScrollingDown) { binding.sortFab.shrink() binding.libraryRandom.shrink() } else { binding.sortFab.extend() binding.libraryRandom.extend() } }) callback@{ searchClickCallback -> // To prevent future accidents debugAssert({ searchClickCallback.card !is SyncAPI.LibraryItem }, { "searchClickCallback ${searchClickCallback.card} is not a LibraryItem" }) val syncId = (searchClickCallback.card as SyncAPI.LibraryItem).syncId val syncName = libraryViewModel.currentSyncApi?.syncIdName ?: return@callback when (searchClickCallback.action) { SEARCH_ACTION_SHOW_METADATA -> { (activity as? MainActivity)?.loadPopup( searchClickCallback.card, load = false ) /*activity?.showPluginSelectionDialog( syncId, syncName, searchClickCallback.card.apiName )*/ } SEARCH_ACTION_LOAD -> { loadLibraryItem(syncName, syncId, searchClickCallback.card) } } } binding.apply { viewpager.offscreenPageLimit = 2 viewpager.reduceDragSensitivity() searchBar.setExpanded(true) } val startLoading = Runnable { binding.apply { gridview.numColumns = root.context.getSpanCount() gridview.adapter = context?.let { LoadingPosterAdapter(it, 6 * 3) } libraryLoadingOverlay.isVisible = true libraryLoadingShimmer.startShimmer() emptyListTextview.isVisible = false } } val stopLoading = Runnable { binding.apply { gridview.adapter = null libraryLoadingOverlay.isVisible = false libraryLoadingShimmer.stopShimmer() } } val handler = Handler(Looper.getMainLooper()) observe(libraryViewModel.pages) { resource -> when (resource) { is Resource.Success -> { handler.removeCallbacks(startLoading) val pages = resource.value val showNotice = pages.all { it.items.isEmpty() } binding.apply { emptyListTextview.isVisible = showNotice if (showNotice) { if (libraryViewModel.availableApiNames.size > 1) { emptyListTextview.setText(R.string.empty_library_logged_in_message) } else { emptyListTextview.setText(R.string.empty_library_no_accounts_message) } } (viewpager.adapter as? ViewpagerAdapter)?.submitList(pages.map { it.copy( items = CopyOnWriteArrayList(it.items) ) }) //fix focus on the viewpager itself (viewpager.getChildAt(0) as RecyclerView).apply { tag = "tv_no_focus_tag" //isFocusable = false } // Using notifyItemRangeChanged keeps the animations when sorting /*viewpager.adapter?.notifyItemRangeChanged( 0, viewpager.adapter?.itemCount ?: 0 )*/ libraryViewModel.currentPage.value?.let { page -> binding.viewpager.setCurrentItem(page, false) binding.searchBar.setExpanded(true) } // Set up random button click listener if (toggleRandomButton) { val randomClickListener = View.OnClickListener { val position = libraryViewModel.currentPage.value ?: 0 val syncIdName = libraryViewModel.currentSyncApi?.syncIdName ?: return@OnClickListener pages[position].items.randomOrNull()?.let { item -> loadLibraryItem(syncIdName, item.syncId, item) } } libraryRandom.setOnClickListener(randomClickListener) libraryRandomButtonTv.setOnClickListener(randomClickListener) } updateRandomVisibility(binding) // Only stop loading after 300ms to hide the fade effect the viewpager produces when updating // Without this there would be a flashing effect: // loading -> show old viewpager -> black screen -> show new viewpager handler.postDelayed(stopLoading, 300) savedInstanceState?.getInt(VIEWPAGER_ITEM_KEY)?.let { currentPos -> if (currentPos < 0) return@let viewpager.setCurrentItem(currentPos, false) // Using remove() sets the key to 0 instead of removing it savedInstanceState.putInt(VIEWPAGER_ITEM_KEY, -1) } // Since the animation to scroll multiple items is so much its better to just hide // the viewpager a bit while the fastest animation is running fun hideViewpager(distance: Int) { if (distance < 3) return val hideAnimation = AlphaAnimation(1f, 0f).apply { duration = distance * 50L fillAfter = true } val showAnimation = AlphaAnimation(0f, 1f).apply { duration = distance * 50L startOffset = distance * 100L fillAfter = true } viewpager.startAnimation(hideAnimation) viewpager.startAnimation(showAnimation) } TabLayoutMediator( libraryTabLayout, viewpager, ) { tab, position -> tab.text = pages.getOrNull(position)?.title?.asStringNull(context) tab.view.tag = "tv_no_focus_tag" tab.view.nextFocusDownId = R.id.search_result_root tab.view.setOnClickListener { val currentItem = binding.viewpager.currentItem val distance = abs(position - currentItem) hideViewpager(distance) } //Expand the appBar on tab focus tab.view.setOnFocusChangeListener { _, _ -> binding.searchBar.setExpanded(true) } }.attach() binding.libraryTabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab?) { binding.libraryTabLayout.selectedTabPosition.let { page -> libraryViewModel.switchPage(page) } } override fun onTabUnselected(tab: TabLayout.Tab?) = Unit override fun onTabReselected(tab: TabLayout.Tab?) = Unit }) } } is Resource.Loading -> { // Only start loading after 200ms to prevent loading cached lists handler.postDelayed(startLoading, 200) } is Resource.Failure -> { stopLoading.run() // No user indication it failed :( // TODO } } } observe(libraryViewModel.currentPage) { position -> updateRandomVisibility(binding) val all = binding.viewpager.allViews.toList() .filterIsInstance() all.forEach { view -> view.isVisible = view.tag == position view.isFocusable = view.tag == position if (view.tag == position) view.descendantFocusability = FOCUS_AFTER_DESCENDANTS else view.descendantFocusability = FOCUS_BLOCK_DESCENDANTS } } } private fun loadLibraryItem( syncName: SyncIdName, syncId: String, card: SearchResponse ) { // This basically first selects the individual opener and if that is default then // selects the whole list opener val savedListSelection = getKey("$currentAccount/$LIBRARY_FOLDER", syncName.name) val savedSelection = getKey( "$currentAccount/$LIBRARY_FOLDER", syncId ).takeIf { it?.openType != LibraryOpenerType.Default } ?: savedListSelection when (savedSelection?.openType) { null, LibraryOpenerType.Default -> { // Prevents opening MAL/AniList as a provider if (APIHolder.getApiFromNameNull(card.apiName) != null) { activity?.loadSearchResult( card ) } else { // Search when no provider can open QuickSearchFragment.pushSearch( activity, card.name ) } } LibraryOpenerType.None -> {} LibraryOpenerType.Provider -> savedSelection.providerData?.apiName?.let { apiName -> activity?.loadResult( card.url, apiName, card.name ) } LibraryOpenerType.Browser -> openBrowser(card.url) LibraryOpenerType.Search -> { QuickSearchFragment.pushSearch( activity, card.name ) } } } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) val adapter = binding?.viewpager?.adapter ?: return adapter.notifyItemRangeChanged(0, adapter.itemCount) } private val sortChangeClickListener = View.OnClickListener { view -> val methods = libraryViewModel.sortingMethods.map { txt(it.stringRes).asString(view.context) } activity?.showBottomDialog( methods, libraryViewModel.sortingMethods.indexOf(libraryViewModel.currentSortingMethod), txt(R.string.sort_by).asString(view.context), false, {}, { val method = libraryViewModel.sortingMethods[it] libraryViewModel.sort(method) }) } } class MenuSearchView(context: Context) : SearchView(context) ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryScrollTransformer.kt ================================================ package com.lagradost.cloudstream3.ui.library import android.view.View import androidx.viewpager2.widget.ViewPager2 import com.lagradost.cloudstream3.R import kotlin.math.roundToInt class LibraryScrollTransformer : ViewPager2.PageTransformer { override fun transformPage(page: View, position: Float) { val padding = (-position * page.width).roundToInt() page.findViewById(R.id.page_recyclerview).setPadding( padding, 0, -padding, 0 ) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/library/LibraryViewModel.kt ================================================ package com.lagradost.cloudstream3.ui.library import androidx.annotation.StringRes import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.throwAbleToResource import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount enum class ListSorting(@StringRes val stringRes: Int) { Query(R.string.none), RatingHigh(R.string.sort_rating_desc), RatingLow(R.string.sort_rating_asc), UpdatedNew(R.string.sort_updated_new), UpdatedOld(R.string.sort_updated_old), AlphabeticalA(R.string.sort_alphabetical_a), AlphabeticalZ(R.string.sort_alphabetical_z), ReleaseDateNew(R.string.sort_release_date_new), ReleaseDateOld(R.string.sort_release_date_old), } const val LAST_SYNC_API_KEY = "last_sync_api" class LibraryViewModel : ViewModel() { fun switchPage(page: Int) { _currentPage.postValue(page) } private val _currentPage: MutableLiveData = MutableLiveData(0) val currentPage: LiveData = _currentPage private val _pages: MutableLiveData>> = MutableLiveData(null) val pages: LiveData>> = _pages private val _currentApiName: MutableLiveData = MutableLiveData("") val currentApiName: LiveData = _currentApiName private val availableSyncApis get() = AccountManager.syncApis.filter { it.isAvailable } var currentSyncApi = availableSyncApis.let { allApis -> val lastSelection = getKey("$currentAccount/$LAST_SYNC_API_KEY") availableSyncApis.firstOrNull { it.name == lastSelection } ?: allApis.firstOrNull() } private set(value) { field = value setKey("$currentAccount/$LAST_SYNC_API_KEY", field?.name) } val availableApiNames: List get() = availableSyncApis.map { it.name } var sortingMethods = emptyList() private set var currentSortingMethod: ListSorting? = sortingMethods.firstOrNull() private set fun switchList(name: String) { currentSyncApi = availableSyncApis[availableApiNames.indexOf(name)] _currentApiName.postValue(currentSyncApi?.name) reloadPages(true) } fun sort(method: ListSorting, query: String? = null) = ioSafe { val value = _pages.value ?: return@ioSafe if (value is Resource.Success) { sort(method, query, value.value) } } private fun sort(method: ListSorting, query: String? = null, items: List) { currentSortingMethod = method DataStoreHelper.librarySortingMode = method.ordinal items.forEach { page -> page.sort(method, query) } _pages.postValue(Resource.Success(items)) } fun reloadPages(forceReload: Boolean) { // Only skip loading if its not forced and pages is not empty if (!forceReload && (pages.value as? Resource.Success)?.value?.isNotEmpty() == true && currentSyncApi?.requireLibraryRefresh != true ) return ioSafe { currentSyncApi?.let { repo -> _currentApiName.postValue(repo.name) _pages.postValue(Resource.Loading()) val libraryResource = repo.library() val err = libraryResource.exceptionOrNull() if (err != null) { _pages.postValue(throwAbleToResource(err)) return@let } val library = libraryResource.getOrNull() if (library == null) { _pages.postValue(Resource.Failure(false, "Unable to fetch library")) return@let } sortingMethods = library.supportedListSorting.toList() repo.requireLibraryRefresh = false val pages = library.allLibraryLists.map { SyncAPI.Page( it.name, it.items ) } val desiredSortingMethod = ListSorting.entries.getOrNull(DataStoreHelper.librarySortingMode) if (desiredSortingMethod != null && library.supportedListSorting.contains( desiredSortingMethod ) ) { sort(desiredSortingMethod, null, pages) } else { // null query = no sorting sort(ListSorting.Query, null, pages) } } } } init { MainActivity.reloadLibraryEvent += ::reloadPages } override fun onCleared() { MainActivity.reloadLibraryEvent -= ::reloadPages super.onCleared() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/library/LoadingPosterAdapter.kt ================================================ package com.lagradost.cloudstream3.ui.library import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.BaseAdapter import com.lagradost.cloudstream3.R class LoadingPosterAdapter(context: Context, private val itemCount: Int) : BaseAdapter() { private val inflater: LayoutInflater = LayoutInflater.from(context) override fun getCount(): Int { return itemCount } override fun getItem(position: Int): Any? { return null } override fun getItemId(position: Int): Long { return position.toLong() } override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { return convertView ?: inflater.inflate(R.layout.loading_poster_dynamic, parent, false) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/library/PageAdapter.kt ================================================ package com.lagradost.cloudstream3.ui.library import android.view.LayoutInflater import android.view.ViewGroup import android.widget.FrameLayout import androidx.core.view.isVisible import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.AutofitRecyclerView import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import kotlin.math.roundToInt class PageAdapter( private val resView: AutofitRecyclerView, val clickCallback: (SearchClickCallback) -> Unit ) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> if (a.id != null || b.id != null) { a.id == b.id } else { a.name == b.name && a.url == b.url } })) { private val coverHeight: Int get() = (resView.itemWidth / 0.68).roundToInt() override fun onCreateContent(parent: ViewGroup): ViewHolderState { return ViewHolderState( SearchResultGridExpandedBinding.inflate( LayoutInflater.from(parent.context), parent, false ) ) } override fun onClearView(holder: ViewHolderState) { when (val binding = holder.view) { is SearchResultGridExpandedBinding -> { clearImage(binding.imageView) } } } override fun onBindContent( holder: ViewHolderState, item: SyncAPI.LibraryItem, position: Int ) { val binding = holder.view as? SearchResultGridExpandedBinding ?: return /** https://stackoverflow.com/questions/8817522/how-to-get-color-code-of-image-view */ SearchResultBuilder.bind( this@PageAdapter.clickCallback, item, position, holder.itemView, ) // See searchAdaptor for this, it basically fixes the height val params = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, coverHeight ) if (params.height != binding.imageView.layoutParams.height || params.width != binding.imageView.layoutParams.width) { binding.imageView.layoutParams = params } val showProgress = item.episodesCompleted?.let{ it>0 } ?: false && item.episodesTotal != null binding.watchProgress.isVisible = showProgress if (showProgress) { binding.watchProgress.max = item.episodesTotal binding.watchProgress.progress = item.episodesCompleted } binding.imageText.text = item.name } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/library/ViewpagerAdapter.kt ================================================ package com.lagradost.cloudstream3.ui.library import android.os.Build import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.doOnAttach import androidx.fragment.app.Fragment import androidx.recyclerview.widget.RecyclerView.OnFlingListener import com.google.android.material.appbar.AppBarLayout import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.LibraryViewpagerPageBinding import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.BaseAdapter import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.home.getSafeParcelable import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount class ViewpagerAdapterViewHolderState(val binding: LibraryViewpagerPageBinding) : ViewHolderState(binding) { override fun save(): Bundle = Bundle().apply { putParcelable( "pageRecyclerview", binding.pageRecyclerview.layoutManager?.onSaveInstanceState() ) } override fun restore(state: Bundle) { state.getSafeParcelable("pageRecyclerview")?.let { recycle -> binding.pageRecyclerview.layoutManager?.onRestoreInstanceState(recycle) } } } class ViewpagerAdapter( val scrollCallback: (isScrollingDown: Boolean) -> Unit, val clickCallback: (SearchClickCallback) -> Unit ) : BaseAdapter( id = "ViewpagerAdapter".hashCode(), diffCallback = BaseDiffCallback( itemSame = { a, b -> a.title == b.title }, contentSame = { a, b -> a.items == b.items && a.title == b.title } )) { override fun onCreateContent(parent: ViewGroup): ViewHolderState { return ViewpagerAdapterViewHolderState( LibraryViewpagerPageBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) } override fun onUpdateContent( holder: ViewHolderState, item: SyncAPI.Page, position: Int ) { val binding = holder.view if (binding !is LibraryViewpagerPageBinding) return (binding.pageRecyclerview.adapter as? PageAdapter)?.submitList(item.items) binding.pageRecyclerview.scrollToPosition(0) } override fun onBindContent(holder: ViewHolderState, item: SyncAPI.Page, position: Int) { val binding = holder.view if (binding !is LibraryViewpagerPageBinding) return binding.pageRecyclerview.tag = position binding.pageRecyclerview.apply { spanCount = binding.root.context.getSpanCount() if (adapter == null) { // || rebind // Only add the items after it has been attached since the items rely on ItemWidth // Which is only determined after the recyclerview is attached. // If this fails then item height becomes 0 when there is only one item doOnAttach { adapter = PageAdapter( this, clickCallback ).apply { submitList(item.items) } } } else { (adapter as? PageAdapter)?.submitList(item.items) // scrollToPosition(0) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> val diff = scrollY - oldScrollY //Expand the top Appbar based on scroll direction up/down, simulate phone behavior if (isLayout(TV or EMULATOR)) { binding.root.rootView.findViewById(R.id.search_bar) ?.apply { if (diff <= 0) setExpanded(true) else setExpanded(false) } } if (diff == 0) return@setOnScrollChangeListener scrollCallback.invoke(diff > 0) } } else { onFlingListener = object : OnFlingListener() { override fun onFling(velocityX: Int, velocityY: Int): Boolean { scrollCallback.invoke(velocityY > 0) return false } } } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/AbstractPlayerFragment.kt ================================================ package com.lagradost.cloudstream3.ui.player import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.graphics.drawable.AnimatedImageDrawable import android.graphics.drawable.AnimatedVectorDrawable import android.media.metrics.PlaybackErrorEvent import android.os.Build import android.os.Bundle import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.widget.FrameLayout import android.widget.ImageView import android.widget.ProgressBar import android.widget.Toast import androidx.annotation.LayoutRes import androidx.annotation.OptIn import androidx.annotation.StringRes import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.media3.common.PlaybackException import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.DefaultTimeBar import androidx.media3.ui.PlayerView import androidx.media3.ui.SubtitleView import androidx.media3.ui.TimeBar import androidx.preference.PreferenceManager import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat import com.github.rubensousa.previewseekbar.PreviewBar import com.github.rubensousa.previewseekbar.media3.PreviewTimeBar import com.lagradost.cloudstream3.CommonActivity.isInPIPMode import com.lagradost.cloudstream3.CommonActivity.keyEventListener import com.lagradost.cloudstream3.CommonActivity.playerEventListener import com.lagradost.cloudstream3.CommonActivity.screenWidth import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import com.lagradost.cloudstream3.utils.AppContextUtils import com.lagradost.cloudstream3.utils.AppContextUtils.requestLocalAudioFocus import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.UIHelper import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import java.net.SocketTimeoutException enum class PlayerResize(@StringRes val nameRes: Int) { Fit(R.string.resize_fit), Fill(R.string.resize_fill), Zoom(R.string.resize_zoom), } // when the player should switch skip op to next episode const val SKIP_OP_VIDEO_PERCENTAGE = 50 // when the player should preload the next episode for faster loading const val PRELOAD_NEXT_EPISODE_PERCENTAGE = 80 // when the player should mark the episode as watched and resume watching the next const val NEXT_WATCH_EPISODE_PERCENTAGE = 90 // when the player should sync the progress of "watched", TODO MAKE SETTING const val UPDATE_SYNC_PROGRESS_PERCENTAGE = 80 @OptIn(UnstableApi::class) abstract class AbstractPlayerFragment( var player: IPlayer = CS3IPlayer() ) : Fragment() { var resizeMode: Int = 0 var subView: SubtitleView? = null protected open var hasPipModeSupport = true var playerPausePlayHolderHolder: FrameLayout? = null var playerPausePlay: ImageView? = null var playerBuffering: ProgressBar? = null var playerView: PlayerView? = null var piphide: FrameLayout? = null var subtitleHolder: FrameLayout? = null var currentPlayerStatus = CSPlayerLoading.IsBuffering @LayoutRes protected open var layout: Int = R.layout.fragment_player open fun nextEpisode() { throw NotImplementedError() } open fun prevEpisode() { throw NotImplementedError() } open fun playerPositionChanged(position: Long, duration: Long) { throw NotImplementedError() } open fun playerStatusChanged() {} open fun playerDimensionsLoaded(width: Int, height: Int) { throw NotImplementedError() } open fun subtitlesChanged() { throw NotImplementedError() } open fun embeddedSubtitlesFetched(subtitles: List) { throw NotImplementedError() } open fun onTracksInfoChanged() { throw NotImplementedError() } open fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { } open fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) { } open fun exitedPipMode() { throw NotImplementedError() } private fun keepScreenOn(on: Boolean) { if (on) { activity?.window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { activity?.window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } } private fun updateIsPlaying( wasPlaying: CSPlayerLoading, isPlaying: CSPlayerLoading ) { val isPlayingRightNow = CSPlayerLoading.IsPlaying == isPlaying val isPausedRightNow = CSPlayerLoading.IsPaused == isPlaying currentPlayerStatus = isPlaying keepScreenOn(!isPausedRightNow) val isBuffering = CSPlayerLoading.IsBuffering == isPlaying if (isBuffering) { playerPausePlayHolderHolder?.isVisible = false playerBuffering?.isVisible = true } else { playerPausePlayHolderHolder?.isVisible = true playerBuffering?.isVisible = false if(isPlaying == CSPlayerLoading.IsEnded && isLayout(PHONE)){ playerPausePlay?.setImageResource(R.drawable.ic_baseline_replay_24) } else if (wasPlaying != isPlaying) { playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.play_to_pause else R.drawable.pause_to_play) val drawable = playerPausePlay?.drawable var startedAnimation = false if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) { if (drawable is AnimatedImageDrawable) { drawable.start() startedAnimation = true } } if (drawable is AnimatedVectorDrawable) { drawable.start() startedAnimation = true } if (drawable is AnimatedVectorDrawableCompat) { drawable.start() startedAnimation = true } // somehow the phone is wacked if (!startedAnimation) { playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) } } else { playerPausePlay?.setImageResource(if (isPlayingRightNow) R.drawable.netflix_pause else R.drawable.netflix_play) } } PlayerPipHelper.updatePIPModeActions( activity, isPlaying, hasPipModeSupport, player.getAspectRatio() ) } private var pipReceiver: BroadcastReceiver? = null override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean) { super.onPictureInPictureModeChanged(isInPictureInPictureMode) try { isInPIPMode = isInPictureInPictureMode if (isInPictureInPictureMode) { // Hide the full-screen UI (controls, etc.) while in picture-in-picture mode. piphide?.isVisible = false pipReceiver = object : BroadcastReceiver() { override fun onReceive( context: Context, intent: Intent, ) { if (ACTION_MEDIA_CONTROL != intent.action) { return } player.handleEvent( CSPlayerEvent.entries[intent.getIntExtra( EXTRA_CONTROL_TYPE, 0 )], source = PlayerEventSource.UI ) } } val filter = IntentFilter() filter.addAction(ACTION_MEDIA_CONTROL) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { activity?.registerReceiver(pipReceiver, filter, Context.RECEIVER_EXPORTED) } else { @SuppressLint("UnspecifiedRegisterReceiverFlag") activity?.registerReceiver(pipReceiver, filter) } val isPlaying = player.getIsPlaying() val isPlayingValue = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused updateIsPlaying(isPlayingValue, isPlayingValue) } else { // Restore the full-screen UI. piphide?.isVisible = true exitedPipMode() pipReceiver?.let { // Prevents java.lang.IllegalArgumentException: Receiver not registered safe { activity?.unregisterReceiver(it) } } activity?.hideSystemUI() this.view?.let { UIHelper.hideKeyboard(it) } } } catch (e: Exception) { logError(e) } } open fun hasNextMirror(): Boolean { throw NotImplementedError() } open fun nextMirror() { throw NotImplementedError() } private fun requestAudioFocus() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { activity?.requestLocalAudioFocus(AppContextUtils.getFocusRequest()) } } open fun playerError(exception: Throwable) { fun showToast(message: String, gotoNext: Boolean = false) { if (gotoNext && hasNextMirror()) { showToast( message, Toast.LENGTH_SHORT ) nextMirror() } else { showToast( context?.getString(R.string.no_links_found_toast) + "\n" + message, Toast.LENGTH_LONG ) activity?.popCurrentPage() } } val ctx = context ?: return when (exception) { is PlaybackException -> { val msg = exception.message ?: "" val errorName = exception.errorCodeName when (val code = exception.errorCode) { PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND, PlaybackException.ERROR_CODE_IO_NO_PERMISSION, PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, PlaybackException.ERROR_CODE_IO_UNSPECIFIED, PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED -> { showToast( "${ctx.getString(R.string.source_error)}\n$errorName ($code)\n$msg", gotoNext = true ) } PlaybackException.ERROR_CODE_REMOTE_ERROR, PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS, PlaybackException.ERROR_CODE_TIMEOUT, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT, PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE -> { showToast( "${ctx.getString(R.string.remote_error)}\n$errorName ($code)\n$msg", gotoNext = true ) } PlaybackErrorEvent.ERROR_AUDIO_TRACK_INIT_FAILED, PlaybackErrorEvent.ERROR_AUDIO_TRACK_OTHER, PlaybackException.ERROR_CODE_DECODING_FAILED, PlaybackException.ERROR_CODE_AUDIO_TRACK_WRITE_FAILED, PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED -> { showToast( "${ctx.getString(R.string.render_error)}\n$errorName ($code)\n$msg", gotoNext = true ) } PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES -> { showToast( "${ctx.getString(R.string.unsupported_error)}\n$errorName ($code)\n$msg", gotoNext = true ) } PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED, PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED -> { showToast( "${ctx.getString(R.string.encoding_error)}\n$errorName ($code)\n$msg", gotoNext = true ) } else -> { showToast( "${ctx.getString(R.string.unexpected_error)}\n$errorName ($code)\n$msg", gotoNext = false ) } } } is InvalidFileException -> { showToast( "${ctx.getString(R.string.source_error)}\n${exception.message}", gotoNext = true ) } is SocketTimeoutException -> { /** * Ensures this is run on the UI thread to prevent issues * caused by SocketTimeoutException in torrents. Running * on another thread can break player interactions or * prevent switching to the next source. */ activity?.runOnUiThread { showToast( "${ctx.getString(R.string.remote_error)}\n${exception.message}", gotoNext = true ) } } is ErrorLoadingException -> { exception.message?.let { showToast( it, gotoNext = true ) } ?: showToast( exception.toString(), gotoNext = true ) } else -> { exception.message?.let { showToast( it, gotoNext = false ) } ?: showToast( exception.toString(), gotoNext = false ) } } } private fun onSubStyleChanged(style: SaveCaptionStyle) { player.updateSubtitleStyle(style) // Forcefully update the subtitle encoding in case the edge size is changed player.seekTime(-1) } @SuppressLint("UnsafeOptInUsageError") open fun playerUpdated(player: Any?) { if (player is ExoPlayer) { context?.let { ctx -> mMediaSession?.release() mMediaSession = MediaSession.Builder(ctx, player) // Ensure unique ID for concurrent players .setId(System.currentTimeMillis().toString()) .build() } // Necessary for multiple combined videos @Suppress("DEPRECATION") playerView?.setShowMultiWindowTimeBar(true) playerView?.player = player playerView?.performClick() } } protected var mMediaSession: MediaSession? = null // this can be used in the future for players other than exoplayer //private val mMediaSessionCallback: MediaSessionCompat.Callback = object : MediaSessionCompat.Callback() { // override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { // val keyEvent = mediaButtonEvent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) as KeyEvent? // if (keyEvent != null) { // if (keyEvent.action == KeyEvent.ACTION_DOWN) { // NO DOUBLE SKIP // val consumed = when (keyEvent.keyCode) { // KeyEvent.KEYCODE_MEDIA_PAUSE -> callOnPause() // KeyEvent.KEYCODE_MEDIA_PLAY -> callOnPlay() // KeyEvent.KEYCODE_MEDIA_STOP -> callOnStop() // KeyEvent.KEYCODE_MEDIA_NEXT -> callOnNext() // else -> false // } // if (consumed) return true // } // } // // return super.onMediaButtonEvent(mediaButtonEvent) // } //} open fun onDownload(event: DownloadEvent) = Unit /** This receives the events from the player, if you want to append functionality you do it here, * do note that this only receives events for UI changes, * and returning early WONT stop it from changing in eg the player time or pause status */ open fun mainCallback(event: PlayerEvent) { // we don't want to spam DownloadEvent if (event !is DownloadEvent) { Log.i(TAG, "Handle event: $event") } when (event) { is DownloadEvent -> { onDownload(event) } is ResizedEvent -> { playerDimensionsLoaded(event.width, event.height) } is PlayerAttachedEvent -> { playerUpdated(event.player) } is SubtitlesUpdatedEvent -> { subtitlesChanged() } is TimestampSkippedEvent -> { onTimestampSkipped(event.timestamp) } is TimestampInvokedEvent -> { onTimestamp(event.timestamp) } is TracksChangedEvent -> { onTracksInfoChanged() } is EmbeddedSubtitlesFetchedEvent -> { embeddedSubtitlesFetched(event.tracks) } is ErrorEvent -> { playerError(event.error) } is RequestAudioFocusEvent -> { requestAudioFocus() } is EpisodeSeekEvent -> { when (event.offset) { -1 -> prevEpisode() 1 -> nextEpisode() else -> {} } } is StatusEvent -> { updateIsPlaying(wasPlaying = event.wasPlaying, isPlaying = event.isPlaying) playerStatusChanged() } is PositionEvent -> { playerPositionChanged(position = event.toMs, duration = event.durationMs) } is VideoEndedEvent -> { context?.let { ctx -> // Only play next episode if autoplay is on (default) if (PreferenceManager.getDefaultSharedPreferences(ctx) ?.getBoolean( ctx.getString(R.string.autoplay_next_key), true ) == true ) { player.handleEvent( CSPlayerEvent.NextEpisode, source = PlayerEventSource.Player ) } } } is PauseEvent -> Unit is PlayEvent -> Unit } } @SuppressLint("SetTextI18n", "UnsafeOptInUsageError") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { resizeMode = DataStoreHelper.resizeMode resize(resizeMode, false) player.releaseCallbacks() player.initCallbacks( eventHandler = ::mainCallback, requestedListeningPercentages = listOf( SKIP_OP_VIDEO_PERCENTAGE, PRELOAD_NEXT_EPISODE_PERCENTAGE, NEXT_WATCH_EPISODE_PERCENTAGE, UPDATE_SYNC_PROGRESS_PERCENTAGE, ), ) val player = player if (player is CS3IPlayer) { // preview bar val progressBar: PreviewTimeBar? = playerView?.findViewById(R.id.exo_progress) val previewImageView: ImageView? = playerView?.findViewById(R.id.previewImageView) val previewFrameLayout: FrameLayout? = playerView?.findViewById(R.id.previewFrameLayout) if (progressBar != null && previewImageView != null && previewFrameLayout != null) { var resume = false progressBar.addOnScrubListener(object : PreviewBar.OnScrubListener { override fun onScrubStart(previewBar: PreviewBar?) { val hasPreview = player.hasPreview() progressBar.isPreviewEnabled = hasPreview resume = player.getIsPlaying() if (resume) player.handleEvent( CSPlayerEvent.Pause, PlayerEventSource.Player ) // No clashing UI if (hasPreview) { subView?.isVisible = false } } override fun onScrubMove( previewBar: PreviewBar?, progress: Int, fromUser: Boolean ) { } override fun onScrubStop(previewBar: PreviewBar?) { if (resume) player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.Player) // Delay to prevent the small flicker of subtitle before seeking subView?.postDelayed({ // If we are not scrubbing then show subtitles again if (previewBar == null || !previewBar.isPreviewEnabled || !previewBar.isShowingPreview) { subView?.isVisible = true } }, 200) } }) progressBar.attachPreviewView(previewFrameLayout) progressBar.setPreviewLoader { currentPosition, max -> val bitmap = player.getPreview(currentPosition.toFloat().div(max.toFloat())) previewImageView.isGone = bitmap == null previewImageView.setImageBitmap(bitmap) } } subView = playerView?.findViewById(androidx.media3.ui.R.id.exo_subtitles) player.initSubtitles(subView, subtitleHolder, CustomDecoder.style) (player.imageGenerator as? PreviewGenerator)?.params = ImageParams.new16by9(screenWidth) /*previewImageView?.doOnLayout { (player.imageGenerator as? PreviewGenerator)?.params = ImageParams( it.measuredWidth, it.measuredHeight ) }*/ /** this might seam a bit fucky and that is because it is, the seek event is captured twice, once by the player * and once by the UI even if it should only be registered once by the UI */ playerView?.findViewById(R.id.exo_progress) ?.addListener(object : TimeBar.OnScrubListener { override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit override fun onScrubMove(timeBar: TimeBar, position: Long) = Unit override fun onScrubStop(timeBar: TimeBar, position: Long, canceled: Boolean) { if (canceled) return val playerDuration = player.getDuration() ?: return val playerPosition = player.getPosition() ?: return mainCallback( PositionEvent( source = PlayerEventSource.UI, durationMs = playerDuration, fromMs = playerPosition, toMs = position ) ) } }) SubtitlesFragment.applyStyleEvent += ::onSubStyleChanged try { context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences( ctx ) val currentPrefCacheSize = settingsManager.getInt(getString(R.string.video_buffer_size_key), 0) val currentPrefDiskSize = settingsManager.getInt(getString(R.string.video_buffer_disk_key), 0) val currentPrefBufferSec = settingsManager.getInt(getString(R.string.video_buffer_length_key), 0) player.cacheSize = currentPrefCacheSize * 1024L * 1024L player.simpleCacheSize = currentPrefDiskSize * 1024L * 1024L player.videoBufferMs = currentPrefBufferSec * 1000L } } catch (e: Exception) { logError(e) } } /*context?.let { ctx -> player.loadPlayer( ctx, false, ExtractorLink( "idk", "bunny", "http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4", "", Qualities.P720.value, false ), ) }*/ } override fun onDestroy() { player.release() player.releaseCallbacks() player = CS3IPlayer() playerEventListener = null keyEventListener = null PlayerPipHelper.updatePIPModeActions(activity, CSPlayerLoading.IsPaused, false, null) mMediaSession?.release() mMediaSession = null playerView?.player = null SubtitlesFragment.applyStyleEvent -= ::onSubStyleChanged keepScreenOn(false) super.onDestroy() } fun nextResize() { resizeMode = (resizeMode + 1) % PlayerResize.entries.size resize(resizeMode, true) } fun resize(resize: Int, showToast: Boolean) { resize(PlayerResize.entries[resize], showToast) } @SuppressLint("UnsafeOptInUsageError") open fun resize(resize: PlayerResize, showToast: Boolean) { DataStoreHelper.resizeMode = resize.ordinal val type = when (resize) { PlayerResize.Fill -> AspectRatioFrameLayout.RESIZE_MODE_FILL PlayerResize.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT PlayerResize.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM } playerView?.resizeMode = type if (showToast) showToast(resize.nameRes, Toast.LENGTH_SHORT) } override fun onStop() { player.onStop() super.onStop() } override fun onResume() { context?.let { ctx -> player.onResume(ctx) } super.onResume() } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { val root = inflater.inflate(layout, container, false) playerPausePlayHolderHolder = root.findViewById(R.id.player_pause_play_holder_holder) playerPausePlay = root.findViewById(R.id.player_pause_play) playerBuffering = root.findViewById(R.id.player_buffering) playerView = root.findViewById(R.id.player_view) piphide = root.findViewById(R.id.piphide) subtitleHolder = root.findViewById(R.id.subtitle_holder) return root } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/CS3IPlayer.kt ================================================ @file:Suppress("DEPRECATION") package com.lagradost.cloudstream3.ui.player import android.annotation.SuppressLint import android.content.Context import android.content.DialogInterface import android.graphics.Bitmap import android.net.Uri import android.os.Handler import android.os.Looper import android.util.Log import android.util.Rational import android.widget.FrameLayout import androidx.annotation.MainThread import androidx.annotation.OptIn import androidx.appcompat.app.AlertDialog import androidx.core.net.toUri import androidx.media3.common.C.TIME_UNSET import androidx.media3.common.C.TRACK_TYPE_AUDIO import androidx.media3.common.C.TRACK_TYPE_TEXT import androidx.media3.common.C.TRACK_TYPE_VIDEO import androidx.media3.common.Format import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.TrackGroup import androidx.media3.common.TrackSelectionOverride import androidx.media3.common.Tracks import androidx.media3.common.VideoSize import androidx.media3.common.util.UnstableApi import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.datasource.DataSource import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.HttpDataSource import androidx.media3.datasource.cache.CacheDataSource import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.SimpleCache import androidx.media3.datasource.cronet.CronetDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.DecoderCounters import androidx.media3.exoplayer.DecoderReuseEvaluation import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultRenderersFactory import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.Renderer.STATE_ENABLED import androidx.media3.exoplayer.Renderer.STATE_STARTED import androidx.media3.exoplayer.SeekParameters import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.dash.DashMediaSource import androidx.media3.exoplayer.drm.DefaultDrmSessionManager import androidx.media3.exoplayer.drm.FrameworkMediaDrm import androidx.media3.exoplayer.drm.HttpMediaDrmCallback import androidx.media3.exoplayer.drm.LocalMediaDrmCallback import androidx.media3.exoplayer.source.ClippingMediaSource import androidx.media3.exoplayer.source.ConcatenatingMediaSource import androidx.media3.exoplayer.source.ConcatenatingMediaSource2 import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MergingMediaSource import androidx.media3.exoplayer.source.SingleSampleMediaSource import androidx.media3.exoplayer.text.TextOutput import androidx.media3.exoplayer.text.TextRenderer import androidx.media3.exoplayer.trackselection.DefaultTrackSelector import androidx.media3.exoplayer.trackselection.TrackSelector import androidx.media3.extractor.mp4.FragmentedMp4Extractor import androidx.media3.ui.SubtitleView import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.AudioFile import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.fixSubtitleAlignment import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.applyStyle import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.CLEARKEY_UUID import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DrmExtractorLink import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkPlayList import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.PLAYREADY_UUID import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName import com.lagradost.cloudstream3.utils.WIDEVINE_UUID import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory import kotlinx.coroutines.delay import okhttp3.Interceptor import org.chromium.net.CronetEngine import java.io.File import java.security.SecureRandom import java.util.UUID import java.util.concurrent.Executors import javax.net.ssl.HttpsURLConnection import javax.net.ssl.SSLContext import javax.net.ssl.SSLSession const val TAG = "CS3ExoPlayer" const val PREFERRED_AUDIO_LANGUAGE_KEY = "preferred_audio_language" /** toleranceBeforeUs – The maximum time that the actual position seeked to may precede the * requested seek position, in microseconds. Must be non-negative. */ const val toleranceBeforeUs = 300_000L /** * toleranceAfterUs – The maximum time that the actual position seeked to may exceed the requested * seek position, in microseconds. Must be non-negative. */ const val toleranceAfterUs = 300_000L @OptIn(UnstableApi::class) class CS3IPlayer : IPlayer { private var playerListener: Player.Listener? = null private var isPlaying = false private var exoPlayer: ExoPlayer? = null set(value) { // If the old value is not null then the player has not been properly released. debugAssert( { field != null && value != null }, { "Previous player instance should be released!" }) field = value } var cacheSize = 0L var simpleCacheSize = 0L var videoBufferMs = 0L val imageGenerator = IPreviewGenerator.new() private val seekActionTime = 30000L private val isMediaSeekable get() = exoPlayer?.let { it.isCommandAvailable(Player.COMMAND_GET_CURRENT_MEDIA_ITEM) && it.isCurrentMediaItemSeekable } ?: false private var ignoreSSL: Boolean = true private var playBackSpeed: Float = 1.0f private var lastMuteVolume: Float = 1.0f private var currentLink: ExtractorLink? = null private var currentDownloadedFile: ExtractorUri? = null private var hasUsedFirstRender = false private var currentWindow: Int = 0 private var playbackPosition: Long = 0 private val subtitleHelper = PlayerSubtitleHelper() /** If we want to play the audio only in the background when the app is not open */ private var isAudioOnlyBackground = false /** * This is a way to combine the MediaItem and its duration for the concatenating MediaSource. * @param durationUs does not matter if only one slice is present, since it will not concatenate * */ data class MediaItemSlice( val mediaItem: MediaItem, val durationUs: Long, val drm: DrmMetadata? = null ) data class DrmMetadata( val kid: String? = null, val key: String? = null, val uuid: UUID, val kty: String? = null, val licenseUrl: String? = null, val keyRequestParameters: HashMap, ) override fun getDuration(): Long? = exoPlayer?.duration override fun getPosition(): Long? = exoPlayer?.currentPosition override fun getIsPlaying(): Boolean = isPlaying override fun getPlaybackSpeed(): Float = playBackSpeed /** * Tracks reported to be used by exoplayer, since sometimes it has a mind of it's own when selecting subs. * String = id (without exoplayer track number) * Boolean = if it's active * */ private var playerSelectedSubtitleTracks = listOf>() private var requestedListeningPercentages: List? = null private var eventHandler: ((PlayerEvent) -> Unit)? = null private val mainHandler = Handler(Looper.getMainLooper()) fun event(event: PlayerEvent) { // Ensure that all work is done on the main looper, aka main thread if (Looper.myLooper() == mainHandler.looper) { eventHandler?.invoke(event) } else { mainHandler.post { eventHandler?.invoke(event) } } } /** * As initCallbacks and releaseCallbacks must always be done, * we use this to say that the player is in use. * */ @Volatile var isPlayerActive: Boolean = false override fun releaseCallbacks() { eventHandler = null if (isPlayerActive) { isPlayerActive = false activePlayers -= 1 releaseCronetEngine() } } override fun initCallbacks( eventHandler: ((PlayerEvent) -> Unit), requestedListeningPercentages: List?, ) { this.requestedListeningPercentages = requestedListeningPercentages this.eventHandler = eventHandler if (!isPlayerActive) { isPlayerActive = true activePlayers += 1 } } // I know, this is not a perfect solution, however it works for fixing subs private fun reloadSubs() { exoPlayer?.applicationLooper?.let { try { Handler(it).post { try { seekTime(1L, source = PlayerEventSource.Player) } catch (e: Exception) { logError(e) } } } catch (e: Exception) { logError(e) } } } fun String.stripTrackId(): String { return this.replace(Regex("""^\d+:"""), "") } fun initSubtitles(subView: SubtitleView?, subHolder: FrameLayout?, style: SaveCaptionStyle?) { subtitleHelper.initSubtitles(subView, subHolder, style) } override fun getPreview(fraction: Float): Bitmap? { return imageGenerator.getPreviewImage(fraction) } override fun hasPreview(): Boolean { return imageGenerator.hasPreview() } override fun loadPlayer( context: Context, sameEpisode: Boolean, link: ExtractorLink?, data: ExtractorUri?, startPosition: Long?, subtitles: Set, subtitle: SubtitleData?, autoPlay: Boolean?, preview: Boolean, ) { Log.i(TAG, "loadPlayer") if (sameEpisode) { saveData() } else { currentSubtitles = subtitle playbackPosition = 0 } startPosition?.let { playbackPosition = it } // we want autoplay because of TV and UX isPlaying = autoPlay ?: isPlaying // release the current exoplayer and cache releasePlayer() if (link != null) { // only video support atm (imageGenerator as? PreviewGenerator)?.let { gen -> if (preview) { gen.load(link, sameEpisode) } else { gen.clear(sameEpisode) } } loadOnlinePlayer(context, link) } else if (data != null) { (imageGenerator as? PreviewGenerator)?.let { gen -> if (preview) { gen.load(context, data, sameEpisode) } else { gen.clear(sameEpisode) } } loadOfflinePlayer(context, data) } else { throw IllegalArgumentException("Requires link or uri") } } override fun setActiveSubtitles(subtitles: Set) { Log.i(TAG, "setActiveSubtitles ${subtitles.size}") subtitleHelper.setAllSubtitles(subtitles) } private var currentSubtitles: SubtitleData? = null private fun List.getTrack(id: String?): Pair? { if (id == null) return null // This beast of an expression does: // 1. Filter all audio tracks // 2. Get all formats in said audio tacks // 3. Gets all ids of the formats // 4. Filters to find the first audio track with the same id as the audio track we are looking for // 5. Returns the media group and the index of the audio track in the group return this.firstNotNullOfOrNull { group -> (0 until group.mediaTrackGroup.length).map { group.getTrackFormat(it) to it }.firstOrNull { // The format id system is "trackNumber:trackID" // The track number is not generated by us so we filter it out it.first.id?.stripTrackId() == id } ?.let { group.mediaTrackGroup to it.second } } } override fun setMaxVideoSize(width: Int, height: Int, id: String?) { if (id != null) { val videoTrack = exoPlayer?.currentTracks?.groups?.filter { it.type == TRACK_TYPE_VIDEO } ?.getTrack(id) if (videoTrack != null) { exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters ?.buildUpon() ?.setOverrideForType( TrackSelectionOverride( videoTrack.first, videoTrack.second ) ) ?.build() ?: return return } } exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters ?.buildUpon() ?.setMaxVideoSize(width, height) ?.build() ?: return } override fun setPreferredAudioTrack(trackLanguage: String?, id: String?, formatIndex: Int?) { preferredAudioTrackLanguage = trackLanguage id?.let { trackId -> val trackFormatIndex = formatIndex ?: 0 exoPlayer?.currentTracks?.groups ?.filter { it.type == TRACK_TYPE_AUDIO } ?.find { group -> group.getFormats().any { (format, _) -> format.id == trackId } } ?.let { group -> exoPlayer?.trackSelectionParameters ?.buildUpon() ?.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, trackFormatIndex)) ?.build() } ?.let { newParams -> exoPlayer?.trackSelectionParameters = newParams return } } // Fallback to language-based selection exoPlayer?.trackSelectionParameters = exoPlayer?.trackSelectionParameters ?.buildUpon() ?.setPreferredAudioLanguage(trackLanguage) ?.build() ?: return } /** * Gets all supported formats in a list * */ private fun List.getFormats(): List> { return this.map { it.getFormats() }.flatten() } private fun Tracks.Group.getFormats(): List> { return (0 until this.mediaTrackGroup.length).mapNotNull { i -> if (this.isSupported) this.mediaTrackGroup.getFormat(i) to i else null } } private fun Format.toAudioTrack(formatIndex: Int?): AudioTrack { return AudioTrack( this.id, this.label, this.language, this.sampleMimeType, this.channelCount, formatIndex ?: 0, ) } private fun Format.toSubtitleTrack(): TextTrack { return TextTrack( this.id?.stripTrackId(), this.label, this.language, this.sampleMimeType, ) } private fun Format.toVideoTrack(): VideoTrack { return VideoTrack( this.id?.stripTrackId(), this.label, this.language, this.width, this.height, this.sampleMimeType ) } override fun getVideoTracks(): CurrentTracks { val allTrackGroups = exoPlayer?.currentTracks?.groups ?: emptyList() val videoTracks = allTrackGroups.filter { it.type == TRACK_TYPE_VIDEO } .getFormats() .map { it.first.toVideoTrack() } var currentAudioTrack: AudioTrack? = null val audioTracks = allTrackGroups.filter { it.type == TRACK_TYPE_AUDIO } .flatMap { group -> group.getFormats().map { (format, formatIndex) -> val audioTrack = format.toAudioTrack(formatIndex) if (group.isTrackSelected(formatIndex)) { currentAudioTrack = audioTrack } audioTrack } } val textTracks = allTrackGroups.filter { it.type == TRACK_TYPE_TEXT } .getFormats() .map { it.first.toSubtitleTrack() } val currentTextTracks = textTracks.filter { track -> playerSelectedSubtitleTracks.any { it.second && it.first == track.id } } return CurrentTracks( exoPlayer?.videoFormat?.toVideoTrack(), currentAudioTrack, currentTextTracks, videoTracks, audioTracks, textTracks ) } /** * @return True if the player should be reloaded * */ override fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean { Log.i(TAG, "setPreferredSubtitles init $subtitle") currentSubtitles = subtitle val trackSelector = exoPlayer?.trackSelector as? DefaultTrackSelector ?: return false // Disable subtitles if null if (subtitle == null) { trackSelector.setParameters( trackSelector.buildUponParameters() .setTrackTypeDisabled(TRACK_TYPE_TEXT, true) .clearOverridesOfType(TRACK_TYPE_TEXT) ) return false } // Handle subtitle based on status when (subtitleHelper.subtitleStatus(subtitle)) { SubtitleStatus.REQUIRES_RELOAD -> { Log.i(TAG, "setPreferredSubtitles REQUIRES_RELOAD") return true } SubtitleStatus.NOT_FOUND -> { Log.i(TAG, "setPreferredSubtitles NOT_FOUND") return true } SubtitleStatus.IS_ACTIVE -> { Log.i(TAG, "setPreferredSubtitles IS_ACTIVE") exoPlayer?.currentTracks?.groups ?.filter { it.type == TRACK_TYPE_TEXT } ?.getTrack(subtitle.getId()) ?.let { (trackGroup, trackIndex) -> trackSelector.setParameters( trackSelector.buildUponParameters() .setTrackTypeDisabled(TRACK_TYPE_TEXT, false) .setOverrideForType(TrackSelectionOverride(trackGroup, trackIndex)) ) } return false } } } private var currentSubtitleOffset: Long = 0 override fun setSubtitleOffset(offset: Long) { currentSubtitleOffset = offset CustomDecoder.subtitleOffset = offset if (currentTextRenderer?.state == STATE_ENABLED || currentTextRenderer?.state == STATE_STARTED) { exoPlayer?.currentPosition?.also { pos -> // This seems to properly refresh all subtitles // It needs to be done as all subtitle cues with timings are pre-processed currentTextRenderer?.resetPosition(pos, false) } } } override fun getSubtitleOffset(): Long { return currentSubtitleOffset } override fun getSubtitleCues(): List { return currentSubtitleDecoder?.getSubtitleCues() ?: emptyList() } override fun getCurrentPreferredSubtitle(): SubtitleData? { return subtitleHelper.getAllSubtitles().firstOrNull { sub -> playerSelectedSubtitleTracks.any { (id, isSelected) -> isSelected && sub.getId() == id } } } override fun getAspectRatio(): Rational? { return exoPlayer?.videoFormat?.let { format -> Rational(format.width, format.height) } } override fun updateSubtitleStyle(style: SaveCaptionStyle) { subtitleHelper.setSubStyle(style) } override fun saveData() { Log.i(TAG, "saveData") updatedTime() exoPlayer?.let { exo -> playbackPosition = exo.currentPosition currentWindow = exo.currentMediaItemIndex isPlaying = exo.isPlaying } } private fun releasePlayer(saveTime: Boolean = true) { Log.i(TAG, "releasePlayer") eventLooperIndex += 1 if (saveTime) updatedTime() currentTextRenderer = null currentSubtitleDecoder = null exoPlayer?.apply { playWhenReady = false // This may look weird, however on some TV devices the audio does not stop playing // so this may fix it? try { pause() } catch (t: Throwable) { // No documented exception, but just to be extra safe logError(t) } playerListener?.let { removeListener(it) playerListener = null } stop() release() } //simpleCache?.release() exoPlayer = null event(PlayerAttachedEvent(null)) //simpleCache = null } override fun onStop() { Log.i(TAG, "onStop") saveData() if (!isAudioOnlyBackground) { handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) } //releasePlayer() } override fun onPause() { Log.i(TAG, "onPause") saveData() if (!isAudioOnlyBackground) { handleEvent(CSPlayerEvent.Pause, PlayerEventSource.Player) } //releasePlayer() } override fun onResume(context: Context) { isAudioOnlyBackground = false if (exoPlayer == null) reloadPlayer(context) } override fun release() { imageGenerator.release() releasePlayer() } override fun setPlaybackSpeed(speed: Float) { exoPlayer?.setPlaybackSpeed(speed) playBackSpeed = speed } companion object { private const val CRONET_TIMEOUT_MS = 15_000 /** * Single shared engine, to minimize the overhead of maintaining many as: * 1. Cpu time/Startup time * 2. Mem consumption/GC * 3. Disk usage, as we simply use the same folder * */ private var cronetEngine: CronetEngine? = null /** * How many active sessions we have. * * However in reality it should never go negative or be more than 1, * but this makes more sense architecturally. * */ @Volatile private var activePlayers = 0 /** Unique monotonically increasing id to keep track of the last release call */ @Volatile private var cronetReleasedId = 0 fun releaseCronetEngine() { if (cronetEngine == null) return // Delayed release, as we do not want to restart it when opening trailers ect val id = ++cronetReleasedId val posted = Handler(Looper.getMainLooper()).postDelayed({ // This might get dropped, but that should be very rare // and should not affect it. releaseCronetEngineInstantly(id) }, 60_000) // 1min timeout before release // If not posted, then run instantly if (!posted) { releaseCronetEngineInstantly(id) } } private fun releaseCronetEngineInstantly(id: Int) { // We should release if and only if this was the last call, and // there is no active players if (activePlayers == 0 && id == cronetReleasedId) { try { cronetEngine?.shutdown() } catch (t: Throwable) { logError(t) } finally { Log.d(TAG, "CronetEngine shutdown") // Even if it fails to shutdown, the GC should take care of it cronetEngine = null } } } /** * Setting this variable is permanent across app sessions. **/ var preferredAudioTrackLanguage: String? = null get() { return field ?: getKey( "$currentAccount/$PREFERRED_AUDIO_LANGUAGE_KEY", field )?.also { field = it } } set(value) { setKey("$currentAccount/$PREFERRED_AUDIO_LANGUAGE_KEY", value) field = value } private var simpleCache: SimpleCache? = null /// Create a small factory for small things, no cache, no cronet private fun createOnlineSource( headers: Map?, interceptor: Interceptor? ): HttpDataSource.Factory { val client = if (interceptor == null) { app.baseClient } else { app.baseClient.newBuilder() .addInterceptor(interceptor) .build() } val source = OkHttpDataSource.Factory(client).setUserAgent(USER_AGENT) if (!headers.isNullOrEmpty()) { source.setDefaultRequestProperties(headers) } return source } fun tryCreateEngine(context: Context, diskCacheSize: Long): CronetEngine? { // Fast case, no need to recreate it cronetEngine?.let { return it } // https://gist.github.com/ShivamKumarJha/3c8398b47053ae05112d2a8f8b5de531 return try { val cacheDirectory = File(context.cacheDir, "CronetEngine") cacheDirectory.deleteRecursively() if (!cacheDirectory.exists()) { cacheDirectory.mkdirs() } CronetEngine.Builder(context) .enableBrotli(true) .enableHttp2(true) .enableQuic(true) .setStoragePath(cacheDirectory.absolutePath) .setLibraryLoader(null) .enableHttpCache(CronetEngine.Builder.HTTP_CACHE_DISK, diskCacheSize) .build().also { buildEngine -> Log.d( TAG, "Created CronetEngine with cache at ${cacheDirectory.absolutePath}" ) cronetEngine = buildEngine } } catch (t: Throwable) { logError(t) // Something went wrong, so we use the backup okhttp null } } private fun createVideoSource( link: ExtractorLink, engine: CronetEngine?, interceptor: Interceptor?, ): HttpDataSource.Factory { val userAgent = link.headers.entries.find { it.key.equals("User-Agent", ignoreCase = true) }?.value ?: USER_AGENT val source = if (interceptor == null) { if (engine == null) { Log.d(TAG, "Using DefaultHttpDataSource for $link") OkHttpDataSource.Factory(app.baseClient).setUserAgent(userAgent) } else { Log.d(TAG, "Using CronetDataSource for $link") CronetDataSource.Factory(engine, Executors.newSingleThreadExecutor()) .setUserAgent(userAgent) .setConnectionTimeoutMs(CRONET_TIMEOUT_MS) .setReadTimeoutMs(CRONET_TIMEOUT_MS) .setResetTimeoutOnRedirects(true) .setHandleSetCookieRequests(true) } } else { Log.d(TAG, "Using OkHttpDataSource for $link") val client = app.baseClient.newBuilder() .addInterceptor(interceptor) .build() OkHttpDataSource.Factory(client).setUserAgent(userAgent) } // Do no include empty referer, if the provider wants those they can use the header map. val refererMap = if (link.referer.isBlank()) emptyMap() else mapOf("referer" to link.referer) // These are extra headers the browser like to insert, not sure if we want to include them // for WIDEVINE/drm as well? Do that if someone gets 404 and creates an issue. val headers = refererMap + link.headers // Adds the headers from the provider, e.g Authorization return source.apply { setDefaultRequestProperties(headers) } } private fun Context.createOfflineSource(): DataSource.Factory { return DefaultDataSource.Factory( this, DefaultHttpDataSource.Factory().setUserAgent(USER_AGENT) ) } private fun getCache(context: Context, cacheSize: Long): SimpleCache? { return try { val databaseProvider = StandaloneDatabaseProvider(context) SimpleCache( File( context.cacheDir, "exoplayer" ).also { deleteFileOnExit(it) }, // Ensures always fresh file LeastRecentlyUsedCacheEvictor(cacheSize), databaseProvider ) } catch (e: Exception) { logError(e) null } } private fun getMediaItemBuilder(mimeType: String): MediaItem.Builder { return MediaItem.Builder() //Replace needed for android 6.0.0 https://github.com/google/ExoPlayer/issues/5983 .setMimeType(mimeType) } private fun getMediaItem(mimeType: String, uri: Uri): MediaItem { return getMediaItemBuilder(mimeType).setUri(uri).build() } private fun getMediaItem(mimeType: String, url: String): MediaItem { return getMediaItemBuilder(mimeType).setUri(url).build() } private fun getTrackSelector(context: Context, maxVideoHeight: Int?): TrackSelector { val trackSelector = DefaultTrackSelector(context) trackSelector.parameters = trackSelector.buildUponParameters() // This will not force higher quality videos to fail // but will make the m3u8 pick the correct preferred .setMaxVideoSize(Int.MAX_VALUE, maxVideoHeight ?: Int.MAX_VALUE) .setPreferredAudioLanguage(null) .build() return trackSelector } private var currentSubtitleDecoder: CustomSubtitleDecoderFactory? = null private var currentTextRenderer: TextRenderer? = null } private fun getCurrentTimestamp(writePosition: Long? = null): EpisodeSkip.SkipStamp? { val position = writePosition ?: this@CS3IPlayer.getPosition() ?: return null for (lastTimeStamp in lastTimeStamps) { if (lastTimeStamp.startMs <= position && (position + (toleranceBeforeUs / 1000L) + 1) < lastTimeStamp.endMs) { return lastTimeStamp } } return null } fun updatedTime( writePosition: Long? = null, source: PlayerEventSource = PlayerEventSource.Player ) { val position = writePosition ?: exoPlayer?.currentPosition getCurrentTimestamp(position)?.let { timestamp -> event(TimestampInvokedEvent(timestamp, source)) } val duration = exoPlayer?.contentDuration if (duration != null && position != null) { event( PositionEvent( source, fromMs = exoPlayer?.currentPosition ?: 0, position, duration ) ) } } override fun seekTime(time: Long, source: PlayerEventSource) { exoPlayer?.seekTime(time, source) } override fun seekTo(time: Long, source: PlayerEventSource) { if (isMediaSeekable) { updatedTime(time, source) exoPlayer?.seekTo(time) } else { Log.i(TAG, "Media is not seekable, we can not seek to $time") } } private fun ExoPlayer.seekTime(time: Long, source: PlayerEventSource) { if (isMediaSeekable) { updatedTime(currentPosition + time, source) seekTo(currentPosition + time) } else { Log.i(TAG, "Media is not seekable, we can not seek to $time") } } override fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource) { Log.i(TAG, "handleEvent ${event.name}") try { exoPlayer?.apply { when (event) { CSPlayerEvent.Play -> { event(PlayEvent(source)) play() } CSPlayerEvent.Pause -> { event(PauseEvent(source)) pause() } CSPlayerEvent.ToggleMute -> { if (volume <= 0) { //is muted volume = lastMuteVolume } else { // is not muted lastMuteVolume = volume volume = 0f } } CSPlayerEvent.PlayPauseToggle -> { if (isPlaying) { handleEvent(CSPlayerEvent.Pause, source) } else { handleEvent(CSPlayerEvent.Play, source) } } CSPlayerEvent.SeekForward -> seekTime(seekActionTime, source) CSPlayerEvent.SeekBack -> seekTime(-seekActionTime, source) CSPlayerEvent.Restart -> seekTo(0, source) CSPlayerEvent.NextEpisode -> event( EpisodeSeekEvent( offset = 1, source = source ) ) CSPlayerEvent.PrevEpisode -> event( EpisodeSeekEvent( offset = -1, source = source ) ) CSPlayerEvent.SkipCurrentChapter -> { //val dur = this@CS3IPlayer.getDuration() ?: return@apply getCurrentTimestamp()?.let { lastTimeStamp -> if (lastTimeStamp.skipToNextEpisode) { handleEvent(CSPlayerEvent.NextEpisode, source) } else { seekTo(lastTimeStamp.endMs + 1L) } event(TimestampSkippedEvent(timestamp = lastTimeStamp, source = source)) } } CSPlayerEvent.PlayAsAudio -> { isAudioOnlyBackground = true activity?.moveTaskToBack(false) } } } } catch (t: Throwable) { Log.e(TAG, "handleEvent error", t) event(ErrorEvent(t)) } } // we want to push metadata when loading torrents, so we just set up a looper that loops until // the index changes, this way only 1 looper is active at a time, and modifying eventLooperIndex // will kill any active loopers private var eventLooperIndex = 0 private fun torrentEventLooper(hash: String) = ioSafe { eventLooperIndex += 2 // very shitty, but should work fine // release player is called once for the new link val currentIndex = eventLooperIndex + 1 while (eventLooperIndex <= currentIndex && eventHandler != null) { try { val status = Torrent.get(hash) event( DownloadEvent( connections = status.activePeers, downloadSpeed = status.downloadSpeed?.toLong()!!, totalBytes = status.torrentSize!!, downloadedBytes = status.bytesRead!!, ) ) } catch (_: NullPointerException) { } catch (t: Throwable) { logError(t) } delay(1000) } } private fun buildExoPlayer( context: Context, mediaItemSlices: List, subSources: List, currentWindow: Int, playbackPosition: Long, playBackSpeed: Float, subtitleOffset: Long, cacheSize: Long, videoBufferMs: Long, onlineSource: HttpDataSource.Factory? = null, playWhenReady: Boolean = true, trackSelector: TrackSelector? = null, /** * Sets the m3u8 preferred video quality, will not force stop anything with higher quality. * Does not work if trackSelector is defined. **/ maxVideoHeight: Int? = null, /** External audio tracks to merge with the video */ audioSources: List = emptyList() ): ExoPlayer { val exoPlayerBuilder = ExoPlayer.Builder(context) .setRenderersFactory { eventHandler, videoRendererEventListener, audioRendererEventListener, textRendererOutput, metadataRendererOutput -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val current = settingsManager.getInt( context.getString(R.string.software_decoding_key), -1 ) val (isSoftwareDecodingEnabled, isSoftwareDecodingPreferred) = when (current) { 0 -> true to false // HW+SW, aka on but prefer hw 2 -> true to true // SW+HW, aka on but prefer sw 1 -> false to false // HW, aka off // -1 = automatic // We do not want tv to have software decoding, because of crashes else -> isLayout(PHONE or EMULATOR) to false } val factory = if (isSoftwareDecodingEnabled) { FixedNextRenderersFactory(context).apply { setEnableDecoderFallback(true) setExtensionRendererMode( if (isSoftwareDecodingPreferred) DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER else DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON ) } } else { // no nextlib = EXTENSION_RENDERER_MODE_OFF DefaultRenderersFactory(context) } val style = CustomDecoder.style // Custom TextOutput to apply cue styling and rules to all subtitles val customTextOutput = TextOutput { cue -> // Do not remove filterNotNull as Java typesystem is fucked val (bitmapCues, textCues) = cue.cues.filterNotNull() .partition { it.bitmap != null } val styledBitmapCues = bitmapCues.map { bitmapCue -> bitmapCue .buildUpon() .fixSubtitleAlignment() .applyStyle(style) .build() } // Reuse memory, to avoid many allocations val set = HashSet() val buffer = StringBuilder() // Move cues into one single one // This is to prevent text overlap in vtt (and potentially other) subtitle files val styledTextCues = textCues.groupBy { // Groups cues which share the same positon it.lineAnchor to it.position.times(1000.0f).toInt() }.mapNotNull { (_, entries) -> set.clear() buffer.clear() var count = 0 for (x in entries) { // Only allow non null text, otherwise we might have "a\n\nb" val text = x.text ?: continue // Prevent duplicate entries, this often happens when the subtitle file // uses multiple text lines as outlines. Most commonly found in fansubs // with fancy subtitle styling. if (!set.add(text)) { continue } if (++count > 1) buffer.append('\n') // Trim to avoid weird formatting if the last line ends with a newline buffer.append(text.trim()) } val combinedCueText = buffer.toString() // Use the style of the first entry as the base entries .firstOrNull() ?.buildUpon() ?.setText(combinedCueText) ?.fixSubtitleAlignment() ?.applyStyle(style) ?.build() } val combinedCues = styledBitmapCues + styledTextCues subtitleHelper.subtitleView?.setCues(combinedCues) } factory.createRenderers( eventHandler, videoRendererEventListener, audioRendererEventListener, customTextOutput, metadataRendererOutput ).map { if (it is TextRenderer) { CustomDecoder.subtitleOffset = subtitleOffset val decoder = CustomSubtitleDecoderFactory() val currentTextRenderer = TextRenderer( customTextOutput, eventHandler.looper, decoder ).apply { // Required to make the decoder work with old subtitles // Upgrade CustomSubtitleDecoderFactory when media3 supports it @Suppress("DEPRECATION") experimentalSetLegacyDecodingEnabled(true) }.also { renderer -> currentTextRenderer = renderer currentSubtitleDecoder = decoder } currentTextRenderer } else it }.toTypedArray() } .setTrackSelector( trackSelector ?: getTrackSelector( context, maxVideoHeight ) ) // Allows any seeking to be +- 0.3s to allow for faster seeking .setSeekParameters(SeekParameters(toleranceBeforeUs, toleranceAfterUs)) .setLoadControl( DefaultLoadControl.Builder() .setTargetBufferBytes( if (cacheSize <= 0) { DefaultLoadControl.DEFAULT_TARGET_BUFFER_BYTES } else { if (cacheSize > Int.MAX_VALUE) Int.MAX_VALUE else cacheSize.toInt() } ) .setBackBuffer( 30000, true ) .setBufferDurationsMs( DefaultLoadControl.DEFAULT_MIN_BUFFER_MS, if (videoBufferMs <= 0) { DefaultLoadControl.DEFAULT_MAX_BUFFER_MS } else { videoBufferMs.toInt() }, DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS ).build() ) // Because "Java rules" the media3 team hates to do open classes so we have to copy paste the entire thing to add a custom extractor // This includes the updated MKV extractor that enabled seeking in formats where the seek information is at the back of the file val extractorFactor = UpdatedDefaultExtractorsFactory() .setFragmentedMp4ExtractorFlags(FragmentedMp4Extractor.FLAG_MERGE_FRAGMENTED_SIDX) // Create an online connection with cache for all online sources val dataSourceFactory = if (onlineSource == null) { null } else { if (simpleCache == null) simpleCache = getCache(context, simpleCacheSize) val cacheFactory = CacheDataSource.Factory().apply { simpleCache?.let { setCache(it) } setUpstreamDataSourceFactory(onlineSource) } cacheFactory } val defaultMediaSourceFactory = if (dataSourceFactory != null) { DefaultMediaSourceFactory(dataSourceFactory, extractorFactor) } else { DefaultMediaSourceFactory(context, extractorFactor) } // If there is only one item then treat it as normal, if multiple: concatenate the items. val videoMediaSource = if (mediaItemSlices.size == 1) { val item = mediaItemSlices.first() item.drm?.let { drm -> when (drm.uuid) { CLEARKEY_UUID -> { // Use headers from DrmMetadata for media requests val client = dataSourceFactory ?: throw IllegalArgumentException("Must supply onlineSource") val drmCallback = LocalMediaDrmCallback("{\"keys\":[{\"kty\":\"${drm.kty}\",\"k\":\"${drm.key}\",\"kid\":\"${drm.kid}\"}],\"type\":\"temporary\"}".toByteArray()) val manager = DefaultDrmSessionManager.Builder() .setPlayClearSamplesWithoutKeys(true) .setMultiSession(false) .setKeyRequestParameters(drm.keyRequestParameters) .setUuidAndExoMediaDrmProvider( drm.uuid, FrameworkMediaDrm.DEFAULT_PROVIDER ) .build(drmCallback) DashMediaSource.Factory(client) .setDrmSessionManagerProvider { manager } .createMediaSource(item.mediaItem) } WIDEVINE_UUID, PLAYREADY_UUID -> { // Use headers from DrmMetadata for media requests val client = dataSourceFactory ?: throw IllegalArgumentException("Must supply onlineSource") val drmCallback = HttpMediaDrmCallback(drm.licenseUrl, client) val manager = DefaultDrmSessionManager.Builder() .setPlayClearSamplesWithoutKeys(true) .setMultiSession(true) .setKeyRequestParameters(drm.keyRequestParameters) .setUuidAndExoMediaDrmProvider( drm.uuid, FrameworkMediaDrm.DEFAULT_PROVIDER ) .build(drmCallback) DashMediaSource.Factory(client) .setDrmSessionManagerProvider { manager } .createMediaSource(item.mediaItem) } else -> { Log.e( TAG, "DRM Metadata class is not supported: ${drm::class.simpleName}" ) null } } } ?: run { defaultMediaSourceFactory.createMediaSource(item.mediaItem) } } else { try { val source = ConcatenatingMediaSource2.Builder() mediaItemSlices.map { item -> source.add( // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 ClippingMediaSource( defaultMediaSourceFactory.createMediaSource(item.mediaItem), item.durationUs ) ) } source.build() } catch (_: IllegalArgumentException) { @Suppress("DEPRECATION") val source = ConcatenatingMediaSource() // FIXME figure out why ConcatenatingMediaSource2 seems to fail with Torrents only mediaItemSlices.map { item -> source.addMediaSource( // The duration MUST be known for it to work properly, see https://github.com/google/ExoPlayer/issues/4727 ClippingMediaSource( defaultMediaSourceFactory.createMediaSource(item.mediaItem), item.durationUs ) ) } source } } return exoPlayerBuilder.build().apply { setPlayWhenReady(playWhenReady) seekTo(currentWindow, playbackPosition) // Merge video, subtitles and external audio tracks val allSources = listOf(videoMediaSource) + subSources + audioSources setMediaSource( MergingMediaSource(*allSources.toTypedArray()), playbackPosition ) setHandleAudioBecomingNoisy(true) setPlaybackSpeed(playBackSpeed) this.addAnalyticsListener(tracksAnalyticsListener) } } private fun loadExo( context: Context, mediaSlices: List, subSources: List, audioSources: List = emptyList(), onlineSource: HttpDataSource.Factory? = null, ) { Log.i(TAG, "loadExo") val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val maxVideoHeight = settingsManager.getInt( context.getString(if (context.isUsingMobileData()) R.string.quality_pref_mobile_data_key else R.string.quality_pref_key), Int.MAX_VALUE ) try { hasUsedFirstRender = false // ye this has to be a val for whatever reason // this makes no sense exoPlayer = buildExoPlayer( context, mediaSlices, subSources, currentWindow, playbackPosition, playBackSpeed, cacheSize = cacheSize, videoBufferMs = videoBufferMs, playWhenReady = isPlaying, // this keep the current state of the player subtitleOffset = currentSubtitleOffset, maxVideoHeight = maxVideoHeight, audioSources = audioSources, onlineSource = onlineSource, ) event(PlayerAttachedEvent(exoPlayer)) exoPlayer?.prepare() exoPlayer?.let { exo -> event(StatusEvent(CSPlayerLoading.IsBuffering, CSPlayerLoading.IsBuffering)) isPlaying = exo.isPlaying } // we want to avoid an empty exoplayer from sending events // this is because we need PlayerAttachedEvent to be called to render the UI // but don't really want the rest like Player.STATE_ENDED calling next episode if (mediaSlices.isEmpty() && subSources.isEmpty()) { return } exoPlayer?.addListener(object : Player.Listener { override fun onTracksChanged(tracks: Tracks) { safe { val textTracks = tracks.groups.filter { it.type == TRACK_TYPE_TEXT } playerSelectedSubtitleTracks = textTracks.map { group -> group.getFormats().mapNotNull { (format, _) -> (format.id?.stripTrackId() ?: return@mapNotNull null) to group.isSelected } }.flatten() val exoPlayerReportedTracks = tracks.groups.filter { it.type == TRACK_TYPE_TEXT }.getFormats() .mapNotNull { (format, _) -> // Filter out non subs, already used subs and subs without languages if (format.id == null || format.language == null || format.language?.startsWith("-") == true ) return@mapNotNull null return@mapNotNull SubtitleData( // Nicer looking displayed names fromTagToLanguageName(format.language) ?: format.language!!, format.label ?: "", // See setPreferredTextLanguage format.id!!.stripTrackId(), SubtitleOrigin.EMBEDDED_IN_VIDEO, format.sampleMimeType ?: MimeTypes.APPLICATION_SUBRIP, emptyMap(), format.language, ) } event(EmbeddedSubtitlesFetchedEvent(tracks = exoPlayerReportedTracks)) event(TracksChangedEvent()) event(SubtitlesUpdatedEvent()) } } // fixme: Use onPlaybackStateChanged(int) and onPlayWhenReadyChanged(boolean, int) instead. @Suppress("OVERRIDE_DEPRECATION") override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { exoPlayer?.let { exo -> event( StatusEvent( wasPlaying = if (isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused, isPlaying = when (playbackState) { Player.STATE_ENDED -> CSPlayerLoading.IsEnded Player.STATE_BUFFERING -> CSPlayerLoading.IsBuffering else -> if (exo.isPlaying) CSPlayerLoading.IsPlaying else CSPlayerLoading.IsPaused } ) ) isPlaying = exo.isPlaying } when (playbackState) { Player.STATE_READY -> { onRenderFirst() } else -> {} } if (playWhenReady) { when (playbackState) { Player.STATE_READY -> { } Player.STATE_ENDED -> { event(VideoEndedEvent()) } Player.STATE_BUFFERING -> { updatedTime(source = PlayerEventSource.Player) } Player.STATE_IDLE -> { } else -> Unit } } } override fun onPlayerError(error: PlaybackException) { // If the Network fails then ignore the exception if the duration is set. // This is to switch mirrors automatically if the stream has not been fetched, but // allow playing the buffer without internet as then the duration is fetched. when { error.errorCode == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED && exoPlayer?.duration != TIME_UNSET -> { exoPlayer?.prepare() } error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW -> { // Re-initialize player at the current live window default position. exoPlayer?.seekToDefaultPosition() exoPlayer?.prepare() } else -> { event(ErrorEvent(error)) } } super.onPlayerError(error) } //override fun onCues(cues: MutableList) { // super.onCues(cues.map { cue -> cue.buildUpon().setText("Hello world").setSize(Cue.DIMEN_UNSET).build() }) //} override fun onIsPlayingChanged(isPlaying: Boolean) { super.onIsPlayingChanged(isPlaying) if (isPlaying) { event(RequestAudioFocusEvent()) onRenderFirst() } } override fun onPlaybackStateChanged(playbackState: Int) { super.onPlaybackStateChanged(playbackState) when (playbackState) { Player.STATE_READY -> { } Player.STATE_ENDED -> { // Only play next episode if autoplay is on (default) if (PreferenceManager.getDefaultSharedPreferences(context) ?.getBoolean( context.getString(R.string.autoplay_next_key), true ) == true ) { handleEvent( CSPlayerEvent.NextEpisode, source = PlayerEventSource.Player ) } } Player.STATE_BUFFERING -> { updatedTime(source = PlayerEventSource.Player) } Player.STATE_IDLE -> { // IDLE } else -> Unit } } override fun onVideoSizeChanged(videoSize: VideoSize) { super.onVideoSizeChanged(videoSize) event(ResizedEvent(height = videoSize.height, width = videoSize.width)) } override fun onRenderedFirstFrame() { super.onRenderedFirstFrame() onRenderFirst() updatedTime(source = PlayerEventSource.Player) } }.also { playerListener = it }) } catch (t: Throwable) { Log.e(TAG, "loadExo error", t) event(ErrorEvent(t)) } } private var lastTimeStamps: List = emptyList() override fun addTimeStamps(timeStamps: List) { lastTimeStamps = timeStamps timeStamps.forEach { timestamp -> exoPlayer?.createMessage { _, _ -> updatedTime(source = PlayerEventSource.Player) //if (payload is EpisodeSkip.SkipStamp) // this should always be true // onTimestampInvoked?.invoke(payload) } ?.setLooper(Looper.getMainLooper()) ?.setPosition(timestamp.startMs) //?.setPayload(timestamp) ?.setDeleteAfterDelivery(false) ?.send() } updatedTime(source = PlayerEventSource.Player) } fun onRenderFirst() { if (hasUsedFirstRender) { // this insures that we only call this once per player load return } Log.i(TAG, "Rendered first frame") hasUsedFirstRender = true setPreferredSubtitles(currentSubtitles) val format = exoPlayer?.videoFormat val width = format?.width val height = format?.height if (height != null && width != null) { event(ResizedEvent(width = width, height = height)) updatedTime() exoPlayer?.apply { requestedListeningPercentages?.forEach { percentage -> createMessage { _, _ -> updatedTime() } .setLooper(Looper.getMainLooper()) .setPosition(contentDuration * percentage / 100) // .setPayload(customPayloadData) .setDeleteAfterDelivery(false) .send() } } } } private fun loadOfflinePlayer(context: Context, data: ExtractorUri) { Log.i(TAG, "loadOfflinePlayer") try { currentDownloadedFile = data val mediaItem = getMediaItem(MimeTypes.VIDEO_MP4, data.uri) val offlineSourceFactory = context.createOfflineSource() val (subSources, activeSubtitles) = getSubSources( offlineSourceFactory = offlineSourceFactory, subHelper = subtitleHelper, interceptor = null, ) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) loadExo(context, listOf(MediaItemSlice(mediaItem, Long.MIN_VALUE)), subSources) } catch (t: Throwable) { Log.e(TAG, "loadOfflinePlayer error", t) event(ErrorEvent(t)) } } private fun getSubSources( offlineSourceFactory: DataSource.Factory?, subHelper: PlayerSubtitleHelper, interceptor: Interceptor?, ): Pair, List> { val activeSubtitles = ArrayList() val subSources = subHelper.getAllSubtitles().mapNotNull { sub -> val subConfig = MediaItem.SubtitleConfiguration.Builder(sub.getFixedUrl().toUri()) .setMimeType(sub.mimeType) .setLanguage("_${sub.name}") .setId(sub.getId()) .setSelectionFlags(0) .build() when (sub.origin) { SubtitleOrigin.DOWNLOADED_FILE, SubtitleOrigin.EMBEDDED_IN_VIDEO -> { if (offlineSourceFactory != null) { activeSubtitles.add(sub) SingleSampleMediaSource.Factory(offlineSourceFactory) .createMediaSource(subConfig, TIME_UNSET) } else { null } } SubtitleOrigin.URL -> { val dataSourceFactory = createOnlineSource(sub.headers, interceptor) activeSubtitles.add(sub) SingleSampleMediaSource.Factory(dataSourceFactory) .createMediaSource(subConfig, TIME_UNSET) } } } return Pair(subSources, activeSubtitles) } /** * Creates audio media sources from ExtractorLink's audioTracks * @param audioTracks List of audio tracks from ExtractorLink * @return List of MediaSource for audio tracks */ private fun getAudioSources( audioTracks: List, interceptor: Interceptor?, ): List { return audioTracks.mapNotNull { audio -> try { val mediaItem = getMediaItem(MimeTypes.AUDIO_UNKNOWN, audio.url) val dataSourceFactory = createOnlineSource(audio.headers, interceptor) DefaultMediaSourceFactory(dataSourceFactory).createMediaSource(mediaItem) } catch (e: Exception) { Log.e(TAG, "Failed to create audio source for ${audio.url}: ${e.message}") null } } } override fun isActive(): Boolean { return exoPlayer != null } @MainThread private fun loadTorrent(context: Context, link: ExtractorLink) { ioSafe { // we check exoPlayer a lot here, and that is because we don't want to load exo after // the user has left the player, in the case that the user click back when this is // happening try { if (exoPlayer == null) return@ioSafe val (newLink, status) = Torrent.transformLink(link) val hash = status.hash if (exoPlayer == null) return@ioSafe runOnMainThread { if (exoPlayer == null) return@runOnMainThread releasePlayer() if (hash != null) { torrentEventLooper(hash) } loadOnlinePlayer(context, newLink) } } catch (t: Throwable) { event(ErrorEvent(t)) } } } @SuppressLint("UnsafeOptInUsageError") @MainThread private fun loadOnlinePlayer(context: Context, link: ExtractorLink, retry: Boolean = false) { Log.i(TAG, "loadOnlinePlayer $link") try { val mime = when (link.type) { ExtractorLinkType.M3U8 -> MimeTypes.APPLICATION_M3U8 ExtractorLinkType.DASH -> MimeTypes.APPLICATION_MPD ExtractorLinkType.VIDEO -> MimeTypes.VIDEO_MP4 ExtractorLinkType.TORRENT, ExtractorLinkType.MAGNET -> { // we check settings first, todo cleanup val default = TvType.entries.toTypedArray() .sorted() .filter { it != TvType.NSFW } .map { it.ordinal } val defaultSet = default.map { it.toString() }.toSet() val currentPrefMedia = try { PreferenceManager.getDefaultSharedPreferences(context) .getStringSet( context.getString(R.string.prefer_media_type_key), defaultSet ) ?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null } } catch (e: Throwable) { null } ?: default if (!currentPrefMedia.contains(TvType.Torrent.ordinal)) { val errorMessage = context.getString(R.string.torrent_preferred_media) event(ErrorEvent(ErrorLoadingException(errorMessage))) return } if (Torrent.hasAcceptedTorrentForThisSession == false) { val errorMessage = context.getString(R.string.torrent_not_accepted) event(ErrorEvent(ErrorLoadingException(errorMessage))) return } // load the initial UI, we require an exoPlayer to be alive if (!retry) { // this causes a *bug* that restarts all torrents from 0 // but I would call this a feature releasePlayer() loadExo(context, listOf(), listOf()) } event( StatusEvent( wasPlaying = CSPlayerLoading.IsPlaying, isPlaying = CSPlayerLoading.IsBuffering ) ) if (Torrent.hasAcceptedTorrentForThisSession == true) { loadTorrent(context, link) return } val builder: AlertDialog.Builder = AlertDialog.Builder(context) val dialogClickListener = DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { Torrent.hasAcceptedTorrentForThisSession = true loadTorrent(context, link) } DialogInterface.BUTTON_NEGATIVE -> { Torrent.hasAcceptedTorrentForThisSession = false val errorMessage = context.getString(R.string.torrent_not_accepted) event(ErrorEvent(ErrorLoadingException(errorMessage))) } } } builder.setTitle(R.string.play_torrent_button) .setMessage(R.string.torrent_info) // Ensure that the user will not accidentally start a torrent session. .setCancelable(false).setOnCancelListener { val errorMessage = context.getString(R.string.torrent_not_accepted) event(ErrorEvent(ErrorLoadingException(errorMessage))) } .setPositiveButton(R.string.ok, dialogClickListener) .setNegativeButton(R.string.go_back, dialogClickListener) .show().setDefaultFocus() return } } currentLink = link if (ignoreSSL) { // Disables ssl check val sslContext: SSLContext = SSLContext.getInstance("TLS") sslContext.init(null, arrayOf(SSLTrustManager()), SecureRandom()) sslContext.createSSLEngine() HttpsURLConnection.setDefaultHostnameVerifier { _: String, _: SSLSession -> true } HttpsURLConnection.setDefaultSSLSocketFactory(sslContext.socketFactory) } val mediaItems = when (link) { is ExtractorLinkPlayList -> link.playlist.map { MediaItemSlice(getMediaItem(mime, it.url), it.durationUs) } is DrmExtractorLink -> { listOf( // Single sliced list with unset length MediaItemSlice( getMediaItem(mime, link.url), Long.MIN_VALUE, drm = DrmMetadata( kid = link.kid, key = link.key, uuid = link.uuid, kty = link.kty, licenseUrl = link.licenseUrl, keyRequestParameters = link.keyRequestParameters, ) ) ) } else -> listOf( // Single sliced list with unset length MediaItemSlice(getMediaItem(mime, link.url), Long.MIN_VALUE) ) } // For DASH or HLS single streams (non-playlist), prefer the player's default // live position instead of starting at 0. Use TIME_UNSET to let ExoPlayer pick // the live/default position when no explicit start position was provided. if (playbackPosition == 0L && (link.type == ExtractorLinkType.M3U8 || link.type == ExtractorLinkType.DASH)) { playbackPosition = TIME_UNSET } val provider = getApiFromNameNull(link.source) val interceptor: Interceptor? = provider?.getVideoInterceptor(link) val onlineSourceFactory = createVideoSource( link = link, engine = tryCreateEngine(context, simpleCacheSize), interceptor = interceptor ) val offlineSourceFactory = context.createOfflineSource() val (subSources, activeSubtitles) = getSubSources( offlineSourceFactory = offlineSourceFactory, subHelper = subtitleHelper, interceptor = interceptor, // Backwards compatibility, needs a new api to work properly ) // Create audio sources from ExtractorLink's audioTracks val audioSources = getAudioSources( audioTracks = link.audioTracks, interceptor = interceptor, // Backwards compatibility, needs a new api to work properly ) subtitleHelper.setActiveSubtitles(activeSubtitles.toSet()) loadExo( context = context, mediaSlices = mediaItems, subSources = subSources, audioSources = audioSources, onlineSource = onlineSourceFactory ) } catch (t: Throwable) { Log.e(TAG, "loadOnlinePlayer error", t) event(ErrorEvent(t)) } } override fun reloadPlayer(context: Context) { Log.i(TAG, "reloadPlayer") releasePlayer(false) currentLink?.let { loadOnlinePlayer(context, it) } ?: currentDownloadedFile?.let { loadOfflinePlayer(context, it) } } private val tracksAnalyticsListener = object : AnalyticsListener { override fun onVideoInputFormatChanged( eventTime: AnalyticsListener.EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation? ) { event(TracksChangedEvent()) } override fun onAudioInputFormatChanged( eventTime: AnalyticsListener.EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation? ) { event(TracksChangedEvent()) } override fun onVideoDisabled( eventTime: AnalyticsListener.EventTime, decoderCounters: DecoderCounters ) { event(TracksChangedEvent()) } override fun onAudioDisabled( eventTime: AnalyticsListener.EventTime, decoderCounters: DecoderCounters ) { event(TracksChangedEvent()) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubripParser.kt ================================================ /* * Copyright (C) 2016 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /* * This is a fork of media3 subrip parses as the developers fear a flexible player, and open classes. */ package com.lagradost.cloudstream3.ui.player import android.text.Html import android.text.Spanned import android.text.TextUtils import androidx.annotation.VisibleForTesting import androidx.media3.common.C import androidx.media3.common.Format import androidx.media3.common.Format.CueReplacementBehavior import androidx.media3.common.text.Cue import androidx.media3.common.text.Cue.AnchorType import androidx.media3.common.util.Assertions import androidx.media3.common.util.Consumer import androidx.media3.common.util.Log import androidx.media3.common.util.ParsableByteArray import androidx.media3.common.util.UnstableApi import androidx.media3.extractor.text.CuesWithTiming import androidx.media3.extractor.text.SubtitleParser import androidx.media3.extractor.text.SubtitleParser.OutputOptions import com.google.common.collect.ImmutableList import java.nio.charset.Charset import java.nio.charset.StandardCharsets import java.util.regex.Matcher import java.util.regex.Pattern /** A [SubtitleParser] for SubRip. */ @UnstableApi class CustomSubripParser : SubtitleParser { private val textBuilder: StringBuilder = StringBuilder() private val tags: ArrayList = ArrayList() private val parsableByteArray: ParsableByteArray = ParsableByteArray() override fun getCueReplacementBehavior(): @CueReplacementBehavior Int { return CUE_REPLACEMENT_BEHAVIOR } override fun parse( data: ByteArray, offset: Int, length: Int, outputOptions: OutputOptions, output: Consumer ) { parsableByteArray.reset(data, /* limit= */offset + length) parsableByteArray.setPosition(offset) val charset = detectUtfCharset(parsableByteArray) val cuesWithTimingBeforeRequestedStartTimeUs: MutableList? = if (outputOptions.startTimeUs != C.TIME_UNSET && outputOptions.outputAllCues) ArrayList() else null var currentLine: String? while ((parsableByteArray.readLine(charset).also { currentLine = it }) != null) { if (currentLine!!.isEmpty()) { // Skip blank lines. continue } // Parse and check the index line. try { currentLine.toInt() } catch (_: NumberFormatException) { Log.w(TAG, "Skipping invalid index: $currentLine") continue } // Read and parse the timing line. currentLine = parsableByteArray.readLine(charset) if (currentLine == null) { Log.w(TAG, "Unexpected end") break } val startTimeUs: Long val endTimeUs: Long val matcher = SUBRIP_TIMING_LINE.matcher(currentLine) if (matcher.matches()) { startTimeUs = parseTimecode(matcher, /* groupOffset= */1) endTimeUs = parseTimecode(matcher, /* groupOffset= */6) } else { Log.w(TAG, "Skipping invalid timing: $currentLine") continue } // Read and parse the text and tags. textBuilder.setLength(0) tags.clear() currentLine = parsableByteArray.readLine(charset) while (!TextUtils.isEmpty(currentLine)) { if (textBuilder.isNotEmpty()) { textBuilder.append("
") } textBuilder.append(processLine(currentLine!!, tags)) currentLine = parsableByteArray.readLine(charset) } val text = Html.fromHtml(textBuilder.toString()) var alignmentTag: String? = null for (i in tags.indices) { val tag = tags[i] if (tag.matches(SUBRIP_ALIGNMENT_TAG.toRegex())) { alignmentTag = tag // Subsequent alignment tags should be ignored. break } } if (outputOptions.startTimeUs == C.TIME_UNSET || endTimeUs >= outputOptions.startTimeUs) { output.accept( CuesWithTiming( ImmutableList.of(buildCue(text, alignmentTag)), startTimeUs, /* durationUs= */ endTimeUs - startTimeUs ) ) } else cuesWithTimingBeforeRequestedStartTimeUs?.add( CuesWithTiming( ImmutableList.of(buildCue(text, alignmentTag)), startTimeUs, /* durationUs= */ endTimeUs - startTimeUs ) ) } if (cuesWithTimingBeforeRequestedStartTimeUs != null) { for (cuesWithTiming in cuesWithTimingBeforeRequestedStartTimeUs) { output.accept(cuesWithTiming) } } } /** * Determine UTF encoding of the byte array from a byte order mark (BOM), defaulting to UTF-8 if * no BOM is found. */ private fun detectUtfCharset(data: ParsableByteArray): Charset { val charset = data.readUtfCharsetFromBom() return charset ?: StandardCharsets.UTF_8 } /** * Trims and removes tags from the given line. The removed tags are added to `tags`. * * @param line The line to process. * @param tags A list to which removed tags will be added. * @return The processed line. */ private fun processLine(line: String, tags: ArrayList): String { var line = line line = line.trim { it <= ' ' } var removedCharacterCount = 0 val processedLine = StringBuilder(line) val matcher = SUBRIP_TAG_PATTERN.matcher(line) while (matcher.find()) { val tag = matcher.group() tags.add(tag) val start = matcher.start() - removedCharacterCount val tagLength = tag.length processedLine.replace(start, /* end= */start + tagLength, /* str= */"") removedCharacterCount += tagLength } return processedLine.toString() } /** * Build a [Cue] based on the given text and alignment tag. * * @param text The text. * @param alignmentTag The alignment tag, or `null` if no alignment tag is available. * @return Built cue */ private fun buildCue(text: Spanned, alignmentTag: String?): Cue { val cue = Cue.Builder().setText(text) if (alignmentTag == null) { return cue.build() } // Horizontal alignment. when (alignmentTag) { ALIGN_BOTTOM_LEFT, ALIGN_MID_LEFT, ALIGN_TOP_LEFT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_START) ALIGN_BOTTOM_RIGHT, ALIGN_MID_RIGHT, ALIGN_TOP_RIGHT -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_END) ALIGN_BOTTOM_MID, ALIGN_MID_MID, ALIGN_TOP_MID -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE) else -> cue.setPositionAnchor(Cue.ANCHOR_TYPE_MIDDLE) } // Vertical alignment. when (alignmentTag) { ALIGN_BOTTOM_LEFT, ALIGN_BOTTOM_MID, ALIGN_BOTTOM_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_END) ALIGN_TOP_LEFT, ALIGN_TOP_MID, ALIGN_TOP_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_START) ALIGN_MID_LEFT, ALIGN_MID_MID, ALIGN_MID_RIGHT -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE) else -> cue.setLineAnchor(Cue.ANCHOR_TYPE_MIDDLE) } return cue.setPosition(getFractionalPositionForAnchorType(cue.getPositionAnchor())) .setLine( getFractionalPositionForAnchorType(cue.getLineAnchor()), Cue.LINE_TYPE_FRACTION ) .build() } companion object { /** * The [CueReplacementBehavior] for consecutive [CuesWithTiming] emitted by this * implementation. */ const val CUE_REPLACEMENT_BEHAVIOR: @CueReplacementBehavior Int = Format.CUE_REPLACEMENT_BEHAVIOR_MERGE // Fractional positions for use when alignment tags are present. private const val START_FRACTION = 0.08f private const val END_FRACTION = 1 - START_FRACTION private const val MID_FRACTION = 0.5f private const val TAG = "SubripParser" // The google devs are useless, this entire class is just to override this private const val SUBRIP_TIMECODE = "(?:(\\d+):)?(\\d+):(\\d+)(?:[,.](\\d+))?" private val SUBRIP_TIMING_LINE: Pattern = Pattern.compile("\\s*($SUBRIP_TIMECODE)\\s*-->\\s*($SUBRIP_TIMECODE)\\s*") // NOTE: Android Studio's suggestion to simplify '\\}' is incorrect [internal: b/144480183]. private val SUBRIP_TAG_PATTERN: Pattern = Pattern.compile("\\{\\\\.*?\\}") private const val SUBRIP_ALIGNMENT_TAG = "\\{\\\\an[1-9]\\}" // Alignment tags for SSA V4+. private const val ALIGN_BOTTOM_LEFT = "{\\an1}" private const val ALIGN_BOTTOM_MID = "{\\an2}" private const val ALIGN_BOTTOM_RIGHT = "{\\an3}" private const val ALIGN_MID_LEFT = "{\\an4}" private const val ALIGN_MID_MID = "{\\an5}" private const val ALIGN_MID_RIGHT = "{\\an6}" private const val ALIGN_TOP_LEFT = "{\\an7}" private const val ALIGN_TOP_MID = "{\\an8}" private const val ALIGN_TOP_RIGHT = "{\\an9}" private fun parseTimecode(matcher: Matcher, groupOffset: Int): Long { val hours = matcher.group(groupOffset + 1) var timestampMs = if (hours != null) hours.toLong() * 60 * 60 * 1000 else 0 timestampMs += Assertions.checkNotNull(matcher.group(groupOffset + 2)) .toLong() * 60 * 1000 timestampMs += Assertions.checkNotNull(matcher.group(groupOffset + 3)) .toLong() * 1000 val millis = matcher.group(groupOffset + 4) timestampMs += when (millis?.length) { null -> 0L 1 -> millis.toLong() * 100L 2 -> millis.toLong() * 10L 3 -> millis.toLong() * 1L else -> millis.substring(0, 3).toLong() } return timestampMs * 1000 } // TODO(b/289983417): Make package-private again, once it is no longer needed in // DelegatingSubtitleDecoderWithSubripParserTest.java (i.e. legacy subtitle flow is removed) @VisibleForTesting(otherwise = VisibleForTesting.Companion.PRIVATE) fun getFractionalPositionForAnchorType(anchorType: @AnchorType Int): Float { return when (anchorType) { Cue.ANCHOR_TYPE_START -> START_FRACTION Cue.ANCHOR_TYPE_MIDDLE -> MID_FRACTION Cue.ANCHOR_TYPE_END -> END_FRACTION Cue.TYPE_UNSET -> // Should never happen. throw IllegalArgumentException() else -> throw IllegalArgumentException() } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/CustomSubtitleDecoderFactory.kt ================================================ package com.lagradost.cloudstream3.ui.player import android.content.Context import android.text.Layout import android.util.Log import androidx.annotation.OptIn import androidx.media3.common.Format import androidx.media3.common.MimeTypes import androidx.media3.common.text.Cue import androidx.media3.common.util.Consumer import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.text.SubtitleDecoderFactory import androidx.media3.extractor.text.CuesWithTiming import androidx.media3.extractor.text.SimpleSubtitleDecoder import androidx.media3.extractor.text.Subtitle import androidx.media3.extractor.text.SubtitleDecoder import androidx.media3.extractor.text.SubtitleParser import androidx.media3.extractor.text.dvb.DvbParser import androidx.media3.extractor.text.pgs.PgsParser import androidx.media3.extractor.text.ssa.SsaParser import androidx.media3.extractor.text.ttml.TtmlParser import androidx.media3.extractor.text.tx3g.Tx3gParser import androidx.media3.extractor.text.webvtt.Mp4WebvttParser import androidx.media3.extractor.text.webvtt.WebvttParser import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import org.mozilla.universalchardet.UniversalDetector import java.lang.ref.WeakReference import java.nio.charset.Charset /** * @param fallbackFormat used to create a decoder based on mimetype if the subtitle string is not * enough to identify the subtitle format. */ @OptIn(UnstableApi::class) class CustomDecoder(private val fallbackFormat: Format?) : SubtitleParser { companion object { fun updateForcedEncoding(context: Context) { val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val value = settingsManager.getString( context.getString(R.string.subtitles_encoding_key), null ) overrideEncoding = if (value.isNullOrBlank()) { null } else { value } } private const val DEFAULT_MARGIN: Float = 0.05f const val SSA_ALIGNMENT_BOTTOM_LEFT = 1 const val SSA_ALIGNMENT_BOTTOM_CENTER = 2 const val SSA_ALIGNMENT_BOTTOM_RIGHT = 3 const val SSA_ALIGNMENT_MIDDLE_LEFT = 4 const val SSA_ALIGNMENT_MIDDLE_CENTER = 5 const val SSA_ALIGNMENT_MIDDLE_RIGHT = 6 const val SSA_ALIGNMENT_TOP_LEFT = 7 const val SSA_ALIGNMENT_TOP_CENTER = 8 const val SSA_ALIGNMENT_TOP_RIGHT = 9 /** Subtitle offset in milliseconds */ var subtitleOffset: Long = 0 private const val UTF_8 = "UTF-8" private const val TAG = "CustomDecoder" private var overrideEncoding: String? = null val style: SaveCaptionStyle get() = SubtitlesFragment.getCurrentSavedStyle() private val locationRegex = Regex("""\{\\an(\d+)\}""", RegexOption.IGNORE_CASE) val bloatRegex = listOf( Regex( """Support\s+us\s+and\s+become\s+VIP\s+member\s+to\s+remove\s+all\s+ads\s+from\s+(www\.|)OpenSubtitles(\.org|)""", RegexOption.IGNORE_CASE ), Regex( """Please\s+rate\s+this\s+subtitle\s+at\s+.*\s+Help\s+other\s+users\s+to\s+choose\s+the\s+best\s+subtitles""", RegexOption.IGNORE_CASE ), Regex( """Contact\s(www\.|)OpenSubtitles(\.org|)\s+today""", RegexOption.IGNORE_CASE ), Regex( """Advertise\s+your\s+product\s+or\s+brand\s+here""", RegexOption.IGNORE_CASE ), ) //https://emptycharacter.com/ //https://www.fileformat.info/info/unicode/char/200b/index.htm fun trimStr(string: String): String { return string.trimStart().trim('\uFEFF', '\u200B').replace( Regex("[\u00A0\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u205F]"), " " ) } private fun computeDefaultLineOrPosition(@Cue.AnchorType anchor: Int) = when (anchor) { Cue.ANCHOR_TYPE_START -> DEFAULT_MARGIN Cue.ANCHOR_TYPE_MIDDLE -> 0.5f Cue.ANCHOR_TYPE_END -> 1.0f - DEFAULT_MARGIN Cue.TYPE_UNSET -> Cue.DIMEN_UNSET else -> Cue.DIMEN_UNSET } /** * Fixes alignment for cues with {\anX}, * this is common for .vtt that should be parsed as .srt * * ``` * WEBVTT * * 00:00.000 --> 00:01.000 * {\an1}Label 1 * * 00:01.000 --> 00:02.000 * {\an2}Label 2 * * 00:02.000 --> 00:03.000 * {\an3}Label 3 * * 00:03.000 --> 00:04.000 * {\an4}Label 4 * * 00:04.000 --> 00:05.000 * {\an5}Label 5 * * 00:05.000 --> 00:06.000 * {\an6}Label 6 * * 00:06.000 --> 00:07.000 * {\an7}Label 7 * * 00:07.000 --> 00:08.000 * {\an8}Label 8 * * 00:08.000 --> 00:09.000 * {\an9}Label 9 * ``` */ fun Cue.Builder.fixSubtitleAlignment(): Cue.Builder { var trimmed = text?.trim() ?: return this // https://github.com/androidx/media/blob/main/libraries/extractor/src/main/java/androidx/media3/extractor/text/ssa/SsaStyle.java // exoplayer can already parse this, however for eg webvtt it fails locationRegex.find(trimmed)?.groupValues?.get(1)?.toIntOrNull()?.let { alignment -> // toLineAnchor this.setSubtitleAlignment(alignment) } // remove all matches, so we do not display \anx trimmed = trimmed.replace(locationRegex, "") setText(trimmed) return this } fun Cue.Builder.setSubtitleAlignment(alignment: Int?): Cue.Builder { if (alignment == null) return this when (alignment) { SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_BOTTOM_RIGHT -> Cue.ANCHOR_TYPE_END SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_MIDDLE_RIGHT -> Cue.ANCHOR_TYPE_MIDDLE SSA_ALIGNMENT_TOP_LEFT, SSA_ALIGNMENT_TOP_CENTER, SSA_ALIGNMENT_TOP_RIGHT -> Cue.ANCHOR_TYPE_START else -> null }?.let { anchor -> setLineAnchor(anchor) setLine( computeDefaultLineOrPosition(anchor), Cue.LINE_TYPE_FRACTION ) } // toPositionAnchor when (alignment) { SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_TOP_LEFT -> Cue.ANCHOR_TYPE_START SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_TOP_CENTER -> Cue.ANCHOR_TYPE_MIDDLE SSA_ALIGNMENT_BOTTOM_RIGHT, SSA_ALIGNMENT_MIDDLE_RIGHT, SSA_ALIGNMENT_TOP_RIGHT -> Cue.ANCHOR_TYPE_END else -> null }?.let { anchor -> setPositionAnchor(anchor) setPosition(computeDefaultLineOrPosition(anchor)) } // toTextAlignment when (alignment) { SSA_ALIGNMENT_BOTTOM_LEFT, SSA_ALIGNMENT_MIDDLE_LEFT, SSA_ALIGNMENT_TOP_LEFT -> Layout.Alignment.ALIGN_NORMAL SSA_ALIGNMENT_BOTTOM_CENTER, SSA_ALIGNMENT_MIDDLE_CENTER, SSA_ALIGNMENT_TOP_CENTER -> Layout.Alignment.ALIGN_CENTER SSA_ALIGNMENT_BOTTOM_RIGHT, SSA_ALIGNMENT_MIDDLE_RIGHT, SSA_ALIGNMENT_TOP_RIGHT -> Layout.Alignment.ALIGN_OPPOSITE else -> null }?.let { anchor -> setTextAlignment(anchor) } return this } } private var realDecoder: SubtitleParser? = null private fun getStr(byteArray: ByteArray): Pair { val encoding = try { val encoding = overrideEncoding ?: run { val detector = UniversalDetector() detector.handleData(byteArray, 0, byteArray.size) detector.dataEnd() detector.detectedCharset // "windows-1256" } Log.i( TAG, "Detected encoding with charset $encoding and override = $overrideEncoding" ) encoding ?: UTF_8 } catch (e: Exception) { Log.e(TAG, "Failed to detect encoding throwing error") logError(e) UTF_8 } return try { val set = charset(encoding) Pair(String(byteArray, set), set) } catch (e: Exception) { Log.e(TAG, "Failed to parse using encoding $encoding") logError(e) Pair(byteArray.decodeToString(), charset(UTF_8)) } } private fun getSubtitleParser(data: String): SubtitleParser? { // This way we read the subtitle file and decide what decoder to use instead of relying fully on mimetype // First we remove all invisible characters at the start, this is an issue in some subtitle files // Cntrl is control characters: https://en.wikipedia.org/wiki/Unicode_control_characters // Cf is formatting characters: https://www.compart.com/en/unicode/category/Cf val controlCharsRegex = Regex("""[\p{Cntrl}\p{Cf}]""") val trimmedText = data.trimStart { it.isWhitespace() || controlCharsRegex.matches(it.toString()) } //https://github.com/LagradOst/CloudStream-2/blob/ddd774ee66810137ff7bd65dae70bcf3ba2d2489/CloudStreamForms/CloudStreamForms/Script/MainChrome.cs#L388 val subtitleParser = when { // "WEBVTT" can be hidden behind invisible characters not filtered by trim trimmedText.substring(0, 10).contains("WEBVTT", ignoreCase = true) -> WebvttParser() trimmedText.startsWith(" TtmlParser() (trimmedText.startsWith( "[Script Info]", ignoreCase = true ) || trimmedText.startsWith( "Title:", ignoreCase = true )) -> SsaParser(fallbackFormat?.initializationData) trimmedText.startsWith("1", ignoreCase = true) -> CustomSubripParser() fallbackFormat != null -> { when (fallbackFormat.sampleMimeType) { MimeTypes.TEXT_VTT -> WebvttParser() MimeTypes.TEXT_SSA -> SsaParser(fallbackFormat.initializationData) MimeTypes.APPLICATION_MP4VTT -> Mp4WebvttParser() MimeTypes.APPLICATION_TTML -> TtmlParser() MimeTypes.APPLICATION_SUBRIP -> CustomSubripParser() MimeTypes.APPLICATION_TX3G -> Tx3gParser(fallbackFormat.initializationData) // These decoders are not converted to parsers yet // TODO // MimeTypes.APPLICATION_CEA608, MimeTypes.APPLICATION_MP4CEA608 -> Cea608Decoder( // mimeType, // fallbackFormat.accessibilityChannel, // Cea608Decoder.MIN_DATA_CHANNEL_TIMEOUT_MS // ) // MimeTypes.APPLICATION_CEA708 -> Cea708Decoder( // fallbackFormat.accessibilityChannel, // fallbackFormat.initializationData // ) MimeTypes.APPLICATION_DVBSUBS -> DvbParser(fallbackFormat.initializationData) MimeTypes.APPLICATION_PGS -> PgsParser() else -> null } } else -> null } return subtitleParser } val currentSubtitleCues = mutableListOf() override fun parse( data: ByteArray, offset: Int, length: Int, outputOptions: SubtitleParser.OutputOptions, output: Consumer ) { val currentStyle = style val customOutput = Consumer { cue -> val newCue = CuesWithTiming(cue.cues, cue.startTimeUs, cue.durationUs) // Do not apply the offset to the currentSubtitleCues as those are then used for sync subs currentSubtitleCues.add( SubtitleCue( newCue.startTimeUs / 1000, newCue.durationUs / 1000, newCue.cues.map { it.text.toString() }) ) // offset timing for the final val updatedCues = CuesWithTiming( newCue.cues, newCue.startTimeUs - subtitleOffset.times(1000), newCue.durationUs ) output.accept(updatedCues) } Log.i(TAG, "Parse subtitle, current parser: $realDecoder") try { val inputString = getStr(data).first Log.i(TAG, "Subtitle preview: ${inputString.substring(0, 30)}") if (inputString.isNotBlank()) { var str: String = trimStr(inputString) realDecoder = realDecoder ?: getSubtitleParser(inputString) Log.i( TAG, "Parser selected: $realDecoder" ) realDecoder?.let { decoder -> if (decoder !is SsaParser) { if (currentStyle.removeBloat) bloatRegex.forEach { rgx -> str = str.replace(rgx, "\n") } if (currentStyle.upperCase) { str = str.uppercase() } } } val array = str.toByteArray() realDecoder?.parse( array, minOf(array.size, offset), minOf(array.size, length), outputOptions, customOutput ) } } catch (e: Exception) { logError(e) } } override fun getCueReplacementBehavior(): Int { // CUE_REPLACEMENT_BEHAVIOR_REPLACE seems most compatible, change if required return realDecoder?.cueReplacementBehavior ?: Format.CUE_REPLACEMENT_BEHAVIOR_REPLACE } override fun reset() { currentSubtitleCues.clear() super.reset() } } /** See https://github.com/google/ExoPlayer/blob/release-v2/library/core/src/main/java/com/google/android/exoplayer2/text/SubtitleDecoderFactory.java */ @OptIn(UnstableApi::class) class CustomSubtitleDecoderFactory : SubtitleDecoderFactory { override fun supportsFormat(format: Format): Boolean { return listOf( MimeTypes.TEXT_VTT, MimeTypes.TEXT_SSA, MimeTypes.APPLICATION_TTML, MimeTypes.APPLICATION_MP4VTT, MimeTypes.APPLICATION_SUBRIP, MimeTypes.APPLICATION_TX3G, //MimeTypes.APPLICATION_CEA608, //MimeTypes.APPLICATION_MP4CEA608, //MimeTypes.APPLICATION_CEA708, MimeTypes.APPLICATION_DVBSUBS, MimeTypes.APPLICATION_PGS, //MimeTypes.TEXT_EXOPLAYER_CUES ).contains(format.sampleMimeType) } private var latestDecoder: WeakReference? = null fun getSubtitleCues(): List? { return latestDecoder?.get()?.currentSubtitleCues } /** * Decoders created here persists across reset() * Do not save state in the decoder which you want to reset (e.g subtitle offset) */ override fun createDecoder(format: Format): SubtitleDecoder { val parser = CustomDecoder(format) // Allow garbage collection if player releases the decoder latestDecoder = WeakReference(parser) return DelegatingSubtitleDecoder( parser::class.simpleName + "Decoder", parser ) } } /** We need to convert the newer SubtitleParser to an older SubtitleDecoder */ @OptIn(UnstableApi::class) class DelegatingSubtitleDecoder(name: String, private val parser: SubtitleParser) : SimpleSubtitleDecoder(name) { override fun decode(data: ByteArray, length: Int, reset: Boolean): Subtitle { if (reset) { parser.reset() } return parser.parseToLegacySubtitle(data, 0, length); } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadFileGenerator.kt ================================================ package com.lagradost.cloudstream3.ui.player import android.net.Uri import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF import com.lagradost.cloudstream3.utils.SubtitleUtils.cleanDisplayName import com.lagradost.cloudstream3.utils.SubtitleUtils.isMatchingSubtitle import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo class DownloadFileGenerator( episodes: List, currentIndex: Int = 0 ) : VideoGenerator(episodes, currentIndex) { override val hasCache = false override val canSkipLoading = false override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, offset: Int, isCasting: Boolean ): Boolean { val meta = getCurrent(offset) ?: return false if (meta.uri == Uri.EMPTY) { // We do this here so that we only load it when // we actually need it as it can be more expensive. val info = meta.id?.let { id -> activity?.let { act -> getDownloadFileInfo(act, id) } } if (info != null) { val newMeta = meta.copy(uri = info.path) callback(null to newMeta) } else callback(null to meta) } else callback(null to meta) val ctx = context ?: return true val relative = meta.relativePath ?: return true val display = meta.displayName ?: return true val cleanDisplay = cleanDisplayName(display) getFolder(ctx, relative, meta.basePath)?.forEach { (name, uri) -> if (isMatchingSubtitle(name, display, cleanDisplay)) { val cleanName = cleanDisplayName(name) val lastNum = Regex(" ([0-9]+)$") val nameSuffix = lastNum.find(cleanName)?.groupValues?.get(1) ?: "" val originalName = cleanName.removePrefix(cleanDisplay).replace(lastNum, "").trim() subtitleCallback( SubtitleData( originalName.ifBlank { ctx.getString(R.string.default_subtitles) }, nameSuffix, uri.toString(), SubtitleOrigin.DOWNLOADED_FILE, name.toSubtitleMimeType(), emptyMap(), fromLanguageToTagIETF(originalName, true) ) ) } } return true } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/DownloadedPlayerActivity.kt ================================================ package com.lagradost.cloudstream3.ui.player import android.content.Intent import android.os.Bundle import android.util.Log import android.view.KeyEvent import androidx.appcompat.app.AppCompatActivity import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playLink import com.lagradost.cloudstream3.ui.player.OfflinePlaybackHelper.playUri import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.UIHelper.enableEdgeToEdgeCompat class DownloadedPlayerActivity : AppCompatActivity() { private val dTAG = "DownloadedPlayerAct" override fun dispatchKeyEvent(event: KeyEvent): Boolean = CommonActivity.dispatchKeyEvent(this, event) ?: super.dispatchKeyEvent(event) override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean = CommonActivity.onKeyDown(this, keyCode, event) ?: super.onKeyDown(keyCode, event) override fun onUserLeaveHint() { super.onUserLeaveHint() CommonActivity.onUserLeaveHint(this) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) CommonActivity.loadThemes(this) CommonActivity.init(this) enableEdgeToEdgeCompat() setContentView(R.layout.empty_layout) Log.i(dTAG, "onCreate") val data = intent.data if (OfflinePlaybackHelper.playIntent(activity = this, intent = intent)) { return } if (intent?.action == Intent.ACTION_SEND || intent?.action == Intent.ACTION_OPEN_DOCUMENT || intent?.action == Intent.ACTION_VIEW) { val extraText = safe { // I dont trust android intent.getStringExtra(Intent.EXTRA_TEXT) } val cd = intent.clipData val item = if (cd != null && cd.itemCount > 0) cd.getItemAt(0) else null val url = item?.text?.toString() // idk what I am doing, just hope any of these work if (item?.uri != null) playUri(this, item.uri) else if (url != null) playLink(this, url) else if (data != null) playUri(this, data) else if (extraText != null) playLink(this, extraText) else { finish() return } } else if (data?.scheme == "content") { playUri(this, data) } else { finish() return } attachBackPressedCallback("DownloadedPlayerActivity") { finish() } } override fun onResume() { super.onResume() CommonActivity.setActivityInstance(this) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/ExtractorLinkGenerator.kt ================================================ package com.lagradost.cloudstream3.ui.player import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType class ExtractorLinkGenerator( private val links: List, private val subtitles: List, ) : NoVideoGenerator() { override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, offset: Int, isCasting: Boolean ): Boolean { subtitles.forEach(subtitleCallback) links.forEach { if(sourceTypes.contains(it.type)) { callback.invoke(it to null) } } return true } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/FixedNextRenderersFactory.kt ================================================ package com.lagradost.cloudstream3.ui.player import android.content.Context import android.os.Looper import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.Renderer import androidx.media3.exoplayer.text.TextOutput import androidx.media3.exoplayer.text.TextRenderer import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory @UnstableApi class FixedNextRenderersFactory(context: Context) : NextRenderersFactory(context) { /** Somehow the nextlib authors decided that we need a text renderer that causes * "ERROR_CODE_FAILED_RUNTIME_CHECK". * * Core issue: https://github.com/anilbeesetti/nextlib/pull/158 * Comment: https://github.com/recloudstream/cloudstream/pull/2342#issuecomment-3917751718 * */ override fun buildTextRenderers( context: Context, output: TextOutput, outputLooper: Looper, extensionRendererMode: Int, out: ArrayList ) { out.add(TextRenderer(output, outputLooper)) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/FullScreenPlayer.kt ================================================ package com.lagradost.cloudstream3.ui.player import android.animation.ObjectAnimator import android.animation.ValueAnimator import android.annotation.SuppressLint import android.app.Activity import android.app.Dialog import android.content.Context import android.content.DialogInterface import android.content.pm.ActivityInfo import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.Color import android.graphics.Matrix import android.media.AudioManager import android.media.audiofx.LoudnessEnhancer import android.os.Build import android.os.Bundle import android.os.Handler import android.os.Looper import android.provider.Settings import android.text.Editable import android.text.format.DateUtils import android.view.KeyEvent import android.view.LayoutInflater import android.view.MotionEvent import android.view.ScaleGestureDetector import android.view.Surface import android.view.View import android.view.ViewGroup import android.view.WindowInsets import android.view.WindowManager import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES import android.view.animation.AccelerateDecelerateInterpolator import android.view.animation.AlphaAnimation import android.view.animation.Animation import android.view.animation.AnimationUtils import android.widget.LinearLayout import androidx.annotation.OptIn import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.graphics.blue import androidx.core.graphics.green import androidx.core.graphics.red import androidx.core.view.children import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.AspectRatioFrameLayout import androidx.preference.PreferenceManager import androidx.recyclerview.widget.SimpleItemAnimator import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CommonActivity.keyEventListener import com.lagradost.cloudstream3.CommonActivity.playerEventListener import com.lagradost.cloudstream3.CommonActivity.screenHeightWithOrientation import com.lagradost.cloudstream3.CommonActivity.screenWidthWithOrientation import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.PlayerCustomLayoutBinding import com.lagradost.cloudstream3.databinding.SpeedDialogBinding import com.lagradost.cloudstream3.databinding.SubtitleOffsetBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.ui.player.GeneratorPlayer.Companion.subsProvidersIsActive import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.isUsingMobileData import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getNavigationBarHeight import com.lagradost.cloudstream3.utils.UIHelper.getStatusBarHeight import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.showSystemUI import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.UserPreferenceDelegate import com.lagradost.cloudstream3.utils.Vector2 import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt import kotlin.math.abs import kotlin.math.absoluteValue import kotlin.math.ceil import kotlin.math.max import kotlin.math.min import kotlin.math.round import kotlin.math.roundToInt // You can zoom out more than 100%, but it will zoom back into 100% const val MINIMUM_ZOOM = 0.95f // How sensitive the auto zoom is to center at the min zoom const val ZOOM_SNAP_SENSITIVITY = 0.07f // Maximum zoom to avoid getting lost const val MAXIMUM_ZOOM = 4.0f const val MINIMUM_SEEK_TIME = 7000L // when swipe seeking const val MINIMUM_VERTICAL_SWIPE = 2.0f // in percentage const val MINIMUM_HORIZONTAL_SWIPE = 2.0f // in percentage const val VERTICAL_MULTIPLIER = 2.0f const val HORIZONTAL_MULTIPLIER = 2.0f const val DOUBLE_TAB_MAXIMUM_HOLD_TIME = 200L const val DOUBLE_TAB_MINIMUM_TIME_BETWEEN = 200L // this also affects the UI show response time const val DOUBLE_TAB_PAUSE_PERCENTAGE = 0.15 // in both directions private const val SUBTITLE_DELAY_BUNDLE_KEY = "subtitle_delay" // All the UI Logic for the player @OptIn(UnstableApi::class) open class FullScreenPlayer : AbstractPlayerFragment() { private var isVerticalOrientation: Boolean = false protected open var lockRotation = true protected open var isFullScreenPlayer = true protected var playerBinding: PlayerCustomLayoutBinding? = null protected var brightnessOverlay: View? = null private var durationMode: Boolean by UserPreferenceDelegate("duration_mode", false) // state of player UI protected var isShowing = false private var uiShowingBeforeGesture = false protected var isLocked = false protected var timestampShowState = false protected var hasEpisodes = false private set // protected val hasEpisodes // get() = episodes.isNotEmpty() // options for player /** * Default profile 1 * Decides how links should be sorted based on a priority system. * This will be set in runtime based on settings. **/ protected var currentQualityProfile = 1 // protected var currentPrefQuality = // Qualities.P2160.value // preferred maximum quality, used for ppl w bad internet or on cell protected var extraBrightnessEnabled = false protected var fastForwardTime = 10000L protected var androidTVInterfaceOffSeekTime = 10000L protected var androidTVInterfaceOnSeekTime = 30000L protected var swipeHorizontalEnabled = false protected var swipeVerticalEnabled = false protected var playBackSpeedEnabled = false protected var playerResizeEnabled = false protected var doubleTapEnabled = false protected var doubleTapPauseEnabled = true protected var playerRotateEnabled = false protected var rotatedManually = false protected var autoPlayerRotateEnabled = false private var hideControlsNames = false protected var speedupEnabled = false protected var subtitleDelay set(value) = try { player.setSubtitleOffset(-value) } catch (e: Exception) { logError(e) } get() = try { -player.getSubtitleOffset() } catch (e: Exception) { logError(e) 0L } // private var useSystemBrightness = false protected var useTrueSystemBrightness = true private val fullscreenNotch = true // TODO SETTING private var statusBarHeight: Int? = null private var navigationBarHeight: Int? = null private val brightnessIcons = listOf( R.drawable.sun_1, R.drawable.sun_2, R.drawable.sun_3, R.drawable.sun_4, R.drawable.sun_5, R.drawable.sun_6, R.drawable.sun_7, // R.drawable.ic_baseline_brightness_1_24, // R.drawable.ic_baseline_brightness_2_24, // R.drawable.ic_baseline_brightness_3_24, // R.drawable.ic_baseline_brightness_4_24, // R.drawable.ic_baseline_brightness_5_24, // R.drawable.ic_baseline_brightness_6_24, // R.drawable.ic_baseline_brightness_7_24, ) private val volumeIcons = listOf( R.drawable.ic_baseline_volume_mute_24, R.drawable.ic_baseline_volume_down_24, R.drawable.ic_baseline_volume_up_24, ) private var isShowingEpisodeOverlay: Boolean = false private var previousPlayStatus: Boolean = false override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null playerBinding = PlayerCustomLayoutBinding.bind(root.findViewById(R.id.player_holder)) // Inject the overlay from a separate XML into the PlayerView content frame safe { val pv = root.findViewById(R.id.player_view) val packageName = context?.packageName ?: return@safe val contentId = resources.getIdentifier("exo_content_frame", "id", packageName) val contentFrame = pv?.findViewById(contentId) if (contentFrame != null) { brightnessOverlay = contentFrame.findViewById(R.id.extra_brightness_overlay) brightnessOverlay = LayoutInflater.from(context).inflate( R.layout.extra_brightness_overlay, contentFrame, false ) contentFrame.addView(brightnessOverlay) requestUpdateBrightnessOverlayOnNextLayout() } } return root } @SuppressLint("UnsafeOptInUsageError") override fun playerUpdated(player: Any?) { super.playerUpdated(player) } override fun onDestroyView() { // Clean up brightness overlay if created safe { // remove overlay if present brightnessOverlay?.let { overlay -> val oParent = overlay.parent as? ViewGroup oParent?.removeView(overlay) } } brightnessOverlay = null playerBinding = null super.onDestroyView() } /** * Resize/position the brightness overlay to exactly match the visible video surface. * This copies the video surface size, scale and translation so the overlay won't cover * letterbox/pillarbox areas when zooming or panning. */ private fun updateBrightnessOverlayBounds() { val overlay = brightnessOverlay ?: return val pv = playerView ?: return val video = pv.videoSurfaceView ?: return // Compute accurate transformed bounding box of the video view after scale+translation val vw = video.width.toFloat() val vh = video.height.toFloat() val sx = video.scaleX val sy = video.scaleY if (vw > 0f && vh > 0f) { // pivot defaults to center if not set val pivotX = if (video.pivotX != 0f) video.pivotX else vw * 0.5f val pivotY = if (video.pivotY != 0f) video.pivotY else vh * 0.5f // Use view position (includes translation) as base; avoid double-counting translation val tx = video.x val ty = video.y // transform function for a local point (lx,ly) fun transform(lx: Float, ly: Float): Pair { val gx = tx + pivotX + (lx - pivotX) * sx val gy = ty + pivotY + (ly - pivotY) * sy return Pair(gx, gy) } val p0 = transform(0f, 0f) val p1 = transform(vw, 0f) val p2 = transform(0f, vh) val p3 = transform(vw, vh) val minX = min(min(p0.first, p1.first), min(p2.first, p3.first)) val maxX = max(max(p0.first, p1.first), max(p2.first, p3.first)) val minY = min(min(p0.second, p1.second), min(p2.second, p3.second)) val maxY = max(max(p0.second, p1.second), max(p2.second, p3.second)) val newW = ceil(maxX - minX).toInt().coerceAtLeast(0) val newH = ceil(maxY - minY).toInt().coerceAtLeast(0) val lp = overlay.layoutParams if (lp == null) { overlay.layoutParams = ViewGroup.LayoutParams(newW, newH) } else { if (lp.width != newW || lp.height != newH) { lp.width = newW lp.height = newH overlay.layoutParams = lp } } overlay.scaleX = 1.0f overlay.scaleY = 1.0f overlay.x = minX overlay.y = minY } } /** * Ensure the overlay is updated once the next layout pass completes. * Adds a one-time global layout listener (PiP/resizing/rotation frames). */ private fun requestUpdateBrightnessOverlayOnNextLayout() { val pv = playerView ?: return safe { val obs = pv.viewTreeObserver val listener = object : android.view.ViewTreeObserver.OnGlobalLayoutListener { override fun onGlobalLayout() { safe { updateBrightnessOverlayBounds() } if (obs.isAlive) { obs.removeOnGlobalLayoutListener(this) } } } if (obs.isAlive) obs.addOnGlobalLayoutListener(listener) } } open fun showMirrorsDialogue() { throw NotImplementedError() } open fun showTracksDialogue() { throw NotImplementedError() } open fun openOnlineSubPicker( context: Context, loadResponse: LoadResponse?, dismissCallback: (() -> Unit) ) { throw NotImplementedError() } open fun showEpisodesOverlay() { throw NotImplementedError() } open fun isThereEpisodes(): Boolean { return false } /** * [isValidTouch] should be called on a [View] spanning across the screen for reliable results. * * Android has supported gesture navigation properly since API-30. We get the absolute screen dimens using * [WindowManager.getCurrentWindowMetrics] and remove the stable insets * {[WindowInsets.getInsetsIgnoringVisibility]} to get a safe perimeter. * This approach supports any and all types of necessary system insets. * * @return false if the touch is on the status bar or navigation bar * */ private fun View.isValidTouch(rawX: Float, rawY: Float): Boolean { // NOTE: screenWidth is without the navbar width when 3button nav is turned on. if (Build.VERSION.SDK_INT >= 30) { // real = absolute dimen without any default deductions like navbar width val windowMetrics = (context?.getSystemService(Context.WINDOW_SERVICE) as? WindowManager)?.currentWindowMetrics val realScreenHeight = windowMetrics?.let { windowMetrics.bounds.bottom - windowMetrics.bounds.top } ?: screenHeightWithOrientation val realScreenWidth = windowMetrics?.let { windowMetrics.bounds.right - windowMetrics.bounds.left } ?: screenWidthWithOrientation val insets = rootWindowInsets.getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()) val isOutsideHeight = rawY < insets.top || rawY > (realScreenHeight - insets.bottom) val isOutsideWidth = if (windowMetrics == null) { rawX < screenWidthWithOrientation } else rawX < insets.left || rawX > realScreenWidth - insets.right return !(isOutsideWidth || isOutsideHeight) } else { val statusHeight = statusBarHeight ?: 0 return rawY > statusHeight && rawX < screenWidthWithOrientation } } override fun exitedPipMode() { animateLayoutChanges() } private fun animateLayoutChangesForSubtitles() = // Post here as bottomPlayerBar is gone the first frame => bottomPlayerBar.height = 0 playerBinding?.bottomPlayerBar?.post { val sView = subView ?: return@post val sStyle = CustomDecoder.style val binding = playerBinding ?: return@post val move = if (isShowing) minOf( // We do not want to drag down subtitles if the subtitle elevation is large -sStyle.elevation.toPx, // The lib uses Invisible instead of Gone for no reason binding.previewFrameLayout.height - binding.bottomPlayerBar.height ) else -sStyle.elevation.toPx ObjectAnimator.ofFloat(sView, "translationY", move.toFloat()).apply { duration = 200 start() } } protected fun animateLayoutChanges() { if (isLayout(PHONE)) { // isEnabled also disables the onKeyDown playerBinding?.exoProgress?.isEnabled = isShowing // Prevent accidental clicks/drags } if (isShowing) { updateUIVisibility() } else { toggleEpisodesOverlay(false) playerBinding?.playerHolder?.postDelayed({ updateUIVisibility() }, 200) } val titleMove = if (isShowing) 0f else -50.toPx.toFloat() playerBinding?.playerVideoTitleHolder?.let { ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { duration = 200 start() } } playerBinding?.playerVideoTitleRez?.let { ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { duration = 200 start() } } playerBinding?.playerVideoInfo?.let { ObjectAnimator.ofFloat(it, "translationY", titleMove).apply { duration = 200 start() } } val playerBarMove = if (isShowing) 0f else 50.toPx.toFloat() playerBinding?.bottomPlayerBar?.let { ObjectAnimator.ofFloat(it, "translationY", playerBarMove).apply { duration = 200 start() } } if (isLayout(PHONE)) { playerBinding?.playerEpisodesButton?.let { ObjectAnimator.ofFloat(it, "translationX", if (isShowing) 0f else 50.toPx.toFloat()) .apply { duration = 200 start() } } } val fadeTo = if (isShowing) 1f else 0f val fadeAnimation = AlphaAnimation(1f - fadeTo, fadeTo) fadeAnimation.duration = 100 fadeAnimation.fillAfter = true animateLayoutChangesForSubtitles() val playerSourceMove = if (isShowing) 0f else -50.toPx.toFloat() playerBinding?.apply { playerOpenSource.let { ObjectAnimator.ofFloat(it, "translationY", playerSourceMove).apply { duration = 200 start() } } if (!isLocked) { playerFfwdHolder.alpha = 1f playerRewHolder.alpha = 1f // player_pause_play_holder?.alpha = 1f shadowOverlay.isVisible = true shadowOverlay.startAnimation(fadeAnimation) playerFfwdHolder.startAnimation(fadeAnimation) playerRewHolder.startAnimation(fadeAnimation) playerPausePlay.startAnimation(fadeAnimation) downloadBothHeader.startAnimation(fadeAnimation) /*if (isBuffering) { player_pause_play?.isVisible = false player_pause_play_holder?.isVisible = false } else { player_pause_play?.isVisible = true player_pause_play_holder?.startAnimation(fadeAnimation) player_pause_play?.startAnimation(fadeAnimation) }*/ // player_buffering?.startAnimation(fadeAnimation) } bottomPlayerBar.startAnimation(fadeAnimation) playerOpenSource.startAnimation(fadeAnimation) playerTopHolder.startAnimation(fadeAnimation) } } override fun subtitlesChanged() { val tracks = player.getVideoTracks() val isBuiltinSubtitles = tracks.currentTextTracks.all { track -> track.sampleMimeType == MimeTypes.APPLICATION_MEDIA3_CUES } // Subtitle offset is not possible on built-in media3 tracks playerBinding?.playerSubtitleOffsetBtt?.isGone = isBuiltinSubtitles || tracks.currentTextTracks.isEmpty() } private fun restoreOrientationWithSensor(activity: Activity) { val currentOrientation = activity.resources.configuration.orientation val orientation = when (currentOrientation) { Configuration.ORIENTATION_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT else -> dynamicOrientation() } activity.requestedOrientation = orientation } private fun toggleOrientationWithSensor(activity: Activity) { val currentOrientation = activity.resources.configuration.orientation val orientation: Int = when (currentOrientation) { Configuration.ORIENTATION_LANDSCAPE -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT Configuration.ORIENTATION_PORTRAIT -> ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE else -> dynamicOrientation() } activity.requestedOrientation = orientation } open fun lockOrientation(activity: Activity) { @Suppress("DEPRECATION") val display = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) (activity.getSystemService(Context.WINDOW_SERVICE) as WindowManager).defaultDisplay else activity.display!! val rotation = display.rotation val currentOrientation = activity.resources.configuration.orientation val orientation: Int when (currentOrientation) { Configuration.ORIENTATION_LANDSCAPE -> orientation = if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_90) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE else ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE Configuration.ORIENTATION_PORTRAIT -> orientation = if (rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_270) ActivityInfo.SCREEN_ORIENTATION_PORTRAIT else ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT else -> orientation = dynamicOrientation() } activity.requestedOrientation = orientation } private fun updateOrientation(ignoreDynamicOrientation: Boolean = false) { activity?.apply { if (lockRotation) { if (isLocked) { lockOrientation(this) } else { if (ignoreDynamicOrientation || rotatedManually) { // restore when lock is disabled restoreOrientationWithSensor(this) } else { this.requestedOrientation = dynamicOrientation() } } } } } protected fun enterFullscreen() { if (isFullScreenPlayer) { activity?.hideSystemUI() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && fullscreenNotch) { val params = activity?.window?.attributes params?.layoutInDisplayCutoutMode = LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES activity?.window?.attributes = params } } updateOrientation() } protected fun exitFullscreen() { resetZoomToDefault() // if (lockRotation) activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER // simply resets brightness and notch settings that might have been overridden val lp = activity?.window?.attributes lp?.screenBrightness = WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { lp?.layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT } activity?.window?.attributes = lp activity?.showSystemUI() } private fun resetZoomToDefault() { if (zoomMatrix != null) resize(PlayerResize.Fit, false) } override fun onResume() { enterFullscreen() verifyVolume() activity?.attachBackPressedCallback("FullScreenPlayer") { if (isShowingEpisodeOverlay) { // isShowingEpisodeOverlay pauses, so this makes it easier to unpause if (isLayout(TV or EMULATOR)) { playerPausePlay?.requestFocus() } toggleEpisodesOverlay(show = false) return@attachBackPressedCallback } else if (isShowing && isLayout(TV or EMULATOR)) { // netflix capture back and hide ~monke onClickChange() } else { activity?.popCurrentPage("FullScreenPlayer") } } requestUpdateBrightnessOverlayOnNextLayout() super.onResume() } override fun onStop() { activity?.detachBackPressedCallback("FullScreenPlayer") super.onStop() } override fun onDestroy() { exitFullscreen() super.onDestroy() } private fun setPlayBackSpeed(speed: Float) { try { DataStoreHelper.playBackSpeed = speed playerBinding?.playerSpeedBtt?.text = getString(R.string.player_speed_text_format).format(speed) .replace(".0x", "x") } catch (e: Exception) { // the format string was wrong logError(e) } player.setPlaybackSpeed(speed) } private fun skipOp() { player.seekTime(85000) // skip 85s } private fun showSubtitleOffsetDialog() { val ctx = context ?: return // Pause player because the subtitles cannot be continuously updated to follow playback. player.handleEvent( CSPlayerEvent.Pause, PlayerEventSource.UI ) val binding = SubtitleOffsetBinding.inflate(LayoutInflater.from(ctx), null, false) // Use dialog as opposed to alertdialog to get fullscreen val dialog = Dialog(ctx, R.style.DialogFullscreenPlayer).apply { setContentView(binding.root) } dialog.show() val isPortrait = ctx.resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT fixSystemBarsPadding(binding.root, fixIme = isPortrait) var currentOffset = subtitleDelay binding.apply { var subtitleAdapter: SubtitleOffsetItemAdapter? = null subtitleOffsetInput.doOnTextChanged { text, _, _, _ -> text?.toString()?.toLongOrNull()?.let { time -> currentOffset = time // Scroll to the first active subtitle val playerPosition = player.getPosition() ?: 0 val totalPosition = playerPosition - currentOffset subtitleAdapter?.updateTime(totalPosition) subtitleAdapter?.getLatestActiveItem(totalPosition) ?.let { subtitlePos -> subtitleOffsetRecyclerview.scrollToPosition(subtitlePos) } val str = when { time > 0L -> { txt(R.string.subtitle_offset_extra_hint_later_format, time) } time < 0L -> { txt(R.string.subtitle_offset_extra_hint_before_format, -time) } else -> { txt(R.string.subtitle_offset_extra_hint_none_format) } } subtitleOffsetSubTitle.setText(str) } } subtitleOffsetInput.text = Editable.Factory.getInstance()?.newEditable(currentOffset.toString()) val subtitles = player.getSubtitleCues().toMutableList() subtitleOffsetRecyclerview.isVisible = subtitles.isNotEmpty() noSubtitlesLoadedNotice.isVisible = subtitles.isEmpty() val initialSubtitlePosition = (player.getPosition() ?: 0) - currentOffset subtitleAdapter = SubtitleOffsetItemAdapter(initialSubtitlePosition) { subtitleCue -> val playerPosition = player.getPosition() ?: 0 subtitleOffsetInput.text = Editable.Factory.getInstance() ?.newEditable((playerPosition - subtitleCue.startTimeMs).toString()) }.apply { submitList(subtitles) } subtitleOffsetRecyclerview.adapter = subtitleAdapter // Prevent flashing changes when changing items (subtitleOffsetRecyclerview.itemAnimator as? SimpleItemAnimator)?.supportsChangeAnimations = false val firstSubtitle = subtitleAdapter.getLatestActiveItem(initialSubtitlePosition) subtitleOffsetRecyclerview.scrollToPosition(firstSubtitle) val buttonChange = 100L val buttonChangeMore = 1000L fun changeBy(by: Long) { val current = (subtitleOffsetInput.text?.toString()?.toLongOrNull() ?: 0) + by subtitleOffsetInput.text = Editable.Factory.getInstance()?.newEditable(current.toString()) } subtitleOffsetAdd.setOnClickListener { changeBy(buttonChange) } subtitleOffsetAddMore.setOnClickListener { changeBy(buttonChangeMore) } subtitleOffsetSubtract.setOnClickListener { changeBy(-buttonChange) } subtitleOffsetSubtractMore.setOnClickListener { changeBy(-buttonChangeMore) } dialog.setOnDismissListener { if (isFullScreenPlayer) activity?.hideSystemUI() } applyBtt.setOnClickListener { subtitleDelay = currentOffset dialog.dismissSafe(activity) player.seekTime(1L) } resetBtt.setOnClickListener { subtitleDelay = 0 dialog.dismissSafe(activity) player.seekTime(1L) } cancelBtt.setOnClickListener { dialog.dismissSafe(activity) } } } @SuppressLint("SetTextI18n") fun updateSpeedDialogBinding(binding: SpeedDialogBinding) { val speed = player.getPlaybackSpeed() binding.speedText.text = "%.2fx".format(speed).replace(".0x", "x") // Android crashes if you don't round to an exact step size binding.speedBar.value = (speed.coerceIn(0.1f, 2.0f) / binding.speedBar.stepSize).roundToInt() .toFloat() * binding.speedBar.stepSize } private fun showSpeedDialog() { val act = activity ?: return val isPlaying = player.getIsPlaying() player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) val binding: SpeedDialogBinding = SpeedDialogBinding.inflate( LayoutInflater.from(act) ) updateSpeedDialogBinding(binding) for ((view, speed) in arrayOf( binding.speed25 to 0.25f, binding.speed100 to 1.0f, binding.speed125 to 1.25f, binding.speed150 to 1.5f, binding.speed200 to 2.0f, )) { view.setOnClickListener { setPlayBackSpeed(speed) updateSpeedDialogBinding(binding) } } binding.speedMinus.setOnClickListener { setPlayBackSpeed(maxOf((player.getPlaybackSpeed() - 0.1f), 0.1f)) updateSpeedDialogBinding(binding) } binding.speedPlus.setOnClickListener { setPlayBackSpeed(minOf((player.getPlaybackSpeed() + 0.1f), 2.0f)) updateSpeedDialogBinding(binding) } binding.speedBar.addOnChangeListener { slider, value, fromUser -> if (fromUser) { setPlayBackSpeed(value) updateSpeedDialogBinding(binding) } } val dismiss = DialogInterface.OnDismissListener { if (isFullScreenPlayer) activity?.hideSystemUI() if (isPlaying) { player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) } } // if (isLayout(PHONE)) { // val builder = // BottomSheetDialog(act, R.style.AlertDialogCustom) // builder.setContentView(binding.root) // builder.setOnDismissListener(dismiss) // builder.show() //} else { val builder = AlertDialog.Builder(act, R.style.AlertDialogCustom) .setView(binding.root) builder.setOnDismissListener(dismiss) val dialog = builder.create() dialog.show() //} } fun resetRewindText() { playerBinding?.exoRewText?.text = getString(R.string.rew_text_regular_format).format(fastForwardTime / 1000) } fun resetFastForwardText() { playerBinding?.exoFfwdText?.text = getString(R.string.ffw_text_regular_format).format(fastForwardTime / 1000) } private fun rewind() { try { playerBinding?.apply { playerCenterMenu.isGone = false playerRewHolder.alpha = 1f val rotateLeft = AnimationUtils.loadAnimation(context, R.anim.rotate_left) playerRew.startAnimation(rotateLeft) val goLeft = AnimationUtils.loadAnimation(context, R.anim.go_left) goLeft.setAnimationListener(object : Animation.AnimationListener { override fun onAnimationStart(animation: Animation?) {} override fun onAnimationRepeat(animation: Animation?) {} override fun onAnimationEnd(animation: Animation?) { exoRewText.post { resetRewindText() playerCenterMenu.isGone = !isShowing playerRewHolder.alpha = if (isShowing) 1f else 0f } } }) exoRewText.startAnimation(goLeft) exoRewText.text = getString(R.string.rew_text_format).format(fastForwardTime / 1000) } player.seekTime(-fastForwardTime) } catch (e: Exception) { logError(e) } } private fun fastForward() { try { playerBinding?.apply { playerCenterMenu.isGone = false playerFfwdHolder.alpha = 1f val rotateRight = AnimationUtils.loadAnimation(context, R.anim.rotate_right) playerFfwd.startAnimation(rotateRight) val goRight = AnimationUtils.loadAnimation(context, R.anim.go_right) goRight.setAnimationListener(object : Animation.AnimationListener { override fun onAnimationStart(animation: Animation?) {} override fun onAnimationRepeat(animation: Animation?) {} override fun onAnimationEnd(animation: Animation?) { exoFfwdText.post { resetFastForwardText() playerCenterMenu.isGone = !isShowing playerFfwdHolder.alpha = if (isShowing) 1f else 0f } } }) exoFfwdText.startAnimation(goRight) exoFfwdText.text = getString(R.string.ffw_text_format).format(fastForwardTime / 1000) } player.seekTime(fastForwardTime) } catch (e: Exception) { logError(e) } } private fun onClickChange() { isShowing = !isShowing if (isShowing) { playerBinding?.playerIntroPlay?.isGone = true autoHide() } if (isFullScreenPlayer) activity?.hideSystemUI() animateLayoutChanges() if (playerBinding?.playerEpisodeOverlay?.isGone == true) playerBinding?.playerPausePlay?.requestFocus() } private fun toggleLock() { if (!isShowing) { onClickChange() } isLocked = !isLocked updateOrientation(true) // set true to ignore auto rotate to stay in current orientation if (isLocked && isShowing) { playerBinding?.playerHolder?.postDelayed({ if (isLocked && isShowing) { onClickChange() } }, 200) } val fadeTo = if (isLocked) 0f else 1f playerBinding?.apply { val fadeAnimation = AlphaAnimation(playerVideoTitleHolder.alpha, fadeTo).apply { duration = 100 fillAfter = true } updateUIVisibility() // MENUS // centerMenu.startAnimation(fadeAnimation) playerPausePlay.startAnimation(fadeAnimation) playerFfwdHolder.startAnimation(fadeAnimation) playerRewHolder.startAnimation(fadeAnimation) downloadBothHeader.startAnimation(fadeAnimation) if (hasEpisodes) playerEpisodesButton.startAnimation(fadeAnimation) // player_media_route_button?.startAnimation(fadeAnimation) // video_bar.startAnimation(fadeAnimation) // TITLE playerVideoTitleRez.startAnimation(fadeAnimation) playerVideoInfo.startAnimation(fadeAnimation) playerEpisodeFiller.startAnimation(fadeAnimation) playerVideoTitleHolder.startAnimation(fadeAnimation) playerTopHolder.startAnimation(fadeAnimation) // BOTTOM playerLockHolder.startAnimation(fadeAnimation) // player_go_back_holder?.startAnimation(fadeAnimation) shadowOverlay.isVisible = true shadowOverlay.startAnimation(fadeAnimation) } updateLockUI() } open fun updateUIVisibility() { val isGone = isLocked || !isShowing var togglePlayerTitleGone = isGone context?.let { val settingsManager = PreferenceManager.getDefaultSharedPreferences(it) val limitTitle = settingsManager.getInt(getString(R.string.prefer_limit_title_key), 0) if (limitTitle < 0) { togglePlayerTitleGone = true } } playerBinding?.apply { playerLockHolder.isGone = isGone playerVideoBar.isGone = isGone playerPausePlay.isGone = isGone // player_buffering?.isGone = isGone playerTopHolder.isGone = isGone val showPlayerEpisodes = !isGone && isThereEpisodes() playerEpisodesButtonRoot.isVisible = showPlayerEpisodes playerEpisodesButton.isVisible = showPlayerEpisodes playerVideoTitleHolder.isGone = togglePlayerTitleGone // player_video_title_rez?.isGone = isGone playerEpisodeFiller.isGone = isGone playerCenterMenu.isGone = isGone playerLock.isGone = !isShowing // player_media_route_button?.isClickable = !isGone playerGoBackHolder.isGone = isGone playerSourcesBtt.isGone = isGone playerSkipEpisode.isClickable = !isGone } } private fun updateLockUI() { playerBinding?.apply { playerLock.setIconResource(if (isLocked) R.drawable.video_locked else R.drawable.video_unlocked) if (layout == R.layout.fragment_player) { val color = if (isLocked) context?.colorFromAttribute(R.attr.colorPrimary) else Color.WHITE if (color != null) { playerLock.setTextColor(color) playerLock.iconTint = ColorStateList.valueOf(color) playerLock.rippleColor = ColorStateList.valueOf(Color.argb(50, color.red, color.green, color.blue)) } } } } private var currentTapIndex = 0 protected fun autoHide() { currentTapIndex++ delayHide() } protected fun hidePlayerUI() { if (isShowing) { isShowing = false animateLayoutChanges() } } override fun playerStatusChanged() { super.playerStatusChanged() delayHide() } private fun delayHide() { val index = currentTapIndex playerBinding?.playerHolder?.postDelayed({ if (!isCurrentTouchValid && isShowing && index == currentTapIndex && player.getIsPlaying()) { onClickChange() } }, 3000) } // this is used because you don't want to hide UI when double tap seeking private var currentDoubleTapIndex = 0 private fun toggleShowDelayed() { if (doubleTapEnabled || doubleTapPauseEnabled) { val index = currentDoubleTapIndex playerBinding?.playerHolder?.postDelayed({ if (index == currentDoubleTapIndex) { onClickChange() } }, DOUBLE_TAB_MINIMUM_TIME_BETWEEN) } else { onClickChange() } } private var isCurrentTouchValid = false private var currentTouchStart: Vector2? = null private var currentTouchLast: Vector2? = null private var currentTouchAction: TouchAction? = null private var currentLastTouchAction: TouchAction? = null private var currentTouchStartPlayerTime: Long? = null // the time in the player when you first click private var currentTouchStartTime: Long? = null // the system time when you first click private var currentLastTouchEndTime: Long = 0 // the system time when you released your finger private var currentClickCount: Int = 0 // amount of times you have double clicked, will reset when other action is taken // requested volume and brightness is used to make swiping smoother // to make it not jump between values, // this value is within the range [0,2] where 1+ is loudness private var currentRequestedVolume: Float = 0.0f // from [0.0f, 1.0f] where 1.0f is max extra brightness, used only to track extra brightness private var currentExtraBrightness: Float = 0.0f // this value is within the range [0,2] where 1+ is extra brightness private var currentRequestedBrightness: Float = 1.0f enum class TouchAction { Brightness, Volume, Time, } companion object { /** * Gets the translationXY + scale form a matrix with no rotation. * * @return (translationX, translationY, scale) * */ fun matrixToTranslationAndScale(matrix: Matrix): Triple { val points = floatArrayOf(0.0f, 0.0f, 1.0f, 1.0f) matrix.mapPoints(points) // A linear matrix will map (0,0) to the translation val translationX = points[0] val translationY = points[1] // The unit vectors (1,0) and (0,1) will map to the scale if you remove the translation // As this assumes a uniform scaling, only a single vector is needed val scaleX = points[2] - translationX val scaleY = points[3] - translationY // The matrix should have the same scaleX and scaleY if (BuildConfig.DEBUG) { assert((scaleX - scaleY).absoluteValue < 0.1f) { "$scaleY != $scaleX" } } return Triple(translationX, translationY, scaleX) } private fun forceLetters(inp: Long, letters: Int = 2): String { val added: Int = letters - inp.toString().length return if (added > 0) { "0".repeat(added) + inp.toString() } else { inp.toString() } } private fun convertTimeToString(sec: Long): String { val rsec = sec % 60L val min = ceil((sec - rsec) / 60.0).toInt() val rmin = min % 60L val h = ceil((min - rmin) / 60.0).toLong() // int rh = h;// h % 24; return (if (h > 0) forceLetters(h) + ":" else "") + (if (rmin >= 0 || h >= 0) forceLetters( rmin ) + ":" else "") + forceLetters( rsec ) } } private fun calculateNewTime( startTime: Long?, touchStart: Vector2?, touchEnd: Vector2? ): Long? { if (touchStart == null || touchEnd == null || startTime == null) return null val diffX = (touchEnd.x - touchStart.x) * HORIZONTAL_MULTIPLIER / screenWidthWithOrientation.toFloat() val duration = player.getDuration() ?: return null return max( min( startTime + ((duration * (diffX * diffX)) * (if (diffX < 0) -1 else 1)).toLong(), duration ), 0 ) } /** * Returns screen brightness in <0.0f, 1.0f> range */ private fun getBrightness(): Float? { return if (useTrueSystemBrightness) { try { Settings.System.getInt( context?.contentResolver, Settings.System.SCREEN_BRIGHTNESS ) / 255f } catch (e: Exception) { // because true system brightness requires // permission, this is a lazy way to check // as it will throw an error if we do not have it useTrueSystemBrightness = false return getBrightness() } } else { try { activity?.window?.attributes?.screenBrightness } catch (e: Exception) { logError(e) null } } } /** * Sets the screen brightness in the range <0.0f, 1.0f>. Values outside this range * will be clamped to the minimum (0.0f) or maximum (1.0f). * * @param brightness desired brightness (values outside the range will be clamped) */ private fun setBrightness(brightness: Float) { if (useTrueSystemBrightness) { try { Settings.System.putInt( context?.contentResolver, Settings.System.SCREEN_BRIGHTNESS_MODE, Settings.System.SCREEN_BRIGHTNESS_MODE_MANUAL ) Settings.System.putInt( context?.contentResolver, Settings.System.SCREEN_BRIGHTNESS, min(1, (brightness.coerceIn(0.0f, 1.0f) * 255).toInt()) ) } catch (e: Exception) { useTrueSystemBrightness = false setBrightness(brightness) } } else { try { val lp = activity?.window?.attributes // use 0.004f instead of 0, because on some devices setting too small value // causes system to override it and in turn system makes the screen apply system brightness level instead // which can be too bright, and it is very hard to fine tune very low brightness, because of it. // Without this clamp, it can jump from almost 0% to 100% brightness when this threshold is crossed. lp?.screenBrightness = brightness.coerceIn(0.004f, 1.0f) // Log.i("Brightness", "clamped brightness: ${lp?.screenBrightness}") activity?.window?.attributes = lp } catch (e: Exception) { logError(e) } } } private var isVolumeLocked: Boolean = false private var hasShownVolumeToast: Boolean = false private var isBrightnessLocked: Boolean = false private var hasShownBrightnessToast: Boolean = false private var progressBarLeftHideRunnable: Runnable? = null private var progressBarRightHideRunnable: Runnable? = null // Verifies that the currentRequestedVolume matches the system volume // if not, then it removes changes currentRequestedVolume and removes the loudnessEnhancer // if the real volume is less than 100% // // This is here to make returning to the player less jarring, if we change the volume outside // the app. Note that this will make it a bit wierd when using loudness in PiP, then returning // however that is the cost of correctness. private fun verifyVolume() { (activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager)?.let { audioManager -> val currentVolumeStep = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) val maxVolumeStep = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) // if we can set the volume directly then do it if (currentVolumeStep < maxVolumeStep || currentRequestedVolume <= 1.0f) { currentRequestedVolume = currentVolumeStep.toFloat() / maxVolumeStep.toFloat() loudnessEnhancer?.release() loudnessEnhancer = null } } } val holdhandler = Handler(Looper.getMainLooper()) var hasTriggeredSpeedUp = false val holdRunnable = Runnable { if (isShowing) { onClickChange() } player.setPlaybackSpeed(2.0f) showOrHideSpeedUp(true) hasTriggeredSpeedUp = true } private fun showOrHideSpeedUp(show: Boolean) { playerBinding?.playerSpeedupButton?.let { button -> button.clearAnimation() button.alpha = if (show) 0f else 1f button.isVisible = show button.animate() .alpha(if (show) 1f else 0f) .setDuration(200L) .start() } } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) // If we rotate the device we need to recalculate the zoom val matrix = zoomMatrix val animation = matrixAnimation if ((animation == null || !animation.isRunning) && matrix != null) { // Ignore if we have no zoom or mid animation playerView?.post { applyZoomMatrix(matrix, true) requestUpdateBrightnessOverlayOnNextLayout() } } } private var scaleGestureDetector: ScaleGestureDetector? = null private var lastPan: Vector2? = null /** * Gets the non-null zoom matrix, * this is different from `zoomMatrix ?: Matrix()` * because it allows used to start zooming at different resizeModes. * * The main issue is that RESIZE_MODE_FIT = 100% zoom, but if you are in RESIZE_MODE_ZOOM * 100% will make the zoom snap to less zoomed in then you already are. * */ fun currentZoomMatrix(): Matrix { val current = zoomMatrix if (current != null) { // Already assigned return current } val playerView = playerView val videoView = playerView?.videoSurfaceView if (playerView == null || videoView == null || playerView.resizeMode != AspectRatioFrameLayout.RESIZE_MODE_ZOOM) { // This is a fit or fill resize mode so start at 100% zoom return Matrix() } val videoWidth = videoView.width.toFloat() val videoHeight = videoView.height.toFloat() val playerWidth = screenWidthWithOrientation val playerHeight = screenHeightWithOrientation // Sanity check if (videoWidth <= 1.0f || videoHeight <= 1.0f || playerWidth <= 1.0f || playerHeight <= 1.0f) { // Something is wrong with the video, return the default 100% zoom return Matrix() } val initAspect = (playerHeight * videoWidth) / (playerWidth * videoHeight) val aspect = max(initAspect, 1.0f / initAspect) // Return the matrix with the correct zoom, as it is already zoomed in return Matrix().apply { postScale(aspect, aspect) } } /** A Matrix encoding the translation and scale of the current zoom */ private var zoomMatrix: Matrix? = null /** A Matrix encoding the translation and scale of the desired zoom, * aka after you release the zoom */ private var desiredMatrix: Matrix? = null /** The animation of zooming to the desiredMatrix */ private var matrixAnimation: ValueAnimator? = null @SuppressLint("UnsafeOptInUsageError") override fun resize(resize: PlayerResize, showToast: Boolean) { // Clear all zoom stuff if we resize matrixAnimation?.cancel() matrixAnimation = null zoomMatrix = null desiredMatrix = null playerView?.videoSurfaceView?.apply { scaleX = 1.0f scaleY = 1.0f translationX = 0.0f translationY = 0.0f } super.resize(resize, showToast) requestUpdateBrightnessOverlayOnNextLayout() } /** * Applies a new zoom matrix to the screen. Matrix should only contain a scale + translation. * * @param newMatrix The new zoom matrix * @param animation If this zoom is part of an animation, * as then it will not auto zoom after we are done */ @OptIn(UnstableApi::class) fun applyZoomMatrix(newMatrix: Matrix, animation: Boolean) { if (!animation) { matrixAnimation?.cancel() matrixAnimation = null } val (translationX, translationY, scale) = matrixToTranslationAndScale(newMatrix) playerView?.let { player -> if (player.resizeMode == AspectRatioFrameLayout.RESIZE_MODE_FIT) { player.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_ZOOM } val videoView = player.videoSurfaceView ?: return@let val videoWidth = videoView.width.toFloat() val videoHeight = videoView.height.toFloat() val playerWidth = screenWidthWithOrientation val playerHeight = screenHeightWithOrientation // Sanity check if (videoWidth <= 1.0f || videoHeight <= 1.0f || playerWidth <= 1.0f || playerHeight <= 1.0f || scale <= 0.01f) { return } // Calculate the scaled aspect ratio as the view height is not real, check the debugger // and you will see videoView.height > screen.heigh val initAspect = (playerHeight * videoWidth) / (playerWidth * videoHeight) val aspect = min(initAspect, 1.0f / initAspect) val scaledAspect = scale * aspect // Calculate clamp, this is very weird because we need to use aspect here as videoHeight > playerHeight val maxTransX = max(0.0f, videoWidth * scaledAspect - playerWidth) * 0.5f val maxTransY = max(0.0f, videoHeight * scaledAspect - playerHeight) * 0.5f // Correct the translation to clamp within the viewing area val expectedTranslationX = translationX.coerceIn(-maxTransX, maxTransX) val expectedTranslationY = translationY.coerceIn(-maxTransY, maxTransY) // Set the transform to the correct x and y newMatrix.postTranslate( expectedTranslationX - translationX, expectedTranslationY - translationY ) zoomMatrix = newMatrix if (!animation) { // If we are not in an animation, set up the values for the animation if ((scaledAspect - 1.0f).absoluteValue < ZOOM_SNAP_SENSITIVITY) { // We are within the correct scaling, so center and fit it playerBinding?.videoOutline?.isVisible = true val desired = Matrix() desired.setScale(1.0f / aspect, 1.0f / aspect) desiredMatrix = desired } else if (scale < 1.0f) { // We have zoomed too far, zoom to 100% playerBinding?.videoOutline?.isVisible = false desiredMatrix = Matrix() } else { // Keep the same scaling after zoom playerBinding?.videoOutline?.isVisible = false desiredMatrix = null } } // Finally set the actual scale + translation videoView.scaleX = scaledAspect videoView.scaleY = scaledAspect videoView.translationX = expectedTranslationX videoView.translationY = expectedTranslationY updateBrightnessOverlayBounds() } } fun createScaleGestureDetector(context: Context) { scaleGestureDetector = ScaleGestureDetector( context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() { override fun onScale(detector: ScaleGestureDetector): Boolean { val matrix = currentZoomMatrix() val (_, _, scale) = matrixToTranslationAndScale(matrix) // Clamp scale of the zoom, do it here as it is easier then doing it within applyZoomMatrix val newScale = (scale * detector.scaleFactor).coerceIn( MINIMUM_ZOOM, MAXIMUM_ZOOM ) // How much we should scale it with to prevent inf scaling val actualScaleFactor = newScale / scale // Scale around the focus point, this is more natural than just zoom val pivotX = detector.focusX - screenWidthWithOrientation.toFloat() * 0.5f val pivotY = detector.focusY - screenHeightWithOrientation.toFloat() * 0.5f matrix.postScale( actualScaleFactor, actualScaleFactor, pivotX, pivotY ) applyZoomMatrix(matrix, false) return true } }) } @SuppressLint("SetTextI18n") private fun handleMotionEvent(view: View?, event: MotionEvent?): Boolean { if (event == null || view == null) return false val currentTouch = Vector2(event.x, event.y) val startTouch = currentTouchStart playerBinding?.playerIntroPlay?.isGone = true // Handle pan with two fingers if (event.pointerCount == 2 && !isLocked && isFullScreenPlayer && !hasTriggeredSpeedUp && currentTouchAction == null) { holdhandler.removeCallbacks(holdRunnable) // remove 2x speed // Gesture detectors for zoom & pan if (scaleGestureDetector == null) { createScaleGestureDetector(view.context) } isCurrentTouchValid = false // Prevent other touches scaleGestureDetector?.onTouchEvent(event) when (event.actionMasked) { MotionEvent.ACTION_POINTER_DOWN -> { // Hide UI if (isShowing) { onClickChange() } } MotionEvent.ACTION_MOVE -> { val newPan = Vector2( (event.getX(0) + event.getX(1)) / 2f, (event.getY(0) + event.getY(1)) / 2f ) val oldPan = lastPan if (oldPan != null) { val matrix = currentZoomMatrix() // Delta move matrix.postTranslate(newPan.x - oldPan.x, newPan.y - oldPan.y) applyZoomMatrix(matrix, false) updateBrightnessOverlayBounds() } lastPan = newPan } MotionEvent.ACTION_POINTER_UP, MotionEvent.ACTION_UP -> { // Reset touch lastPan = null currentTouchStart = null currentLastTouchAction = null currentTouchAction = null currentTouchStartPlayerTime = null currentTouchLast = null currentTouchStartTime = null // Reset views playerBinding?.videoOutline?.isVisible = false matrixAnimation?.cancel() matrixAnimation = null // After we have zoomed in, snap to matrixAnimation = ValueAnimator.ofFloat(0.0f, 1.0f).apply { startDelay = 0 duration = 200 val startMatrix = currentZoomMatrix() val endMatrix = desiredMatrix ?: return@apply val (startX, startY, startScale) = matrixToTranslationAndScale(startMatrix) val (endX, endY, endScale) = matrixToTranslationAndScale(endMatrix) addUpdateListener { animation -> val value = animation.animatedValue as Float // ValueAnimator.ofFloat // Linear interpolation of scale and translation between startMatrix and endMatrix val valueInv = 1.0f - value val x = startX * valueInv + endX * value val y = startY * valueInv + endY * value val s = startScale * valueInv + endScale * value val m = Matrix() m.setScale(s, s) m.postTranslate(x, y) applyZoomMatrix(m, true) } start() } } } return true } playerBinding?.apply { when (event.action) { MotionEvent.ACTION_DOWN -> { // validates if the touch is inside of the player area isCurrentTouchValid = view.isValidTouch(currentTouch.x, currentTouch.y) if (isCurrentTouchValid && isShowingEpisodeOverlay) { toggleEpisodesOverlay(show = false) } else if (isCurrentTouchValid) { if (speedupEnabled) { hasTriggeredSpeedUp = false if (player.getIsPlaying() && !isLocked && isFullScreenPlayer) { holdhandler.postDelayed(holdRunnable, 500) } } isVolumeLocked = currentRequestedVolume < 1.0f if (currentRequestedVolume <= 1.0f) { hasShownVolumeToast = false } isBrightnessLocked = currentRequestedBrightness < 1.0f if (currentRequestedBrightness <= 1.0f) { hasShownBrightnessToast = false } currentTouchStartTime = System.currentTimeMillis() currentTouchStart = currentTouch currentTouchLast = currentTouch currentTouchStartPlayerTime = player.getPosition() getBrightness()?.let { currentRequestedBrightness = it + currentExtraBrightness } verifyVolume() } } MotionEvent.ACTION_UP -> { holdhandler.removeCallbacks(holdRunnable) if (hasTriggeredSpeedUp) { player.setPlaybackSpeed(DataStoreHelper.playBackSpeed) showOrHideSpeedUp(false) } if (isCurrentTouchValid && !isLocked && isFullScreenPlayer) { // seek time if (swipeHorizontalEnabled && currentTouchAction == TouchAction.Time) { val startTime = currentTouchStartPlayerTime if (startTime != null) { calculateNewTime( startTime, startTouch, currentTouch )?.let { seekTo -> if (abs(seekTo - startTime) > MINIMUM_SEEK_TIME) { player.seekTo(seekTo, PlayerEventSource.UI) } } } } } // see if click is eligible for seek 10s val holdTime = currentTouchStartTime?.minus(System.currentTimeMillis()) if (isCurrentTouchValid // is valid && currentTouchAction == null // no other action like swiping is taking place && currentLastTouchAction == null // last action was none, this prevents mis input random seek && holdTime != null && holdTime < DOUBLE_TAB_MAXIMUM_HOLD_TIME // it is a click not a long hold ) { if (!isLocked && (System.currentTimeMillis() - currentLastTouchEndTime) < DOUBLE_TAB_MINIMUM_TIME_BETWEEN // the time since the last action is short ) { currentClickCount++ if (currentClickCount >= 1) { // have double clicked currentDoubleTapIndex++ if (doubleTapPauseEnabled && isFullScreenPlayer) { // you can pause if your tap is in the middle of the screen when { currentTouch.x < screenWidthWithOrientation / 2 - (DOUBLE_TAB_PAUSE_PERCENTAGE * screenWidthWithOrientation) -> { if (doubleTapEnabled) rewind() } currentTouch.x > screenWidthWithOrientation / 2 + (DOUBLE_TAB_PAUSE_PERCENTAGE * screenWidthWithOrientation) -> { if (doubleTapEnabled) fastForward() } else -> { player.handleEvent( CSPlayerEvent.PlayPauseToggle, PlayerEventSource.UI ) } } } else if (doubleTapEnabled && isFullScreenPlayer) { if (currentTouch.x < screenWidthWithOrientation / 2) { rewind() } else { fastForward() } } } } else { // is a valid click but not fast enough for seek currentClickCount = 0 if (!hasTriggeredSpeedUp) { toggleShowDelayed() } // onClickChange() } } else { currentClickCount = 0 } // If we hid the UI for a gesture and playback is paused, show it again if (!player.getIsPlaying()) { val didGesture = currentTouchAction != null || currentLastTouchAction != null if (didGesture && uiShowingBeforeGesture && !isShowing) { isShowing = true animateLayoutChanges() } } // call auto hide as it wont hide when you have your finger down autoHide() // reset variables isCurrentTouchValid = false currentTouchStart = null currentLastTouchAction = currentTouchAction currentTouchAction = null currentTouchStartPlayerTime = null currentTouchLast = null currentTouchStartTime = null uiShowingBeforeGesture = false // resets UI playerTimeText.isVisible = false currentLastTouchEndTime = System.currentTimeMillis() } MotionEvent.ACTION_MOVE -> { // if current touch is valid if (hasTriggeredSpeedUp) { return true } if (startTouch != null && isCurrentTouchValid && !isLocked && isFullScreenPlayer) { // action is unassigned and can therefore be assigned if (currentTouchAction == null) { val diffFromStart = startTouch - currentTouch if (swipeVerticalEnabled) { if (abs(diffFromStart.y * 100 / screenHeightWithOrientation) > MINIMUM_VERTICAL_SWIPE) { // left = Brightness, right = Volume, but the UI is reversed to show the UI better uiShowingBeforeGesture = isShowing currentTouchAction = if (startTouch.x < screenWidthWithOrientation / 2) { // hide the UI if you hold brightness to show screen better, better UX hidePlayerUI() TouchAction.Brightness } else { // hide the UI if you hold volume to show screen better, better UX hidePlayerUI() TouchAction.Volume } } } if (swipeHorizontalEnabled) { if (abs(diffFromStart.x * 100 / screenHeightWithOrientation) > MINIMUM_HORIZONTAL_SWIPE) { currentTouchAction = TouchAction.Time } } } // display action val lastTouch = currentTouchLast if (lastTouch != null) { val diffFromLast = lastTouch - currentTouch val verticalAddition = diffFromLast.y * VERTICAL_MULTIPLIER / screenHeightWithOrientation.toFloat() // update UI playerTimeText.isVisible = false when (currentTouchAction) { TouchAction.Time -> { holdhandler.removeCallbacks(holdRunnable) // this simply updates UI as the seek logic happens on release // startTime is rounded to make the UI sync in a nice way val startTime = currentTouchStartPlayerTime?.div(1000L)?.times(1000L) if (startTime != null) { calculateNewTime( startTime, startTouch, currentTouch )?.let { newMs -> val skipMs = newMs - startTime playerTimeText.apply { text = "${convertTimeToString(newMs / 1000)} [${ (if (abs(skipMs) < 1000) "" else (if (skipMs > 0) "+" else "-")) }${convertTimeToString(abs(skipMs / 1000))}]" isVisible = true } } } } TouchAction.Brightness -> { holdhandler.removeCallbacks(holdRunnable) playerBinding?.playerProgressbarRightHolder?.apply { if (!isVisible || alpha < 1f) { alpha = 1f isVisible = true } progressBarRightHideRunnable?.let { removeCallbacks(it) } progressBarRightHideRunnable = Runnable { // Fade out the progress bar animate().cancel() animate() .alpha(0f) .setDuration(300) .withEndAction { isVisible = false } .start() } // Show the progress bar for 1.5 seconds postDelayed(progressBarRightHideRunnable, 1500) } val lastRequested = currentRequestedBrightness val nextBrightness = if (extraBrightnessEnabled) { currentRequestedBrightness + verticalAddition } else { (currentRequestedBrightness + verticalAddition).coerceIn( 0.0f, 1.0f ) } // Log.e("Brightness", "Current: $currentRequestedBrightness, Next: $nextBrightness") // show toast if (extraBrightnessEnabled && nextBrightness > 1.0f && isBrightnessLocked && !hasShownBrightnessToast) { showToast(R.string.slide_up_again_to_exceed_100) hasShownBrightnessToast = true } currentRequestedBrightness = nextBrightness // this is to not spam request it, just in case it fucks over someone if (lastRequested != currentRequestedBrightness) setBrightness(currentRequestedBrightness) val level1ProgressBar = playerProgressbarRightLevel1 // max is set high to make it smooth level1ProgressBar.max = 100_000 level1ProgressBar.progress = max( 2_000, (min( 1.0f, currentRequestedBrightness ) * 100_000f).toInt() ) if (extraBrightnessEnabled && !isBrightnessLocked) { val level2ProgressBar = playerProgressbarRightLevel2 currentExtraBrightness = if (currentRequestedBrightness > 1.0f) min(2.0f, currentRequestedBrightness) - 1.0f else 0.0f level2ProgressBar.max = 100_000 level2ProgressBar.progress = (currentExtraBrightness * 100_000f).toInt().coerceIn(2_000, 100_000) level2ProgressBar.isVisible = currentRequestedBrightness > 1.0f brightnessOverlay?.let { it.alpha = currentExtraBrightness } } // Log.i("Brightness", "current: $currentRequestedBrightness, ce: $currentExtraBrightness L1: ${level1ProgressBar.progress}, L2: ${level2ProgressBar.progress}") playerProgressbarRightIcon.setImageResource( brightnessIcons[min( // clamp the value in case of extra brightness brightnessIcons.size - 1, max( 0, round(currentRequestedBrightness * (brightnessIcons.size - 1)).toInt() ) )] ) } TouchAction.Volume -> { holdhandler.removeCallbacks(holdRunnable) handleVolumeAdjustment( verticalAddition, false ) } else -> Unit } } } } } } currentTouchLast = currentTouch return true } @SuppressLint("GestureBackNavigation") private fun handleKeyEvent(event: KeyEvent, hasNavigated: Boolean): Boolean { if (hasNavigated) { autoHide() return false } val keyCode = event.keyCode if (event.action == KeyEvent.ACTION_DOWN) { when (keyCode) { KeyEvent.KEYCODE_DPAD_CENTER -> { if (!isShowing) { // If UI is not shown make click instantly skip to next chapter even if locked if (timestampShowState) { player.handleEvent(CSPlayerEvent.SkipCurrentChapter) } else if (!isLocked) { player.handleEvent(CSPlayerEvent.PlayPauseToggle) } onClickChange() return true } } KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_UP -> { if (!isShowing && !isShowingEpisodeOverlay) { onClickChange() return true } } KeyEvent.KEYCODE_DPAD_LEFT -> { if (!isShowing && !isLocked && !isShowingEpisodeOverlay) { player.seekTime(-androidTVInterfaceOffSeekTime) return true } else if (playerBinding?.playerPausePlay?.isFocused == true) { player.seekTime(-androidTVInterfaceOnSeekTime) return true } } KeyEvent.KEYCODE_DPAD_RIGHT -> { if (!isShowing && !isLocked && !isShowingEpisodeOverlay) { player.seekTime(androidTVInterfaceOffSeekTime) return true } else if (playerBinding?.playerPausePlay?.isFocused == true) { player.seekTime(androidTVInterfaceOnSeekTime) return true } } KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_UP -> { if (isLayout(PHONE or EMULATOR) && isFullScreenPlayer) { /** * Some TVs do not support volume boosting, and overriding * the volume buttons can be inconvenient for TV users. * Since boosting volume is mainly useful on phones and emulators, * we limit this feature to those devices. */ verifyVolume() if (currentRequestedVolume <= 1.0f) { hasShownVolumeToast = false } isVolumeLocked = currentRequestedVolume < 1.0f handleVolumeAdjustment( // +- 5% if (keyCode == KeyEvent.KEYCODE_VOLUME_UP) { 0.05f } else { -0.05f }, true ) return true } } } } when (keyCode) { // don't allow dpad move when hidden KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN_LEFT, KeyEvent.KEYCODE_DPAD_DOWN_RIGHT, KeyEvent.KEYCODE_DPAD_UP_LEFT, KeyEvent.KEYCODE_DPAD_UP_RIGHT -> { if (!isShowing) { return true } else { autoHide() } } // netflix capture back and hide ~monke // This is removed due to inconsistent behavior on A36 vs A22, see https://github.com/recloudstream/cloudstream/issues/1804 /*KeyEvent.KEYCODE_BACK -> { if (isShowing && isLayout(TV or EMULATOR)) { onClickChange() return true } }*/ } return false } private var loudnessEnhancer: LoudnessEnhancer? = null private fun handleVolumeAdjustment( delta: Float, fromButton: Boolean, ) { val audioManager = activity?.getSystemService(Context.AUDIO_SERVICE) as? AudioManager ?: return val currentVolumeStep = audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) val maxVolumeStep = audioManager.getStreamMaxVolume(AudioManager.STREAM_MUSIC) val currentVolume = currentRequestedVolume val isCurrentVolumeLocked = isVolumeLocked val nextVolume = (currentVolume + delta).coerceIn(0.0f, if (isCurrentVolumeLocked) 1.0f else 2.0f) val nextVolumeStep = (nextVolume * maxVolumeStep.toFloat()).roundToInt().coerceIn(0, maxVolumeStep) // show toast if (fromButton) { // for button related request we only show a toast when we exceeded the volume if (currentVolume <= 1.0f && nextVolume > 1.0f && !hasShownVolumeToast) { showToast(R.string.volume_exceeded_100) hasShownVolumeToast = true } } else { val nextRequestedVolume = currentVolume + delta // for swipes, we show toast that we need to swipe again if (nextRequestedVolume > 1.0 && isCurrentVolumeLocked && !hasShownVolumeToast) { showToast(R.string.slide_up_again_to_exceed_100) hasShownVolumeToast = true } } // set the current volume step if (nextVolumeStep != currentVolumeStep) { audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, nextVolumeStep, 0) } var hasBoostError = false // Apply loudness enhancer for volumes > 100%, removes it if less if (nextVolume > 1.0f) { val boostFactor = ((nextVolume - 1.0f) * 1000).toInt() val currentEnhancer = loudnessEnhancer if (currentEnhancer != null) { currentEnhancer.setTargetGain(boostFactor) } else { val audioSessionId = (playerView?.player as? ExoPlayer)?.audioSessionId if (audioSessionId != null && audioSessionId != AudioManager.ERROR) { try { loudnessEnhancer = LoudnessEnhancer(audioSessionId).apply { setTargetGain(boostFactor) enabled = true } } catch (t: Throwable) { logError(t) hasBoostError = true } } } } else { loudnessEnhancer?.release() loudnessEnhancer = null } currentRequestedVolume = nextVolume // Update the progress bar playerBinding?.apply { val level1ProgressBar = playerProgressbarLeftLevel1 val level2ProgressBar = playerProgressbarLeftLevel2 // Change color to show that LoudnessEnhancer broke // this is not a real fix, but solves the crash issue if (nextVolume > 1.0f) { level2ProgressBar.progressTintList = ColorStateList.valueOf( ContextCompat.getColor( level2ProgressBar.context, if (hasBoostError) { R.color.colorPrimaryRed } else { R.color.colorPrimaryOrange } ) ) } level1ProgressBar.max = 100_000 level1ProgressBar.progress = (nextVolume * 100_000f).toInt().coerceIn(2_000, 100_000) level2ProgressBar.max = 100_000 level2ProgressBar.progress = if (nextVolume > 1.0f) ((nextVolume - 1.0) * 100_000f).toInt() .coerceIn(2_000, 100_000) else 0 level2ProgressBar.isVisible = nextVolume > 1.0f // Calculate the clamped index for the volume icon based on the requested volume val iconIndex = (nextVolume * (volumeIcons.lastIndex)) .roundToInt() .coerceIn(0, volumeIcons.lastIndex) // Update icon playerProgressbarLeftIcon.setImageResource(volumeIcons[iconIndex]) } // alpha fade playerBinding?.playerProgressbarLeftHolder?.apply { if (!isVisible || alpha < 1f) { alpha = 1f isVisible = true } progressBarLeftHideRunnable?.let { removeCallbacks(it) } progressBarLeftHideRunnable = Runnable { // Fade out the progress bar animate().cancel() animate() .alpha(0f) .setDuration(300) .withEndAction { isVisible = false } .start() } // Show the progress bar for 1.5 seconds postDelayed(progressBarLeftHideRunnable, 1500) } } protected fun uiReset() { isShowing = false toggleEpisodesOverlay(false) // if nothing has loaded these buttons should not be visible playerBinding?.apply { playerSkipEpisode.isVisible = false playerGoForwardRoot.isVisible = false playerTracksBtt.isVisible = false playerSkipOp.isVisible = false shadowOverlay.isVisible = false } updateLockUI() updateUIVisibility() animateLayoutChanges() resetFastForwardText() resetRewindText() } override fun onSaveInstanceState(outState: Bundle) { // As this is video specific it is better to not do any setKey/getKey outState.putLong(SUBTITLE_DELAY_BUNDLE_KEY, subtitleDelay) super.onSaveInstanceState(outState) } @SuppressLint("ClickableViewAccessibility") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // init variables setPlayBackSpeed(DataStoreHelper.playBackSpeed) savedInstanceState?.getLong(SUBTITLE_DELAY_BUNDLE_KEY)?.let { subtitleDelay = it } // handle tv controls playerEventListener = { eventType -> when (eventType) { PlayerEventType.Lock -> { toggleLock() } PlayerEventType.NextEpisode -> { player.handleEvent(CSPlayerEvent.NextEpisode) } PlayerEventType.Pause -> { player.handleEvent(CSPlayerEvent.Pause) } PlayerEventType.PlayPauseToggle -> { player.handleEvent(CSPlayerEvent.PlayPauseToggle) } PlayerEventType.Play -> { player.handleEvent(CSPlayerEvent.Play) } PlayerEventType.SkipCurrentChapter -> { player.handleEvent(CSPlayerEvent.SkipCurrentChapter) } PlayerEventType.Resize -> { nextResize() } PlayerEventType.PrevEpisode -> { player.handleEvent(CSPlayerEvent.PrevEpisode) } PlayerEventType.SeekForward -> { player.handleEvent(CSPlayerEvent.SeekForward) } PlayerEventType.ShowSpeed -> { showSpeedDialog() } PlayerEventType.SeekBack -> { player.handleEvent(CSPlayerEvent.SeekBack) } PlayerEventType.Restart -> { player.handleEvent(CSPlayerEvent.Restart) } PlayerEventType.ToggleMute -> { player.handleEvent(CSPlayerEvent.ToggleMute) } PlayerEventType.ToggleHide -> { onClickChange() } PlayerEventType.ShowMirrors -> { showMirrorsDialogue() } PlayerEventType.SearchSubtitlesOnline -> { if (subsProvidersIsActive) { openOnlineSubPicker(view.context, null) {} } } PlayerEventType.SkipOp -> { skipOp() } } } // handle tv controls directly based on player state keyEventListener = { eventNav -> // Don't hook player keys if player isn't active if (player.isActive()) { val (event, hasNavigated) = eventNav if (event != null) handleKeyEvent(event, hasNavigated) else false } else false } try { context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) fastForwardTime = settingsManager.getInt(ctx.getString(R.string.double_tap_seek_time_key), 10) .toLong() * 1000L androidTVInterfaceOffSeekTime = settingsManager.getInt( ctx.getString(R.string.android_tv_interface_off_seek_key), 10 ) .toLong() * 1000L androidTVInterfaceOnSeekTime = settingsManager.getInt( ctx.getString(R.string.android_tv_interface_on_seek_key), 10 ) .toLong() * 1000L navigationBarHeight = ctx.getNavigationBarHeight() statusBarHeight = ctx.getStatusBarHeight() swipeHorizontalEnabled = settingsManager.getBoolean(ctx.getString(R.string.swipe_enabled_key), true) swipeVerticalEnabled = settingsManager.getBoolean( ctx.getString(R.string.swipe_vertical_enabled_key), true ) playBackSpeedEnabled = settingsManager.getBoolean( ctx.getString(R.string.playback_speed_enabled_key), false ) playerRotateEnabled = settingsManager.getBoolean( ctx.getString(R.string.rotate_video_key), false ) autoPlayerRotateEnabled = settingsManager.getBoolean( ctx.getString(R.string.auto_rotate_video_key), true ) playerResizeEnabled = settingsManager.getBoolean( ctx.getString(R.string.player_resize_enabled_key), true ) doubleTapEnabled = settingsManager.getBoolean( ctx.getString(R.string.double_tap_enabled_key), false ) doubleTapPauseEnabled = settingsManager.getBoolean( ctx.getString(R.string.double_tap_pause_enabled_key), false ) hideControlsNames = settingsManager.getBoolean( ctx.getString(R.string.hide_player_control_names_key), false ) speedupEnabled = settingsManager.getBoolean( ctx.getString(R.string.speedup_key), false ) extraBrightnessEnabled = settingsManager.getBoolean( ctx.getString(R.string.extra_brightness_key), false ) val profiles = QualityDataHelper.getProfiles() val type = if (ctx.isUsingMobileData()) QualityDataHelper.QualityProfileType.Data else QualityDataHelper.QualityProfileType.WiFi currentQualityProfile = profiles.firstOrNull { it.types.contains(type) }?.id ?: profiles.firstOrNull()?.id ?: currentQualityProfile // currentPrefQuality = settingsManager.getInt( // ctx.getString(if (ctx.isUsingMobileData()) R.string.quality_pref_mobile_data_key else R.string.quality_pref_key), // currentPrefQuality // ) // useSystemBrightness = // settingsManager.getBoolean(ctx.getString(R.string.use_system_brightness_key), false) } playerBinding?.apply { playerSpeedBtt.isVisible = playBackSpeedEnabled playerResizeBtt.isVisible = playerResizeEnabled playerRotateBtt.isVisible = if (isLayout(TV or EMULATOR)) false else playerRotateEnabled if (hideControlsNames) { hideControlsNames() } } } catch (e: Exception) { logError(e) } playerBinding?.apply { if (isLayout(TV or EMULATOR)) { mapOf( playerGoBack to playerGoBackText, playerRestart to playerRestartText, playerGoForward to playerGoForwardText, downloadHeaderToggle to downloadHeaderToggleText, playerEpisodesButton to playerEpisodesButtonText ).forEach { (button, text) -> button.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) { text.isSelected = false text.isVisible = false return@setOnFocusChangeListener } if (button.id == R.id.player_episodes_button) { toggleEpisodesOverlay(show = true) } else { toggleEpisodesOverlay(show = false) } text.isSelected = true text.isVisible = true } } } playerPausePlay.setOnClickListener { autoHide() if (currentPlayerStatus == CSPlayerLoading.IsEnded && isLayout(PHONE)) { player.handleEvent(CSPlayerEvent.Restart) } else { player.handleEvent(CSPlayerEvent.PlayPauseToggle) } } exoDuration.setOnClickListener { setRemainingTimeCounter(true) } timeLeft.setOnClickListener { setRemainingTimeCounter(false) } skipChapterButton.setOnClickListener { player.handleEvent(CSPlayerEvent.SkipCurrentChapter) } playerRotateBtt.setOnClickListener { autoHide() toggleRotate() } // init clicks playerResizeBtt.setOnClickListener { autoHide() nextResize() } playerSpeedBtt.setOnClickListener { autoHide() showSpeedDialog() } playerSkipOp.setOnClickListener { autoHide() skipOp() } playerSkipEpisode.setOnClickListener { autoHide() player.handleEvent(CSPlayerEvent.NextEpisode) } playerGoForward.setOnClickListener { autoHide() player.handleEvent(CSPlayerEvent.NextEpisode) } playerRestart.setOnClickListener { autoHide() player.handleEvent(CSPlayerEvent.Restart) } playerLock.setOnClickListener { autoHide() toggleLock() } playerSubtitleOffsetBtt.setOnClickListener { showSubtitleOffsetDialog() } playerRew.setOnClickListener { autoHide() rewind() } playerFfwd.setOnClickListener { autoHide() fastForward() } playerGoBack.setOnClickListener { activity?.popCurrentPage("FullScreenPlayer") } playerSourcesBtt.setOnClickListener { showMirrorsDialogue() } playerTracksBtt.setOnClickListener { showTracksDialogue() } // it is !not! a bug that you cant touch the right side, it does not register inputs on navbar or status bar playerHolder.setOnTouchListener { callView, event -> return@setOnTouchListener handleMotionEvent(callView, event) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { playerControlsScroll.setOnScrollChangeListener { _, _, _, _, _ -> autoHide() } } exoProgress.setOnTouchListener { _, event -> // this makes the bar not disappear when sliding when (event.action) { MotionEvent.ACTION_DOWN -> { currentTapIndex++ } MotionEvent.ACTION_MOVE -> { currentTapIndex++ } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_BUTTON_RELEASE -> { autoHide() } } return@setOnTouchListener false } playerEpisodesButton.setOnClickListener { toggleEpisodesOverlay(show = true) } } // cs3 is peak media center setRemainingTimeCounter(durationMode || isLayout(TV)) playerBinding?.exoPosition?.doOnTextChanged { _, _, _, _ -> updateRemainingTime() } // init UI try { uiReset() } catch (e: Exception) { logError(e) } } @SuppressLint("SourceLockedOrientationActivity") private fun toggleRotate() { activity?.let { toggleOrientationWithSensor(it) rotatedManually = true } } private fun PlayerCustomLayoutBinding.hideControlsNames() { fun iterate(layout: LinearLayout) { layout.children.forEach { if (it is MaterialButton) { it.textSize = 0f it.iconPadding = 0 it.iconGravity = MaterialButton.ICON_GRAVITY_TEXT_START it.setPadding(0, 0, 0, 0) } else if (it is LinearLayout) { iterate(it) } } } iterate(playerLockHolder.parent as LinearLayout) } override fun playerDimensionsLoaded(width: Int, height: Int) { isVerticalOrientation = height > width updateOrientation() } private fun updateRemainingTime() { val duration = player.getDuration() val position = player.getPosition() if (duration != null && duration > 1 && position != null) { val remainingTimeSeconds = (duration - position + 500) / 1000 val formattedTime = "-${DateUtils.formatElapsedTime(remainingTimeSeconds)}" playerBinding?.timeLeft?.text = formattedTime } } private fun setRemainingTimeCounter(showRemaining: Boolean) { durationMode = showRemaining playerBinding?.exoDuration?.isInvisible = showRemaining playerBinding?.timeLeft?.isVisible = showRemaining } private fun dynamicOrientation(): Int { return if (autoPlayerRotateEnabled) { if (isVerticalOrientation) { ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT } else { ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE } } else { ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE // default orientation } } private fun toggleEpisodesOverlay(show: Boolean) { if (show && !isShowingEpisodeOverlay) { previousPlayStatus = player.getIsPlaying() player.handleEvent(CSPlayerEvent.Pause) showEpisodesOverlay() isShowingEpisodeOverlay = true animateEpisodesOverlay(true) } else if (isShowingEpisodeOverlay) { if (previousPlayStatus) player.handleEvent(CSPlayerEvent.Play) isShowingEpisodeOverlay = false animateEpisodesOverlay(false) } } private fun animateEpisodesOverlay(show: Boolean) { playerBinding?.playerEpisodeOverlay?.let { overlay -> overlay.animate().cancel() (overlay.parent as? ViewGroup)?.layoutTransition = null // Disable layout transitions val offset = 50 * overlay.resources.displayMetrics.density overlay.translationX = if (show) offset else 0f playerBinding?.playerEpisodeOverlay?.isVisible = true overlay.animate() .translationX(if (show) 0f else offset) .alpha(if (show) 1f else 0f) .setDuration(300) .setInterpolator(AccelerateDecelerateInterpolator()).withEndAction { if (!show) { playerBinding?.playerEpisodeOverlay?.isGone = true } } .start() } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/GeneratorPlayer.kt ================================================ package com.lagradost.cloudstream3.ui.player import android.animation.ValueAnimator import android.annotation.SuppressLint import android.app.Dialog import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.res.ColorStateList import android.graphics.Bitmap import android.os.Build import android.os.Bundle import android.text.Spanned import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.AbsListView import android.widget.ArrayAdapter import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.MainThread import androidx.annotation.OptIn import androidx.core.animation.addListener import androidx.core.app.NotificationCompat import androidx.core.app.PendingIntentCompat import androidx.core.content.ContextCompat import androidx.core.content.edit import androidx.core.text.toSpanned import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.media3.common.Format.NO_VALUE import androidx.media3.common.MimeTypes import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerNotificationManager import androidx.media3.ui.PlayerNotificationManager.EXTRA_INSTANCE_ID import androidx.media3.ui.PlayerNotificationManager.MediaDescriptionAdapter import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId import com.lagradost.cloudstream3.LoadResponse.Companion.getImdbId import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId import com.lagradost.cloudstream3.LoadResponse.Companion.getTMDbId import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.databinding.DialogOnlineSubtitlesBinding import com.lagradost.cloudstream3.databinding.FragmentPlayerBinding import com.lagradost.cloudstream3.databinding.PlayerSelectSourceAndSubsBinding import com.lagradost.cloudstream3.databinding.PlayerSelectTracksBinding import com.lagradost.cloudstream3.isAnimeOp import com.lagradost.cloudstream3.isEpisodeBased import com.lagradost.cloudstream3.isLiveStream import com.lagradost.cloudstream3.isMovieType import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities import com.lagradost.cloudstream3.subtitles.AbstractSubtitleEntities.SubtitleSearch import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subtitleProviders import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup import com.lagradost.cloudstream3.ui.player.CS3IPlayer.Companion.preferredAudioTrackLanguage import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.updateForcedEncoding import com.lagradost.cloudstream3.ui.player.PlayerSubtitleHelper.Companion.toSubtitleMimeType import com.lagradost.cloudstream3.ui.player.source_priority.LinkSource import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog import com.lagradost.cloudstream3.ui.result.ACTION_CLICK_DEFAULT import com.lagradost.cloudstream3.ui.result.EpisodeAdapter import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.ResultFragment import com.lagradost.cloudstream3.ui.result.ResultViewModel2 import com.lagradost.cloudstream3.ui.result.SyncViewModel import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.subtitles.SUBTITLE_AUTO_SELECT_KEY import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.getAutoSelectLanguageTagIETF import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToLanguageName import com.lagradost.cloudstream3.utils.SubtitleHelper.languages import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt import com.lagradost.safefile.SafeFile import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.io.Serializable import java.util.Calendar @OptIn(UnstableApi::class) class GeneratorPlayer : FullScreenPlayer() { companion object { const val NOTIFICATION_ID = 2326 const val CHANNEL_ID = 7340 const val STOP_ACTION = "stopcs3" private var lastUsedGenerator: IGenerator? = null fun newInstance(generator: IGenerator, syncData: HashMap? = null): Bundle { Log.i(TAG, "newInstance = $syncData") lastUsedGenerator = generator return Bundle().apply { if (syncData != null) putSerializable("syncData", syncData) } } val subsProviders = subtitleProviders val subsProvidersIsActive get() = subsProviders.isNotEmpty() } private var limitTitle = 0 private var showTitle = false private var showName = false private var showResolution = false private var showMediaInfo = false private lateinit var viewModel: PlayerGeneratorViewModel //by activityViewModels() private lateinit var sync: SyncViewModel private var currentLinks: Set> = setOf() private var currentSubs: Set = setOf() private var currentSelectedLink: Pair? = null private var currentSelectedSubtitles: SubtitleData? = null private var currentMeta: Any? = null private var nextMeta: Any? = null private var isActive: Boolean = false private var isNextEpisode: Boolean = false // this is used to reset the watch time private var preferredAutoSelectSubtitles: String? = null // null means do nothing, "" means none private var binding: FragmentPlayerBinding? = null private var allMeta: List? = null private fun startLoading() { player.release() currentSelectedSubtitles = null isActive = false binding?.overlayLoadingSkipButton?.isVisible = false binding?.playerLoadingOverlay?.isVisible = true } private fun setSubtitles(subtitle: SubtitleData?, userInitiated: Boolean): Boolean { // If subtitle is changed and user initiated -> Save the language if (subtitle != currentSelectedSubtitles && userInitiated) { val subtitleLanguageTagIETF = if (subtitle == null) { "" // -> No Subtitles } else { subtitle.getIETF_tag() } if (subtitleLanguageTagIETF != null) { Log.i(TAG, "Set SUBTITLE_AUTO_SELECT_KEY to '$subtitleLanguageTagIETF'") setKey(SUBTITLE_AUTO_SELECT_KEY, subtitleLanguageTagIETF) preferredAutoSelectSubtitles = subtitleLanguageTagIETF } } currentSelectedSubtitles = subtitle //Log.i(TAG, "setSubtitles = $subtitle") return player.setPreferredSubtitles(subtitle) } override fun embeddedSubtitlesFetched(subtitles: List) { viewModel.addSubtitles(subtitles.toSet()) } override fun onTracksInfoChanged() { val tracks = player.getVideoTracks() playerBinding?.playerTracksBtt?.isVisible = tracks.allVideoTracks.size > 1 || tracks.allAudioTracks.size > 1 // Only set the preferred language if it is available. // Otherwise it may give some users audio track init failed! if (tracks.allAudioTracks.any { it.language == preferredAudioTrackLanguage }) { player.setPreferredAudioTrack(preferredAudioTrackLanguage) } updatePlayerInfo() } override fun playerStatusChanged() { super.playerStatusChanged() if (player.getIsPlaying()) { viewModel.forceClearCache = false } } private fun noSubtitles(): Boolean { return setSubtitles(null, true) } private fun getPos(): Long { val durPos = getViewPos(viewModel.getId()) ?: return 0L if (durPos.duration == 0L) return 0L if (durPos.position * 100L / durPos.duration > 95L) { return 0L } return durPos.position } private var currentVerifyLink: Job? = null private fun loadExtractorJob(extractorLink: ExtractorLink?) { currentVerifyLink?.cancel() extractorLink?.let { link -> currentVerifyLink = ioSafe { if (link.extractorData != null) { getApiFromNameNull(link.source)?.extractorVerifierJob(link.extractorData) } } } } // https://github.com/androidx/media/blob/main/libraries/ui/src/main/java/androidx/media3/ui/PlayerNotificationManager.java#L1517 private fun createBroadcastIntent( action: String, context: Context, instanceId: Int ): PendingIntent { val intent: Intent = Intent(action).setPackage(context.packageName) intent.putExtra(EXTRA_INSTANCE_ID, instanceId) val pendingFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE } else PendingIntent.FLAG_UPDATE_CURRENT return PendingIntent.getBroadcast(context, instanceId, intent, pendingFlags) } private var cachedPlayerNotificationManager: PlayerNotificationManager? = null private fun getMediaNotification(context: Context): PlayerNotificationManager { val cache = cachedPlayerNotificationManager if (cache != null) return cache return PlayerNotificationManager.Builder( context, NOTIFICATION_ID, CHANNEL_ID.toString() ) .setChannelNameResourceId(R.string.player_notification_channel_name) .setChannelDescriptionResourceId(R.string.player_notification_channel_description) .setMediaDescriptionAdapter(object : MediaDescriptionAdapter { override fun getCurrentContentTitle(player: Player): CharSequence { return when (val meta = currentMeta) { is ResultEpisode -> { meta.headerName } is ExtractorUri -> { meta.headerName ?: meta.name } else -> null } ?: "Unknown" } override fun createCurrentContentIntent(player: Player): PendingIntent? { // Open the app without creating a new task to resume playback seamlessly return PendingIntentCompat.getActivity( context, 0, Intent(context, MainActivity::class.java), 0, false ) } override fun getCurrentContentText(player: Player): CharSequence? { return when (val meta = currentMeta) { is ResultEpisode -> { meta.name } is ExtractorUri -> { if (meta.headerName == null) { null } else { meta.name } } else -> null } } override fun getCurrentLargeIcon( player: Player, callback: PlayerNotificationManager.BitmapCallback ): Bitmap? { ioSafe { val url = when (val meta = currentMeta) { is ResultEpisode -> { meta.poster } else -> null } // if we have a poster url try with it first if (url != null) { val urlBitmap = context.getImageBitmapFromUrl(url) if (urlBitmap != null) { callback.onBitmap(urlBitmap) return@ioSafe } } // retry several times with a preview in case the preview generator is slow for (i in 0..10) { val preview = this@GeneratorPlayer.player.getPreview(0.5f) if (preview == null) { delay(1000L) continue } callback.onBitmap( preview ) break } } // return null as we want to use the callback return null } }).setCustomActionReceiver(object : PlayerNotificationManager.CustomActionReceiver { // we have to use a custom action for stop if we want to exit the player instead of just stopping playback override fun createCustomActions( context: Context, instanceId: Int ): MutableMap { return mutableMapOf( STOP_ACTION to NotificationCompat.Action( R.drawable.baseline_stop_24, context.getString(androidx.media3.ui.R.string.exo_controls_stop_description), createBroadcastIntent(STOP_ACTION, context, instanceId) ) ) } override fun getCustomActions(player: Player): MutableList { return mutableListOf(STOP_ACTION) } override fun onCustomAction(player: Player, action: String, intent: Intent) { when (action) { STOP_ACTION -> { exitFullscreen() this@GeneratorPlayer.player.release() activity?.popCurrentPage() } } } }) .setPlayActionIconResourceId(R.drawable.ic_baseline_play_arrow_24) .setPauseActionIconResourceId(R.drawable.netflix_pause) .setSmallIconResourceId(R.drawable.baseline_headphones_24) .setStopActionIconResourceId(R.drawable.baseline_stop_24) .setRewindActionIconResourceId(R.drawable.go_back_30) .setFastForwardActionIconResourceId(R.drawable.go_forward_30) .setNextActionIconResourceId(R.drawable.ic_baseline_skip_next_24) .setPreviousActionIconResourceId(R.drawable.baseline_skip_previous_24) .build().apply { setColorized(true) // Color setUseChronometer(true) // Seekbar // Don't show the prev episode button setUsePreviousAction(false) setUsePreviousActionInCompactView(false) // Don't show the next episode button setUseNextAction(false) setUseNextActionInCompactView(false) // Show the skip 30s in both modes setUseFastForwardAction(true) setUseFastForwardActionInCompactView(true) // Only show rewind in expanded setUseRewindAction(true) setUseFastForwardActionInCompactView(false) // Use custom stop action setUseStopAction(false) } .also { cachedPlayerNotificationManager = it } } override fun playerUpdated(player: Any?) { super.playerUpdated(player) // Cancel the notification when released if (player == null) { cachedPlayerNotificationManager?.setPlayer(null) cachedPlayerNotificationManager = null return } // setup the notification when starting the player if (player is ExoPlayer) { val ctx = context ?: return getMediaNotification(ctx).apply { setPlayer(player) mMediaSession?.platformToken?.let { setMediaSessionToken(it) } } } } override fun onDownload(event: DownloadEvent) { super.onDownload(event) showDownloadProgress(event) } private fun showDownloadProgress(event: DownloadEvent) { activity?.runOnUiThread { playerBinding?.downloadedProgress?.apply { val indeterminate = event.totalBytes <= 0 || event.downloadedBytes <= 0 isIndeterminate = indeterminate if (!indeterminate) { max = (event.totalBytes / 1000).toInt() progress = (event.downloadedBytes / 1000).toInt() } } playerBinding?.downloadedProgressText.setText( txt( R.string.download_size_format, android.text.format.Formatter.formatShortFileSize( context, event.downloadedBytes ), android.text.format.Formatter.formatShortFileSize(context, event.totalBytes) ) ) val downloadSpeed = android.text.format.Formatter.formatShortFileSize(context, event.downloadSpeed) playerBinding?.downloadedProgressSpeedText?.text = // todo string fmt event.connections?.let { connections -> "%s/s - %d Connections".format(downloadSpeed, connections) } ?: downloadSpeed // don't display when done playerBinding?.downloadedProgressSpeedText?.isGone = event.downloadedBytes != 0L && event.downloadedBytes - 1024 >= event.totalBytes } } private fun loadLink(link: Pair?, sameEpisode: Boolean) { if (link == null) return // manage UI binding?.playerLoadingOverlay?.isVisible = false val isTorrent = link.first?.type == ExtractorLinkType.MAGNET || link.first?.type == ExtractorLinkType.TORRENT playerBinding?.downloadHeader?.isVisible = false playerBinding?.downloadHeaderToggle?.isVisible = isTorrent if (!isLayout(PHONE)) { playerBinding?.downloadBothHeader?.isVisible = isTorrent } showDownloadProgress(DownloadEvent(0, 0, 0, null)) uiReset() currentSelectedLink = link currentMeta = viewModel.getMeta() nextMeta = viewModel.getNextMeta() allMeta = viewModel.getAllMeta()?.filterIsInstance()?.map { episode -> // Refresh all the episodes watch duration getViewPos(episode.id)?.let { data -> episode.copy(position = data.position, duration = data.duration) } ?: episode } // setEpisodes(viewModel.getAllMeta() ?: emptyList()) isActive = true setPlayerDimen(null) setTitle() if (!sameEpisode) hasRequestedStamps = false loadExtractorJob(link.first) // load player context?.let { ctx -> val (url, uri) = link player.loadPlayer( ctx, sameEpisode, url, uri, startPosition = if (sameEpisode) null else { if (isNextEpisode) 0L else getPos() }, currentSubs, (if (sameEpisode) currentSelectedSubtitles else null) ?: getAutoSelectSubtitle( currentSubs, settings = true, downloads = true ), preview = isFullScreenPlayer ) } if (!sameEpisode) { player.addTimeStamps(emptyList()) // clear stamps // Resets subtitle delay, as we watch some other content player.setSubtitleOffset(0) } } private fun sortLinks(qualityProfile: Int): List> { return currentLinks.sortedBy { // negative because we want to sort highest quality first -getLinkPriority(qualityProfile, it.first) } } data class TempMetaData( var episode: Int? = null, var season: Int? = null, var name: String? = null, var imdbId: String? = null, ) private fun getMetaData(): TempMetaData { val meta = TempMetaData() when (val newMeta = currentMeta) { is ResultEpisode -> { if (!newMeta.tvType.isMovieType()) { meta.episode = newMeta.episode meta.season = newMeta.season } meta.name = newMeta.headerName } is ExtractorUri -> { if (newMeta.tvType?.isMovieType() == false) { meta.episode = newMeta.episode meta.season = newMeta.season } meta.name = newMeta.headerName } } return meta } fun getName(entry: AbstractSubtitleEntities.SubtitleEntity, withLanguage: Boolean): String { if (entry.lang.isBlank() || !withLanguage) { return entry.name } val language = fromTagToLanguageName(entry.lang.trim()) ?: entry.lang return "$language ${entry.name}" } override fun openOnlineSubPicker( context: Context, loadResponse: LoadResponse?, dismissCallback: (() -> Unit) ) { val providers = subsProviders.toList() val isSingleProvider = subsProviders.size == 1 val dialog = Dialog(context, R.style.DialogFullscreenPlayer) val binding = DialogOnlineSubtitlesBinding.inflate(LayoutInflater.from(context), null, false) dialog.setContentView(binding.root) fixSystemBarsPadding(binding.root) var currentSubtitles: List = emptyList() var currentSubtitle: AbstractSubtitleEntities.SubtitleEntity? = null val layout = R.layout.sort_bottom_single_choice_double_text val arrayAdapter = object : ArrayAdapter(dialog.context, layout) { fun setHearingImpairedIcon( imageViewEnd: ImageView?, position: Int ) { if (imageViewEnd == null) return val isHearingImpaired = currentSubtitles.getOrNull(position)?.isHearingImpaired ?: false val drawableEnd = if (isHearingImpaired) { ContextCompat.getDrawable( context, R.drawable.ic_baseline_hearing_24 )?.apply { setTint( ContextCompat.getColor( context, R.color.textColor ) ) } } else null imageViewEnd.setImageDrawable(drawableEnd) } @SuppressLint("SetTextI18n") override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { val view = convertView ?: LayoutInflater.from(context).inflate(layout, null) val item = getItem(position) val mainTextView = view.findViewById(R.id.main_text) val secondaryTextView = view.findViewById(R.id.secondary_text) val drawableEnd = view.findViewById(R.id.drawable_end) mainTextView?.text = item?.let { getName(it, false) } val language = item?.let { fromTagToLanguageName(it.lang) ?: it.lang } ?: "" val providerSuffix = if (isSingleProvider || item == null) "" else " · ${item.source}" secondaryTextView?.text = language + providerSuffix setHearingImpairedIcon(drawableEnd, position) return view } } dialog.show() binding.cancelBtt.setOnClickListener { dialog.dismissSafe() } binding.subtitleAdapter.choiceMode = AbsListView.CHOICE_MODE_SINGLE binding.subtitleAdapter.adapter = arrayAdapter binding.subtitleAdapter.setOnItemClickListener { _, _, position, _ -> currentSubtitle = currentSubtitles.getOrNull(position) ?: return@setOnItemClickListener } var currentLanguageTagIETF: String = getAutoSelectLanguageTagIETF() fun setSubtitlesList(list: List) { currentSubtitles = list arrayAdapter.clear() arrayAdapter.addAll(currentSubtitles) } val currentTempMeta = getMetaData() // bruh idk why it is not correct val color = ColorStateList.valueOf(context.colorFromAttribute(androidx.appcompat.R.attr.colorAccent)) binding.searchLoadingBar.progressTintList = color binding.searchLoadingBar.indeterminateTintList = color observeNullable(viewModel.currentSubtitleYear) { // When year is changed search again binding.subtitlesSearch.setQuery(binding.subtitlesSearch.query, true) binding.yearBtt.text = it?.toString() ?: txt(R.string.none).asString(context) } binding.yearBtt.setOnClickListener { val none = txt(R.string.none).asString(context) val currentYear = Calendar.getInstance().get(Calendar.YEAR) val earliestYear = 1900 val years = (currentYear downTo earliestYear).toList() val options = listOf(none) + years.map { it.toString() } val selectedIndex = viewModel.currentSubtitleYear.value ?.let { // + 1 since none also takes a space years.indexOf(it) + 1 } ?.takeIf { it >= 0 } ?: 0 activity?.showDialog( options, selectedIndex, txt(R.string.year).asString(context), true, { }, { index -> viewModel.setSubtitleYear(years.getOrNull(index - 1)) } ) } binding.subtitlesSearch.setOnQueryTextListener(object : androidx.appcompat.widget.SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { binding.searchLoadingBar.show() ioSafe { val search = SubtitleSearch( query = query ?: return@ioSafe, imdbId = loadResponse?.getImdbId(), tmdbId = loadResponse?.getTMDbId()?.toInt(), malId = loadResponse?.getMalId()?.toInt(), aniListId = loadResponse?.getAniListId()?.toInt(), epNumber = currentTempMeta.episode, seasonNumber = currentTempMeta.season, lang = currentLanguageTagIETF.ifBlank { null }, year = viewModel.currentSubtitleYear.value ) // TODO Make ui a lot better, like search with tabs val results = providers.amap { when (val response = Resource.fromResult(it.search(search))) { is Resource.Success -> { response.value } is Resource.Loading -> { emptyList() } is Resource.Failure -> { showToast(response.errorString) emptyList() } } } val max = results.maxOfOrNull { it.size } ?: return@ioSafe // very ugly val items = ArrayList() val arrays = results.size for (index in 0 until max) { for (i in 0 until arrays) { items.add(results[i].getOrNull(index) ?: continue) } } // ugly ik activity?.runOnUiThread { setSubtitlesList(items) binding.searchLoadingBar.hide() } } return true } override fun onQueryTextChange(newText: String?): Boolean { return true } }) binding.searchFilter.setOnClickListener { view -> val languagesTagName = languages .map { Pair(it.IETF_tag, it.nameNextToFlagEmoji()) } .sortedBy { it.second.substringAfter("\u00a0").lowercase() } // name ignoring flag emoji val (langTagsIETF, langNames) = languagesTagName.unzip() activity?.showDialog( langNames, langTagsIETF.indexOf(currentLanguageTagIETF), view?.context?.getString(R.string.subs_subtitle_languages) ?: return@setOnClickListener, true, { }) { index -> currentLanguageTagIETF = langTagsIETF[index] binding.subtitlesSearch.setQuery(binding.subtitlesSearch.query, true) } } binding.applyBtt.setOnClickListener { currentSubtitle?.let { currentSubtitle -> providers.firstOrNull { it.idPrefix == currentSubtitle.idPrefix }?.let { api -> ioSafe { when (val apiResource = Resource.fromResult(api.resource(currentSubtitle))) { is Resource.Success -> { val subtitles = apiResource.value.getSubtitles().map { resource -> SubtitleData( originalName = resource.name ?: getName( currentSubtitle, true ), nameSuffix = "", url = resource.url, origin = resource.origin, mimeType = resource.url.toSubtitleMimeType(), headers = currentSubtitle.headers, languageCode = currentSubtitle.lang ) } if (subtitles.isEmpty()) { showToast(R.string.no_subtitles) return@ioSafe } runOnMainThread { addAndSelectSubtitles(*subtitles.toTypedArray()) } } is Resource.Failure -> { showToast(apiResource.errorString) } is Resource.Loading -> { // not possible } } } } } dialog.dismissSafe() } dialog.setOnDismissListener { dismissCallback.invoke() } dialog.show() binding.subtitlesSearch.setQuery(currentTempMeta.name, true) //TODO: Set year text from currently loaded movie on Player //dialog.subtitles_search_year?.setText(currentTempMeta.year) } private fun openSubPicker() { try { subsPathPicker.launch( arrayOf( "text/plain", "text/str", "application/octet-stream", MimeTypes.TEXT_UNKNOWN, MimeTypes.TEXT_VTT, MimeTypes.TEXT_SSA, MimeTypes.APPLICATION_TTML, MimeTypes.APPLICATION_MP4VTT, MimeTypes.APPLICATION_SUBRIP, ) ) } catch (e: Exception) { logError(e) } } @MainThread private fun addAndSelectSubtitles( vararg subtitleData: SubtitleData ) { if (subtitleData.isEmpty()) return val selectedSubtitle = subtitleData.first() val ctx = context ?: return val subs = currentSubs + subtitleData // this is used instead of observe(viewModel._currentSubs), because observe is too slow player.setActiveSubtitles(subs) // Save current time as to not reset player to 00:00 player.saveData() player.reloadPlayer(ctx) setSubtitles(selectedSubtitle, false) viewModel.addSubtitles(subtitleData.toSet()) selectSourceDialog?.dismissSafe() showToast( String.format(ctx.getString(R.string.player_loaded_subtitles), selectedSubtitle.name), Toast.LENGTH_LONG ) } // Open file picker private val subsPathPicker = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> safe { // It lies, it can be null if file manager quits. if (uri == null) return@safe val ctx = context ?: CloudStreamApp.context ?: return@safe // RW perms for the path ctx.contentResolver.takePersistableUriPermission( uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION ) val file = SafeFile.fromUri(ctx, uri) val fileName = file?.name() println("Loaded subtitle file. Selected URI path: $uri - Name: $fileName") // DO NOT REMOVE THE FILE EXTENSION FROM NAME, IT'S NEEDED FOR MIME TYPES val name = fileName ?: uri.toString() val subtitleData = SubtitleData( name, "", uri.toString(), SubtitleOrigin.DOWNLOADED_FILE, name.toSubtitleMimeType(), emptyMap(), null ) addAndSelectSubtitles(subtitleData) } } private var selectSourceDialog: Dialog? = null // var selectTracksDialog: AlertDialog? = null /** Will toast both when an error is found and when a subtitle is selected, * so only use from a user click and not a background process */ private fun addFirstSub(query: SubtitleSearch) = viewModel.viewModelScope.launch { // async should not have a race condition if they are on the same group var hasSelectASubtitle = false // first come first served with these subtitles // we might want to change it to prefer different sources when used multiple times, // however caching might make this random after the first click too subsProviders.toList().amap { provider -> val success = when (val result = Resource.fromResult( provider.search( query = query ) )) { is Resource.Failure -> { // scope might cancel, so we do an extra check if (this.isActive) { showToast("${provider.idPrefix}${result.errorString}") } return@amap } is Resource.Loading -> { // unreachable return@amap } is Resource.Success -> { result.value } } // try to add every subtitle until we have added a new subtitle file for (subtitleEntry in success) { if (hasSelectASubtitle || !this.isActive) { break } val subtitleResources = provider.resource(subtitleEntry).getOrNull() ?: continue val subtitles = subtitleResources.getSubtitles().map { resource -> SubtitleData( originalName = resource.name ?: getName(subtitleEntry, true), nameSuffix = "", url = resource.url, origin = resource.origin, mimeType = resource.url.toSubtitleMimeType(), headers = subtitleEntry.headers, languageCode = subtitleEntry.lang, ) } // checks for both a race condition and if any of the subs generated is new if (this.isActive && !currentSubs.containsAll(subtitles) && !hasSelectASubtitle) { hasSelectASubtitle = true runOnMainThread { addAndSelectSubtitles(*subtitles.toTypedArray()) } break } } } // maybe better error here? if (!hasSelectASubtitle && this.isActive) { showToast(R.string.no_subtitles) } } override fun showMirrorsDialogue() { try { currentSelectedSubtitles = player.getCurrentPreferredSubtitle() //println("CURRENT SELECTED :$currentSelectedSubtitles of $currentSubs") context?.let { ctx -> val isPlaying = player.getIsPlaying() player.handleEvent(CSPlayerEvent.Pause, PlayerEventSource.UI) val currentSubtitles = sortSubs(currentSubs) val sourceDialog = Dialog(ctx, R.style.DialogFullscreenPlayer) val binding = PlayerSelectSourceAndSubsBinding.inflate(LayoutInflater.from(ctx), null, false) sourceDialog.setContentView(binding.root) fixSystemBarsPadding(binding.root) selectSourceDialog = sourceDialog sourceDialog.show() val providerList = binding.sortProviders val subtitleList = binding.sortSubtitles val subtitleOptionList = binding.sortSubtitlesOptions val loadFromFileFooter: TextView = layoutInflater.inflate(R.layout.sort_bottom_footer_add_choice, null) as TextView loadFromFileFooter.text = ctx.getString(R.string.player_load_subtitles) loadFromFileFooter.setOnClickListener { openSubPicker() } subtitleList.addFooterView(loadFromFileFooter) var shouldDismiss = true binding.subtitleSettingsBtt.setOnClickListener { safe { val subtitlesFragment = SubtitlesFragment() subtitlesFragment.systemBarsAddPadding = true subtitlesFragment.show(this.parentFragmentManager, "SubtitleSettings") } } fun dismiss() { if (isPlaying) { player.handleEvent(CSPlayerEvent.Play) } activity?.hideSystemUI() } if (subsProvidersIsActive) { val currentLoadResponse = viewModel.getLoadResponse() val loadFromOpenSubsFooter: TextView = layoutInflater.inflate( R.layout.sort_bottom_footer_add_choice, null ) as TextView loadFromOpenSubsFooter.text = ctx.getString(R.string.player_load_subtitles_online) loadFromOpenSubsFooter.setOnClickListener { shouldDismiss = false sourceDialog.dismissSafe(activity) openOnlineSubPicker(it.context, currentLoadResponse) { dismiss() } } subtitleList.addFooterView(loadFromOpenSubsFooter) // subs from 1 button here val metadata = getMetaData() val queryName = metadata.name ?: currentLoadResponse?.name if (queryName != null) { val currentLanguageTagIETF: String = getAutoSelectLanguageTagIETF() val loadFromFirstSubsFooter: TextView = layoutInflater.inflate( R.layout.sort_bottom_footer_add_choice, null ) as TextView loadFromFirstSubsFooter.text = ctx.getString(R.string.player_load_one_subtitle_online) loadFromFirstSubsFooter.setOnClickListener { sourceDialog.dismissSafe(activity) showToast(R.string.loading) addFirstSub( SubtitleSearch( query = queryName, imdbId = currentLoadResponse?.getImdbId(), tmdbId = currentLoadResponse?.getTMDbId()?.toInt(), malId = currentLoadResponse?.getMalId()?.toInt(), aniListId = currentLoadResponse?.getAniListId()?.toInt(), epNumber = metadata.episode, seasonNumber = metadata.season, lang = currentLanguageTagIETF.ifBlank { null }, year = viewModel.currentSubtitleYear.value ) ) } subtitleList.addFooterView(loadFromFirstSubsFooter) } } var sourceIndex = 0 var startSource = 0 var sortedUrls = emptyList>() fun refreshLinks(qualityProfile: Int) { sortedUrls = sortLinks(qualityProfile) if (sortedUrls.isEmpty()) { sourceDialog.findViewById(R.id.sort_sources_holder)?.isGone = true } else { startSource = sortedUrls.indexOf(currentSelectedLink) sourceIndex = startSource val sourcesArrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) sourcesArrayAdapter.addAll(sortedUrls.map { (link, uri) -> val name = link?.name ?: uri?.name ?: "NULL" "$name ${Qualities.getStringByInt(link?.quality)}" }) providerList.choiceMode = AbsListView.CHOICE_MODE_SINGLE providerList.adapter = sourcesArrayAdapter providerList.setSelection(sourceIndex) providerList.setItemChecked(sourceIndex, true) providerList.setOnItemClickListener { _, _, which, _ -> sourceIndex = which providerList.setItemChecked(which, true) } providerList.setOnItemLongClickListener { _, _, position, _ -> sortedUrls.getOrNull(position)?.first?.url?.let { clipboardHelper( txt(R.string.video_source), it ) } true } } } refreshLinks(currentQualityProfile) sourceDialog.setOnDismissListener { if (shouldDismiss) dismiss() selectSourceDialog = null } val subsArrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) subsArrayAdapter.add(ctx.getString(R.string.no_subtitles).html()) val subtitlesGrouped = currentSubtitles.groupBy { it.originalName }.map { (key, value) -> key to value.sortedBy { it.nameSuffix.toIntOrNull() ?: 0 } }.toMap() val subtitlesGroupedList = subtitlesGrouped.entries.toList() val subtitles = subtitlesGrouped.map { it.key.html() } val subtitleGroupIndexStart = subtitlesGrouped.keys.indexOf(currentSelectedSubtitles?.originalName) + 1 var subtitleGroupIndex = subtitleGroupIndexStart val subtitleOptionIndexStart = subtitlesGrouped[currentSelectedSubtitles?.originalName]?.indexOfFirst { it.nameSuffix == currentSelectedSubtitles?.nameSuffix } ?: 0 var subtitleOptionIndex = subtitleOptionIndexStart subsArrayAdapter.addAll(subtitles) subtitleList.adapter = subsArrayAdapter subtitleList.choiceMode = AbsListView.CHOICE_MODE_SINGLE subtitleList.setSelection(subtitleGroupIndex) subtitleList.setItemChecked(subtitleGroupIndex, true) val subsOptionsArrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) subtitleOptionList.adapter = subsOptionsArrayAdapter subtitleOptionList.choiceMode = AbsListView.CHOICE_MODE_SINGLE fun updateSubtitleOptionList() { subsOptionsArrayAdapter.clear() val subtitleOptions = subtitlesGroupedList .getOrNull(subtitleGroupIndex - 1)?.value?.map { subtitle -> val nameSuffix = subtitle.nameSuffix.html() nameSuffix.ifBlank { when (subtitle.origin) { SubtitleOrigin.URL -> txt(R.string.subtitles_from_online) SubtitleOrigin.DOWNLOADED_FILE -> txt(R.string.downloaded) SubtitleOrigin.EMBEDDED_IN_VIDEO -> txt(R.string.subtitles_from_embedded) }.asString(ctx).toSpanned() } } ?: emptyList() // Show nothing if there is nothing to select val shouldHide = subtitleOptions.size < 2 subtitleOptionList.isGone = shouldHide // Make it easier to click if (shouldHide) return subsOptionsArrayAdapter.addAll(subtitleOptions) subtitleOptionList.setSelection(subtitleOptionIndex) subtitleOptionList.setItemChecked(subtitleOptionIndex, true) } updateSubtitleOptionList() subtitleList.setOnItemClickListener { _, _, which, _ -> if (which > subtitlesGrouped.size) { // Since android TV is funky the setOnItemClickListener will be triggered // instead of setOnClickListener when selecting. To override this we programmatically // click the view when selecting an item outside the list. // Cheeky way of getting the view at that position to click it // to avoid keeping track of the various footers. // getChildAt() gives null :( val child = subtitleList.adapter.getView(which, null, subtitleList) child?.performClick() } else { if (subtitleGroupIndex != which) { subtitleGroupIndex = which subtitleOptionIndex = if (subtitleGroupIndex == subtitleGroupIndexStart) { subtitleOptionIndexStart } else { 0 } } subtitleList.setItemChecked(which, true) updateSubtitleOptionList() } } subtitleOptionList.setOnItemClickListener { _, _, which, _ -> if (which >= (subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.size ?: -1) ) { val child = subtitleOptionList.adapter.getView(which, null, subtitleList) child?.performClick() } else { subtitleOptionIndex = which subtitleOptionList.setItemChecked(which, true) } } binding.cancelBtt.setOnClickListener { sourceDialog.dismissSafe(activity) } fun setProfileName(profile: Int) { binding.sourceSettingsBtt.setText( QualityDataHelper.getProfileName( profile ) ) } setProfileName(currentQualityProfile) binding.profilesClickSettings.setOnClickListener { val activity = activity ?: return@setOnClickListener QualityProfileDialog( activity, R.style.DialogFullscreenPlayer, currentLinks.mapNotNull { it.first?.let { extractorLink -> LinkSource(extractorLink) } }, currentQualityProfile ) { profile -> currentQualityProfile = profile.id setProfileName(profile.id) refreshLinks(profile.id) }.show() } binding.subtitlesEncodingFormat.apply { val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list) val prefValues = ctx.resources.getStringArray(R.array.subtitles_encoding_values) val value = settingsManager.getString( ctx.getString(R.string.subtitles_encoding_key), null ) val index = prefValues.indexOf(value) text = prefNames[if (index == -1) 0 else index] } binding.subtitlesEncodingFormat.setOnClickListener { val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val prefNames = ctx.resources.getStringArray(R.array.subtitles_encoding_list) val prefValues = ctx.resources.getStringArray(R.array.subtitles_encoding_values) val currentPrefMedia = settingsManager.getString( ctx.getString(R.string.subtitles_encoding_key), null ) shouldDismiss = false sourceDialog.dismissSafe(activity) val index = prefValues.indexOf(currentPrefMedia) activity?.showDialog( prefNames.toList(), if (index == -1) 0 else index, ctx.getString(R.string.subtitles_encoding), true, {}) { settingsManager.edit { putString( ctx.getString(R.string.subtitles_encoding_key), prefValues[it] ) } updateForcedEncoding(ctx) dismiss() player.seekTime(-1) // to update subtitles, a dirty trick } } binding.applyBtt.setOnClickListener { var init = sourceIndex != startSource if (subtitleGroupIndex != subtitleGroupIndexStart || subtitleOptionIndex != subtitleOptionIndexStart) { init = init or if (subtitleGroupIndex <= 0) { noSubtitles() } else { subtitlesGroupedList.getOrNull(subtitleGroupIndex - 1)?.value?.getOrNull( subtitleOptionIndex )?.let { setSubtitles(it, true) } ?: false } } if (init) { sortedUrls.getOrNull(sourceIndex)?.let { loadLink(it, true) } } sourceDialog.dismissSafe(activity) } } } catch (e: Exception) { logError(e) } } override fun showTracksDialogue() { try { //println("CURRENT SELECTED :$currentSelectedSubtitles of $currentSubs") context?.let { ctx -> val tracks = player.getVideoTracks() val isPlaying = player.getIsPlaying() player.handleEvent(CSPlayerEvent.Pause) val currentVideoTracks = tracks.allVideoTracks.sortedBy { it.height?.times(-1) } val currentAudioTracks = tracks.allAudioTracks val binding: PlayerSelectTracksBinding = PlayerSelectTracksBinding.inflate(LayoutInflater.from(ctx), null, false) val trackDialog = Dialog(ctx, R.style.DialogFullscreenPlayer) trackDialog.setContentView(binding.root) trackDialog.show() fixSystemBarsPadding(binding.root) // selectTracksDialog = tracksDialog val videosList = binding.videoTracksList val audioList = binding.autoTracksList binding.videoTracksHolder.isVisible = currentVideoTracks.size > 1 binding.audioTracksHolder.isVisible = currentAudioTracks.size > 1 fun dismiss() { if (isPlaying) { player.handleEvent(CSPlayerEvent.Play) } activity?.hideSystemUI() } val videosArrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) videosArrayAdapter.addAll(currentVideoTracks.mapIndexed { index, format -> format.label ?: (if (format.height == NO_VALUE || format.width == NO_VALUE) index else "${format.width}x${format.height}").toString() }) videosList.choiceMode = AbsListView.CHOICE_MODE_SINGLE videosList.adapter = videosArrayAdapter // Sometimes the data is not the same because some data gets resolved at different stages i think var videoIndex = currentVideoTracks.indexOf(tracks.currentVideoTrack).takeIf { it != -1 } ?: currentVideoTracks.indexOfFirst { tracks.currentVideoTrack?.id == it.id } videosList.setSelection(videoIndex) videosList.setItemChecked(videoIndex, true) videosList.setOnItemClickListener { _, _, which, _ -> videoIndex = which videosList.setItemChecked(which, true) } trackDialog.setOnDismissListener { dismiss() // selectTracksDialog = null } var audioIndexStart = currentAudioTracks.indexOfFirst { track -> track.id == tracks.currentAudioTrack?.id && track.formatIndex == tracks.currentAudioTrack?.formatIndex }.coerceAtLeast(0) val audioArrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) audioArrayAdapter.addAll(currentAudioTracks.mapIndexed { index, track -> val language = track.language?.let { fromTagToLanguageName(it) ?: it } ?: track.label ?: "Audio" val codec = track.sampleMimeType?.let { mimeType -> when { mimeType.contains("mp4a") || mimeType.contains("aac") -> "aac" mimeType.contains("ac-3") || mimeType.contains("ac3") -> "ac3" mimeType.contains("eac3-joc") -> "Dolby Atmos" mimeType.contains("eac3") -> "eac3" mimeType.contains("opus") -> "opus" mimeType.contains("vorbis") -> "vorbis" mimeType.contains("mp3") || mimeType.contains("mpeg") -> "mp3" mimeType.contains("flac") -> "flac" mimeType.contains("dts") -> "dts" else -> mimeType.substringAfter("/") } } ?: "codec?" val channels: Int = track.channelCount ?: 0 val channelConfig = when (channels) { 1 -> "mono" 2 -> "stereo" 6 -> "5.1" 8 -> "7.1" else -> "${channels}Ch" } listOfNotNull( "[$index]", language.replaceFirstChar { it.uppercaseChar() }, codec.uppercase(), channelConfig.replaceFirstChar { it.uppercaseChar() } ).joinToString(" • ") "[$index] $language $codec $channelConfig" }) audioList.adapter = audioArrayAdapter audioList.choiceMode = AbsListView.CHOICE_MODE_SINGLE audioList.setSelection(audioIndexStart) audioList.setItemChecked(audioIndexStart, true) audioList.setOnItemClickListener { _, _, which, _ -> audioIndexStart = which audioList.setItemChecked(which, true) } binding.cancelBtt.setOnClickListener { trackDialog.dismissSafe(activity) } binding.applyBtt.setOnClickListener { val currentTrack = currentAudioTracks.getOrNull(audioIndexStart) player.setPreferredAudioTrack( currentTrack?.language, currentTrack?.id, currentTrack?.formatIndex, ) val currentVideo = currentVideoTracks.getOrNull(videoIndex) val width = currentVideo?.width ?: NO_VALUE val height = currentVideo?.height ?: NO_VALUE if (width != NO_VALUE && height != NO_VALUE) { player.setMaxVideoSize(width, height, currentVideo?.id) } trackDialog.dismissSafe(activity) } } } catch (e: Exception) { logError(e) } } override fun playerError(exception: Throwable) { val currentUrl = currentSelectedLink?.let { it.first?.url ?: it.second?.uri?.toString() } ?: "unknown" val headers = currentSelectedLink?.first?.headers?.toString() ?: "none" val referer = currentSelectedLink?.first?.referer ?: "none" Log.e( TAG, "playerError: $currentSelectedLink, " + "type=${exception::class.java.canonicalName}, " + "message=${exception.message}, url=$currentUrl, headers=$headers, " + "referer=$referer, position=${player.getPosition() ?: "unknown"}, " + "duration=${player.getDuration() ?: "unknown"}, " + "isPlaying=${player.getIsPlaying()}", exception ) if (!hasNextMirror()) { viewModel.forceClearCache = true } super.playerError(exception) } private fun noLinksFound() { viewModel.forceClearCache = true showToast(R.string.no_links_found_toast, Toast.LENGTH_SHORT) activity?.popCurrentPage() } private fun startPlayer() { if (isActive) return // we don't want double load when you skip loading val links = sortLinks(currentQualityProfile) if (links.isEmpty()) { noLinksFound() return } loadLink(links.first(), false) } override fun nextEpisode() { if (viewModel.hasNextEpisode() == true) { isNextEpisode = true player.release() viewModel.loadLinksNext() } } override fun prevEpisode() { if (viewModel.hasPrevEpisode() == true) { isNextEpisode = true player.release() viewModel.loadLinksPrev() } } override fun hasNextMirror(): Boolean { val links = sortLinks(currentQualityProfile) return links.isNotEmpty() && links.indexOf(currentSelectedLink) + 1 < links.size } override fun nextMirror() { val links = sortLinks(currentQualityProfile) if (links.isEmpty()) { noLinksFound() return } val newIndex = links.indexOf(currentSelectedLink) + 1 if (newIndex >= links.size) { noLinksFound() return } loadLink(links[newIndex], true) } override fun onDestroy() { ResultFragment.updateUI() currentVerifyLink?.cancel() super.onDestroy() } var maxEpisodeSet: Int? = null var hasRequestedStamps: Boolean = false override fun playerPositionChanged(position: Long, duration: Long) { // Don't save livestream data if ((currentMeta as? ResultEpisode)?.tvType?.isLiveStream() == true) return // Don't save NSFW data if ((currentMeta as? ResultEpisode)?.tvType == TvType.NSFW) return if (duration <= 0L) return // idk how you achieved this, but div by zero crash if (!hasRequestedStamps) { hasRequestedStamps = true val fetchStamps = context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) settingsManager.getBoolean( ctx.getString(R.string.enable_skip_op_from_database), true ) } ?: true if (fetchStamps) viewModel.loadStamps(duration) } val percentage = position * 100L / duration DataStoreHelper.setViewPosAndResume( viewModel.getId(), position, duration, currentMeta, nextMeta ) var isOpVisible = false when (val meta = currentMeta) { is ResultEpisode -> { if (percentage >= UPDATE_SYNC_PROGRESS_PERCENTAGE && (maxEpisodeSet ?: -1) < meta.episode ) { context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) if (settingsManager.getBoolean( ctx.getString(R.string.episode_sync_enabled_key), true ) ) maxEpisodeSet = meta.episode sync.modifyMaxEpisode(meta.totalEpisodeIndex ?: meta.episode) } } if (meta.tvType.isAnimeOp()) isOpVisible = percentage < SKIP_OP_VIDEO_PERCENTAGE } } playerBinding?.playerSkipOp?.isVisible = isOpVisible when { isLayout(PHONE) -> playerBinding?.playerSkipEpisode?.isVisible = !isOpVisible && viewModel.hasNextEpisode() == true else -> { val hasNextEpisode = viewModel.hasNextEpisode() == true playerBinding?.playerGoForward?.isVisible = hasNextEpisode playerBinding?.playerGoForwardRoot?.isVisible = hasNextEpisode } } if (percentage >= PRELOAD_NEXT_EPISODE_PERCENTAGE) { viewModel.preLoadNextLinks() } } private fun getAutoSelectSubtitle( subtitles: Set, settings: Boolean, downloads: Boolean ): SubtitleData? { val langCode = preferredAutoSelectSubtitles ?: return null if (downloads) { return sortSubs(subtitles).firstOrNull { it.origin == SubtitleOrigin.DOWNLOADED_FILE && it.matchesLanguageCode(langCode) } } if (!settings) return null return sortSubs(subtitles).firstOrNull { it.matchesLanguageCode(langCode) } } private fun autoSelectFromSettings(): Boolean { // auto select subtitle based on settings val langCode = preferredAutoSelectSubtitles val current = player.getCurrentPreferredSubtitle() Log.i(TAG, "autoSelectFromSettings = $current") context?.let { ctx -> // Only use the player preferred subtitle if it matches the available language if (current != null && (langCode == null || current.matchesLanguageCode(langCode))) { if (setSubtitles(current, false)) { player.saveData() player.reloadPlayer(ctx) player.handleEvent(CSPlayerEvent.Play) return true } } else if (!langCode.isNullOrEmpty()) { getAutoSelectSubtitle( currentSubs, settings = true, downloads = false )?.let { sub -> if (setSubtitles(sub, false)) { player.saveData() player.reloadPlayer(ctx) player.handleEvent(CSPlayerEvent.Play) return true } } } } return false } private fun autoSelectFromDownloads(): Boolean { if (player.getCurrentPreferredSubtitle() == null) { getAutoSelectSubtitle(currentSubs, settings = false, downloads = true)?.let { sub -> context?.let { ctx -> if (setSubtitles(sub, false)) { player.saveData() player.reloadPlayer(ctx) player.handleEvent(CSPlayerEvent.Play) return true } } } } return false } private fun autoSelectSubtitles() { //Log.i(TAG, "autoSelectSubtitles") safe { if (!autoSelectFromSettings()) { autoSelectFromDownloads() } } } private fun getHeaderName(): String? { return when (val meta = currentMeta) { is ResultEpisode -> meta.headerName is ExtractorUri -> meta.headerName else -> null } } private fun getPlayerVideoTitle(): String { var headerName: String? = null var subName: String? = null var episode: Int? = null var season: Int? = null var tvType: TvType? = null when (val meta = currentMeta) { is ResultEpisode -> { headerName = meta.headerName subName = meta.name episode = meta.episode season = meta.season tvType = meta.tvType } is ExtractorUri -> { headerName = meta.headerName subName = meta.name episode = meta.episode season = meta.season tvType = meta.tvType } } context?.let { ctx -> //Generate video title val playerVideoTitle = if (headerName != null) { (headerName + if (tvType.isEpisodeBased() && episode != null) if (season == null) " - ${ ctx.getString( R.string.episode ) } $episode" else " \"${ctx.getString(R.string.season_short)}${season}:${ ctx.getString( R.string.episode_short ) }${episode}\"" else "") + if (subName.isNullOrBlank() || subName == headerName) "" else " - $subName" } else { "" } return playerVideoTitle } return "" } @SuppressLint("SetTextI18n") fun setTitle() { var playerVideoTitle = getPlayerVideoTitle() //Hide title, if set in setting if (limitTitle < 0) { playerBinding?.playerVideoTitle?.visibility = View.GONE } else { //Truncate video title if it exceeds limit val differenceInLength = playerVideoTitle.length - limitTitle val margin = 3 //If the difference is smaller than or equal to this value, ignore it if (limitTitle > 0 && differenceInLength > margin) { playerVideoTitle = playerVideoTitle.substring(0, limitTitle - 1) + "..." } } val isFiller: Boolean? = (currentMeta as? ResultEpisode)?.isFiller playerBinding?.playerEpisodeFillerHolder?.isVisible = isFiller ?: false playerBinding?.playerVideoTitle?.text = playerVideoTitle playerBinding?.offlinePin?.isVisible = lastUsedGenerator is DownloadFileGenerator } @SuppressLint("SetTextI18n") fun setPlayerDimen(widthHeight: Pair?) { val resolution = widthHeight?.let { "${it.first}x${it.second}" } val name = currentSelectedLink?.first?.name ?: currentSelectedLink?.second?.name val title = getHeaderName() val result = listOfNotNull( title?.takeIf { showTitle && it.isNotBlank() }, name?.takeIf { showName && it.isNotBlank() }, resolution?.takeIf { showResolution && it.isNotBlank() }, ).joinToString(" - ") playerBinding?.playerVideoTitleRez?.apply { text = result isVisible = result.isNotBlank() } } private fun updatePlayerInfo() { val tracks = player.getVideoTracks() val videoTrack = tracks.currentVideoTrack val audioTrack = tracks.currentAudioTrack val ctx = context ?: return val prefs = PreferenceManager.getDefaultSharedPreferences(ctx) showMediaInfo = prefs.getBoolean(ctx.getString(R.string.show_media_info_key), false) val videoCodec = videoTrack?.sampleMimeType?.substringAfterLast('/')?.uppercase() val audioCodec = audioTrack?.sampleMimeType?.substringAfterLast('/')?.uppercase() val language = listOfNotNull( audioTrack?.label, fromTagToLanguageName(audioTrack?.language)?.let { "[$it]" } ).joinToString(" ") val stats = arrayOf(videoCodec, audioCodec, language).filter { !it.isNullOrBlank() }.joinToString(" • ") playerBinding?.playerVideoInfo?.apply { text = stats isVisible = showMediaInfo && stats.isNotBlank() } } override fun playerDimensionsLoaded(width: Int, height: Int) { super.playerDimensionsLoaded(width, height) setPlayerDimen(width to height) } private fun unwrapBundle(savedInstanceState: Bundle?) { Log.i(TAG, "unwrapBundle = $savedInstanceState") savedInstanceState?.let { bundle -> sync.addSyncs(bundle.getSafeSerializable>("syncData")) } } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { // this is used instead of layout-television to follow the settings and some TV devices are not classified as TV for some reason layout = if (isLayout(TV or EMULATOR)) R.layout.fragment_player_tv else R.layout.fragment_player viewModel = ViewModelProvider(this)[PlayerGeneratorViewModel::class.java] sync = ViewModelProvider(this)[SyncViewModel::class.java] viewModel.attachGenerator(lastUsedGenerator) unwrapBundle(savedInstanceState) unwrapBundle(arguments) val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null binding = FragmentPlayerBinding.bind(root) return root } override fun onDestroyView() { binding = null super.onDestroyView() } var skipAnimator: ValueAnimator? = null var skipIndex = 0 private fun displayTimeStamp(show: Boolean) { if (timestampShowState == show) return skipIndex++ timestampShowState = show playerBinding?.skipChapterButton?.apply { val showWidth = 170.toPx val noShowWidth = 10.toPx //if((show && width == showWidth) || (!show && width == noShowWidth)) { // return //} val to = if (show) showWidth else noShowWidth val from = if (!show) showWidth else noShowWidth skipAnimator?.cancel() isVisible = true // just in case val lay = layoutParams lay.width = from layoutParams = lay skipAnimator = ValueAnimator.ofInt( from, to ).apply { addListener(onEnd = { if (show) { if (!isShowing) { // Automatically request focus if the menu is not opened playerBinding?.skipChapterButton?.requestFocus() } } else { playerBinding?.skipChapterButton?.isVisible = false if (!isShowing) { // Automatically return focus to play pause playerBinding?.playerPausePlay?.requestFocus() } } }) addUpdateListener { valueAnimator -> val value = valueAnimator.animatedValue as Int val layoutParams: ViewGroup.LayoutParams = layoutParams layoutParams.width = value setLayoutParams(layoutParams) } duration = 500 start() } } } override fun onTimestampSkipped(timestamp: EpisodeSkip.SkipStamp) { displayTimeStamp(false) } override fun onTimestamp(timestamp: EpisodeSkip.SkipStamp?) { if (timestamp != null) { playerBinding?.skipChapterButton?.setText(timestamp.uiText) displayTimeStamp(true) val currentIndex = skipIndex playerBinding?.skipChapterButton?.handler?.postDelayed({ if (skipIndex == currentIndex) displayTimeStamp(false) }, 6000) } else { displayTimeStamp(false) } } override fun isThereEpisodes(): Boolean { val meta = allMeta return !meta.isNullOrEmpty() && meta.size > 1 } override fun showEpisodesOverlay() { try { playerBinding?.apply { playerEpisodeList.setRecycledViewPool(EpisodeAdapter.sharedPool) playerEpisodeList.adapter = EpisodeAdapter( false, { episodeClick -> if (episodeClick.action == ACTION_CLICK_DEFAULT) { isNextEpisode = false player.release() playerEpisodeOverlay.isGone = true episodeClick.position?.let { viewModel.loadThisEpisode(it) } } }, { downloadClickEvent -> DownloadButtonSetup.handleDownloadClick(downloadClickEvent) } ) playerEpisodeList.setLinearListLayout( isHorizontal = false, nextUp = FOCUS_SELF, nextDown = FOCUS_SELF, nextRight = FOCUS_SELF, ) val episodes = allMeta ?: emptyList() (playerEpisodeList.adapter as? EpisodeAdapter)?.submitList(episodes) // Scroll to current episode viewModel.getCurrentIndex()?.let { index -> playerEpisodeList.scrollToPosition(index) // Ensure focus on tv if (isLayout(TV)) { playerEpisodeList.post { val viewHolder = playerEpisodeList.findViewHolderForAdapterPosition(index) viewHolder?.itemView?.requestFocus() viewHolder?.itemView?.let { itemView -> itemView.isFocusableInTouchMode = true itemView.requestFocus() } } } } // update overlay season title var lastTopIndex = -1 playerEpisodeList.addOnScrollListener(object : RecyclerView.OnScrollListener() { @SuppressLint("SetTextI18n", "DefaultLocale") override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { val layoutManager = recyclerView.layoutManager as? LinearLayoutManager ?: return val topIndex = layoutManager.findFirstCompletelyVisibleItemPosition() if (topIndex != RecyclerView.NO_POSITION && topIndex != lastTopIndex) { lastTopIndex = topIndex val topItem = episodes.getOrNull(topIndex) topItem?.let { playerEpisodeOverlayTitle.setText( ResultViewModel2.seasonToTxt( topItem.seasonData, topItem.seasonIndex ) ) } } } }) } } catch (e: Exception) { logError(e) } } @SuppressLint("SetTextI18n") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) var langFilterList = listOf() var filterSubByLang = false context?.let { ctx -> val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) showName = settingsManager.getBoolean(ctx.getString(R.string.show_name_key), true) showResolution = settingsManager.getBoolean(ctx.getString(R.string.show_resolution_key), true) showMediaInfo = settingsManager.getBoolean(ctx.getString(R.string.show_media_info_key), false) limitTitle = settingsManager.getInt(ctx.getString(R.string.prefer_title_limit_key), 0) updateForcedEncoding(ctx) filterSubByLang = settingsManager.getBoolean(getString(R.string.filter_sub_lang_key), false) if (filterSubByLang) { val langFromPrefMedia = settingsManager.getStringSet( this.getString(R.string.provider_lang_key), mutableSetOf("en") ) langFilterList = langFromPrefMedia?.mapNotNull { fromTagToEnglishLanguageName(it)?.lowercase() ?: return@mapNotNull null } ?: listOf() } } unwrapBundle(savedInstanceState) unwrapBundle(arguments) sync.updateUserData() preferredAutoSelectSubtitles = getAutoSelectLanguageTagIETF() if (currentSelectedLink == null) { viewModel.loadLinks() } binding?.overlayLoadingSkipButton?.setOnClickListener { startPlayer() } binding?.playerLoadingGoBack?.setOnClickListener { exitFullscreen() player.release() activity?.popCurrentPage() } playerBinding?.downloadHeader?.setOnClickListener { it?.isVisible = false } playerBinding?.downloadHeaderToggle?.setOnClickListener { playerBinding?.downloadHeader?.let { it.isVisible = !it.isVisible } } observe(viewModel.currentStamps) { stamps -> player.addTimeStamps(stamps) } observe(viewModel.loadingLinks) { when (it) { is Resource.Loading -> { startLoading() } is Resource.Success -> { // provider returned false //if (it.value != true) { // showToast(activity, R.string.unexpected_error, Toast.LENGTH_SHORT) //} startPlayer() } is Resource.Failure -> { showToast(it.errorString, Toast.LENGTH_LONG) startPlayer() } } } observe(viewModel.currentLinks) { currentLinks = it val turnVisible = it.isNotEmpty() && lastUsedGenerator?.canSkipLoading == true val wasGone = binding?.overlayLoadingSkipButton?.isGone == true binding?.overlayLoadingSkipButton?.apply { isVisible = turnVisible val value = viewModel.currentLinks.value if (value.isNullOrEmpty()) { setText(R.string.skip_loading) } else { text = "${context.getString(R.string.skip_loading)} (${value.size})" } } safe { if (currentLinks.any { link -> getLinkPriority(currentQualityProfile, link.first) >= QualityDataHelper.AUTO_SKIP_PRIORITY } ) { startPlayer() } } if (turnVisible && wasGone) { binding?.overlayLoadingSkipButton?.requestFocus() } } observe(viewModel.currentSubs) { set -> val setOfSub = mutableSetOf() if (langFilterList.isNotEmpty() && filterSubByLang) { Log.i("subfilter", "Filtering subtitle") langFilterList.forEach { lang -> Log.i("subfilter", "Lang: $lang") setOfSub += set.filter { it.originalName.contains(lang, ignoreCase = true) || it.origin != SubtitleOrigin.URL } } currentSubs = setOfSub } else { currentSubs = set } player.setActiveSubtitles(set) // If the file is downloaded then do not select auto select the subtitles // Downloaded subtitles cannot be selected immediately after loading since // player.getCurrentPreferredSubtitle() cannot fetch data from non-loaded subtitles // Resulting in unselecting the downloaded subtitle if (set.lastOrNull()?.origin != SubtitleOrigin.DOWNLOADED_FILE) { autoSelectSubtitles() } } } } @Suppress("DEPRECATION") inline fun Bundle.getSafeSerializable(key: String): T? = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) getSerializable(key) as? T else getSerializable( key, T::class.java ) ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/IGenerator.kt ================================================ package com.lagradost.cloudstream3.ui.player import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import kotlin.math.max import kotlin.math.min val LOADTYPE_INAPP = setOf( ExtractorLinkType.VIDEO, ExtractorLinkType.DASH, ExtractorLinkType.M3U8, ExtractorLinkType.TORRENT, ExtractorLinkType.MAGNET ) val LOADTYPE_INAPP_DOWNLOAD = setOf( ExtractorLinkType.VIDEO, ExtractorLinkType.M3U8 ) val LOADTYPE_CHROMECAST = setOf( ExtractorLinkType.VIDEO, ExtractorLinkType.DASH, ExtractorLinkType.M3U8 ) val LOADTYPE_ALL = ExtractorLinkType.entries.toSet() abstract class NoVideoGenerator : VideoGenerator(emptyList(), 0) { override val hasCache = false override val canSkipLoading = false } abstract class VideoGenerator(val videos: List, var videoIndex: Int = 0) : IGenerator { override fun hasNext(): Boolean = videoIndex < videos.lastIndex override fun hasPrev(): Boolean = videoIndex > 0 override fun getAll(): List? = videos override fun getCurrent(offset: Int): T? = videos.getOrNull(videoIndex + offset) override fun next() { if (hasNext()) { videoIndex += 1 } } override fun prev() { if (hasPrev()) { videoIndex -= 1 } } override fun goto(index: Int) { videoIndex = min(videos.lastIndex, max(0, index)) } override fun getCurrentId(): Int? { return when (val current = getCurrent()) { is ResultEpisode -> { current.id } is ExtractorUri -> { current.id } else -> null } } } // TODO deprecate/remove IGenerator in favor of a more ergonomic and correct implementation interface IGenerator { val hasCache: Boolean val canSkipLoading: Boolean fun hasNext(): Boolean fun hasPrev(): Boolean fun next() fun prev() fun goto(index: Int) fun getCurrentId(): Int? // this is used to save data or read data about this id fun getCurrent(offset: Int = 0): Any? // this is used to get metadata about the current playing, can return null fun getAll(): List? // this us used to get the metadata about all entries, not needed /* not safe, must use try catch */ suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, offset: Int = 0, isCasting: Boolean = false ): Boolean } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/IPlayer.kt ================================================ package com.lagradost.cloudstream3.ui.player import android.content.Context import android.graphics.Bitmap import android.util.Rational import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink enum class PlayerEventType(val value: Int) { Pause(0), Play(1), SeekForward(2), SeekBack(3), SkipCurrentChapter(4), NextEpisode(5), PrevEpisode(6), PlayPauseToggle(7), ToggleMute(8), Lock(9), ToggleHide(10), ShowSpeed(11), ShowMirrors(12), Resize(13), SearchSubtitlesOnline(14), SkipOp(15), Restart(16), } enum class CSPlayerEvent(val value: Int) { Pause(0), Play(1), SeekForward(2), SeekBack(3), SkipCurrentChapter(4), NextEpisode(5), PrevEpisode(6), PlayPauseToggle(7), ToggleMute(8), Restart(9), PlayAsAudio(10), } enum class CSPlayerLoading { IsPaused, IsPlaying, IsBuffering, IsEnded, } enum class PlayerEventSource { /** This event was invoked from the user pressing some button or selecting something */ UI, /** This event was invoked automatically */ Player, /** This event was invoked from a external sync tool like WatchTogether */ Sync, } abstract class PlayerEvent { abstract val source: PlayerEventSource } /** this is used to update UI based of the current time, * using requestedListeningPercentages as well as saving time */ data class PositionEvent( override val source: PlayerEventSource, val fromMs: Long, val toMs: Long, /** duration of the entire video */ val durationMs: Long, ) : PlayerEvent() { /** how many ms (+-) we have skipped */ val seekMs : Long get() = toMs - fromMs } /** player error when rendering or misc, used to display toast or log */ data class ErrorEvent( val error: Throwable, override val source: PlayerEventSource = PlayerEventSource.Player, ) : PlayerEvent() /** Event when timestamps appear, null when it should disappear */ data class TimestampInvokedEvent( val timestamp: EpisodeSkip.SkipStamp, override val source: PlayerEventSource = PlayerEventSource.Player, ) : PlayerEvent() /** Event for when a chapter is skipped, aka when event is handled (or for future use when skip automatically ads/sponsor) */ data class TimestampSkippedEvent( val timestamp: EpisodeSkip.SkipStamp, override val source: PlayerEventSource = PlayerEventSource.Player, ) : PlayerEvent() /** this is used by the player to load the next or prev episode */ data class EpisodeSeekEvent( /** -1 = prev, 1 = next, will never be 0, atm the user cant seek more than +-1 */ val offset: Int, override val source: PlayerEventSource = PlayerEventSource.Player, ) : PlayerEvent() { init { assert(offset != 0) } } /** Event when the video is resized aka changed resolution or mirror */ data class ResizedEvent( val height: Int, val width: Int, override val source: PlayerEventSource = PlayerEventSource.Player, ) : PlayerEvent() /** Event when the player status update, along with the previous status (for animation)*/ data class StatusEvent( val wasPlaying: CSPlayerLoading, val isPlaying: CSPlayerLoading, override val source: PlayerEventSource = PlayerEventSource.Player ) : PlayerEvent() /** Event when tracks are changed, used for UI changes */ data class TracksChangedEvent( override val source: PlayerEventSource = PlayerEventSource.Player ) : PlayerEvent() /** Event from player to give all embedded subtitles */ data class EmbeddedSubtitlesFetchedEvent( val tracks: List, override val source: PlayerEventSource = PlayerEventSource.Player ) : PlayerEvent() /** on attach player to view */ data class PlayerAttachedEvent( val player: Any?, override val source: PlayerEventSource = PlayerEventSource.Player ) : PlayerEvent() /** Event from player to inform that subtitles have updated in some way */ data class SubtitlesUpdatedEvent( override val source: PlayerEventSource = PlayerEventSource.Player ) : PlayerEvent() /** current player starts, asking for all other programs to shut the fuck up */ data class RequestAudioFocusEvent( override val source: PlayerEventSource = PlayerEventSource.Player ) : PlayerEvent() /** Pause event, separate from StatusEvent */ data class PauseEvent( override val source: PlayerEventSource = PlayerEventSource.Player ) : PlayerEvent() /** Play event, separate from StatusEvent */ data class PlayEvent( override val source: PlayerEventSource = PlayerEventSource.Player ) : PlayerEvent() /** Event when the player video has ended, up to the settings on what to do when that happens */ data class VideoEndedEvent( override val source: PlayerEventSource = PlayerEventSource.Player ) : PlayerEvent() /** Used for torrent to pre-download a video before playing it */ data class DownloadEvent( val downloadedBytes: Long, val totalBytes: Long, /** bytes / sec */ val downloadSpeed: Long, val connections: Int?, override val source: PlayerEventSource = PlayerEventSource.Player ) : PlayerEvent() interface Track { /** * Unique among the class, used to check which track is used. * VideoTrack and AudioTrack can have the same id **/ val id: String? val label: String? val language: String? val sampleMimeType : String? } data class VideoTrack( override val id: String?, override val label: String?, override val language: String?, val width: Int?, val height: Int?, override val sampleMimeType: String?, ) : Track data class AudioTrack( override val id: String?, override val label: String?, override val language: String?, override val sampleMimeType: String?, val channelCount: Int?, val formatIndex: Int?, ) : Track data class TextTrack( override val id: String?, override val label: String?, override val language: String?, override val sampleMimeType: String?, ) : Track data class CurrentTracks( val currentVideoTrack: VideoTrack?, val currentAudioTrack: AudioTrack?, val currentTextTracks: List, val allVideoTracks: List, val allAudioTracks: List, val allTextTracks: List, ) class InvalidFileException(msg: String) : Exception(msg) //http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 const val ACTION_MEDIA_CONTROL = "media_control" const val EXTRA_CONTROL_TYPE = "control_type" /** Abstract Exoplayer logic, can be expanded to other players */ interface IPlayer { fun getPlaybackSpeed(): Float fun setPlaybackSpeed(speed: Float) fun getIsPlaying(): Boolean /** Current player duration in milliseconds */ fun getDuration(): Long? /** Current player position in milliseconds */ fun getPosition(): Long? fun seekTime(time: Long, source: PlayerEventSource = PlayerEventSource.UI) fun seekTo(time: Long, source: PlayerEventSource = PlayerEventSource.UI) fun getSubtitleOffset(): Long // in ms fun setSubtitleOffset(offset: Long) // in ms fun initCallbacks( eventHandler: ((PlayerEvent) -> Unit), /** this is used to request when the player should report back view percentage */ requestedListeningPercentages: List? = null, ) fun releaseCallbacks() fun updateSubtitleStyle(style: SaveCaptionStyle) fun saveData() fun addTimeStamps(timeStamps: List) fun loadPlayer( context: Context, sameEpisode: Boolean, link: ExtractorLink? = null, data: ExtractorUri? = null, startPosition: Long? = null, subtitles: Set, subtitle: SubtitleData?, autoPlay: Boolean? = true, preview : Boolean = true, ) fun reloadPlayer(context: Context) fun getPreview(fraction : Float) : Bitmap? fun hasPreview() : Boolean fun setActiveSubtitles(subtitles: Set) fun setPreferredSubtitles(subtitle: SubtitleData?): Boolean // returns true if the player requires a reload, null for nothing fun getCurrentPreferredSubtitle(): SubtitleData? fun handleEvent(event: CSPlayerEvent, source: PlayerEventSource = PlayerEventSource.UI) fun onStop() fun onPause() fun onResume(context: Context) fun release() /** Get if player is actually used */ fun isActive(): Boolean fun getVideoTracks(): CurrentTracks /** * Original video aspect ratio used for PiP mode * * Set using: Width, Height. * Example: Rational(16, 9) * * If null will default to set no aspect ratio. * * PiP functions calling this needs to coerce this value between 0.418410 and 2.390000 * to prevent crashes. */ fun getAspectRatio(): Rational? /** If no parameters are set it'll default to no set size, Specifying the id allows for track overrides to force the player to pick the quality. */ fun setMaxVideoSize(width: Int = Int.MAX_VALUE, height: Int = Int.MAX_VALUE, id: String? = null) /** If no trackLanguage is set it'll default to first track. Specifying the id allows for track overrides as the language can be identical. */ fun setPreferredAudioTrack(trackLanguage: String?, id: String? = null, trackIndex: Int? = null) /** Get the current subtitle cues, for use with syncing */ fun getSubtitleCues(): List } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/LinkGenerator.kt ================================================ package com.lagradost.cloudstream3.ui.player import android.net.Uri import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.actions.temp.CloudStreamPackage import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.INFER_TYPE import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.loadExtractor import com.lagradost.cloudstream3.utils.newExtractorLink import com.lagradost.cloudstream3.utils.unshortenLinkSafe data class ExtractorUri( val uri: Uri, val name: String, val basePath: String? = null, val relativePath: String? = null, val displayName: String? = null, val id: Int? = null, val parentId: Int? = null, val episode: Int? = null, val season: Int? = null, val headerName: String? = null, val tvType: TvType? = null, ) /** * Used to open the player more easily with the LinkGenerator **/ data class BasicLink( val url: String, val name: String? = null, ) class LinkGenerator( private val links: List, private val extract: Boolean = true, private val refererUrl: String? = null, ) : NoVideoGenerator() { override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, offset: Int, isCasting: Boolean ): Boolean { links.amap { link -> if (!extract || !loadExtractor(link.url, refererUrl, { subtitleCallback(PlayerSubtitleHelper.getSubtitleData(it)) }) { callback(it to null) }) { // if don't extract or if no extractor found simply return the link callback( newExtractorLink( "", link.name ?: link.url, unshortenLinkSafe(link.url), // unshorten because it might be a raw link type = INFER_TYPE, ) { this.referer = refererUrl ?: "" this.quality = Qualities.Unknown.value } to null ) } } return true } } class MinimalLinkGenerator( private val links: List, private val subs: List, private val id: Int? = null ) : NoVideoGenerator() { override fun getCurrentId(): Int? = id override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, offset: Int, isCasting: Boolean ): Boolean { for (link in links) { callback(link.toExtractorLink()) } for (link in subs) { subtitleCallback(link.toSubtitleData()) } return true } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/OfflinePlaybackHelper.kt ================================================ package com.lagradost.cloudstream3.ui.player import android.app.Activity import android.content.ContentUris import android.content.Intent import android.net.Uri import androidx.core.content.ContextCompat.getString import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.temp.CloudStreamPackage import com.lagradost.cloudstream3.utils.AppUtils.tryParseJson import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.safefile.SafeFile object OfflinePlaybackHelper { fun playLink(activity: Activity, url: String) { activity.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( LinkGenerator( listOf( BasicLink(url) ) ) ) ) } // See CloudStreamPackage fun playIntent(activity: Activity, intent: Intent?): Boolean { if (intent == null) return false val links = intent.getStringArrayExtra(CloudStreamPackage.LINKS_EXTRA) ?.mapNotNull { tryParseJson(it) } ?: emptyList() if (links.isEmpty()) return false val subs = intent.getStringArrayExtra(CloudStreamPackage.SUBTITLE_EXTRA) ?.mapNotNull { tryParseJson(it) } ?: emptyList() val id = intent.getIntExtra(CloudStreamPackage.ID_EXTRA, -1) //val title = intent.getStringExtra(CloudStreamPackage.TITLE_EXTRA) // unused val pos = intent.getLongExtra(CloudStreamPackage.POSITION_EXTRA, -1L) val dur = intent.getLongExtra(CloudStreamPackage.DURATION_EXTRA, -1L) if (id != -1 && pos != -1L) { val duration = if (dur != -1L) { dur } else DataStoreHelper.getViewPos(id)?.duration ?: pos DataStoreHelper.setViewPos(id, pos, duration) } activity.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( MinimalLinkGenerator( links, subs, if (id != -1) id else null, ) ) ) return true } fun playUri(activity: Activity, uri: Uri) { if (uri.scheme == "magnet") { playLink(activity, uri.toString()) return } val name = SafeFile.fromUri(activity, uri)?.name() activity.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( DownloadFileGenerator( listOf( ExtractorUri( uri = uri, name = name ?: getString(activity, R.string.downloaded_file), // well not the same as a normal id, but we take it as users may want to // play downloaded files and save the location id = kotlin.runCatching { ContentUris.parseId(uri) }.getOrNull() ?.hashCode() ) ) ) ) ) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/OutlineSpan.kt ================================================ package com.lagradost.cloudstream3.ui.player import android.text.TextPaint import android.text.style.CharacterStyle import androidx.annotation.Px // source: https://github.com/androidx/media/pull/1840 class OutlineSpan(@Px val outlineWidth : Float) : CharacterStyle() { override fun updateDrawState(tp: TextPaint?) { tp?.strokeWidth = outlineWidth } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerGeneratorViewModel.kt ================================================ package com.lagradost.cloudstream3.ui.player import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.EpisodeSkip import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import kotlinx.coroutines.Job import kotlinx.coroutines.launch class PlayerGeneratorViewModel : ViewModel() { companion object { const val TAG = "PlayViewGen" } private var generator: IGenerator? = null private val _currentLinks = MutableLiveData>>(setOf()) val currentLinks: LiveData>> = _currentLinks private val _currentSubs = MutableLiveData>(setOf()) val currentSubs: LiveData> = _currentSubs private val _loadingLinks = MutableLiveData>() val loadingLinks: LiveData> = _loadingLinks private val _currentStamps = MutableLiveData>(emptyList()) val currentStamps: LiveData> = _currentStamps private val _currentSubtitleYear = MutableLiveData(null) val currentSubtitleYear: LiveData = _currentSubtitleYear /** * Save the Episode ID to prevent starting multiple link loading Jobs when preloading links. */ private var currentLoadingEpisodeId: Int? = null var forceClearCache = false fun setSubtitleYear(year: Int?) { _currentSubtitleYear.postValue(year) } fun getId(): Int? { return generator?.getCurrentId() } fun loadLinks(episode: Int) { generator?.goto(episode) loadLinks() } fun loadLinksPrev() { Log.i(TAG, "loadLinksPrev") if (generator?.hasPrev() == true) { generator?.prev() loadLinks() } } fun loadLinksNext() { Log.i(TAG, "loadLinksNext") if (generator?.hasNext() == true) { generator?.next() loadLinks() } } fun hasNextEpisode(): Boolean? { return generator?.hasNext() } fun hasPrevEpisode(): Boolean? { return generator?.hasPrev() } fun preLoadNextLinks() { val id = getId() // Do not preload if already loading if (id == currentLoadingEpisodeId) return Log.i(TAG, "preLoadNextLinks") currentJob?.cancel() currentLoadingEpisodeId = id currentJob = viewModelScope.launch { try { if (generator?.hasCache == true && generator?.hasNext() == true) { safeApiCall { generator?.generateLinks( sourceTypes = LOADTYPE_INAPP, clearCache = false, callback = {}, subtitleCallback = {}, offset = 1 ) } } } catch (t: Throwable) { logError(t) } finally { if (currentLoadingEpisodeId == id) { currentLoadingEpisodeId = null } } } } fun getLoadResponse(): LoadResponse? { return safe { (generator as? RepoLinkGenerator?)?.page } } fun getMeta(): Any? { return safe { generator?.getCurrent() } } fun getAllMeta(): List? { return safe { generator?.getAll() } } fun getNextMeta(): Any? { return safe { if (generator?.hasNext() == false) return@safe null generator?.getCurrent(offset = 1) } } fun loadThisEpisode(index:Int) { generator?.goto(index) loadLinks() } fun getCurrentIndex():Int?{ val repoGen = generator as? RepoLinkGenerator ?: return null return repoGen.videoIndex } fun attachGenerator(newGenerator: IGenerator?) { if (generator == null) { generator = newGenerator } } private var extraSubtitles : MutableSet = mutableSetOf() /** * If duplicate nothing will happen * */ fun addSubtitles(file: Set) = synchronized(extraSubtitles) { extraSubtitles += file val current = _currentSubs.value ?: emptySet() val next = extraSubtitles + current // if it is of a different size then we have added distinct items if (next.size != current.size) { // Posting will refresh subtitles which will in turn // make the subs to english if previously unselected _currentSubs.postValue(next) } } private var currentJob: Job? = null private var currentStampJob: Job? = null fun loadStamps(duration: Long) { //currentStampJob?.cancel() currentStampJob = ioSafe { val meta = generator?.getCurrent() val page = (generator as? RepoLinkGenerator?)?.page if (page != null && meta is ResultEpisode) { _currentStamps.postValue(listOf()) _currentStamps.postValue( EpisodeSkip.getStamps( page, meta, duration, hasNextEpisode() ?: false ) ) } } } fun loadLinks(sourceTypes: Set = LOADTYPE_INAPP) { Log.i(TAG, "loadLinks") currentJob?.cancel() currentJob = viewModelScope.launchSafe { // if we load links then we clear the prev loaded links synchronized(extraSubtitles) { extraSubtitles.clear() } val currentLinks = mutableSetOf>() val currentSubs = mutableSetOf() // clear old data _currentSubs.postValue(emptySet()) _currentLinks.postValue(emptySet()) // load more data _loadingLinks.postValue(Resource.Loading()) val loadingState = safeApiCall { generator?.generateLinks( sourceTypes = sourceTypes, clearCache = forceClearCache, callback = { synchronized(currentLinks) { currentLinks.add(it) // Clone to prevent ConcurrentModificationException safe { // Extra safe since .toSet() iterates. _currentLinks.postValue(currentLinks.toSet()) } } }, subtitleCallback = { synchronized(extraSubtitles) { currentSubs.add(it) safe { _currentSubs.postValue(currentSubs + extraSubtitles) } } }) } _loadingLinks.postValue(loadingState) _currentLinks.postValue(currentLinks) synchronized(extraSubtitles) { _currentSubs.postValue(currentSubs + extraSubtitles) } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerPipHelper.kt ================================================ package com.lagradost.cloudstream3.ui.player import android.app.Activity import android.app.AppOpsManager import android.app.PendingIntent import android.app.PictureInPictureParams import android.app.RemoteAction import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.graphics.drawable.Icon import android.os.Build import android.util.Rational import androidx.annotation.RequiresApi import androidx.annotation.StringRes import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import kotlin.math.roundToInt object PlayerPipHelper { /** Is pip (Player in Player) supported, and enabled? */ fun Context.isPIPPossible() : Boolean { return try { this.hasPIPEnabled() && this.hasPIPFeature() } catch (t : Throwable) { // While both hasPIPEnabled and hasPIPFeature should never throw, this catches it just in case logError(t) false } } /** Is pip enabled in app settings? */ private fun Context.hasPIPEnabled(): Boolean { return try { val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) settingsManager?.getBoolean( getString(R.string.pip_enabled_key), true ) ?: true } catch (e: Exception) { logError(e) false } } /** * Is pip supported by the OS? * * Source: * https://stackoverflow.com/questions/52594181/how-to-know-if-user-has-disabled-picture-in-picture-feature-permission * https://developer.android.com/guide/topics/ui/picture-in-picture * */ private fun Context.hasPIPFeature(): Boolean = // OS Support Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && // Might have the feature, but OS blocked due to power drain this.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) && // Might have been disabled by the user this.hasPIPPermission() /** Is pip enabled in the OS settings? */ private fun Context.hasPIPPermission(): Boolean { val appOps = getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { appOps.checkOpNoThrow( AppOpsManager.OPSTR_PICTURE_IN_PICTURE, android.os.Process.myUid(), packageName ) == AppOpsManager.MODE_ALLOWED } else true } @RequiresApi(Build.VERSION_CODES.O) private fun getPen(activity: Activity, code: Int): PendingIntent { return PendingIntent.getBroadcast( activity, code, Intent(ACTION_MEDIA_CONTROL).putExtra(EXTRA_CONTROL_TYPE, code), PendingIntent.FLAG_IMMUTABLE ) } @RequiresApi(Build.VERSION_CODES.O) private fun getRemoteAction( activity: Activity, id: Int, @StringRes title: Int, event: CSPlayerEvent ): RemoteAction { val text = activity.getString(title) return RemoteAction( Icon.createWithResource(activity, id), text, text, getPen(activity, event.value) ) } fun updatePIPModeActions( activity: Activity?, status: CSPlayerLoading, pipEnabled: Boolean, aspectRatio: Rational? ) { // Is it even desired to enter pip mode right now if we ignore all settings? // This does not check for isPIPPossible as that is deferred to later val isPipDesired = when (status) { CSPlayerLoading.IsBuffering, CSPlayerLoading.IsPlaying -> pipEnabled else -> false } // On lower api ver setPictureInPictureParams is not supported, // so we enter pip manually in onUserLeaveHint if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { CommonActivity.isPipDesired = isPipDesired return } if(activity == null) return val actions: ArrayList = ArrayList() actions.add( getRemoteAction( activity, R.drawable.baseline_headphones_24, R.string.audio_singluar, CSPlayerEvent.PlayAsAudio ) ) /*actions.add( getRemoteAction( activity, R.drawable.go_back_30, R.string.go_back_30, CSPlayerEvent.SeekBack ) )*/ if (status == CSPlayerLoading.IsPlaying) { actions.add( getRemoteAction( activity, R.drawable.netflix_pause, R.string.pause, CSPlayerEvent.Pause ) ) } else { actions.add( getRemoteAction( activity, R.drawable.ic_baseline_play_arrow_24, R.string.pause, CSPlayerEvent.Play ) ) } actions.add( getRemoteAction( activity, R.drawable.go_forward_30, R.string.go_forward_30, CSPlayerEvent.SeekForward ) ) // Necessary to prevent crashing. val mixAspectRatio = 0.41841f // ~1/2.39 val maxAspectRatio = 2.39f // widescreen standard val ratioAccuracy = 100000 // To convert the float to int // java.lang.IllegalArgumentException: setPictureInPictureParams: Aspect ratio is too extreme // (must be between 0.418410 and 2.390000) val fixedRational = aspectRatio?.toFloat()?.coerceIn(mixAspectRatio, maxAspectRatio)?.let { Rational((it * ratioAccuracy).roundToInt(), ratioAccuracy) } safe { activity.setPictureInPictureParams( PictureInPictureParams.Builder() .apply { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { setSeamlessResizeEnabled(true) setAutoEnterEnabled(isPipDesired && activity.isPIPPossible()) } else { // We enter pip manually in onUserLeaveHint as the smooth transition // is not supported yet CommonActivity.isPipDesired = isPipDesired } } .setAspectRatio(fixedRational) .setActions(actions) .build() ) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/PlayerSubtitleHelper.kt ================================================ package com.lagradost.cloudstream3.ui.player import android.util.Log import android.util.TypedValue import android.view.ViewGroup import android.widget.FrameLayout import androidx.annotation.OptIn import androidx.media3.common.MimeTypes import androidx.media3.common.util.UnstableApi import androidx.media3.ui.SubtitleView import com.lagradost.cloudstream3.SubtitleFile import com.lagradost.cloudstream3.ui.subtitles.SaveCaptionStyle import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment.Companion.setSubtitleViewStyle import com.lagradost.cloudstream3.utils.SubtitleHelper.fromLanguageToTagIETF import com.lagradost.cloudstream3.utils.UIHelper.toPx enum class SubtitleStatus { IS_ACTIVE, REQUIRES_RELOAD, NOT_FOUND, } enum class SubtitleOrigin { URL, DOWNLOADED_FILE, EMBEDDED_IN_VIDEO } /** * @param originalName the start of the name to be displayed in the player * @param nameSuffix An extra suffix added to the subtitle to make sure it is unique * @param url Url for the subtitle, when EMBEDDED_IN_VIDEO this variable is used as the real backend id * @param headers if empty it will use the base onlineDataSource headers else only the specified headers * @param languageCode usually, tags such as "en", "es-mx", or "zh-hant-TW". But it could be something like "English 4" * */ data class SubtitleData( val originalName: String, val nameSuffix: String, val url: String, val origin: SubtitleOrigin, val mimeType: String, val headers: Map, val languageCode: String?, ) { /** Internal ID for exoplayer, unique for each link*/ fun getId(): String { return if (origin == SubtitleOrigin.EMBEDDED_IN_VIDEO) url else "$url|$name" } /** Returns true if langCode is the same as the IETF tag */ fun matchesLanguageCode(langCode: String): Boolean { return getIETF_tag() == langCode } /** Tries hard to figure out a valid IETF tag based on language code and name. Will return null if not found. */ fun getIETF_tag(): String? { return fromLanguageToTagIETF(this.languageCode) ?: fromLanguageToTagIETF(this.originalName, halfMatch = true) } val name = "$originalName $nameSuffix" /** * Gets the URL, but tries to fix it if it is malformed. */ fun getFixedUrl(): String { // Some extensions fail to include the protocol, this helps with that. val fixedSubUrl = if (this.url.startsWith("//")) { "https:${this.url}" } else { this.url } return fixedSubUrl } } @OptIn(UnstableApi::class) class PlayerSubtitleHelper { private var activeSubtitles: Set = emptySet() private var allSubtitles: Set = emptySet() fun getAllSubtitles(): Set { return allSubtitles } fun setActiveSubtitles(list: Set) { activeSubtitles = list } fun setAllSubtitles(list: Set) { allSubtitles = list } var subtitleView: SubtitleView? = null companion object { fun String.toSubtitleMimeType(): String { return when { endsWith("vtt", true) -> MimeTypes.TEXT_VTT endsWith("srt", true) -> MimeTypes.APPLICATION_SUBRIP endsWith("xml", true) || endsWith("ttml", true) -> MimeTypes.APPLICATION_TTML else -> MimeTypes.APPLICATION_SUBRIP } } fun getSubtitleData(subtitleFile: SubtitleFile): SubtitleData { return SubtitleData( originalName = subtitleFile.lang, nameSuffix = "", url = subtitleFile.url, origin = SubtitleOrigin.URL, mimeType = subtitleFile.url.toSubtitleMimeType(), headers = subtitleFile.headers ?: emptyMap(), languageCode = subtitleFile.langTag ?: subtitleFile.lang ) } } fun subtitleStatus(sub: SubtitleData?): SubtitleStatus { if (activeSubtitles.contains(sub)) { return SubtitleStatus.IS_ACTIVE } if (allSubtitles.contains(sub)) { return SubtitleStatus.REQUIRES_RELOAD } return SubtitleStatus.NOT_FOUND } fun setSubStyle(style: SaveCaptionStyle) { Log.i(TAG, "SET STYLE = $style") subtitleView?.translationY = -style.elevation.toPx.toFloat() setSubtitleViewStyle(subtitleView, style, true) } fun initSubtitles(subView: SubtitleView?, subHolder: FrameLayout?, style: SaveCaptionStyle?) { subtitleView = subView subView?.let { sView -> (sView.parent as ViewGroup?)?.removeView(sView) subHolder?.addView(sView) } style?.let { setSubStyle(it) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/PreviewGenerator.kt ================================================ package com.lagradost.cloudstream3.ui.player import android.content.Context import android.graphics.Bitmap import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Build import android.util.Log import androidx.annotation.WorkerThread import androidx.core.graphics.scale import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.M3u8Helper import com.lagradost.cloudstream3.utils.M3u8Helper2 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext import kotlin.math.absoluteValue import kotlin.math.ceil import kotlin.math.log2 const val MAX_LOD = 6 const val MIN_LOD = 3 data class ImageParams( val width: Int, val height: Int, ) { companion object { val DEFAULT = ImageParams(200, 320) fun new16by9(width: Int): ImageParams { if (width < 100) { return DEFAULT } return ImageParams( width / 4, (width * 9) / (4 * 16) ) } } init { assert(width > 0 && height > 0) } } interface IPreviewGenerator { fun hasPreview(): Boolean fun getPreviewImage(fraction: Float): Bitmap? fun release() var params: ImageParams var durationMs: Long var loadedImages: Int companion object { fun new(): IPreviewGenerator { val userDisabled = CloudStreamApp.context?.let { ctx -> PreferenceManager.getDefaultSharedPreferences(ctx)?.getBoolean( ctx.getString(R.string.preview_seekbar_key), true ) == false } ?: false /** because TV has low ram + not show we disable this for now */ return if (isLayout(TV) || userDisabled) { empty() } else { PreviewGenerator() } } fun empty(): IPreviewGenerator { return NoPreviewGenerator() } } } private fun rescale(image: Bitmap, params: ImageParams): Bitmap { if (image.width <= params.width && image.height <= params.height) return image val new = image.scale(params.width, params.height) // throw away the old image if (new != image) { image.recycle() } return new } /** rescale to not take up as much memory */ private fun MediaMetadataRetriever.image(timeUs: Long, params: ImageParams): Bitmap? { /*if (timeUs <= 0 && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { try { val primary = this.primaryImage if (primary != null) { return rescale(primary, params) } } catch (t: Throwable) { logError(t) } }*/ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { this.getScaledFrameAtTime( timeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, params.width, params.height ) } else { return rescale(this.getFrameAtTime(timeUs) ?: return null, params) } } /** PreviewGenerator that hides the implementation details of the sub generators that is used, used for source switch cache */ class PreviewGenerator : IPreviewGenerator { /** the most up to date generator, will always mirror the actual source in the player */ private var currentGenerator: IPreviewGenerator = NoPreviewGenerator() /** the longest generated preview of the same episode */ private var lastGenerator: IPreviewGenerator = NoPreviewGenerator() /** always NoPreviewGenerator, used as a cache for nothing */ private val dummy: IPreviewGenerator = NoPreviewGenerator() /** if the current generator is the same as the last by checking time */ private fun isSameLength(): Boolean = currentGenerator.durationMs.minus(lastGenerator.durationMs).absoluteValue < 10_000L /** use the backup if the current generator is init or if they have the same length */ private val backupGenerator: IPreviewGenerator get() { if (currentGenerator.durationMs == 0L || isSameLength()) { return lastGenerator } return dummy } override fun hasPreview(): Boolean { return currentGenerator.hasPreview() || backupGenerator.hasPreview() } override fun getPreviewImage(fraction: Float): Bitmap? { return try { currentGenerator.getPreviewImage(fraction) ?: backupGenerator.getPreviewImage(fraction) } catch (t: Throwable) { logError(t) null } } override fun release() { lastGenerator.release() currentGenerator.release() lastGenerator = NoPreviewGenerator() currentGenerator = NoPreviewGenerator() } override var params: ImageParams = ImageParams.DEFAULT set(value) { field = value lastGenerator.params = value backupGenerator.params = value currentGenerator.params = value } override var durationMs: Long get() = currentGenerator.durationMs set(_) {} override var loadedImages: Int get() = currentGenerator.loadedImages set(_) {} fun clear(keepCache: Boolean) { if (keepCache) { if (!isSameLength() || currentGenerator.loadedImages >= lastGenerator.loadedImages || lastGenerator.durationMs == 0L) { // the current generator is better than the last generator, therefore keep the current // or the lengths are not the same, therefore favoring the more recent selection // if they are the same we favor the current generator lastGenerator.release() lastGenerator = currentGenerator } else { // otherwise just keep the last generator and throw away the current generator currentGenerator.release() } } else { // we switched the episode, therefore keep nothing lastGenerator.release() lastGenerator = NoPreviewGenerator() currentGenerator.release() // we assume that we set currentGenerator right after this, so currentGenerator != NoPreviewGenerator } } fun load(link: ExtractorLink, keepCache: Boolean) { clear(keepCache) when (link.type) { ExtractorLinkType.M3U8 -> { currentGenerator = M3u8PreviewGenerator(params).apply { load(url = link.url, headers = link.getAllHeaders()) } } ExtractorLinkType.VIDEO -> { currentGenerator = Mp4PreviewGenerator(params).apply { load(url = link.url, headers = link.getAllHeaders()) } } else -> { Log.i("PreviewImg", "unsupported format for $link") } } } fun load(context: Context, link: ExtractorUri, keepCache: Boolean) { clear(keepCache) currentGenerator = Mp4PreviewGenerator(params).apply { load(keepCache = keepCache, context = context, uri = link.uri) } } } @Suppress("UNUSED_PARAMETER") private class NoPreviewGenerator : IPreviewGenerator { override fun hasPreview(): Boolean = false override fun getPreviewImage(fraction: Float): Bitmap? = null override fun release() = Unit override var params: ImageParams get() = ImageParams(0, 0) set(value) {} override var durationMs: Long = 0L override var loadedImages: Int = 0 } private class M3u8PreviewGenerator(override var params: ImageParams) : IPreviewGenerator { // generated images 1:1 to idx of hsl private var images: Array = arrayOf() companion object { private const val TAG = "PreviewImgM3u8" } // prefixSum[i] = sum(hsl.ts[0..i].time) // where [0] = 0, [1] = hsl.ts[0].time aka time at start of segment, do [b] - [a] for range a,b private var prefixSum: Array = arrayOf() // how many images has been generated override var loadedImages: Int = 0 // how many images we can generate in total, == hsl.size ?: 0 private var totalImages: Int = 0 override fun hasPreview(): Boolean { return totalImages > 0 && loadedImages >= minOf(totalImages, 4) } override fun getPreviewImage(fraction: Float): Bitmap? { var bestIdx = -1 var bestDiff = Double.MAX_VALUE synchronized(images) { // just find the best one in a for loop, we don't care about bin searching rn for (i in images.indices) { val diff = prefixSum[i].minus(fraction).absoluteValue if (diff > bestDiff) { break } if (images[i] != null) { bestIdx = i bestDiff = diff } } return images.getOrNull(bestIdx) } /* val targetIndex = prefixSum.binarySearch(target) var ret = images[targetIndex] if (ret != null) { return ret } for (i in 0..images.size) { ret = images.getOrNull(i+targetIndex) ?: }*/ } private fun clear() { synchronized(images) { currentJob?.cancel() // for (i in images.indices) { // images[i]?.recycle() // } images = arrayOf() prefixSum = arrayOf() loadedImages = 0 totalImages = 0 } } override fun release() { clear() images = arrayOf() } override var durationMs: Long = 0L private var currentJob: Job? = null fun load(url: String, headers: Map) { clear() currentJob?.cancel() currentJob = ioSafe { withContext(Dispatchers.IO) { Log.i(TAG, "Loading with url = $url headers = $headers") //tmpFile = // File.createTempFile("video", ".ts", context.cacheDir).apply { // deleteOnExit() // } val retriever = MediaMetadataRetriever() val hsl = M3u8Helper2.hslLazy( M3u8Helper.M3u8Stream( streamUrl = url, headers = headers ), selectBest = false, requireAudio = false, ) // no support for encryption atm if (hsl.isEncrypted) { Log.i(TAG, "m3u8 is encrypted") totalImages = 0 return@withContext } // total duration of the entire m3u8 in seconds val duration = hsl.allTsLinks.sumOf { it.time ?: 0.0 } durationMs = (duration * 1000.0).toLong() val durationInv = 1.0 / duration // if the total duration is less then 10s then something is very wrong or // too short playback to matter if (duration <= 10.0) { totalImages = 0 return@withContext } totalImages = hsl.allTsLinks.size // we cant init directly as it is no guarantee of in order prefixSum = Array(hsl.allTsLinks.size + 1) { 0.0 } var runningSum = 0.0 for (i in hsl.allTsLinks.indices) { runningSum += (hsl.allTsLinks[i].time ?: 0.0) prefixSum[i + 1] = runningSum * durationInv } synchronized(images) { images = Array(hsl.size) { null } loadedImages = 0 } val maxLod = ceil(log2(duration)).toInt().coerceIn(MIN_LOD, MAX_LOD) val count = hsl.allTsLinks.size for (l in 1..maxLod) { val items = (1 shl (l - 1)) for (i in 0 until items) { val index = (count.div(1 shl l) + (i * count) / items).coerceIn(0, hsl.size) if (synchronized(images) { images[index] } != null) { continue } Log.i(TAG, "Generating preview for $index") val ts = hsl.allTsLinks[index] try { retriever.setDataSource(ts.url, hsl.headers) if (!isActive) { return@withContext } val img = retriever.image(0, params) if (!isActive) { return@withContext } if (img == null || img.width <= 1 || img.height <= 1) continue synchronized(images) { images[index] = img loadedImages += 1 } } catch (t: Throwable) { logError(t) continue } } } } } } } private class Mp4PreviewGenerator(override var params: ImageParams) : IPreviewGenerator { // lod = level of detail where the number indicates how many ones there is // 2^(lod-1) = images private var loadedLod = 0 override var loadedImages = 0 private var images = Array((1 shl MAX_LOD) - 1) { null } companion object { private const val TAG = "PreviewImgMp4" } override fun hasPreview(): Boolean { synchronized(images) { return loadedLod >= MIN_LOD } } override fun getPreviewImage(fraction: Float): Bitmap? { synchronized(images) { if (loadedLod < MIN_LOD) { Log.i(TAG, "Requesting preview for $fraction but $loadedLod < $MIN_LOD") return null } Log.i(TAG, "Requesting preview for $fraction") var bestIdx = 0 var bestDiff = 0.5f.minus(fraction).absoluteValue // this should be done mathematically, but for now we just loop all images for (l in 1..loadedLod + 1) { val items = (1 shl (l - 1)) for (i in 0 until items) { val idx = items - 1 + i if (idx > loadedImages) { break } if (images[idx] == null) { continue } val currentFraction = (1.0f.div((1 shl l).toFloat()) + i * 1.0f.div(items.toFloat())) val diff = currentFraction.minus(fraction).absoluteValue if (diff < bestDiff) { bestDiff = diff bestIdx = idx } } } Log.i(TAG, "Best diff found at ${bestDiff * 100}% diff (${bestIdx})") return images[bestIdx] } } // also check out https://github.com/wseemann/FFmpegMediaMetadataRetriever private val retriever: MediaMetadataRetriever = MediaMetadataRetriever() private fun clear(keepCache: Boolean) { if (keepCache) return synchronized(images) { loadedLod = 0 loadedImages = 0 // for (i in images.indices) { // images[i]?.recycle() // images[i] = null //} images.fill(null) } } private var currentJob: Job? = null fun load(url: String, headers: Map) { currentJob?.cancel() currentJob = ioSafe { Log.i(TAG, "Loading with url = $url headers = $headers") clear(true) retriever.setDataSource(url, headers) start(this) } } fun load(keepCache: Boolean, context: Context, uri: Uri) { currentJob?.cancel() currentJob = ioSafe { Log.i(TAG, "Loading with uri = $uri") clear(keepCache) retriever.setDataSource(context, uri) start(this) } } override fun release() { currentJob?.cancel() clear(false) } override var durationMs: Long = 0L @Throws @WorkerThread private fun start(scope: CoroutineScope) { Log.i(TAG, "Started loading preview") val durationMs = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: throw IllegalArgumentException("Bad video duration") this.durationMs = durationMs val durationUs = (durationMs * 1000L).toFloat() //val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() ?: throw IllegalArgumentException("Bad video width") //val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() ?: throw IllegalArgumentException("Bad video height") // log2 # 10s durations in the video ~= how many segments we have val maxLod = ceil(log2((durationMs / 10_000).toFloat())).toInt().coerceIn(MIN_LOD, MAX_LOD) for (l in 1..maxLod) { val items = (1 shl (l - 1)) for (i in 0 until items) { val idx = items - 1 + i // as sum(prev) = cur-1 // frame = 100 / 2^lod + i * 100 / 2^(lod-1) = duration % where lod is one indexed val fraction = (1.0f.div((1 shl l).toFloat()) + i * 1.0f.div(items.toFloat())) Log.i(TAG, "Generating preview for ${fraction * 100}%") val frame = durationUs * fraction val img = retriever.image(frame.toLong(), params) if (!scope.isActive) return if (img == null || img.width <= 1 || img.height <= 1) continue synchronized(images) { images[idx] = img loadedImages = maxOf(loadedImages, idx) } } synchronized(images) { loadedLod = maxOf(loadedLod, l) } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/RepoLinkGenerator.kt ================================================ package com.lagradost.cloudstream3.ui.player import android.util.Log import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import kotlin.math.max import kotlin.math.min data class Cache( val linkCache: MutableSet, val subtitleCache: MutableSet, /** When it was last updated */ var lastCachedTimestamp: Long = unixTime, /** If it has fully loaded */ var saturated: Boolean, ) class RepoLinkGenerator( episodes: List, currentIndex: Int = 0, val page: LoadResponse? = null, ) : VideoGenerator(episodes, currentIndex) { companion object { const val TAG = "RepoLink" val cache: HashMap, Cache> = hashMapOf() } override val hasCache = true override val canSkipLoading = true // this is a simple array that is used to instantly load links if they are already loaded //var linkCache = Array>(size = episodes.size, init = { setOf() }) //var subsCache = Array>(size = episodes.size, init = { setOf() }) @Throws override suspend fun generateLinks( clearCache: Boolean, sourceTypes: Set, callback: (Pair) -> Unit, subtitleCallback: (SubtitleData) -> Unit, offset: Int, isCasting: Boolean, ): Boolean { val current = getCurrent(offset) ?: return false val currentCache = synchronized(cache) { cache[current.apiName to current.id] ?: Cache( mutableSetOf(), mutableSetOf(), unixTime, false ).also { cache[current.apiName to current.id] = it } } // these act as a general filter to prevent duplication of links or names val currentLinksUrls = mutableSetOf() // makes all urls unique val currentSubsUrls = mutableSetOf() // makes all subs urls unique val lastCountedSuffix = mutableMapOf() synchronized(currentCache) { val outdatedCache = unixTime - currentCache.lastCachedTimestamp > 60 * 20 // 20 minutes if (outdatedCache || clearCache) { currentCache.linkCache.clear() currentCache.subtitleCache.clear() currentCache.saturated = false } else if (currentCache.linkCache.isNotEmpty()) { Log.d(TAG, "Resumed previous loading from ${unixTime - currentCache.lastCachedTimestamp}s ago") } // call all callbacks currentCache.linkCache.forEach { link -> currentLinksUrls.add(link.url) if (sourceTypes.contains(link.type)) { callback(link to null) } } currentCache.subtitleCache.forEach { sub -> currentSubsUrls.add(sub.url) val suffixCount = lastCountedSuffix.getOrDefault(sub.originalName, 0u) + 1u lastCountedSuffix[sub.originalName] = suffixCount subtitleCallback(sub) } // this stops all execution if links are cached // no extra get requests if (currentCache.saturated) { return true } } val result = APIRepository( getApiFromNameNull(current.apiName) ?: throw Exception("This provider does not exist") ).loadLinks( current.data, isCasting = isCasting, subtitleCallback = { file -> Log.d(TAG, "Loaded SubtitleFile: $file") val correctFile = PlayerSubtitleHelper.getSubtitleData(file) if (correctFile.url.isBlank() || currentSubsUrls.contains(correctFile.url)) { return@loadLinks } currentSubsUrls.add(correctFile.url) // this part makes sure that all names are unique for UX val nameDecoded = correctFile.originalName.html().toString().trim() // `%3Ch1%3Esub%20name…` → `

sub name…` → `sub name…` val suffixCount = lastCountedSuffix.getOrDefault(nameDecoded, 0u) +1u lastCountedSuffix[nameDecoded] = suffixCount val updatedFile = correctFile.copy(originalName = nameDecoded, nameSuffix = "$suffixCount") synchronized(currentCache) { if (currentCache.subtitleCache.add(updatedFile)) { subtitleCallback(updatedFile) currentCache.lastCachedTimestamp = unixTime } } }, callback = { link -> Log.d(TAG, "Loaded ExtractorLink: $link") if (link.url.isBlank() || currentLinksUrls.contains(link.url)) { return@loadLinks } currentLinksUrls.add(link.url) synchronized(currentCache) { if (currentCache.linkCache.add(link)) { if (sourceTypes.contains(link.type)) { callback(Pair(link, null)) } currentCache.linkCache.add(link) currentCache.lastCachedTimestamp = unixTime } } } ) synchronized(currentCache) { currentCache.saturated = currentCache.linkCache.isNotEmpty() currentCache.lastCachedTimestamp = unixTime } return result } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/RoundedBackgroundColorSpan.kt ================================================ package com.lagradost.cloudstream3.ui.player /** * Inspired by https://medium.com/@Semper_Viventem/simple-implementation-of-rounded-background-for-text-in-android-60a7706c0419 * however the connecting triangles cant be rendered on a transparent bg, also does not support alignment. * * This current implementation may be expanded to only draw the drawRoundRect with rounded corners iff * it is on an edge for a nice look: * * /----------\ * | large | * \----------/ * | | <- this instead of / and \ * | small | * \-------/ * * Also note that the background may be drawn wildly different from where exoplayer places it * because exoplayer has their own custom drawing. This is only an attempt to correlate it. * * Additionally, not tested on RTL */ import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint import android.os.Build import android.text.Layout.Alignment import android.text.StaticLayout import android.text.TextPaint import android.text.style.LineBackgroundSpan class RoundedBackgroundColorSpan( private val backgroundColor: Int, private val alignment: Alignment, private val padding: Float, private val radius: Float ) : LineBackgroundSpan { private val paint = Paint().apply { color = backgroundColor isAntiAlias = true } override fun drawBackground( c: Canvas, p: Paint, left: Int, right: Int, top: Int, baseline: Int, bottom: Int, text: CharSequence, start: Int, end: Int, lineNumber: Int ) { // https://github.com/androidx/media/blob/main/libraries/ui/src/main/java/androidx/media3/ui/SubtitlePainter.java if (Color.alpha(backgroundColor) <= 0) { return } val width = p.measureText(text, start, end) val textLayout: StaticLayout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { StaticLayout.Builder .obtain(text, 0, text.length, TextPaint(p), width.toInt()) .setAlignment(alignment) .setLineSpacing(0.0f, 1.0f) .setIncludePad(true) .build() } else { @Suppress("DEPRECATION") StaticLayout( text, TextPaint(p), width.toInt(), alignment, 1.0f, 0.0f, true ) } val center = (left + right).toFloat() * 0.5f // I know this is not how you actually do it, but fuck it. // You have to override the subtitle painter to get all the correct value val textLeft = when (alignment) { Alignment.ALIGN_NORMAL -> { 0.0f } Alignment.ALIGN_OPPOSITE -> { right - width } Alignment.ALIGN_CENTER -> { center - width * 0.5f } } val textTop = textLayout.getLineTop(lineNumber).toFloat() val textBottom = textLayout.getLineBottom(lineNumber).toFloat() c.drawRoundRect( textLeft - padding, textTop, textLeft + width + padding, textBottom, radius, radius, paint ) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/SSLTrustManager.kt ================================================ package com.lagradost.cloudstream3.ui.player import java.security.cert.X509Certificate import javax.net.ssl.X509TrustManager class SSLTrustManager : X509TrustManager { override fun checkClientTrusted(p0: Array?, p1: String?) { } override fun checkServerTrusted(p0: Array?, p1: String?) { } override fun getAcceptedIssuers(): Array { return arrayOf() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/SubtitleOffsetItemAdapter.kt ================================================ package com.lagradost.cloudstream3.ui.player import android.animation.ObjectAnimator import android.view.LayoutInflater import android.view.ViewGroup import android.view.animation.DecelerateInterpolator import androidx.core.view.isInvisible import com.lagradost.cloudstream3.databinding.SubtitleOffsetItemBinding import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState import kotlin.math.roundToInt data class SubtitleCue(val startTimeMs: Long, val durationMs: Long, val text: List) { val endTimeMs = startTimeMs + durationMs } class SubtitleOffsetItemAdapter( private var currentTimeMs: Long, val clickCallback: (SubtitleCue) -> Unit ) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> a.startTimeMs == b.startTimeMs })) { override fun onCreateContent(parent: ViewGroup): ViewHolderState { val inflater = LayoutInflater.from(parent.context) val binding = SubtitleOffsetItemBinding.inflate(inflater, parent, false) return ViewHolderState(binding) } override fun onBindContent(holder: ViewHolderState, item: SubtitleCue, position: Int) { val binding = holder.view as? SubtitleOffsetItemBinding ?: return binding.root.setOnClickListener { clickCallback.invoke(item) } binding.subtitleText.text = item.text.joinToString("\n") val timeMs = currentTimeMs val startTime = item.startTimeMs val endTime = item.endTimeMs val newAlpha = if (timeMs >= startTime) 1f else 0.5f ObjectAnimator.ofFloat( binding.subtitleText, "alpha", binding.subtitleText.alpha, newAlpha ).apply { interpolator = DecelerateInterpolator() }.start() val showProgress = timeMs in startTime..= it.value.startTimeMs }?.index ?: 0 } fun updateTime(timeMs: Long) { val previousTime = currentTimeMs currentTimeMs = timeMs val earlyTime = minOf(previousTime, timeMs) val lateTime = maxOf(previousTime, timeMs) // TODO Add binary search and notifyItemRangeChanged val affectedItems = immutableCurrentList.withIndex().filter { cue -> // Padding is required in the range because changes can be done within one single subtitle range, // and that subtitle needs to be updated cue.value.startTimeMs in (earlyTime - cue.value.durationMs)..(lateTime + cue.value.durationMs) } affectedItems.forEach { item -> // This could likely be a range this.notifyItemChanged(item.index) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/Torrent.kt ================================================ package com.lagradost.cloudstream3.ui.player import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.api.Log import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.newExtractorLink import torrServer.TorrServer import java.io.File import java.net.ConnectException import java.net.URLEncoder object Torrent { var hasAcceptedTorrentForThisSession: Boolean? = null private const val TORRENT_SERVER_PATH: String = "torrent_tmp" private const val TIMEOUT: Long = 3 private const val TAG: String = "Torrent" /** Cleans up both old aria2c files and newer go server, (even if the new is also self cleaning) */ @Throws fun deleteAllFiles(): Boolean { val act = CommonActivity.activity ?: return false val defaultDirectory = "${act.cacheDir.path}/$TORRENT_SERVER_PATH" return File(defaultDirectory).deleteRecursively() } private var TORRENT_SERVER_URL = "" // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/main/server.go#L23 /** Returns true if the server is up */ private suspend fun echo(): Boolean { if(TORRENT_SERVER_URL.isEmpty()) { return false } return try { app.get( "$TORRENT_SERVER_URL/echo", ).text.isNotEmpty() } catch (e: ConnectException) { // `Failed to connect to /127.0.0.1:8090` if the server is down false } catch (t: Throwable) { logError(t) false } } // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/c18f58e51b6738f053261bc863177078aa9c1c98/web/api/shutdown.go#L22 /** Gracefully shutdown the server. * should not be used because I am unable to start it again, and the stopTorrentServer() crashes the app */ suspend fun shutdown(): Boolean { if(TORRENT_SERVER_URL.isEmpty()) { return false } return try { app.get( "$TORRENT_SERVER_URL/shutdown", ).isSuccessful } catch (t: Throwable) { logError(t) false } } /** Lists all torrents by the server */ @Throws private suspend fun list(): Array { if(TORRENT_SERVER_URL.isEmpty()) { throw ErrorLoadingException("Not initialized") } return app.post( "$TORRENT_SERVER_URL/torrents", json = TorrentRequest( action = "list", ), timeout = TIMEOUT, headers = emptyMap() ).parsed>() } /** Drops a single torrent, (I think) this means closing the stream. Returns returns if it is successful */ private suspend fun drop(hash: String): Boolean { if(TORRENT_SERVER_URL.isEmpty()) { return false } return try { return app.post( "$TORRENT_SERVER_URL/torrents", json = TorrentRequest( action = "drop", hash = hash ), timeout = TIMEOUT, headers = emptyMap() ).isSuccessful } catch (t: Throwable) { logError(t) false } } /** Removes a single torrent from the server registry */ private suspend fun rem(hash: String): Boolean { if(TORRENT_SERVER_URL.isEmpty()) { return false } return try { return app.post( "$TORRENT_SERVER_URL/torrents", json = TorrentRequest( action = "rem", hash = hash ), timeout = TIMEOUT, headers = emptyMap() ).isSuccessful } catch (t: Throwable) { logError(t) false } } /** Removes all torrents from the server, and returns if it is successful */ suspend fun clearAll(): Boolean { if(TORRENT_SERVER_URL.isEmpty()) { return true } return try { val items = list() var allSuccess = true for (item in items) { val hash = item.hash if (hash == null) { Log.i(TAG, "No hash on ${item.name}") allSuccess = false continue } if (drop(hash)) { Log.i(TAG, "Successfully dropped ${item.name}") } else { Log.i(TAG, "Failed to drop ${item.name}") allSuccess = false continue } if (rem(hash)) { Log.i(TAG, "Successfully removed ${item.name}") } else { Log.i(TAG, "Failed to remove ${item.name}") allSuccess = false continue } } allSuccess } catch (t: Throwable) { logError(t) false } } /** Gets all the metadata of a torrent, will throw if that hash does not exists * https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/c18f58e51b6738f053261bc863177078aa9c1c98/web/api/torrents.go#L126 */ @Throws suspend fun get( hash: String, ): TorrentStatus { if(TORRENT_SERVER_URL.isEmpty()) { throw ErrorLoadingException("Not initialized") } return app.post( "$TORRENT_SERVER_URL/torrents", json = TorrentRequest( action = "get", hash = hash, ), timeout = TIMEOUT, headers = emptyMap() ).parsed() } /** Adds a torrent to the server, this is needed for us to get the hash for further modification, as well as start streaming it*/ @Throws private suspend fun add(url: String): TorrentStatus { if(TORRENT_SERVER_URL.isEmpty()) { throw ErrorLoadingException("Not initialized") } return app.post( "$TORRENT_SERVER_URL/torrents", json = TorrentRequest( action = "add", link = url, ), headers = emptyMap() ).parsed() } /** Spins up the torrent server. */ private suspend fun setup(dir: String): Boolean { go.Seq.load() if (echo()) { return true } val port = TorrServer.startTorrentServer(dir, 0) if(port < 0) { return false } TORRENT_SERVER_URL = "http://127.0.0.1:$port" TorrServer.addTrackers(trackers.joinToString(separator = ",\n")) return echo() } /** Transforms a torrent link into a streamable link via the server */ @Throws suspend fun transformLink(link: ExtractorLink): Pair { val act = CommonActivity.activity ?: throw IllegalArgumentException("No activity") val defaultDirectory = "${act.cacheDir.path}/$TORRENT_SERVER_PATH" File(defaultDirectory).mkdir() if (!setup(defaultDirectory)) { throw ErrorLoadingException("Unable to setup the torrent server") } val status = add(link.url) return newExtractorLink( source = link.source, name = link.name, url = status.streamUrl(link.url), type = ExtractorLinkType.VIDEO ) { this.referer = "" this.quality = link.quality } to status } private val trackers = listOf( "udp://tracker.opentrackr.org:1337/announce", "https://tracker2.ctix.cn/announce", "https://tracker1.520.jp:443/announce", "udp://opentracker.i2p.rocks:6969/announce", "udp://open.tracker.cl:1337/announce", "udp://open.demonii.com:1337/announce", "http://tracker.openbittorrent.com:80/announce", "udp://tracker.openbittorrent.com:6969/announce", "udp://open.stealth.si:80/announce", "udp://exodus.desync.com:6969/announce", "udp://tracker-udp.gbitt.info:80/announce", "udp://explodie.org:6969/announce", "https://tracker.gbitt.info:443/announce", "http://tracker.gbitt.info:80/announce", "udp://uploads.gamecoast.net:6969/announce", "udp://tracker1.bt.moack.co.kr:80/announce", "udp://tracker.tiny-vps.com:6969/announce", "udp://tracker.theoks.net:6969/announce", "udp://tracker.dump.cl:6969/announce", "udp://tracker.bittor.pw:1337/announce", "https://tracker1.520.jp:443/announce", "udp://opentracker.i2p.rocks:6969/announce", "udp://open.tracker.cl:1337/announce", "udp://open.demonii.com:1337/announce", "http://tracker.openbittorrent.com:80/announce", "udp://tracker.openbittorrent.com:6969/announce", "udp://open.stealth.si:80/announce", "udp://exodus.desync.com:6969/announce", "udp://tracker-udp.gbitt.info:80/announce", "udp://explodie.org:6969/announce", "https://tracker.gbitt.info:443/announce", "http://tracker.gbitt.info:80/announce", "udp://uploads.gamecoast.net:6969/announce", "udp://tracker1.bt.moack.co.kr:80/announce", "udp://tracker.tiny-vps.com:6969/announce", "udp://tracker.theoks.net:6969/announce", "udp://tracker.dump.cl:6969/announce", "udp://tracker.bittor.pw:1337/announce" ) // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/c18f58e51b6738f053261bc863177078aa9c1c98/web/api/torrents.go#L18 // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/main/web/api/route.go#L7 data class TorrentRequest( @JsonProperty("action") val action: String, @JsonProperty("hash") val hash: String = "", @JsonProperty("link") val link: String = "", @JsonProperty("title") val title: String = "", @JsonProperty("poster") val poster: String = "", @JsonProperty("data") val data: String = "", @JsonProperty("save_to_db") val saveToDB: Boolean = false, ) // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/c18f58e51b6738f053261bc863177078aa9c1c98/torr/state/state.go#L33 // omitempty = nullable data class TorrentStatus( @JsonProperty("title") var title: String, @JsonProperty("poster") var poster: String, @JsonProperty("data") var data: String?, @JsonProperty("timestamp") var timestamp: Long, @JsonProperty("name") var name: String?, @JsonProperty("hash") var hash: String?, @JsonProperty("stat") var stat: Int, @JsonProperty("stat_string") var statString: String, @JsonProperty("loaded_size") var loadedSize: Long?, @JsonProperty("torrent_size") var torrentSize: Long?, @JsonProperty("preloaded_bytes") var preloadedBytes: Long?, @JsonProperty("preload_size") var preloadSize: Long?, @JsonProperty("download_speed") var downloadSpeed: Double?, @JsonProperty("upload_speed") var uploadSpeed: Double?, @JsonProperty("total_peers") var totalPeers: Int?, @JsonProperty("pending_peers") var pendingPeers: Int?, @JsonProperty("active_peers") var activePeers: Int?, @JsonProperty("connected_seeders") var connectedSeeders: Int?, @JsonProperty("half_open_peers") var halfOpenPeers: Int?, @JsonProperty("bytes_written") var bytesWritten: Long?, @JsonProperty("bytes_written_data") var bytesWrittenData: Long?, @JsonProperty("bytes_read") var bytesRead: Long?, @JsonProperty("bytes_read_data") var bytesReadData: Long?, @JsonProperty("bytes_read_useful_data") var bytesReadUsefulData: Long?, @JsonProperty("chunks_written") var chunksWritten: Long?, @JsonProperty("chunks_read") var chunksRead: Long?, @JsonProperty("chunks_read_useful") var chunksReadUseful: Long?, @JsonProperty("chunks_read_wasted") var chunksReadWasted: Long?, @JsonProperty("pieces_dirtied_good") var piecesDirtiedGood: Long?, @JsonProperty("pieces_dirtied_bad") var piecesDirtiedBad: Long?, @JsonProperty("duration_seconds") var durationSeconds: Double?, @JsonProperty("bit_rate") var bitRate: String?, @JsonProperty("file_stats") var fileStats: List?, @JsonProperty("trackers") var trackers: List?, ) { fun streamUrl(url: String): String { val fileName = this.fileStats?.first { !it.path.isNullOrBlank() }?.path ?: throw ErrorLoadingException("Null path") val index = url.substringAfter("index=").substringBefore("&").toIntOrNull() ?: 0 // https://github.com/Diegopyl1209/torrentserver-aniyomi/blob/c18f58e51b6738f053261bc863177078aa9c1c98/web/api/stream.go#L18 return "$TORRENT_SERVER_URL/stream/${ URLEncoder.encode(fileName, "utf-8") }?link=${this.hash}&index=$index&play" } } data class TorrentFileStat( @JsonProperty("id") val id: Int?, @JsonProperty("path") val path: String?, @JsonProperty("length") val length: Long?, ) } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedDefaultExtractorsFactory.kt ================================================ @file:Suppress( "ALL", "DEPRECATION", "RedundantVisibilityModifier", "RemoveRedundantQualifierName", "UNCHECKED_CAST", "UNUSED", "UNUSED_PARAMETER", "UNUSED_VARIABLE" ) package com.lagradost.cloudstream3.ui.player import android.net.Uri import androidx.annotation.GuardedBy import androidx.media3.common.C import androidx.media3.common.FileTypes import androidx.media3.common.Format import androidx.media3.common.util.TimestampAdjuster import androidx.media3.common.util.UnstableApi import androidx.media3.extractor.Extractor import androidx.media3.extractor.ExtractorsFactory import androidx.media3.extractor.amr.AmrExtractor import androidx.media3.extractor.avi.AviExtractor import androidx.media3.extractor.avif.AvifExtractor import androidx.media3.extractor.bmp.BmpExtractor import androidx.media3.extractor.flac.FlacExtractor import androidx.media3.extractor.flv.FlvExtractor import androidx.media3.extractor.heif.HeifExtractor import androidx.media3.extractor.jpeg.JpegExtractor import androidx.media3.extractor.mkv.UpdatedMatroskaExtractor import androidx.media3.extractor.mp3.Mp3Extractor import androidx.media3.extractor.mp4.FragmentedMp4Extractor import androidx.media3.extractor.mp4.Mp4Extractor import androidx.media3.extractor.ogg.OggExtractor import androidx.media3.extractor.png.PngExtractor import androidx.media3.extractor.text.DefaultSubtitleParserFactory import androidx.media3.extractor.text.SubtitleParser import androidx.media3.extractor.ts.Ac3Extractor import androidx.media3.extractor.ts.Ac4Extractor import androidx.media3.extractor.ts.AdtsExtractor import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory import androidx.media3.extractor.ts.PsExtractor import androidx.media3.extractor.ts.TsExtractor import androidx.media3.extractor.wav.WavExtractor import androidx.media3.extractor.webp.WebpExtractor import com.google.common.collect.ImmutableList import java.lang.reflect.Constructor import java.lang.reflect.InvocationTargetException import java.util.concurrent.atomic.AtomicBoolean /** * An [ExtractorsFactory] that provides an array of extractors for the following formats: * * * * MP4, including M4A ([Mp4Extractor]) * * fMP4 ([FragmentedMp4Extractor]) * * Matroska and WebM ([UpdatedMatroskaExtractor]) * * Ogg Vorbis/FLAC ([OggExtractor] * * MP3 ([Mp3Extractor]) * * AAC ([AdtsExtractor]) * * MPEG TS ([TsExtractor]) * * MPEG PS ([PsExtractor]) * * FLV ([FlvExtractor]) * * WAV ([WavExtractor]) * * AC3 ([Ac3Extractor]) * * AC4 ([Ac4Extractor]) * * AMR ([AmrExtractor]) * * FLAC * * * If available, the FLAC extension's `androidx.media3.decoder.flac.FlacExtractor` * is used. * * Otherwise, the core [FlacExtractor] is used. Note that Android devices do not * generally include a FLAC decoder before API 27. This can be worked around by using * the FLAC extension or the FFmpeg extension. * * * JPEG ([JpegExtractor]) * * PNG ([PngExtractor]) * * WEBP ([WebpExtractor]) * * BMP ([BmpExtractor]) * * HEIF ([HeifExtractor]) * * AVIF ([AvifExtractor]) * * MIDI, if available, the MIDI extension's `androidx.media3.decoder.midi.MidiExtractor` * is used. * */ @UnstableApi class UpdatedDefaultExtractorsFactory : ExtractorsFactory { private var constantBitrateSeekingEnabled = false private var constantBitrateSeekingAlwaysEnabled = false private var adtsFlags: @AdtsExtractor.Flags Int = 0 private var amrFlags: @AmrExtractor.Flags Int = 0 private var flacFlags: @FlacExtractor.Flags Int = 0 private var matroskaFlags: @UpdatedMatroskaExtractor.Flags Int = 0 private var mp4Flags: @Mp4Extractor.Flags Int = 0 private var fragmentedMp4Flags: @FragmentedMp4Extractor.Flags Int = 0 private var mp3Flags: @Mp3Extractor.Flags Int = 0 private var tsMode: @TsExtractor.Mode Int private var tsFlags: @DefaultTsPayloadReaderFactory.Flags Int = 0 // TODO (b/261183220): Initialize tsSubtitleFormats in constructor once shrinking bug is fixed. private var tsSubtitleFormats: ImmutableList? = null private var tsTimestampSearchBytes: Int private var textTrackTranscodingEnabled: Boolean private var subtitleParserFactory: SubtitleParser.Factory private var codecsToParseWithinGopSampleDependencies: @C.VideoCodecFlags Int private var jpegFlags: @JpegExtractor.Flags Int = 0 private var heifFlags: @HeifExtractor.Flags Int = 0 init { tsMode = TsExtractor.MODE_SINGLE_PMT tsTimestampSearchBytes = TsExtractor.DEFAULT_TIMESTAMP_SEARCH_BYTES subtitleParserFactory = DefaultSubtitleParserFactory() textTrackTranscodingEnabled = true codecsToParseWithinGopSampleDependencies = C.VIDEO_CODEC_FLAG_H264 or C.VIDEO_CODEC_FLAG_H265 } /** * Convenience method to set whether approximate seeking using constant bitrate assumptions should * be enabled for all extractors that support it. If set to true, the flags required to enable * this functionality will be OR'd with those passed to the setters when creating extractor * instances. If set to false then the flags passed to the setters will be used without * modification. * * @param constantBitrateSeekingEnabled Whether approximate seeking using a constant bitrate * assumption should be enabled for all extractors that support it. * @return The factory, for convenience. */ @Synchronized fun setConstantBitrateSeekingEnabled( constantBitrateSeekingEnabled: Boolean ): UpdatedDefaultExtractorsFactory { this.constantBitrateSeekingEnabled = constantBitrateSeekingEnabled return this } /** * Convenience method to set whether approximate seeking using constant bitrate assumptions should * be enabled for all extractors that support it, and if it should be enabled even if the content * length (and hence the duration of the media) is unknown. If set to true, the flags required to * enable this functionality will be OR'd with those passed to the setters when creating extractor * instances. If set to false then the flags passed to the setters will be used without * modification. * * * When seeking into content where the length is unknown, application code should ensure that * requested seek positions are valid, or should be ready to handle playback failures reported * through [Player.Listener.onPlayerError] with [PlaybackException.errorCode] set to * [PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE]. * * @param constantBitrateSeekingAlwaysEnabled Whether approximate seeking using a constant bitrate * assumption should be enabled for all extractors that support it, including when the content * duration is unknown. * @return The factory, for convenience. */ @Synchronized fun setConstantBitrateSeekingAlwaysEnabled( constantBitrateSeekingAlwaysEnabled: Boolean ): UpdatedDefaultExtractorsFactory { this.constantBitrateSeekingAlwaysEnabled = constantBitrateSeekingAlwaysEnabled return this } /** * Sets flags for [AdtsExtractor] instances created by the factory. * * @see AdtsExtractor.AdtsExtractor * @param flags The flags to use. * @return The factory, for convenience. */ @Synchronized fun setAdtsExtractorFlags( flags: @AdtsExtractor.Flags Int ): UpdatedDefaultExtractorsFactory { this.adtsFlags = flags return this } /** * Sets flags for [AmrExtractor] instances created by the factory. * * @see AmrExtractor.AmrExtractor * @param flags The flags to use. * @return The factory, for convenience. */ @Synchronized fun setAmrExtractorFlags(flags: @AmrExtractor.Flags Int): UpdatedDefaultExtractorsFactory { this.amrFlags = flags return this } /** * Sets flags for [FlacExtractor] instances created by the factory. The flags are also used * by `androidx.media3.decoder.flac.FlacExtractor` instances if the FLAC extension is being * used. * * @see FlacExtractor.FlacExtractor * @param flags The flags to use. * @return The factory, for convenience. */ @Synchronized fun setFlacExtractorFlags( flags: @FlacExtractor.Flags Int ): UpdatedDefaultExtractorsFactory { this.flacFlags = flags return this } /** * Sets flags for [UpdatedMatroskaExtractor] instances created by the factory. * * @see UpdatedMatroskaExtractor.MatroskaExtractor * @param flags The flags to use. * @return The factory, for convenience. */ @Synchronized fun setMatroskaExtractorFlags( flags: @UpdatedMatroskaExtractor.Flags Int ): UpdatedDefaultExtractorsFactory { this.matroskaFlags = flags return this } /** * Sets flags for [Mp4Extractor] instances created by the factory. * * @see Mp4Extractor.Mp4Extractor * @param flags The flags to use. * @return The factory, for convenience. */ @Synchronized fun setMp4ExtractorFlags(flags: @Mp4Extractor.Flags Int): UpdatedDefaultExtractorsFactory { this.mp4Flags = flags return this } /** * Sets flags for [FragmentedMp4Extractor] instances created by the factory. * * @see FragmentedMp4Extractor.FragmentedMp4Extractor * @param flags The flags to use. * @return The factory, for convenience. */ @Synchronized fun setFragmentedMp4ExtractorFlags( flags: @FragmentedMp4Extractor.Flags Int ): UpdatedDefaultExtractorsFactory { this.fragmentedMp4Flags = flags return this } /** * Sets flags for [Mp3Extractor] instances created by the factory. * * @see Mp3Extractor.Mp3Extractor * @param flags The flags to use. * @return The factory, for convenience. */ @Synchronized fun setMp3ExtractorFlags(flags: @Mp3Extractor.Flags Int): UpdatedDefaultExtractorsFactory { mp3Flags = flags return this } /** * Sets the mode for [TsExtractor] instances created by the factory. * * @see TsExtractor.TsExtractor * @param mode The mode to use. * @return The factory, for convenience. */ @Synchronized fun setTsExtractorMode(mode: @TsExtractor.Mode Int): UpdatedDefaultExtractorsFactory { tsMode = mode return this } /** * Sets flags for [DefaultTsPayloadReaderFactory]s used by [TsExtractor] instances * created by the factory. * * @see TsExtractor.TsExtractor * @param flags The flags to use. * @return The factory, for convenience. */ @Synchronized fun setTsExtractorFlags( flags: @DefaultTsPayloadReaderFactory.Flags Int ): UpdatedDefaultExtractorsFactory { tsFlags = flags return this } /** * Sets a list of subtitle formats to pass to the [DefaultTsPayloadReaderFactory] used by * [TsExtractor] instances created by the factory. * * @see DefaultTsPayloadReaderFactory.DefaultTsPayloadReaderFactory * @param subtitleFormats The subtitle formats. * @return The factory, for convenience. */ @Synchronized fun setTsSubtitleFormats(subtitleFormats: List?): UpdatedDefaultExtractorsFactory { tsSubtitleFormats = subtitleFormats?.let { ImmutableList.copyOf(it) } return this } /** * Sets the number of bytes searched to find a timestamp for [TsExtractor] instances created * by the factory. * * @see TsExtractor.TsExtractor * @param timestampSearchBytes The number of search bytes to use. * @return The factory, for convenience. */ @Synchronized fun setTsExtractorTimestampSearchBytes( timestampSearchBytes: Int ): UpdatedDefaultExtractorsFactory { tsTimestampSearchBytes = timestampSearchBytes return this } @Deprecated( """This method (and all support for 'legacy' subtitle decoding during rendering) will be removed in a future release.""" ) @Synchronized fun setTextTrackTranscodingEnabled( textTrackTranscodingEnabled: Boolean ): UpdatedDefaultExtractorsFactory { return experimentalSetTextTrackTranscodingEnabled(textTrackTranscodingEnabled) } @Deprecated("") @Synchronized override fun experimentalSetTextTrackTranscodingEnabled( textTrackTranscodingEnabled: Boolean ): UpdatedDefaultExtractorsFactory { this.textTrackTranscodingEnabled = textTrackTranscodingEnabled return this } @Synchronized override fun setSubtitleParserFactory( subtitleParserFactory: SubtitleParser.Factory ): UpdatedDefaultExtractorsFactory { this.subtitleParserFactory = subtitleParserFactory return this } @Synchronized override fun experimentalSetCodecsToParseWithinGopSampleDependencies( codecsToParseWithinGopSampleDependencies: @C.VideoCodecFlags Int ): UpdatedDefaultExtractorsFactory { this.codecsToParseWithinGopSampleDependencies = codecsToParseWithinGopSampleDependencies return this } /** * Sets flags for [JpegExtractor] instances created by the factory. * * @see JpegExtractor.JpegExtractor * @param flags The flags to use. * @return The factory, for convenience. */ @Synchronized fun setJpegExtractorFlags( flags: @JpegExtractor.Flags Int ): UpdatedDefaultExtractorsFactory { this.jpegFlags = flags return this } /** * Sets flags for [HeifExtractor] instances created by the factory. * * @see HeifExtractor.HeifExtractor * @param flags The flags to use. * @return The factory, for convenience. */ @Synchronized fun setHeifExtractorFlags( flags: @HeifExtractor.Flags Int ): UpdatedDefaultExtractorsFactory { this.heifFlags = flags return this } @Synchronized override fun createExtractors(): Array { return createExtractors(Uri.EMPTY, HashMap()) } @Synchronized override fun createExtractors( uri: Uri, responseHeaders: Map> ): Array { val extractors: MutableList = ArrayList( /* initialCapacity= */DEFAULT_EXTRACTOR_ORDER.size) val responseHeadersInferredFileType: @FileTypes.Type Int = FileTypes.inferFileTypeFromResponseHeaders(responseHeaders) if (responseHeadersInferredFileType != FileTypes.UNKNOWN) { addExtractorsForFileType(responseHeadersInferredFileType, extractors) } val uriInferredFileType: @FileTypes.Type Int = FileTypes.inferFileTypeFromUri(uri) if (uriInferredFileType != FileTypes.UNKNOWN && uriInferredFileType != responseHeadersInferredFileType ) { addExtractorsForFileType(uriInferredFileType, extractors) } for (fileType in DEFAULT_EXTRACTOR_ORDER) { if (fileType != responseHeadersInferredFileType && fileType != uriInferredFileType) { addExtractorsForFileType(fileType, extractors) } } return extractors.toTypedArray() } private fun addExtractorsForFileType( fileType: @FileTypes.Type Int, extractors: MutableList ) { when (fileType) { FileTypes.AC3 -> extractors.add(Ac3Extractor()) FileTypes.AC4 -> extractors.add(Ac4Extractor()) FileTypes.ADTS -> extractors.add( AdtsExtractor( (adtsFlags or (if (constantBitrateSeekingEnabled) AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING else 0) or (if (constantBitrateSeekingAlwaysEnabled) AdtsExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING_ALWAYS else 0)) ) ) FileTypes.AMR -> extractors.add( AmrExtractor( (amrFlags or (if (constantBitrateSeekingEnabled) AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING else 0) or (if (constantBitrateSeekingAlwaysEnabled) AmrExtractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING_ALWAYS else 0)) ) ) FileTypes.FLAC -> { val flacExtractor: Extractor? = FLAC_EXTENSION_LOADER.getExtractor(flacFlags) if (flacExtractor != null) { extractors.add(flacExtractor) } else { extractors.add(FlacExtractor(flacFlags)) } } FileTypes.FLV -> extractors.add(FlvExtractor()) FileTypes.MATROSKA -> extractors.add( UpdatedMatroskaExtractor( subtitleParserFactory, matroskaFlags or (if (textTrackTranscodingEnabled) 0 else UpdatedMatroskaExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA) ) ) FileTypes.MP3 -> extractors.add( Mp3Extractor( (mp3Flags or (if (constantBitrateSeekingEnabled) Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING else 0) or (if (constantBitrateSeekingAlwaysEnabled) Mp3Extractor.FLAG_ENABLE_CONSTANT_BITRATE_SEEKING_ALWAYS else 0)) ) ) FileTypes.MP4 -> { extractors.add( FragmentedMp4Extractor( subtitleParserFactory, fragmentedMp4Flags or FragmentedMp4Extractor .codecsToParseWithinGopSampleDependenciesAsFlags( codecsToParseWithinGopSampleDependencies ) or if (textTrackTranscodingEnabled) 0 else FragmentedMp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA ) ) extractors.add( Mp4Extractor( subtitleParserFactory, mp4Flags or Mp4Extractor .codecsToParseWithinGopSampleDependenciesAsFlags( codecsToParseWithinGopSampleDependencies ) or if (textTrackTranscodingEnabled) 0 else Mp4Extractor.FLAG_EMIT_RAW_SUBTITLE_DATA ) ) } FileTypes.OGG -> extractors.add(OggExtractor()) FileTypes.PS -> extractors.add(PsExtractor()) FileTypes.TS -> { if (tsSubtitleFormats == null) { tsSubtitleFormats = ImmutableList.of() } extractors.add( TsExtractor( tsMode, (if (textTrackTranscodingEnabled) 0 else TsExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA), subtitleParserFactory, TimestampAdjuster(0), DefaultTsPayloadReaderFactory(tsFlags, tsSubtitleFormats!!), tsTimestampSearchBytes ) ) } FileTypes.WAV -> extractors.add(WavExtractor()) FileTypes.JPEG -> extractors.add(JpegExtractor(jpegFlags)) FileTypes.MIDI -> { val midiExtractor: Extractor? = MIDI_EXTENSION_LOADER.getExtractor() if (midiExtractor != null) { extractors.add(midiExtractor) } } FileTypes.AVI -> extractors.add( AviExtractor( (if (textTrackTranscodingEnabled) 0 else AviExtractor.FLAG_EMIT_RAW_SUBTITLE_DATA), subtitleParserFactory ) ) FileTypes.PNG -> extractors.add(PngExtractor()) FileTypes.WEBP -> extractors.add(WebpExtractor()) FileTypes.BMP -> extractors.add(BmpExtractor()) FileTypes.HEIF -> extractors.add(HeifExtractor(heifFlags)) FileTypes.AVIF -> extractors.add(AvifExtractor()) FileTypes.WEBVTT, FileTypes.UNKNOWN -> {} else -> {} } } private class ExtensionLoader(private val constructorSupplier: ConstructorSupplier) { interface ConstructorSupplier { @get:Throws( InvocationTargetException::class, IllegalAccessException::class, NoSuchMethodException::class, ClassNotFoundException::class ) val constructor: Constructor? } private val extensionLoaded = AtomicBoolean(false) @GuardedBy("extensionLoaded") private val extractorConstructor: Constructor? = null fun getExtractor(vararg constructorParams: Any?): Extractor? { val extractorConstructor: Constructor = maybeLoadExtractorConstructor() ?: return null try { return extractorConstructor.newInstance(*constructorParams) } catch (e: Exception) { throw IllegalStateException("Unexpected error creating extractor", e) } } fun maybeLoadExtractorConstructor(): Constructor? { synchronized(extensionLoaded) { if (extensionLoaded.get()) { return extractorConstructor } try { return constructorSupplier.constructor } catch (e: ClassNotFoundException) { // Expected if the app was built without the extension. } catch (e: Exception) { // The extension is present, but instantiation failed. throw RuntimeException("Error instantiating extension", e) } extensionLoaded.set(true) return extractorConstructor } } } companion object { // Extractors order is optimized according to // https://docs.google.com/document/d/1w2mKaWMxfz2Ei8-LdxqbPs1VLe_oudB-eryXXw9OvQQ. // The JPEG extractor appears after audio/video extractors because we expect audio/video input to // be more common. private val DEFAULT_EXTRACTOR_ORDER = intArrayOf( FileTypes.FLV, FileTypes.FLAC, FileTypes.WAV, FileTypes.MP4, FileTypes.AMR, FileTypes.PS, FileTypes.OGG, FileTypes.TS, FileTypes.MATROSKA, FileTypes.ADTS, FileTypes.AC3, FileTypes.AC4, FileTypes.MP3, // The following extractors are not part of the optimized ordering, and were appended // without further analysis. FileTypes.AVI, FileTypes.MIDI, FileTypes.JPEG, FileTypes.PNG, FileTypes.WEBP, FileTypes.BMP, FileTypes.HEIF, FileTypes.AVIF ) private val FLAC_EXTENSION_LOADER = ExtensionLoader(object : ExtensionLoader.ConstructorSupplier { override val constructor get() = flacExtractorConstructor }) private val MIDI_EXTENSION_LOADER = ExtensionLoader(object : ExtensionLoader.ConstructorSupplier { override val constructor get() = midiExtractorConstructor }) @get:Throws( ClassNotFoundException::class, NoSuchMethodException::class ) private val midiExtractorConstructor: Constructor get() = Class.forName("androidx.media3.decoder.midi.MidiExtractor") .asSubclass(Extractor::class.java) .getConstructor() @get:Throws( ClassNotFoundException::class, NoSuchMethodException::class, InvocationTargetException::class, IllegalAccessException::class ) private val flacExtractorConstructor: Constructor? get() { val isFlacNativeLibraryAvailable = java.lang.Boolean.TRUE == Class.forName("androidx.media3.decoder.flac.FlacLibrary") .getMethod("isAvailable") .invoke( /* obj= */null) if (isFlacNativeLibraryAvailable) { return Class.forName("androidx.media3.decoder.flac.FlacExtractor") .asSubclass(Extractor::class.java) .getConstructor(Int::class.javaPrimitiveType) } return null } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/UpdatedMatroskaExtractor.kt ================================================ @file:Suppress( "ALL", "DEPRECATION", "RedundantVisibilityModifier", "RemoveRedundantQualifierName", "UNCHECKED_CAST", "UNUSED", "UNUSED_PARAMETER", "UNUSED_VARIABLE" ) /* * Copyright (C) 2016 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package androidx.media3.extractor.mkv // we cant change the pkg as EbmlReader is private import android.util.Pair import android.util.SparseArray import androidx.annotation.CallSuper import androidx.annotation.IntDef import androidx.media3.common.C import androidx.media3.common.C.BufferFlags import androidx.media3.common.C.ColorRange import androidx.media3.common.C.ColorTransfer import androidx.media3.common.C.PcmEncoding import androidx.media3.common.C.SelectionFlags import androidx.media3.common.C.StereoMode import androidx.media3.common.ColorInfo import androidx.media3.common.DrmInitData import androidx.media3.common.DrmInitData.SchemeData import androidx.media3.common.Format import androidx.media3.common.Metadata import androidx.media3.common.MimeTypes import androidx.media3.common.ParserException import androidx.media3.common.util.Log import androidx.media3.common.util.ParsableByteArray import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.Util import androidx.media3.container.DolbyVisionConfig import androidx.media3.container.NalUnitUtil import androidx.media3.extractor.AacUtil import androidx.media3.extractor.AvcConfig import androidx.media3.extractor.ChunkIndex import androidx.media3.extractor.ChunkIndexProvider import androidx.media3.extractor.DtsUtil import androidx.media3.extractor.Extractor import androidx.media3.extractor.ExtractorInput import androidx.media3.extractor.ExtractorOutput import androidx.media3.extractor.ExtractorsFactory import androidx.media3.extractor.HevcConfig import androidx.media3.extractor.MpegAudioUtil import androidx.media3.extractor.PositionHolder import androidx.media3.extractor.SeekMap import androidx.media3.extractor.SeekMap.SeekPoints import androidx.media3.extractor.SeekPoint import androidx.media3.extractor.TrackAwareSeekMap import androidx.media3.extractor.TrackOutput import androidx.media3.extractor.TrackOutput.CryptoData import androidx.media3.extractor.TrueHdSampleRechunker import androidx.media3.extractor.metadata.ThumbnailMetadata import androidx.media3.extractor.text.SubtitleParser import androidx.media3.extractor.text.SubtitleTranscodingExtractorOutput import com.google.common.base.Preconditions.checkArgument import com.google.common.base.Preconditions.checkNotNull import com.google.common.base.Preconditions.checkState import com.google.common.collect.ImmutableList import java.io.IOException import java.nio.ByteBuffer import java.nio.ByteOrder import java.util.Arrays import java.util.Collections import java.util.Locale import java.util.Objects import java.util.UUID import kotlin.math.max import kotlin.math.min /** Extracts data from the Matroska and WebM container formats. */ @UnstableApi class UpdatedMatroskaExtractor private constructor( private val reader: EbmlReader, flags: @Flags Int, subtitleParserFactory: SubtitleParser.Factory ) : Extractor { /** * Flags controlling the behavior of the extractor. Possible flag values are [ ][.FLAG_DISABLE_SEEK_FOR_CUES] and {#FLAG_EMIT_RAW_SUBTITLE_DATA}. */ @MustBeDocumented @Retention(AnnotationRetention.SOURCE) @Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE, AnnotationTarget.TYPE_PARAMETER) @IntDef(flag = true, value = [FLAG_DISABLE_SEEK_FOR_CUES, FLAG_EMIT_RAW_SUBTITLE_DATA]) annotation class Flags private val varintReader: VarintReader private val tracks: SparseArray private val seekForCuesEnabled: Boolean private val parseSubtitlesDuringExtraction: Boolean private val subtitleParserFactory: SubtitleParser.Factory // Temporary arrays. private val nalStartCode: ParsableByteArray private val nalLength: ParsableByteArray private val scratch: ParsableByteArray private val vorbisNumPageSamples: ParsableByteArray private val seekEntryIdBytes: ParsableByteArray private val sampleStrippedBytes: ParsableByteArray private val subtitleSample: ParsableByteArray private val encryptionInitializationVector: ParsableByteArray private val encryptionSubsampleData: ParsableByteArray private val supplementalData: ParsableByteArray private var encryptionSubsampleDataBuffer: ByteBuffer? = null private var segmentContentSize: Long = 0 private var segmentContentPosition = C.INDEX_UNSET.toLong() private var timecodeScale = C.TIME_UNSET private var durationTimecode = C.TIME_UNSET private var durationUs = C.TIME_UNSET private var isWebm: Boolean = false private var pendingEndTracks: Boolean // The track corresponding to the current TrackEntry element, or null. private var currentTrack: Track? = null // Whether a seek map has been sent to the output. private var sentSeekMap = false // Master seek entry related elements. private var seekEntryId = 0 private var seekEntryPosition: Long = 0 // Cue related elements. private val perTrackCues: SparseArray> private var inCuesElement = false private var currentCueTimeUs: Long = C.TIME_UNSET private var currentCueTrackNumber: Int = C.INDEX_UNSET private var currentCueClusterPosition: Long = C.INDEX_UNSET.toLong() private var currentCueRelativePosition: Long = C.INDEX_UNSET.toLong() private var primarySeekTrackNumber: Int = C.INDEX_UNSET private var seekForCues = false private var seekForSeekContent = false private var visitedSeekHeads: HashSet = HashSet() private var pendingSeekHeads: ArrayList = ArrayList() private var seekPositionAfterSeekingForHead = C.INDEX_UNSET.toLong() private var cuesContentPosition = C.INDEX_UNSET.toLong() private var seekPositionAfterBuildingCues = C.INDEX_UNSET.toLong() private var clusterTimecodeUs = C.TIME_UNSET // Reading state. private var haveOutputSample = false // Block reading state. private var blockState = 0 private var blockTimeUs: Long = 0 private var blockDurationUs: Long = 0 private var blockSampleIndex = 0 private var blockSampleCount = 0 private var blockSampleSizes: IntArray private var blockTrackNumber = 0 private var blockTrackNumberLength = 0 private var blockFlags: @BufferFlags Int = 0 private var blockAdditionalId = 0 private var blockHasReferenceBlock = false private var blockGroupDiscardPaddingNs: Long = 0 // Sample writing state. private var sampleBytesRead = 0 private var sampleBytesWritten = 0 private var sampleCurrentNalBytesRemaining = 0 private var sampleEncodingHandled = false private var sampleSignalByteRead = false private var samplePartitionCountRead = false private var samplePartitionCount = 0 private var sampleSignalByte: Byte = 0 private var sampleInitializationVectorRead = false // Extractor outputs. private var extractorOutput: ExtractorOutput? = null @Deprecated("Use {@link #MatroskaExtractor(SubtitleParser.Factory)} instead.") constructor() : this( DefaultEbmlReader(), FLAG_EMIT_RAW_SUBTITLE_DATA, SubtitleParser.Factory.UNSUPPORTED ) @Deprecated("Use {@link #MatroskaExtractor(SubtitleParser.Factory, int)} instead.") constructor(flags: @Flags Int) : this( DefaultEbmlReader(), flags or FLAG_EMIT_RAW_SUBTITLE_DATA, SubtitleParser.Factory.UNSUPPORTED ) /** * Constructs an instance. * * @param subtitleParserFactory The [SubtitleParser.Factory] for parsing subtitles during * extraction. */ constructor(subtitleParserFactory: SubtitleParser.Factory) : this( DefaultEbmlReader(), /* flags= */ 0, subtitleParserFactory ) /** * Constructs an instance. * * @param subtitleParserFactory The [SubtitleParser.Factory] for parsing subtitles during * extraction. * @param flags Flags that control the extractor's behavior. */ constructor(subtitleParserFactory: SubtitleParser.Factory, flags: @Flags Int) : this( DefaultEbmlReader(), flags, subtitleParserFactory ) /* package */ init { reader.init(InnerEbmlProcessor()) this.subtitleParserFactory = subtitleParserFactory this.perTrackCues = SparseArray() seekForCuesEnabled = (flags and FLAG_DISABLE_SEEK_FOR_CUES) == 0 parseSubtitlesDuringExtraction = (flags and FLAG_EMIT_RAW_SUBTITLE_DATA) == 0 varintReader = VarintReader() tracks = SparseArray() scratch = ParsableByteArray(4) vorbisNumPageSamples = ParsableByteArray(ByteBuffer.allocate(4).putInt(-1).array()) seekEntryIdBytes = ParsableByteArray(4) nalStartCode = ParsableByteArray(NalUnitUtil.NAL_START_CODE) nalLength = ParsableByteArray(4) sampleStrippedBytes = ParsableByteArray() subtitleSample = ParsableByteArray() encryptionInitializationVector = ParsableByteArray(ENCRYPTION_IV_SIZE) encryptionSubsampleData = ParsableByteArray() supplementalData = ParsableByteArray() blockSampleSizes = IntArray(1) pendingEndTracks = true } @Throws(IOException::class) override fun sniff(input: ExtractorInput): Boolean { return Sniffer().sniff(input) } override fun init(output: ExtractorOutput) { extractorOutput = if (parseSubtitlesDuringExtraction) SubtitleTranscodingExtractorOutput(output, subtitleParserFactory) else output } @CallSuper override fun seek(position: Long, timeUs: Long) { clusterTimecodeUs = C.TIME_UNSET blockState = BLOCK_STATE_START reader.reset() varintReader.reset() resetWriteSampleData() inCuesElement = false currentCueTimeUs = C.TIME_UNSET currentCueTrackNumber = C.INDEX_UNSET currentCueClusterPosition = C.INDEX_UNSET.toLong() currentCueRelativePosition = C.INDEX_UNSET.toLong() // To prevent creating duplicate cue points on a re-parse, clear any existing cue data if the // seek map has not yet been sent. Once sent, the cue data is considered final, and subsequent // Cues elements will be ignored by the parsing logic. if (!sentSeekMap) { perTrackCues.clear() } for (i in 0.. EbmlProcessor.ELEMENT_TYPE_MASTER ID_EBML_READ_VERSION, ID_DOC_TYPE_READ_VERSION, ID_SEEK_POSITION, ID_TIMECODE_SCALE, ID_TIME_CODE, ID_BLOCK_DURATION, ID_PIXEL_WIDTH, ID_PIXEL_HEIGHT, ID_DISPLAY_WIDTH, ID_DISPLAY_HEIGHT, ID_DISPLAY_UNIT, ID_TRACK_NUMBER, ID_TRACK_TYPE, ID_FLAG_DEFAULT, ID_FLAG_FORCED, ID_DEFAULT_DURATION, ID_MAX_BLOCK_ADDITION_ID, ID_BLOCK_ADD_ID_TYPE, ID_CODEC_DELAY, ID_SEEK_PRE_ROLL, ID_DISCARD_PADDING, ID_CHANNELS, ID_AUDIO_BIT_DEPTH, ID_CONTENT_ENCODING_ORDER, ID_CONTENT_ENCODING_SCOPE, ID_CONTENT_COMPRESSION_ALGORITHM, ID_CONTENT_ENCRYPTION_ALGORITHM, ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE, ID_CUE_TIME, ID_CUE_CLUSTER_POSITION, ID_CUE_RELATIVE_POSITION, ID_CUE_TRACK, ID_REFERENCE_BLOCK, ID_STEREO_MODE, ID_COLOUR_BITS_PER_CHANNEL, ID_COLOUR_RANGE, ID_COLOUR_TRANSFER, ID_COLOUR_PRIMARIES, ID_MAX_CLL, ID_MAX_FALL, ID_PROJECTION_TYPE, ID_BLOCK_ADD_ID -> EbmlProcessor.ELEMENT_TYPE_UNSIGNED_INT ID_DOC_TYPE, ID_NAME, ID_CODEC_ID, ID_LANGUAGE -> EbmlProcessor.ELEMENT_TYPE_STRING ID_SEEK_ID, ID_BLOCK_ADD_ID_EXTRA_DATA, ID_CONTENT_COMPRESSION_SETTINGS, ID_CONTENT_ENCRYPTION_KEY_ID, ID_SIMPLE_BLOCK, ID_BLOCK, ID_CODEC_PRIVATE, ID_PROJECTION_PRIVATE, ID_BLOCK_ADDITIONAL -> EbmlProcessor.ELEMENT_TYPE_BINARY ID_DURATION, ID_SAMPLING_FREQUENCY, ID_PRIMARY_R_CHROMATICITY_X, ID_PRIMARY_R_CHROMATICITY_Y, ID_PRIMARY_G_CHROMATICITY_X, ID_PRIMARY_G_CHROMATICITY_Y, ID_PRIMARY_B_CHROMATICITY_X, ID_PRIMARY_B_CHROMATICITY_Y, ID_WHITE_POINT_CHROMATICITY_X, ID_WHITE_POINT_CHROMATICITY_Y, ID_LUMNINANCE_MAX, ID_LUMNINANCE_MIN, ID_PROJECTION_POSE_YAW, ID_PROJECTION_POSE_PITCH, ID_PROJECTION_POSE_ROLL -> EbmlProcessor.ELEMENT_TYPE_FLOAT else -> EbmlProcessor.ELEMENT_TYPE_UNKNOWN } } /** * Checks if the given id is that of a level 1 element. * * @see EbmlProcessor.isLevel1Element */ @CallSuper protected fun isLevel1Element(id: Int): Boolean { return id == ID_SEGMENT_INFO || id == ID_CLUSTER || id == ID_CUES || id == ID_TRACKS } /** * Called when the start of a master element is encountered. * * @see EbmlProcessor.startMasterElement */ @CallSuper @Throws(ParserException::class) protected fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) { assertInitialized() when (id) { ID_SEGMENT -> { if (segmentContentPosition != C.INDEX_UNSET.toLong() && segmentContentPosition != contentPosition) { throw ParserException.createForMalformedContainer( "Multiple Segment elements not supported", /* cause= */null ) } segmentContentPosition = contentPosition segmentContentSize = contentSize } ID_SEEK -> { seekEntryId = UNSET_ENTRY_ID seekEntryPosition = C.INDEX_UNSET.toLong() } ID_CUES -> { if (!sentSeekMap) { inCuesElement = true } } ID_CUE_POINT -> { if (!sentSeekMap) { assertInCues(id) currentCueTimeUs = C.TIME_UNSET } } ID_CUE_TRACK_POSITIONS -> { if (!sentSeekMap) { assertInCues(id) currentCueTrackNumber = C.INDEX_UNSET currentCueClusterPosition = C.INDEX_UNSET.toLong() currentCueRelativePosition = C.INDEX_UNSET.toLong() } } ID_CLUSTER -> if (!sentSeekMap) { // We need to build cues before parsing the cluster. if (seekForCuesEnabled && cuesContentPosition != C.INDEX_UNSET.toLong()) { // We know where the Cues element is located. Seek to request it. seekForCues = true } else if (seekForCuesEnabled && pendingSeekHeads.isNotEmpty()) { // We do not know where the cues are located, however we have seek-heads // we have not yet visited seekForSeekContent = true } else { // We don't know where the Cues element is located. It's most likely omitted. Allow // playback, but disable seeking. extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) sentSeekMap = true } } ID_BLOCK_GROUP -> { blockHasReferenceBlock = false blockGroupDiscardPaddingNs = 0L } ID_CONTENT_ENCODING -> {} ID_CONTENT_ENCRYPTION -> getCurrentTrack(id).hasContentEncryption = true ID_TRACK_ENTRY -> { currentTrack = Track() currentTrack!!.isWebm = isWebm } ID_MASTERING_METADATA -> getCurrentTrack(id).hasColorInfo = true else -> {} } } /** * Called when the end of a master element is encountered. * * @see EbmlProcessor.endMasterElement */ @CallSuper @Throws(ParserException::class) protected fun endMasterElement(id: Int) { assertInitialized() when (id) { ID_SEGMENT_INFO -> { if (timecodeScale == C.TIME_UNSET) { // timecodeScale was omitted. Use the default value. timecodeScale = 1000000 } if (durationTimecode != C.TIME_UNSET) { durationUs = scaleTimecodeToUs(durationTimecode) } } ID_SEGMENT -> { // We only care if we have not already sent the seek map if (!sentSeekMap) { // We have reached the end of the segment, however we can still decide how to handle // pending seek heads. // // This is treated as the end as "Multiple Segment elements not supported" if (pendingSeekHeads.isNotEmpty() && seekForCuesEnabled) { // We seek to the next seek point if we can seek and there is seek heads seekForSeekContent = true } else { // Otherwise, if we not found any cues nor any more seek heads then we mark // this as unseekable. extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) sentSeekMap = true } } } ID_SEEK -> { if (seekEntryId == UNSET_ENTRY_ID || seekEntryPosition == C.INDEX_UNSET.toLong()) { throw ParserException.createForMalformedContainer( "Mandatory element SeekID or SeekPosition not found", /* cause= */null ) } else if (seekEntryId == ID_SEEK_HEAD) { // We have a set here to prevent inf recursion, only if this seek head is non // visited we add it. VLC limits this to 10, but this should work equally as well. if (visitedSeekHeads.add(seekEntryPosition)) { pendingSeekHeads.add(seekEntryPosition) } } else if (seekEntryId == ID_CUES) { cuesContentPosition = seekEntryPosition // We are currently seeking from the seek-head, so we seek again to get to the cues // instead of waiting for the cluster if (seekForCuesEnabled && seekPositionAfterSeekingForHead != C.INDEX_UNSET.toLong()) { seekForCues = true } } } ID_CUES -> { if (!sentSeekMap) { var hasAnyCues = false for (i in 0 until perTrackCues.size()) { if (perTrackCues.valueAt(i).isNotEmpty()) { hasAnyCues = true break } } if (!hasAnyCues || durationUs == C.TIME_UNSET) { // Cues are missing, empty, or duration is unknown. extractorOutput!!.seekMap(SeekMap.Unseekable(durationUs)) } else { for (i in 0 until perTrackCues.size()) { perTrackCues.valueAt(i).sort() } val seekMap = MatroskaSeekMap( perTrackCues, durationUs, primarySeekTrackNumber, segmentContentPosition, segmentContentSize ) extractorOutput!!.seekMap(seekMap) } sentSeekMap = true inCuesElement = false for (i in 0 until tracks.size()) { val track: Track = tracks.valueAt(i) track.maybeAddThumbnailMetadata(perTrackCues, durationUs, segmentContentPosition, segmentContentSize) if (!track.waitingForDtsAnalysis) { track.assertOutputInitialized() track.output!!.format(requireNotNull(track.format)) } } maybeEndTracks() } } ID_CUE_TRACK_POSITIONS -> { if (!sentSeekMap) { assertInCues(id) if (currentCueTimeUs != C.TIME_UNSET && currentCueTrackNumber != C.INDEX_UNSET && currentCueClusterPosition != C.INDEX_UNSET.toLong() ) { var trackCues = perTrackCues[currentCueTrackNumber] if (trackCues == null) { trackCues = ArrayList() perTrackCues.put(currentCueTrackNumber, trackCues) } trackCues.add( MatroskaSeekMap.CuePointData( currentCueTimeUs, /* clusterPosition= */ segmentContentPosition + currentCueClusterPosition, /* relativePosition= */ currentCueRelativePosition ) ) } } } ID_BLOCK_GROUP -> { if (blockState != BLOCK_STATE_DATA) { // We've skipped this block (due to incompatible track number). return } val track = tracks[blockTrackNumber] track.assertOutputInitialized() if (blockGroupDiscardPaddingNs > 0L && CODEC_ID_OPUS == track.codecId) { // For Opus, attach DiscardPadding to the block group samples as supplemental data. supplementalData.reset( ByteBuffer.allocate(8) .order(ByteOrder.LITTLE_ENDIAN) .putLong(blockGroupDiscardPaddingNs) .array() ) } // Commit sample metadata. var sampleOffset = 0 run { var i = 0 while (i < blockSampleCount) { sampleOffset += blockSampleSizes[i] i++ } } var i = 0 while (i < blockSampleCount) { val sampleTimeUs = blockTimeUs + (i * track.defaultSampleDurationNs) / 1000 var sampleFlags = blockFlags if (i == 0 && !blockHasReferenceBlock) { // If the ReferenceBlock element was not found in this block, then the first frame is a // keyframe. sampleFlags = sampleFlags or C.BUFFER_FLAG_KEY_FRAME } val sampleSize = blockSampleSizes[i] sampleOffset -= sampleSize // The offset is to the end of the sample. commitSampleToOutput(track, sampleTimeUs, sampleFlags, sampleSize, sampleOffset) i++ } blockState = BLOCK_STATE_START } ID_CONTENT_ENCODING -> { assertInTrackEntry(id) if (currentTrack!!.hasContentEncryption) { if (currentTrack!!.cryptoData == null) { throw ParserException.createForMalformedContainer( "Encrypted Track found but ContentEncKeyID was not found", /* cause= */ null ) } currentTrack!!.drmInitData = DrmInitData( SchemeData( C.UUID_NIL, MimeTypes.VIDEO_WEBM, currentTrack!!.cryptoData!!.encryptionKey ) ) } } ID_CONTENT_ENCODINGS -> { assertInTrackEntry(id) if (currentTrack!!.hasContentEncryption && currentTrack!!.sampleStrippedBytes != null) { throw ParserException.createForMalformedContainer( "Combining encryption and compression is not supported", /* cause= */null ) } } ID_TRACK_ENTRY -> { val currentTrack = checkNotNull(this.currentTrack) if (currentTrack.codecId == null) { throw ParserException.createForMalformedContainer( "CodecId is missing in TrackEntry element", /* cause= */null ) } else { if (isCodecSupported(currentTrack.codecId!!)) { currentTrack.initializeFormat(currentTrack.number); currentTrack.output = extractorOutput!!.track(currentTrack.number, currentTrack.type); tracks.put(currentTrack.number, currentTrack) } } this.currentTrack = null } ID_TRACKS -> { if (tracks.size() == 0) { throw ParserException.createForMalformedContainer( "No valid tracks were found", /* cause= */ null ) } // Determine the track to use for default seeking. var defaultVideoTrackNumber: Int = C.INDEX_UNSET var firstVideoTrackNumber: Int = C.INDEX_UNSET var defaultAudioTrackNumber: Int = C.INDEX_UNSET var firstAudioTrackNumber: Int = C.INDEX_UNSET // If we're not going to seek for cues, output the formats immediately. val mayBeSendFormatsEarly = !seekForCuesEnabled || cuesContentPosition == C.INDEX_UNSET.toLong(); for (i in 0 until tracks.size()) { val trackItem: Track = tracks.valueAt(i) val trackType: @C.TrackType Int = trackItem.type when (trackType) { C.TRACK_TYPE_VIDEO -> { if (trackItem.flagDefault) { defaultVideoTrackNumber = trackItem.number } if (firstVideoTrackNumber == C.INDEX_UNSET) { firstVideoTrackNumber = trackItem.number } } C.TRACK_TYPE_AUDIO -> { if (trackItem.flagDefault) { defaultAudioTrackNumber = trackItem.number } if (firstAudioTrackNumber == C.INDEX_UNSET) { firstAudioTrackNumber = trackItem.number } } } if (mayBeSendFormatsEarly) { trackItem.assertOutputInitialized() if (!trackItem.waitingForDtsAnalysis) { trackItem.output!!.format(checkNotNull(trackItem.format)) } } } primarySeekTrackNumber = when { defaultVideoTrackNumber != C.INDEX_UNSET -> defaultVideoTrackNumber firstVideoTrackNumber != C.INDEX_UNSET -> firstVideoTrackNumber defaultAudioTrackNumber != C.INDEX_UNSET -> defaultAudioTrackNumber firstAudioTrackNumber != C.INDEX_UNSET -> firstAudioTrackNumber tracks.size() > 0 -> tracks.valueAt(0).number else -> C.INDEX_UNSET } if (mayBeSendFormatsEarly) { maybeEndTracks() } } else -> {} } } /** * Called when an integer element is encountered. * * @see EbmlProcessor.integerElement */ @CallSuper @Throws(ParserException::class) protected fun integerElement(id: Int, value: Long) { when (id) { ID_EBML_READ_VERSION -> // Validate that EBMLReadVersion is supported. This extractor only supports v1. if (value != 1L) { throw ParserException.createForMalformedContainer( "EBMLReadVersion $value not supported", /* cause= */null ) } ID_DOC_TYPE_READ_VERSION -> // Validate that DocTypeReadVersion is supported. This extractor only supports up to v2. if (value < 1 || value > 2) { throw ParserException.createForMalformedContainer( "DocTypeReadVersion $value not supported", /* cause= */null ) } ID_SEEK_POSITION -> // Seek Position is the relative offset beginning from the Segment. So to get absolute // offset from the beginning of the file, we need to add segmentContentPosition to it. seekEntryPosition = value + segmentContentPosition ID_TIMECODE_SCALE -> timecodeScale = value ID_PIXEL_WIDTH -> getCurrentTrack(id).width = value.toInt() ID_PIXEL_HEIGHT -> getCurrentTrack(id).height = value.toInt() ID_DISPLAY_WIDTH -> getCurrentTrack(id).displayWidth = value.toInt() ID_DISPLAY_HEIGHT -> getCurrentTrack(id).displayHeight = value.toInt() ID_DISPLAY_UNIT -> getCurrentTrack(id).displayUnit = value.toInt() ID_TRACK_NUMBER -> getCurrentTrack(id).number = value.toInt() ID_FLAG_DEFAULT -> getCurrentTrack(id).flagDefault = value == 1L ID_FLAG_FORCED -> getCurrentTrack(id).flagForced = value == 1L ID_TRACK_TYPE -> { val matroskaTrackType = value.toInt() getCurrentTrack(id).type = when (matroskaTrackType) { 1 -> C.TRACK_TYPE_VIDEO // Matroska video 2 -> C.TRACK_TYPE_AUDIO // Matroska audio 17 -> C.TRACK_TYPE_TEXT // Matroska subtitle 33 -> C.TRACK_TYPE_METADATA // Matroska metadata else -> C.TRACK_TYPE_UNKNOWN } } ID_DEFAULT_DURATION -> getCurrentTrack(id).defaultSampleDurationNs = value.toInt() ID_MAX_BLOCK_ADDITION_ID -> getCurrentTrack(id).maxBlockAdditionId = value.toInt() ID_BLOCK_ADD_ID_TYPE -> getCurrentTrack(id).blockAddIdType = value.toInt() ID_CODEC_DELAY -> getCurrentTrack(id).codecDelayNs = value ID_SEEK_PRE_ROLL -> getCurrentTrack(id).seekPreRollNs = value ID_DISCARD_PADDING -> blockGroupDiscardPaddingNs = value ID_CHANNELS -> getCurrentTrack(id).channelCount = value.toInt() ID_AUDIO_BIT_DEPTH -> getCurrentTrack(id).audioBitDepth = value.toInt() ID_REFERENCE_BLOCK -> blockHasReferenceBlock = true ID_CONTENT_ENCODING_ORDER -> // This extractor only supports one ContentEncoding element and hence the order has to be 0. if (value != 0L) { throw ParserException.createForMalformedContainer( "ContentEncodingOrder $value not supported", /* cause= */null ) } ID_CONTENT_ENCODING_SCOPE -> // This extractor only supports the scope of all frames. if (value != 1L) { throw ParserException.createForMalformedContainer( "ContentEncodingScope $value not supported", /* cause= */null ) } ID_CONTENT_COMPRESSION_ALGORITHM -> // This extractor only supports header stripping. if (value != 3L) { throw ParserException.createForMalformedContainer( "ContentCompAlgo $value not supported", /* cause= */null ) } ID_CONTENT_ENCRYPTION_ALGORITHM -> // Only the value 5 (AES) is allowed according to the WebM specification. if (value != 5L) { throw ParserException.createForMalformedContainer( "ContentEncAlgo $value not supported", /* cause= */null ) } ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE -> // Only the value 1 is allowed according to the WebM specification. if (value != 1L) { throw ParserException.createForMalformedContainer( "AESSettingsCipherMode $value not supported", /* cause= */null ) } ID_CUE_TIME -> { if (!sentSeekMap) { assertInCues(id) currentCueTimeUs = scaleTimecodeToUs(value) } } ID_CUE_TRACK -> { if (!sentSeekMap) { assertInCues(id) currentCueTrackNumber = value.toInt() } } ID_CUE_CLUSTER_POSITION -> { if (!sentSeekMap) { assertInCues(id) if (currentCueClusterPosition == C.INDEX_UNSET.toLong()) { currentCueClusterPosition = value } } } ID_CUE_RELATIVE_POSITION -> { if (!sentSeekMap) { assertInCues(id) if (currentCueRelativePosition == C.INDEX_UNSET.toLong()) { currentCueRelativePosition = value } } } ID_TIME_CODE -> clusterTimecodeUs = scaleTimecodeToUs(value) ID_BLOCK_DURATION -> blockDurationUs = scaleTimecodeToUs(value) ID_STEREO_MODE -> { val layout = value.toInt() assertInTrackEntry(id) when (layout) { 0 -> currentTrack!!.stereoMode = C.STEREO_MODE_MONO 1 -> currentTrack!!.stereoMode = C.STEREO_MODE_LEFT_RIGHT 3 -> currentTrack!!.stereoMode = C.STEREO_MODE_TOP_BOTTOM 15 -> currentTrack!!.stereoMode = C.STEREO_MODE_STEREO_MESH else -> {} } } ID_COLOUR_PRIMARIES -> { assertInTrackEntry(id) currentTrack!!.hasColorInfo = true val colorSpace = ColorInfo.isoColorPrimariesToColorSpace(value.toInt()) if (colorSpace != Format.NO_VALUE) { currentTrack!!.colorSpace = colorSpace } } ID_COLOUR_TRANSFER -> { assertInTrackEntry(id) val colorTransfer = ColorInfo.isoTransferCharacteristicsToColorTransfer(value.toInt()) if (colorTransfer != Format.NO_VALUE) { currentTrack!!.colorTransfer = colorTransfer } } ID_COLOUR_BITS_PER_CHANNEL -> { assertInTrackEntry(id) currentTrack!!.hasColorInfo = true currentTrack!!.bitsPerChannel = value.toInt() } ID_COLOUR_RANGE -> { assertInTrackEntry(id) when (value.toInt()) { 1 -> currentTrack!!.colorRange = C.COLOR_RANGE_LIMITED 2 -> currentTrack!!.colorRange = C.COLOR_RANGE_FULL else -> {} } } ID_MAX_CLL -> getCurrentTrack(id).maxContentLuminance = value.toInt() ID_MAX_FALL -> getCurrentTrack(id).maxFrameAverageLuminance = value.toInt() ID_PROJECTION_TYPE -> { assertInTrackEntry(id) when (value.toInt()) { 0 -> currentTrack!!.projectionType = C.PROJECTION_RECTANGULAR 1 -> currentTrack!!.projectionType = C.PROJECTION_EQUIRECTANGULAR 2 -> currentTrack!!.projectionType = C.PROJECTION_CUBEMAP 3 -> currentTrack!!.projectionType = C.PROJECTION_MESH else -> {} } } ID_BLOCK_ADD_ID -> blockAdditionalId = value.toInt() else -> {} } } /** * Called when a float element is encountered. * * @see EbmlProcessor.floatElement */ @CallSuper @Throws(ParserException::class) protected fun floatElement(id: Int, value: Double) { when (id) { ID_DURATION -> durationTimecode = value.toLong() ID_SAMPLING_FREQUENCY -> getCurrentTrack(id).sampleRate = value.toInt() ID_PRIMARY_R_CHROMATICITY_X -> getCurrentTrack(id).primaryRChromaticityX = value.toFloat() ID_PRIMARY_R_CHROMATICITY_Y -> getCurrentTrack(id).primaryRChromaticityY = value.toFloat() ID_PRIMARY_G_CHROMATICITY_X -> getCurrentTrack(id).primaryGChromaticityX = value.toFloat() ID_PRIMARY_G_CHROMATICITY_Y -> getCurrentTrack(id).primaryGChromaticityY = value.toFloat() ID_PRIMARY_B_CHROMATICITY_X -> getCurrentTrack(id).primaryBChromaticityX = value.toFloat() ID_PRIMARY_B_CHROMATICITY_Y -> getCurrentTrack(id).primaryBChromaticityY = value.toFloat() ID_WHITE_POINT_CHROMATICITY_X -> getCurrentTrack(id).whitePointChromaticityX = value.toFloat() ID_WHITE_POINT_CHROMATICITY_Y -> getCurrentTrack(id).whitePointChromaticityY = value.toFloat() ID_LUMNINANCE_MAX -> getCurrentTrack(id).maxMasteringLuminance = value.toFloat() ID_LUMNINANCE_MIN -> getCurrentTrack(id).minMasteringLuminance = value.toFloat() ID_PROJECTION_POSE_YAW -> getCurrentTrack(id).projectionPoseYaw = value.toFloat() ID_PROJECTION_POSE_PITCH -> getCurrentTrack(id).projectionPosePitch = value.toFloat() ID_PROJECTION_POSE_ROLL -> getCurrentTrack(id).projectionPoseRoll = value.toFloat() else -> {} } } /** * Called when a string element is encountered. * * @see EbmlProcessor.stringElement */ @CallSuper @Throws(ParserException::class) protected fun stringElement(id: Int, value: String) { when (id) { ID_DOC_TYPE -> // Validate that DocType is supported. if (DOC_TYPE_WEBM != value && DOC_TYPE_MATROSKA != value) { throw ParserException.createForMalformedContainer( "DocType $value not supported", /* cause= */null ) } ID_NAME -> getCurrentTrack(id).name = value ID_CODEC_ID -> getCurrentTrack(id).codecId = value ID_LANGUAGE -> getCurrentTrack(id).language = value else -> {} } } /** * Called when a binary element is encountered. * * @see EbmlProcessor.binaryElement */ @CallSuper @Throws(IOException::class) protected fun binaryElement(id: Int, contentSize: Int, input: ExtractorInput) { when (id) { ID_SEEK_ID -> { Arrays.fill(seekEntryIdBytes.data, 0.toByte()) input.readFully(seekEntryIdBytes.data, 4 - contentSize, contentSize) seekEntryIdBytes.position = 0 seekEntryId = seekEntryIdBytes.readUnsignedInt().toInt() } ID_BLOCK_ADD_ID_EXTRA_DATA -> handleBlockAddIDExtraData( getCurrentTrack(id), input, contentSize ) ID_CODEC_PRIVATE -> { assertInTrackEntry(id) currentTrack!!.codecPrivate = ByteArray(contentSize) input.readFully(currentTrack!!.codecPrivate!!, 0, contentSize) } ID_PROJECTION_PRIVATE -> { assertInTrackEntry(id) currentTrack!!.projectionData = ByteArray(contentSize) input.readFully(currentTrack!!.projectionData!!, 0, contentSize) } ID_CONTENT_COMPRESSION_SETTINGS -> { assertInTrackEntry(id) // This extractor only supports header stripping, so the payload is the stripped bytes. currentTrack!!.sampleStrippedBytes = ByteArray(contentSize) input.readFully(currentTrack!!.sampleStrippedBytes!!, 0, contentSize) } ID_CONTENT_ENCRYPTION_KEY_ID -> { val encryptionKey = ByteArray(contentSize) input.readFully(encryptionKey, 0, contentSize) getCurrentTrack(id).cryptoData = CryptoData( C.CRYPTO_MODE_AES_CTR, encryptionKey, 0, 0 ) // We assume patternless AES-CTR. } ID_SIMPLE_BLOCK, ID_BLOCK -> { // Please refer to http://www.matroska.org/technical/specs/index.html#simpleblock_structure // and http://matroska.org/technical/specs/index.html#block_structure // for info about how data is organized in SimpleBlock and Block elements respectively. They // differ only in the way flags are specified. if (blockState == BLOCK_STATE_START) { blockTrackNumber = varintReader.readUnsignedVarint(input, false, true, 8).toInt() blockTrackNumberLength = varintReader.lastLength blockDurationUs = C.TIME_UNSET blockState = BLOCK_STATE_HEADER scratch.reset( /* limit= */0) } val track = tracks[blockTrackNumber] // Ignore the block if we don't know about the track to which it belongs. if (track == null) { input.skipFully(contentSize - blockTrackNumberLength) blockState = BLOCK_STATE_START return } track.assertOutputInitialized() if (blockState == BLOCK_STATE_HEADER) { // Read the relative timecode (2 bytes) and flags (1 byte). readScratch(input, 3) val lacing = (scratch.data[2].toInt() and 0x06) shr 1 if (lacing == LACING_NONE) { blockSampleCount = 1 blockSampleSizes = ensureArrayCapacity(blockSampleSizes, 1) blockSampleSizes[0] = contentSize - blockTrackNumberLength - 3 } else { // Read the sample count (1 byte). readScratch(input, 4) blockSampleCount = (scratch.data[3].toInt() and 0xFF) + 1 blockSampleSizes = ensureArrayCapacity(blockSampleSizes, blockSampleCount) if (lacing == LACING_FIXED_SIZE) { val blockLacingSampleSize = (contentSize - blockTrackNumberLength - 4) / blockSampleCount Arrays.fill( blockSampleSizes, 0, blockSampleCount, blockLacingSampleSize ) } else if (lacing == LACING_XIPH) { var totalSamplesSize = 0 var headerSize = 4 var sampleIndex = 0 while (sampleIndex < blockSampleCount - 1) { blockSampleSizes[sampleIndex] = 0 var byteValue: Int do { readScratch(input, ++headerSize) byteValue = scratch.data[headerSize - 1].toInt() and 0xFF blockSampleSizes[sampleIndex] += byteValue } while (byteValue == 0xFF) totalSamplesSize += blockSampleSizes[sampleIndex] sampleIndex++ } blockSampleSizes[blockSampleCount - 1] = contentSize - blockTrackNumberLength - headerSize - totalSamplesSize } else if (lacing == LACING_EBML) { var totalSamplesSize = 0 var headerSize = 4 var sampleIndex = 0 while (sampleIndex < blockSampleCount - 1) { blockSampleSizes[sampleIndex] = 0 readScratch(input, ++headerSize) if (scratch.data[headerSize - 1].toInt() == 0) { throw ParserException.createForMalformedContainer( "No valid varint length mask found", /* cause= */null ) } var readValue: Long = 0 var i = 0 while (i < 8) { val lengthMask = 1 shl (7 - i) if ((scratch.data[headerSize - 1].toInt() and lengthMask) != 0) { var readPosition = headerSize - 1 headerSize += i readScratch(input, headerSize) readValue = ((scratch.data[readPosition++].toInt() and 0xFF) and lengthMask.inv()).toLong() while (readPosition < headerSize) { readValue = readValue shl 8 readValue = readValue or (scratch.data[readPosition++].toInt() and 0xFF).toLong() } // The first read value is the first size. Later values are signed offsets. if (sampleIndex > 0) { readValue -= (1L shl (6 + i * 7)) - 1 } break } i++ } if (readValue < Int.MIN_VALUE || readValue > Int.MAX_VALUE) { throw ParserException.createForMalformedContainer( "EBML lacing sample size out of range.", /* cause= */null ) } val intReadValue = readValue.toInt() blockSampleSizes[sampleIndex] = if (sampleIndex == 0) intReadValue else blockSampleSizes[sampleIndex - 1] + intReadValue totalSamplesSize += blockSampleSizes[sampleIndex] sampleIndex++ } blockSampleSizes[blockSampleCount - 1] = contentSize - blockTrackNumberLength - headerSize - totalSamplesSize } else { // Lacing is always in the range 0--3. throw ParserException.createForMalformedContainer( "Unexpected lacing value: $lacing", /* cause= */null ) } } val timecode = (scratch.data[0].toInt() shl 8) or (scratch.data[1].toInt() and 0xFF) blockTimeUs = clusterTimecodeUs + scaleTimecodeToUs(timecode.toLong()) val isKeyframe = track.type == C.TRACK_TYPE_AUDIO || (id == ID_SIMPLE_BLOCK && (scratch.data[2].toInt() and 0x80) == 0x80) blockFlags = if (isKeyframe) C.BUFFER_FLAG_KEY_FRAME else 0 blockState = BLOCK_STATE_DATA blockSampleIndex = 0 } if (id == ID_SIMPLE_BLOCK) { // For SimpleBlock, we can write sample data and immediately commit the corresponding // sample metadata. while (blockSampleIndex < blockSampleCount) { val sampleSize = writeSampleData( input, track, blockSampleSizes[blockSampleIndex], /* isBlockGroup= */ false ) val sampleTimeUs = blockTimeUs + (blockSampleIndex * track.defaultSampleDurationNs) / 1000 commitSampleToOutput( track, sampleTimeUs, blockFlags, sampleSize, /* offset= */ 0 ) blockSampleIndex++ } blockState = BLOCK_STATE_START } else { // For Block, we need to wait until the end of the BlockGroup element before committing // sample metadata. This is so that we can handle ReferenceBlock (which can be used to // infer whether the first sample in the block is a keyframe), and BlockAdditions (which // can contain additional sample data to append) contained in the block group. Just output // the sample data, storing the final sample sizes for when we commit the metadata. while (blockSampleIndex < blockSampleCount) { blockSampleSizes[blockSampleIndex] = writeSampleData( input, track, blockSampleSizes[blockSampleIndex], /* isBlockGroup= */ true ) blockSampleIndex++ } } } ID_BLOCK_ADDITIONAL -> { if (blockState != BLOCK_STATE_DATA) { return } handleBlockAdditionalData( tracks[blockTrackNumber], blockAdditionalId, input, contentSize ) } else -> throw ParserException.createForMalformedContainer( "Unexpected id: $id", /* cause= */null ) } } @Throws(IOException::class) protected fun handleBlockAddIDExtraData(track: Track, input: ExtractorInput, contentSize: Int) { if (track.blockAddIdType == BLOCK_ADD_ID_TYPE_DVVC || track.blockAddIdType == BLOCK_ADD_ID_TYPE_DVCC ) { track.dolbyVisionConfigBytes = ByteArray(contentSize) input.readFully(track.dolbyVisionConfigBytes!!, 0, contentSize) } else { // Unhandled BlockAddIDExtraData. input.skipFully(contentSize) } } @Throws(IOException::class) protected fun handleBlockAdditionalData( track: Track, blockAdditionalId: Int, input: ExtractorInput, contentSize: Int ) { if (blockAdditionalId == BLOCK_ADDITIONAL_ID_VP9_ITU_T_35 && CODEC_ID_VP9 == track.codecId ) { supplementalData.reset(contentSize) input.readFully(supplementalData.data, 0, contentSize) } else { // Unhandled block additional data. input.skipFully(contentSize) } } @Throws(ParserException::class) private fun assertInTrackEntry(id: Int) { if (currentTrack == null) { throw ParserException.createForMalformedContainer( "Element $id must be in a TrackEntry", /* cause= */null ) } } @Throws(ParserException::class) private fun assertInCues(id: Int) { if (!inCuesElement) { throw ParserException.createForMalformedContainer( "Element $id must be in a Cues", /* cause= */null ) } } /** * Returns the track corresponding to the current TrackEntry element. * * @throws ParserException if the element id is not in a TrackEntry. */ @Throws(ParserException::class) protected fun getCurrentTrack(currentElementId: Int): Track { assertInTrackEntry(currentElementId) return currentTrack!! } private fun commitSampleToOutput( track: Track, timeUs: Long, flags: @BufferFlags Int, size: Int, offset: Int ) { var size = size if (track.trueHdSampleRechunker != null) { track.trueHdSampleRechunker!!.sampleMetadata( track.output!!, timeUs, flags, size, offset, track.cryptoData ) } else { if (CODEC_ID_SUBRIP == track.codecId || CODEC_ID_ASS == track.codecId || CODEC_ID_SSA == track.codecId || CODEC_ID_VTT == track.codecId ) { if (blockSampleCount > 1) { Log.w(TAG, "Skipping subtitle sample in laced block.") } else if (blockDurationUs == C.TIME_UNSET) { Log.w(TAG, "Skipping subtitle sample with no duration.") } else { setSubtitleEndTime( track.codecId!!, blockDurationUs, subtitleSample.data ) // The Matroska spec doesn't clearly define whether subtitle samples are null-terminated // or the sample should instead be sized precisely. We truncate the sample at a null-byte // to gracefully handle null-terminated strings followed by garbage bytes. for (i in subtitleSample.position.. 1) { // There were multiple samples in the block. Appending the additional data to the last // sample doesn't make sense. Skip instead. supplementalData.reset( /* limit= */0) } else { // Append supplemental data. val supplementalDataSize = supplementalData.limit() track.output!!.sampleData( supplementalData, supplementalDataSize, TrackOutput.SAMPLE_DATA_PART_SUPPLEMENTAL ) size += supplementalDataSize } } track.output!!.sampleMetadata(timeUs, flags, size, offset, track.cryptoData) } haveOutputSample = true } /** * Ensures [.scratch] contains at least `requiredLength` bytes of data, reading from * the extractor input if necessary. */ @Throws(IOException::class) private fun readScratch(input: ExtractorInput, requiredLength: Int) { if (scratch.limit() >= requiredLength) { return } if (scratch.capacity() < requiredLength) { scratch.ensureCapacity( max( (scratch.capacity() * 2).toDouble(), requiredLength.toDouble() ).toInt() ) } input.readFully(scratch.data, scratch.limit(), requiredLength - scratch.limit()) scratch.setLimit(requiredLength) } /** * Writes data for a single sample to the track output. * * @param input The input from which to read sample data. * @param track The track to output the sample to. * @param size The size of the sample data on the input side. * @param isBlockGroup Whether the samples are from a BlockGroup. * @return The final size of the written sample. * @throws IOException If an error occurs reading from the input. */ @Throws(IOException::class) private fun writeSampleData( input: ExtractorInput, track: Track, size: Int, isBlockGroup: Boolean ): Int { var size = size if (CODEC_ID_SUBRIP == track.codecId) { writeSubtitleSampleData(input, SUBRIP_PREFIX, size) return finishWriteSampleData() } else if (CODEC_ID_ASS == track.codecId || CODEC_ID_SSA == track.codecId) { writeSubtitleSampleData(input, SSA_PREFIX, size) return finishWriteSampleData() } else if (CODEC_ID_VTT == track.codecId) { writeSubtitleSampleData(input, VTT_PREFIX, size) return finishWriteSampleData() } if (track.waitingForDtsAnalysis) { checkNotNull(track.format) if (DtsUtil.isSampleDtsHd(input, size)) { track.format = track.format!! .buildUpon() .setSampleMimeType(MimeTypes.AUDIO_DTS_HD) .build() } track.output!!.format(track.format!!) track.waitingForDtsAnalysis = false maybeEndTracks() } val output = track.output if (!sampleEncodingHandled) { if (track.hasContentEncryption) { // If the sample is encrypted, read its encryption signal byte and set the IV size. // Clear the encrypted flag. blockFlags = blockFlags and C.BUFFER_FLAG_ENCRYPTED.inv() if (!sampleSignalByteRead) { input.readFully(scratch.data, 0, 1) sampleBytesRead++ if ((scratch.data[0].toInt() and 0x80) == 0x80) { throw ParserException.createForMalformedContainer( "Extension bit is set in signal byte", /* cause= */null ) } sampleSignalByte = scratch.data[0] sampleSignalByteRead = true } val isEncrypted = (sampleSignalByte.toInt() and 0x01) == 0x01 if (isEncrypted) { val hasSubsampleEncryption = (sampleSignalByte.toInt() and 0x02) == 0x02 blockFlags = blockFlags or C.BUFFER_FLAG_ENCRYPTED if (!sampleInitializationVectorRead) { input.readFully(encryptionInitializationVector.data, 0, ENCRYPTION_IV_SIZE) sampleBytesRead += ENCRYPTION_IV_SIZE sampleInitializationVectorRead = true // Write the signal byte, containing the IV size and the subsample encryption flag. scratch.data[0] = (ENCRYPTION_IV_SIZE or (if (hasSubsampleEncryption) 0x80 else 0x00)).toByte() scratch.position = 0 output!!.sampleData(scratch, 1, TrackOutput.SAMPLE_DATA_PART_ENCRYPTION) sampleBytesWritten++ // Write the IV. encryptionInitializationVector.position = 0 output.sampleData( encryptionInitializationVector, ENCRYPTION_IV_SIZE, TrackOutput.SAMPLE_DATA_PART_ENCRYPTION ) sampleBytesWritten += ENCRYPTION_IV_SIZE } if (hasSubsampleEncryption) { if (!samplePartitionCountRead) { input.readFully(scratch.data, 0, 1) sampleBytesRead++ scratch.position = 0 samplePartitionCount = scratch.readUnsignedByte() samplePartitionCountRead = true } val samplePartitionDataSize = samplePartitionCount * 4 scratch.reset(samplePartitionDataSize) input.readFully(scratch.data, 0, samplePartitionDataSize) sampleBytesRead += samplePartitionDataSize val subsampleCount = (1 + (samplePartitionCount / 2)).toShort() val subsampleDataSize = 2 + 6 * subsampleCount if (encryptionSubsampleDataBuffer == null || encryptionSubsampleDataBuffer!!.capacity() < subsampleDataSize ) { encryptionSubsampleDataBuffer = ByteBuffer.allocate(subsampleDataSize) } encryptionSubsampleDataBuffer!!.position(0) encryptionSubsampleDataBuffer!!.putShort(subsampleCount) // Loop through the partition offsets and write out the data in the way ExoPlayer // wants it (ISO 23001-7 Part 7): // 2 bytes - sub sample count. // for each sub sample: // 2 bytes - clear data size. // 4 bytes - encrypted data size. var partitionOffset = 0 for (i in 0.. 0) { sampleStrippedBytes.readBytes(target, offset, pendingStrippedBytes) } } /** * Outputs up to `length` bytes of sample data to `output`, consisting of either * [.sampleStrippedBytes] or data read from `input`. */ @Throws(IOException::class) private fun writeToOutput(input: ExtractorInput, output: TrackOutput, length: Int): Int { val bytesWritten: Int val strippedBytesLeft = sampleStrippedBytes.bytesLeft() if (strippedBytesLeft > 0) { bytesWritten = min(length.toDouble(), strippedBytesLeft.toDouble()).toInt() output.sampleData(sampleStrippedBytes, bytesWritten) } else { bytesWritten = output.sampleData(input, length, false) } return bytesWritten } /** * Updates the position of the holder to Cues element's position if the extractor configuration * permits use of master seek entry. After building Cues sets the holder's position back to where * it was before. * * @param seekPosition The holder whose position will be updated. * @param currentPosition Current position of the input. * @return Whether the seek position was updated. */ private fun maybeSeekForCues(seekPosition: PositionHolder, currentPosition: Long): Boolean { // This seeks in a lazy manner, unlike VLC that seeks immediately when encountering a seek head // This minimizes the amount of seeking done, but also does not seek if the cues element is // already found, even if seek heads exits. This might be nice to change if we need other // critical information from seek heads. // // The nature of each recursive query becomes to consume as much content as possible // (until cues or end of segment). However this also means that we only need to seek // back to the top once, instead seeking back in a stack like manner. if (seekForSeekContent) { checkArgument(pendingSeekHeads.isNotEmpty(), "Illegal value of seekForSeekContent") // The exact order does not really matter, but it is easiest to just do stack (FILO) val next = pendingSeekHeads.removeAt(pendingSeekHeads.size - 1) seekPosition.position = next seekForSeekContent = false if (seekPositionAfterSeekingForHead == C.INDEX_UNSET.toLong()) { seekPositionAfterSeekingForHead = currentPosition } return true } if (seekForCues) { seekPositionAfterBuildingCues = currentPosition seekPosition.position = cuesContentPosition seekForCues = false return true } // After parsing Cues, seek back to original position if available. We will not do this unless // we seeked to get to the Cues in the first place. if (sentSeekMap && seekPositionAfterBuildingCues != C.INDEX_UNSET.toLong()) { seekPosition.position = seekPositionAfterBuildingCues seekPositionAfterBuildingCues = C.INDEX_UNSET.toLong() return true } // After we have seeked back from seekPositionAfterBuildingCues seek back again to the seek head if (sentSeekMap && seekPositionAfterSeekingForHead != C.INDEX_UNSET.toLong()) { seekPosition.position = seekPositionAfterSeekingForHead seekPositionAfterSeekingForHead = C.INDEX_UNSET.toLong() return true } return false } @Throws(ParserException::class) private fun scaleTimecodeToUs(unscaledTimecode: Long): Long { if (timecodeScale == C.TIME_UNSET) { throw ParserException.createForMalformedContainer( "Can't scale timecode prior to timecodeScale being set.", /* cause= */null ) } return Util.scaleLargeTimestamp(unscaledTimecode, timecodeScale, 1000) } private fun assertInitialized() { checkNotNull( extractorOutput ) } private fun maybeEndTracks() { if (!pendingEndTracks) return for (i in 0 until tracks.size()) { if (tracks.valueAt(i).waitingForDtsAnalysis) return } checkNotNull(extractorOutput).endTracks() pendingEndTracks = false } /** Passes events through to the outer [UpdatedMatroskaExtractor]. */ private inner class InnerEbmlProcessor : EbmlProcessor { override fun getElementType(id: Int): @EbmlProcessor.ElementType Int { return this@UpdatedMatroskaExtractor.getElementType(id) } override fun isLevel1Element(id: Int): Boolean { return this@UpdatedMatroskaExtractor.isLevel1Element(id) } @Throws(ParserException::class) override fun startMasterElement(id: Int, contentPosition: Long, contentSize: Long) { this@UpdatedMatroskaExtractor.startMasterElement(id, contentPosition, contentSize) } @Throws(ParserException::class) override fun endMasterElement(id: Int) { this@UpdatedMatroskaExtractor.endMasterElement(id) } @Throws(ParserException::class) override fun integerElement(id: Int, value: Long) { this@UpdatedMatroskaExtractor.integerElement(id, value) } @Throws(ParserException::class) override fun floatElement(id: Int, value: Double) { this@UpdatedMatroskaExtractor.floatElement(id, value) } @Throws(ParserException::class) override fun stringElement(id: Int, value: String) { this@UpdatedMatroskaExtractor.stringElement(id, value) } @Throws(IOException::class) override fun binaryElement(id: Int, contentsSize: Int, input: ExtractorInput) { this@UpdatedMatroskaExtractor.binaryElement(id, contentsSize, input) } } /** Holds data corresponding to a single track. */ protected class Track { // Common elements. var isWebm: Boolean = false var name: String? = null var codecId: String? = null var number: Int = 0 var type: @C.TrackType Int = 0 var defaultSampleDurationNs: Int = 0 var maxBlockAdditionId: Int = 0 var blockAddIdType: Int = 0 var hasContentEncryption: Boolean = false var sampleStrippedBytes: ByteArray? = null var cryptoData: CryptoData? = null var codecPrivate: ByteArray? = null var drmInitData: DrmInitData? = null // Video elements. var width: Int = Format.NO_VALUE var height: Int = Format.NO_VALUE var bitsPerChannel: Int = Format.NO_VALUE var displayWidth: Int = Format.NO_VALUE var displayHeight: Int = Format.NO_VALUE var displayUnit: Int = DISPLAY_UNIT_PIXELS var projectionType: @C.Projection Int = Format.NO_VALUE var projectionPoseYaw: Float = 0f var projectionPosePitch: Float = 0f var projectionPoseRoll: Float = 0f var projectionData: ByteArray? = null var stereoMode: @StereoMode Int = Format.NO_VALUE var hasColorInfo: Boolean = false var colorSpace: @C.ColorSpace Int = Format.NO_VALUE var colorTransfer: @ColorTransfer Int = Format.NO_VALUE var colorRange: @ColorRange Int = Format.NO_VALUE var maxContentLuminance: Int = DEFAULT_MAX_CLL var maxFrameAverageLuminance: Int = DEFAULT_MAX_FALL var primaryRChromaticityX: Float = Format.NO_VALUE.toFloat() var primaryRChromaticityY: Float = Format.NO_VALUE.toFloat() var primaryGChromaticityX: Float = Format.NO_VALUE.toFloat() var primaryGChromaticityY: Float = Format.NO_VALUE.toFloat() var primaryBChromaticityX: Float = Format.NO_VALUE.toFloat() var primaryBChromaticityY: Float = Format.NO_VALUE.toFloat() var whitePointChromaticityX: Float = Format.NO_VALUE.toFloat() var whitePointChromaticityY: Float = Format.NO_VALUE.toFloat() var maxMasteringLuminance: Float = Format.NO_VALUE.toFloat() var minMasteringLuminance: Float = Format.NO_VALUE.toFloat() var dolbyVisionConfigBytes: ByteArray? = null // Audio elements. Initially set to their default values. var channelCount: Int = 1 var audioBitDepth: Int = Format.NO_VALUE var sampleRate: Int = 8000 var codecDelayNs: Long = 0 var seekPreRollNs: Long = 0 var trueHdSampleRechunker: TrueHdSampleRechunker? = null var waitingForDtsAnalysis: Boolean = false // Text elements. var flagForced: Boolean = false // Common track elements. var flagDefault: Boolean = true var language: String = "eng" // Set when the output is initialized. nalUnitLengthFieldLength is only set for H264/H265. var output: TrackOutput? = null var format: Format? = null var nalUnitLengthFieldLength: Int = 0 /** Builds the [Format] for the track. */ @Throws(ParserException::class) fun initializeFormat(trackId: Int) { var mimeType: String var maxInputSize = Format.NO_VALUE var pcmEncoding: @PcmEncoding Int = Format.NO_VALUE var initializationData: List? = null var codecs: String? = null when (codecId) { CODEC_ID_VP8 -> mimeType = MimeTypes.VIDEO_VP8 CODEC_ID_VP9 -> { mimeType = MimeTypes.VIDEO_VP9 initializationData = if (codecPrivate == null) null else ImmutableList.of( codecPrivate!! ) } CODEC_ID_AV1 -> { mimeType = MimeTypes.VIDEO_AV1 initializationData = if (codecPrivate == null) null else ImmutableList.of( codecPrivate!! ) } CODEC_ID_MPEG2 -> mimeType = MimeTypes.VIDEO_MPEG2 CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP -> { mimeType = MimeTypes.VIDEO_MP4V initializationData = if (codecPrivate == null) null else listOf( codecPrivate!! ) } CODEC_ID_H264 -> { mimeType = MimeTypes.VIDEO_H264 val avcConfig = AvcConfig.parse( ParsableByteArray( getCodecPrivate( codecId!! ) ) ) initializationData = avcConfig.initializationData nalUnitLengthFieldLength = avcConfig.nalUnitLengthFieldLength codecs = avcConfig.codecs } CODEC_ID_H265 -> { mimeType = MimeTypes.VIDEO_H265 val hevcConfig = HevcConfig.parse( ParsableByteArray( getCodecPrivate( codecId!! ) ) ) initializationData = hevcConfig.initializationData nalUnitLengthFieldLength = hevcConfig.nalUnitLengthFieldLength codecs = hevcConfig.codecs } CODEC_ID_FOURCC -> { val pair = parseFourCcPrivate( ParsableByteArray( getCodecPrivate( codecId!! ) ) ) mimeType = pair.first initializationData = pair.second } CODEC_ID_THEORA -> // TODO: This can be set to the real mimeType if/when we work out what initializationData // should be set to for this case. mimeType = MimeTypes.VIDEO_UNKNOWN CODEC_ID_VORBIS -> { mimeType = MimeTypes.AUDIO_VORBIS maxInputSize = VORBIS_MAX_INPUT_SIZE initializationData = parseVorbisCodecPrivate( getCodecPrivate( codecId!! ) ) } CODEC_ID_OPUS -> { mimeType = MimeTypes.AUDIO_OPUS maxInputSize = OPUS_MAX_INPUT_SIZE initializationData = ArrayList(3) initializationData.add(getCodecPrivate(codecId!!)) initializationData.add( ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(codecDelayNs) .array() ) initializationData.add( ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(seekPreRollNs) .array() ) } CODEC_ID_AAC -> { mimeType = MimeTypes.AUDIO_AAC initializationData = listOf( getCodecPrivate( codecId!! ) ) val aacConfig = AacUtil.parseAudioSpecificConfig(codecPrivate!!) // Update sampleRate and channelCount from the AudioSpecificConfig initialization data, // which is more reliable. See [Internal: b/10903778]. sampleRate = aacConfig.sampleRateHz channelCount = aacConfig.channelCount codecs = aacConfig.codecs } CODEC_ID_MP2 -> { mimeType = MimeTypes.AUDIO_MPEG_L2 maxInputSize = MpegAudioUtil.MAX_FRAME_SIZE_BYTES } CODEC_ID_MP3 -> { mimeType = MimeTypes.AUDIO_MPEG maxInputSize = MpegAudioUtil.MAX_FRAME_SIZE_BYTES } CODEC_ID_AC3 -> mimeType = MimeTypes.AUDIO_AC3 CODEC_ID_E_AC3 -> mimeType = MimeTypes.AUDIO_E_AC3 CODEC_ID_TRUEHD -> { mimeType = MimeTypes.AUDIO_TRUEHD trueHdSampleRechunker = TrueHdSampleRechunker() } CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS -> { mimeType = MimeTypes.AUDIO_DTS // temporary waitingForDtsAnalysis = true } CODEC_ID_DTS_LOSSLESS -> mimeType = MimeTypes.AUDIO_DTS_HD CODEC_ID_FLAC -> { mimeType = MimeTypes.AUDIO_FLAC initializationData = listOf( getCodecPrivate( codecId!! ) ) } CODEC_ID_ACM -> { mimeType = MimeTypes.AUDIO_RAW if (parseMsAcmCodecPrivate( ParsableByteArray( getCodecPrivate( codecId!! ) ) ) ) { pcmEncoding = Util.getPcmEncoding(audioBitDepth) if (pcmEncoding == C.ENCODING_INVALID) { pcmEncoding = Format.NO_VALUE mimeType = MimeTypes.AUDIO_UNKNOWN Log.w( TAG, ("Unsupported PCM bit depth: " + audioBitDepth + ". Setting mimeType to " + mimeType) ) } } else { mimeType = MimeTypes.AUDIO_UNKNOWN Log.w( TAG, "Non-PCM MS/ACM is unsupported. Setting mimeType to $mimeType" ) } } CODEC_ID_PCM_INT_LIT -> { mimeType = MimeTypes.AUDIO_RAW pcmEncoding = Util.getPcmEncoding(audioBitDepth) if (pcmEncoding == C.ENCODING_INVALID) { pcmEncoding = Format.NO_VALUE mimeType = MimeTypes.AUDIO_UNKNOWN Log.w( TAG, ("Unsupported little endian PCM bit depth: " + audioBitDepth + ". Setting mimeType to " + mimeType) ) } } CODEC_ID_PCM_INT_BIG -> { mimeType = MimeTypes.AUDIO_RAW if (audioBitDepth == 8) { pcmEncoding = C.ENCODING_PCM_8BIT } else if (audioBitDepth == 16) { pcmEncoding = C.ENCODING_PCM_16BIT_BIG_ENDIAN } else if (audioBitDepth == 24) { pcmEncoding = C.ENCODING_PCM_24BIT_BIG_ENDIAN } else if (audioBitDepth == 32) { pcmEncoding = C.ENCODING_PCM_32BIT_BIG_ENDIAN } else { pcmEncoding = Format.NO_VALUE mimeType = MimeTypes.AUDIO_UNKNOWN Log.w( TAG, ("Unsupported big endian PCM bit depth: " + audioBitDepth + ". Setting mimeType to " + mimeType) ) } } CODEC_ID_PCM_FLOAT -> { mimeType = MimeTypes.AUDIO_RAW if (audioBitDepth == 32) { pcmEncoding = C.ENCODING_PCM_FLOAT } else { pcmEncoding = Format.NO_VALUE mimeType = MimeTypes.AUDIO_UNKNOWN Log.w( TAG, ("Unsupported floating point PCM bit depth: " + audioBitDepth + ". Setting mimeType to " + mimeType) ) } } CODEC_ID_SUBRIP -> mimeType = MimeTypes.APPLICATION_SUBRIP CODEC_ID_ASS, CODEC_ID_SSA -> { mimeType = MimeTypes.TEXT_SSA initializationData = ImmutableList.of( SSA_DIALOGUE_FORMAT, getCodecPrivate( codecId!! ) ) } CODEC_ID_VTT -> mimeType = MimeTypes.TEXT_VTT CODEC_ID_VOBSUB -> { mimeType = MimeTypes.APPLICATION_VOBSUB initializationData = ImmutableList.of( getCodecPrivate( codecId!! ) ) } CODEC_ID_PGS -> mimeType = MimeTypes.APPLICATION_PGS CODEC_ID_DVBSUB -> { mimeType = MimeTypes.APPLICATION_DVBSUBS // Init data: composition_page (2), ancillary_page (2) val initializationDataBytes = ByteArray(4) System.arraycopy(getCodecPrivate(codecId!!), 0, initializationDataBytes, 0, 4) initializationData = ImmutableList.of(initializationDataBytes) } else -> throw ParserException.createForMalformedContainer( "Unrecognized codec identifier.", /* cause= */null ) } if (dolbyVisionConfigBytes != null) { val dolbyVisionConfig = DolbyVisionConfig.parse(ParsableByteArray(dolbyVisionConfigBytes!!)) if (dolbyVisionConfig != null) { codecs = dolbyVisionConfig.codecs mimeType = MimeTypes.VIDEO_DOLBY_VISION } } var selectionFlags: @SelectionFlags Int = 0 selectionFlags = selectionFlags or if (flagDefault) C.SELECTION_FLAG_DEFAULT else 0 selectionFlags = selectionFlags or if (flagForced) C.SELECTION_FLAG_FORCED else 0 val formatBuilder = Format.Builder() // TODO: Consider reading the name elements of the tracks and, if present, incorporating them // into the trackId passed when creating the formats. if (MimeTypes.isAudio(mimeType)) { formatBuilder .setChannelCount(channelCount) .setSampleRate(sampleRate) .setPcmEncoding(pcmEncoding) } else if (MimeTypes.isVideo(mimeType)) { if (displayUnit == DISPLAY_UNIT_PIXELS) { displayWidth = if (displayWidth == Format.NO_VALUE) width else displayWidth displayHeight = if (displayHeight == Format.NO_VALUE) height else displayHeight } var pixelWidthHeightRatio = Format.NO_VALUE.toFloat() if (displayWidth != Format.NO_VALUE && displayHeight != Format.NO_VALUE) { pixelWidthHeightRatio = ((height * displayWidth).toFloat()) / (width * displayHeight) } var colorInfo: ColorInfo? = null if (hasColorInfo) { val hdrStaticInfo = hdrStaticInfo colorInfo = ColorInfo.Builder() .setColorSpace(colorSpace) .setColorRange(colorRange) .setColorTransfer(colorTransfer) .setHdrStaticInfo(hdrStaticInfo) .setLumaBitdepth(bitsPerChannel) .setChromaBitdepth(bitsPerChannel) .build() } var rotationDegrees = Format.NO_VALUE if (name != null && TRACK_NAME_TO_ROTATION_DEGREES.containsKey(name)) { rotationDegrees = TRACK_NAME_TO_ROTATION_DEGREES[name]!! } if (projectionType == C.PROJECTION_RECTANGULAR && java.lang.Float.compare( projectionPoseYaw, 0f ) == 0 && java.lang.Float.compare(projectionPosePitch, 0f) == 0 ) { // The range of projectionPoseRoll is [-180, 180]. if (java.lang.Float.compare(projectionPoseRoll, 0f) == 0) { rotationDegrees = 0 } else if (java.lang.Float.compare(projectionPoseRoll, 90f) == 0) { rotationDegrees = 90 } else if (java.lang.Float.compare(projectionPoseRoll, -180f) == 0 || java.lang.Float.compare(projectionPoseRoll, 180f) == 0 ) { rotationDegrees = 180 } else if (java.lang.Float.compare(projectionPoseRoll, -90f) == 0) { rotationDegrees = 270 } } formatBuilder .setWidth(width) .setHeight(height) .setPixelWidthHeightRatio(pixelWidthHeightRatio) .setRotationDegrees(rotationDegrees) .setProjectionData(projectionData) .setStereoMode(stereoMode) .setColorInfo(colorInfo) } else if (MimeTypes.APPLICATION_SUBRIP == mimeType || MimeTypes.TEXT_SSA == mimeType || MimeTypes.TEXT_VTT == mimeType || MimeTypes.APPLICATION_VOBSUB == mimeType || MimeTypes.APPLICATION_PGS == mimeType || MimeTypes.APPLICATION_DVBSUBS == mimeType ) { } else { throw ParserException.createForMalformedContainer( "Unexpected MIME type.", /* cause= */null ) } if (name != null && !TRACK_NAME_TO_ROTATION_DEGREES.containsKey(name)) { formatBuilder.setLabel(name) } format = formatBuilder .setId(trackId) .setContainerMimeType(if (isWebm) MimeTypes.VIDEO_WEBM else MimeTypes.VIDEO_MATROSKA) .setSampleMimeType(mimeType) .setMaxInputSize(maxInputSize) .setLanguage(language) .setSelectionFlags(selectionFlags) .setInitializationData(initializationData) .setCodecs(codecs) .setDrmInitData(drmInitData) .build() } /** Forces any pending sample metadata to be flushed to the output. */ fun outputPendingSampleMetadata() { if (trueHdSampleRechunker != null) { trueHdSampleRechunker!!.outputPendingSampleMetadata(output!!, cryptoData) } } /** Resets any state stored in the track in response to a seek. */ fun reset() { if (trueHdSampleRechunker != null) { trueHdSampleRechunker!!.reset() } } /** * Returns true if supplemental data will be attached to the samples. * * @param isBlockGroup Whether the samples are from a BlockGroup. */ fun samplesHaveSupplementalData(isBlockGroup: Boolean): Boolean { if (CODEC_ID_OPUS == codecId) { // At the end of a BlockGroup, a positive DiscardPadding value will be written out as // supplemental data for Opus codec. Otherwise (i.e. DiscardPadding <= 0) supplemental data // size will be 0. return isBlockGroup } return maxBlockAdditionId > 0 } private val hdrStaticInfo: ByteArray? /** Returns the HDR Static Info as defined in CTA-861.3. */ get() { // Are all fields present. if (primaryRChromaticityX == Format.NO_VALUE.toFloat() || primaryRChromaticityY == Format.NO_VALUE.toFloat() || primaryGChromaticityX == Format.NO_VALUE.toFloat() || primaryGChromaticityY == Format.NO_VALUE.toFloat() || primaryBChromaticityX == Format.NO_VALUE.toFloat() || primaryBChromaticityY == Format.NO_VALUE.toFloat() || whitePointChromaticityX == Format.NO_VALUE.toFloat() || whitePointChromaticityY == Format.NO_VALUE.toFloat() || maxMasteringLuminance == Format.NO_VALUE.toFloat() || minMasteringLuminance == Format.NO_VALUE.toFloat()) { return null } val hdrStaticInfoData = ByteArray(25) val hdrStaticInfo = ByteBuffer.wrap(hdrStaticInfoData).order(ByteOrder.LITTLE_ENDIAN) hdrStaticInfo.put(0.toByte()) // Type. hdrStaticInfo.putShort( ((primaryRChromaticityX * MAX_CHROMATICITY) + 0.5f).toInt().toShort() ) hdrStaticInfo.putShort( ((primaryRChromaticityY * MAX_CHROMATICITY) + 0.5f).toInt().toShort() ) hdrStaticInfo.putShort( ((primaryGChromaticityX * MAX_CHROMATICITY) + 0.5f).toInt().toShort() ) hdrStaticInfo.putShort( ((primaryGChromaticityY * MAX_CHROMATICITY) + 0.5f).toInt().toShort() ) hdrStaticInfo.putShort( ((primaryBChromaticityX * MAX_CHROMATICITY) + 0.5f).toInt().toShort() ) hdrStaticInfo.putShort( ((primaryBChromaticityY * MAX_CHROMATICITY) + 0.5f).toInt().toShort() ) hdrStaticInfo.putShort( ((whitePointChromaticityX * MAX_CHROMATICITY) + 0.5f).toInt().toShort() ) hdrStaticInfo.putShort( ((whitePointChromaticityY * MAX_CHROMATICITY) + 0.5f).toInt().toShort() ) hdrStaticInfo.putShort((maxMasteringLuminance + 0.5f).toInt().toShort()) hdrStaticInfo.putShort((minMasteringLuminance + 0.5f).toInt().toShort()) hdrStaticInfo.putShort(maxContentLuminance.toShort()) hdrStaticInfo.putShort(maxFrameAverageLuminance.toShort()) return hdrStaticInfoData } /** * Finds the best thumbnail timestamp from the cue points and adds it to the track's format as * [ThumbnailMetadata]. */ fun maybeAddThumbnailMetadata( perTrackCues: SparseArray>, durationUs: Long, segmentContentPosition: Long, segmentContentSize: Long ) { if (type != C.TRACK_TYPE_VIDEO) return val cuePoints = perTrackCues[number] if (cuePoints.isNullOrEmpty()) return val thumbnailTimestampUs = findBestThumbnailPresentationTimeUs( cuePoints, durationUs, segmentContentPosition, segmentContentSize ) if (thumbnailTimestampUs != C.TIME_UNSET) { val currentFormat = requireNotNull(format) val existingMetadata = currentFormat.metadata val thumbnailMetadata = ThumbnailMetadata(thumbnailTimestampUs) val newMetadata = if (existingMetadata == null) { Metadata(thumbnailMetadata) } else { existingMetadata.copyWithAppendedEntries(thumbnailMetadata) } format = currentFormat.buildUpon().setMetadata(newMetadata).build() } } /** * Finds the best thumbnail timestamp from the provided cue points. * *

The heuristic seeks to find a visually interesting frame by assuming that a larger chunk * size corresponds to a more complex and representative frame. It calculates an approximate * bitrate for each chunk and selects the timestamp of the chunk with the highest bitrate. */ private fun findBestThumbnailPresentationTimeUs( cuePoints: MutableList, durationUs: Long, segmentContentPosition: Long, segmentContentSize: Long ): Long { if (cuePoints.isEmpty()) return C.TIME_UNSET var maxBitrate = 0.0 var bestCueIndex = -1 val scanLimit = min(cuePoints.size, MAX_CHUNKS_TO_SCAN_FOR_THUMBNAIL) for (i in 0 until scanLimit) { val cue = cuePoints[i] if (cue.timeUs > MAX_DURATION_US_TO_SCAN_FOR_THUMBNAIL) break val bytesBetweenCues: Long val durationBetweenCuesUs: Long if (i < cuePoints.size - 1) { val nextCue = cuePoints[i + 1] bytesBetweenCues = (nextCue.clusterPosition + nextCue.relativePosition) - (cue.clusterPosition + cue.relativePosition) durationBetweenCuesUs = nextCue.timeUs - cue.timeUs } else { // Last cue point bytesBetweenCues = (segmentContentPosition + segmentContentSize) - (cue.clusterPosition + cue.relativePosition) durationBetweenCuesUs = durationUs - cue.timeUs } if (durationBetweenCuesUs > 0) { // This is an approximation of the bitrate for thumbnail heuristic. val bitrate = bytesBetweenCues.toDouble() / durationBetweenCuesUs if (bitrate > maxBitrate) { maxBitrate = bitrate bestCueIndex = i } } } return if (bestCueIndex == -1) C.TIME_UNSET else cuePoints[bestCueIndex].timeUs } /** * Checks that the track has an output. * * * It is unfortunately not possible to mark [UpdatedMatroskaExtractor.tracks] as only * containing tracks with output with the nullness checker. This method is used to check that * fact at runtime. */ fun assertOutputInitialized() { checkNotNull( output ) } @Throws(ParserException::class) private fun getCodecPrivate(codecId: String): ByteArray { if (codecPrivate == null) { throw ParserException.createForMalformedContainer( "Missing CodecPrivate for codec $codecId", /* cause= */null ) } return codecPrivate!! } companion object { private const val DISPLAY_UNIT_PIXELS = 0 private const val MAX_CHROMATICITY = 50000 // Defined in CTA-861.3. /** Default max content light level (CLL) that should be encoded into hdrStaticInfo. */ private const val DEFAULT_MAX_CLL = 1000 // nits. /** Default frame-average light level (FALL) that should be encoded into hdrStaticInfo. */ private const val DEFAULT_MAX_FALL = 200 // nits. /** * Builds initialization data for a [Format] from FourCC codec private data. * * @return The codec MIME type and initialization data. If the compression type is not supported * then the MIME type is set to [MimeTypes.VIDEO_UNKNOWN] and the initialization data * is `null`. * @throws ParserException If the initialization data could not be built. */ @Throws(ParserException::class) private fun parseFourCcPrivate( buffer: ParsableByteArray ): Pair> { try { buffer.skipBytes(16) // size(4), width(4), height(4), planes(2), bitcount(2). val compression = buffer.readLittleEndianUnsignedInt() if (compression == FOURCC_COMPRESSION_DIVX.toLong()) { return Pair(MimeTypes.VIDEO_DIVX, null) } else if (compression == FOURCC_COMPRESSION_H263.toLong()) { return Pair(MimeTypes.VIDEO_H263, null) } else if (compression == FOURCC_COMPRESSION_VC1.toLong()) { // Search for the initialization data from the end of the BITMAPINFOHEADER. The last 20 // bytes of which are: sizeImage(4), xPel/m (4), yPel/m (4), clrUsed(4), clrImportant(4). val startOffset = buffer.position + 20 val bufferData = buffer.data for (offset in startOffset.. { try { if (codecPrivate[0].toInt() != 0x02) { throw ParserException.createForMalformedContainer( "Error parsing vorbis codec private", /* cause= */null ) } var offset = 1 var vorbisInfoLength = 0 while ((codecPrivate[offset].toInt() and 0xFF) == 0xFF) { vorbisInfoLength += 0xFF offset++ } vorbisInfoLength += codecPrivate[offset++].toInt() and 0xFF var vorbisSkipLength = 0 while ((codecPrivate[offset].toInt() and 0xFF) == 0xFF) { vorbisSkipLength += 0xFF offset++ } vorbisSkipLength += codecPrivate[offset++].toInt() and 0xFF if (codecPrivate[offset].toInt() != 0x01) { throw ParserException.createForMalformedContainer( "Error parsing vorbis codec private", /* cause= */null ) } val vorbisInfo = ByteArray(vorbisInfoLength) System.arraycopy(codecPrivate, offset, vorbisInfo, 0, vorbisInfoLength) offset += vorbisInfoLength if (codecPrivate[offset].toInt() != 0x03) { throw ParserException.createForMalformedContainer( "Error parsing vorbis codec private", /* cause= */null ) } offset += vorbisSkipLength if (codecPrivate[offset].toInt() != 0x05) { throw ParserException.createForMalformedContainer( "Error parsing vorbis codec private", /* cause= */null ) } val vorbisBooks = ByteArray(codecPrivate.size - offset) System.arraycopy( codecPrivate, offset, vorbisBooks, 0, codecPrivate.size - offset ) val initializationData: MutableList = ArrayList(2) initializationData.add(vorbisInfo) initializationData.add(vorbisBooks) return initializationData } catch (e: ArrayIndexOutOfBoundsException) { throw ParserException.createForMalformedContainer( "Error parsing vorbis codec private", /* cause= */null ) } } /** * Parses an MS/ACM codec private, returning whether it indicates PCM audio. * * @return Whether the codec private indicates PCM audio. * @throws ParserException If a parsing error occurs. */ @Throws(ParserException::class) private fun parseMsAcmCodecPrivate(buffer: ParsableByteArray): Boolean { try { val formatTag = buffer.readLittleEndianUnsignedShort() if (formatTag == WAVE_FORMAT_PCM) { return true } else if (formatTag == WAVE_FORMAT_EXTENSIBLE) { buffer.position = WAVE_FORMAT_SIZE + 6 // unionSamples(2), channelMask(4) return buffer.readLong() == WAVE_SUBFORMAT_PCM.mostSignificantBits && buffer.readLong() == WAVE_SUBFORMAT_PCM.leastSignificantBits } else { return false } } catch (e: ArrayIndexOutOfBoundsException) { throw ParserException.createForMalformedContainer( "Error parsing MS/ACM codec private", /* cause= */null ) } } } } companion object { /** * Creates a factory for [UpdatedMatroskaExtractor] instances with the provided [ ]. */ fun newFactory(subtitleParserFactory: SubtitleParser.Factory): ExtractorsFactory { return ExtractorsFactory { arrayOf( UpdatedMatroskaExtractor(subtitleParserFactory) ) } } /** * Flag to disable seeking for cues. * * * Normally (i.e. when this flag is not set) the extractor will seek to the cues element if its * position is specified in the seek head and if it's after the first cluster. Setting this flag * disables seeking to the cues element. If the cues element is after the first cluster then the * media is treated as being unseekable. */ const val FLAG_DISABLE_SEEK_FOR_CUES: Int = 1 /** * Flag to use the source subtitle formats without modification. If unset, subtitles will be * transcoded to [MimeTypes.APPLICATION_MEDIA3_CUES] during extraction. */ const val FLAG_EMIT_RAW_SUBTITLE_DATA: Int = 1 shl 1 // 2 @Deprecated("Use {@link #newFactory(SubtitleParser.Factory)} instead.") val FACTORY: ExtractorsFactory = ExtractorsFactory { arrayOf( UpdatedMatroskaExtractor( SubtitleParser.Factory.UNSUPPORTED, FLAG_EMIT_RAW_SUBTITLE_DATA ) ) } private const val TAG = "MatroskaExtractor" private const val UNSET_ENTRY_ID = -1 private const val BLOCK_STATE_START = 0 private const val BLOCK_STATE_HEADER = 1 private const val BLOCK_STATE_DATA = 2 private const val DOC_TYPE_MATROSKA = "matroska" private const val DOC_TYPE_WEBM = "webm" private const val CODEC_ID_VP8 = "V_VP8" private const val CODEC_ID_VP9 = "V_VP9" private const val CODEC_ID_AV1 = "V_AV1" private const val CODEC_ID_MPEG2 = "V_MPEG2" private const val CODEC_ID_MPEG4_SP = "V_MPEG4/ISO/SP" private const val CODEC_ID_MPEG4_ASP = "V_MPEG4/ISO/ASP" private const val CODEC_ID_MPEG4_AP = "V_MPEG4/ISO/AP" private const val CODEC_ID_H264 = "V_MPEG4/ISO/AVC" private const val CODEC_ID_H265 = "V_MPEGH/ISO/HEVC" private const val CODEC_ID_FOURCC = "V_MS/VFW/FOURCC" private const val CODEC_ID_THEORA = "V_THEORA" private const val CODEC_ID_VORBIS = "A_VORBIS" private const val CODEC_ID_OPUS = "A_OPUS" private const val CODEC_ID_AAC = "A_AAC" private const val CODEC_ID_MP2 = "A_MPEG/L2" private const val CODEC_ID_MP3 = "A_MPEG/L3" private const val CODEC_ID_AC3 = "A_AC3" private const val CODEC_ID_E_AC3 = "A_EAC3" private const val CODEC_ID_TRUEHD = "A_TRUEHD" private const val CODEC_ID_DTS = "A_DTS" private const val CODEC_ID_DTS_EXPRESS = "A_DTS/EXPRESS" private const val CODEC_ID_DTS_LOSSLESS = "A_DTS/LOSSLESS" private const val CODEC_ID_FLAC = "A_FLAC" private const val CODEC_ID_ACM = "A_MS/ACM" private const val CODEC_ID_PCM_INT_LIT = "A_PCM/INT/LIT" private const val CODEC_ID_PCM_INT_BIG = "A_PCM/INT/BIG" private const val CODEC_ID_PCM_FLOAT = "A_PCM/FLOAT/IEEE" private const val CODEC_ID_SUBRIP = "S_TEXT/UTF8" private const val CODEC_ID_ASS = "S_TEXT/ASS" private const val CODEC_ID_SSA = "S_TEXT/SSA" private const val CODEC_ID_VTT = "S_TEXT/WEBVTT" private const val CODEC_ID_VOBSUB = "S_VOBSUB" private const val CODEC_ID_PGS = "S_HDMV/PGS" private const val CODEC_ID_DVBSUB = "S_DVBSUB" private const val VORBIS_MAX_INPUT_SIZE = 8192 private const val OPUS_MAX_INPUT_SIZE = 5760 private const val ENCRYPTION_IV_SIZE = 8 private const val TRACK_TYPE_AUDIO = 2 private const val ID_EBML = 0x1A45DFA3 private const val ID_EBML_READ_VERSION = 0x42F7 private const val ID_DOC_TYPE = 0x4282 private const val ID_DOC_TYPE_READ_VERSION = 0x4285 private const val ID_SEGMENT = 0x18538067 private const val ID_SEGMENT_INFO = 0x1549A966 private const val ID_SEEK_HEAD = 0x114D9B74 private const val ID_SEEK = 0x4DBB private const val ID_SEEK_ID = 0x53AB private const val ID_SEEK_POSITION = 0x53AC private const val ID_INFO = 0x1549A966 private const val ID_TIMECODE_SCALE = 0x2AD7B1 private const val ID_DURATION = 0x4489 private const val ID_CLUSTER = 0x1F43B675 private const val ID_TIME_CODE = 0xE7 private const val ID_SIMPLE_BLOCK = 0xA3 private const val ID_BLOCK_GROUP = 0xA0 private const val ID_BLOCK = 0xA1 private const val ID_BLOCK_DURATION = 0x9B private const val ID_BLOCK_ADDITIONS = 0x75A1 private const val ID_BLOCK_MORE = 0xA6 private const val ID_BLOCK_ADD_ID = 0xEE private const val ID_BLOCK_ADDITIONAL = 0xA5 private const val ID_REFERENCE_BLOCK = 0xFB private const val ID_TRACKS = 0x1654AE6B private const val ID_TRACK_ENTRY = 0xAE private const val ID_TRACK_NUMBER = 0xD7 private const val ID_TRACK_TYPE = 0x83 private const val ID_FLAG_DEFAULT = 0x88 private const val ID_FLAG_FORCED = 0x55AA private const val ID_DEFAULT_DURATION = 0x23E383 private const val ID_MAX_BLOCK_ADDITION_ID = 0x55EE private const val ID_BLOCK_ADDITION_MAPPING = 0x41E4 private const val ID_BLOCK_ADD_ID_TYPE = 0x41E7 private const val ID_BLOCK_ADD_ID_EXTRA_DATA = 0x41ED private const val ID_NAME = 0x536E private const val ID_CODEC_ID = 0x86 private const val ID_CODEC_PRIVATE = 0x63A2 private const val ID_CODEC_DELAY = 0x56AA private const val ID_SEEK_PRE_ROLL = 0x56BB private const val ID_DISCARD_PADDING = 0x75A2 private const val ID_VIDEO = 0xE0 private const val ID_PIXEL_WIDTH = 0xB0 private const val ID_PIXEL_HEIGHT = 0xBA private const val ID_DISPLAY_WIDTH = 0x54B0 private const val ID_DISPLAY_HEIGHT = 0x54BA private const val ID_DISPLAY_UNIT = 0x54B2 private const val ID_AUDIO = 0xE1 private const val ID_CHANNELS = 0x9F private const val ID_AUDIO_BIT_DEPTH = 0x6264 private const val ID_SAMPLING_FREQUENCY = 0xB5 private const val ID_CONTENT_ENCODINGS = 0x6D80 private const val ID_CONTENT_ENCODING = 0x6240 private const val ID_CONTENT_ENCODING_ORDER = 0x5031 private const val ID_CONTENT_ENCODING_SCOPE = 0x5032 private const val ID_CONTENT_COMPRESSION = 0x5034 private const val ID_CONTENT_COMPRESSION_ALGORITHM = 0x4254 private const val ID_CONTENT_COMPRESSION_SETTINGS = 0x4255 private const val ID_CONTENT_ENCRYPTION = 0x5035 private const val ID_CONTENT_ENCRYPTION_ALGORITHM = 0x47E1 private const val ID_CONTENT_ENCRYPTION_KEY_ID = 0x47E2 private const val ID_CONTENT_ENCRYPTION_AES_SETTINGS = 0x47E7 private const val ID_CONTENT_ENCRYPTION_AES_SETTINGS_CIPHER_MODE = 0x47E8 private const val ID_CUES = 0x1C53BB6B private const val ID_CUE_POINT = 0xBB private const val ID_CUE_TIME = 0xB3 private const val ID_CUE_TRACK = 0xF7 private const val ID_CUE_TRACK_POSITIONS = 0xB7 private const val ID_CUE_CLUSTER_POSITION = 0xF1 private const val ID_CUE_RELATIVE_POSITION = 0xF0 private const val ID_LANGUAGE = 0x22B59C private const val ID_PROJECTION = 0x7670 private const val ID_PROJECTION_TYPE = 0x7671 private const val ID_PROJECTION_PRIVATE = 0x7672 private const val ID_PROJECTION_POSE_YAW = 0x7673 private const val ID_PROJECTION_POSE_PITCH = 0x7674 private const val ID_PROJECTION_POSE_ROLL = 0x7675 private const val ID_STEREO_MODE = 0x53B8 private const val ID_COLOUR = 0x55B0 private const val ID_COLOUR_RANGE = 0x55B9 private const val ID_COLOUR_BITS_PER_CHANNEL = 0x55B2 private const val ID_COLOUR_TRANSFER = 0x55BA private const val ID_COLOUR_PRIMARIES = 0x55BB private const val ID_MAX_CLL = 0x55BC private const val ID_MAX_FALL = 0x55BD private const val ID_MASTERING_METADATA = 0x55D0 private const val ID_PRIMARY_R_CHROMATICITY_X = 0x55D1 private const val ID_PRIMARY_R_CHROMATICITY_Y = 0x55D2 private const val ID_PRIMARY_G_CHROMATICITY_X = 0x55D3 private const val ID_PRIMARY_G_CHROMATICITY_Y = 0x55D4 private const val ID_PRIMARY_B_CHROMATICITY_X = 0x55D5 private const val ID_PRIMARY_B_CHROMATICITY_Y = 0x55D6 private const val ID_WHITE_POINT_CHROMATICITY_X = 0x55D7 private const val ID_WHITE_POINT_CHROMATICITY_Y = 0x55D8 private const val ID_LUMNINANCE_MAX = 0x55D9 private const val ID_LUMNINANCE_MIN = 0x55DA /** * BlockAddID value for ITU T.35 metadata in a VP9 track. See also * https://www.webmproject.org/docs/container/. */ private const val BLOCK_ADDITIONAL_ID_VP9_ITU_T_35 = 4 /** * BlockAddIdType value for Dolby Vision configuration with profile <= 7. See also * https://www.matroska.org/technical/codec_specs.html. */ private const val BLOCK_ADD_ID_TYPE_DVCC = 0x64766343 /** * BlockAddIdType value for Dolby Vision configuration with profile > 7. See also * https://www.matroska.org/technical/codec_specs.html. */ private const val BLOCK_ADD_ID_TYPE_DVVC = 0x64767643 private const val LACING_NONE = 0 private const val LACING_XIPH = 1 private const val LACING_FIXED_SIZE = 2 private const val LACING_EBML = 3 private const val FOURCC_COMPRESSION_DIVX = 0x58564944 private const val FOURCC_COMPRESSION_H263 = 0x33363248 private const val FOURCC_COMPRESSION_VC1 = 0x31435657 /** The maximum number of chunks to scan when searching for a thumbnail. */ private const val MAX_CHUNKS_TO_SCAN_FOR_THUMBNAIL = 20 /** The maximum duration to scan for a thumbnail, in microseconds. */ private const val MAX_DURATION_US_TO_SCAN_FOR_THUMBNAIL = 10_000_000L /** * A template for the prefix that must be added to each subrip sample. * * * The display time of each subtitle is passed as `timeUs` to [ ][TrackOutput.sampleMetadata]. The start and end timecodes in this template are relative to * `timeUs`. Hence the start timecode is always zero. The 12 byte end timecode starting at * [.SUBRIP_PREFIX_END_TIMECODE_OFFSET] is set to a placeholder value, and must be replaced * with the duration of the subtitle. * * * Equivalent to the UTF-8 string: "1\n00:00:00,000 --> 00:00:00,000\n". */ private val SUBRIP_PREFIX = byteArrayOf( 49, 10, 48, 48, 58, 48, 48, 58, 48, 48, 44, 48, 48, 48, 32, 45, 45, 62, 32, 48, 48, 58, 48, 48, 58, 48, 48, 44, 48, 48, 48, 10 ) /** The byte offset of the end timecode in [.SUBRIP_PREFIX]. */ private const val SUBRIP_PREFIX_END_TIMECODE_OFFSET = 19 /** * The value by which to divide a time in microseconds to convert it to the unit of the last value * in a subrip timecode (milliseconds). */ private const val SUBRIP_TIMECODE_LAST_VALUE_SCALING_FACTOR: Long = 1000 /** The format of a subrip timecode. */ private const val SUBRIP_TIMECODE_FORMAT = "%02d:%02d:%02d,%03d" /** Matroska specific format line for SSA subtitles. */ private val SSA_DIALOGUE_FORMAT = Util.getUtf8Bytes( "Format: Start, End, " + "ReadOrder, Layer, Style, Name, MarginL, MarginR, MarginV, Effect, Text" ) /** * A template for the prefix that must be added to each SSA sample. * * * The display time of each subtitle is passed as `timeUs` to [ ][TrackOutput.sampleMetadata]. The start and end timecodes in this template are relative to * `timeUs`. Hence the start timecode is always zero. The 12 byte end timecode starting at * [.SUBRIP_PREFIX_END_TIMECODE_OFFSET] is set to a placeholder value, and must be replaced * with the duration of the subtitle. * * * Equivalent to the UTF-8 string: "Dialogue: 0:00:00:00,0:00:00:00,". */ private val SSA_PREFIX = byteArrayOf( 68, 105, 97, 108, 111, 103, 117, 101, 58, 32, 48, 58, 48, 48, 58, 48, 48, 58, 48, 48, 44, 48, 58, 48, 48, 58, 48, 48, 58, 48, 48, 44 ) /** The byte offset of the end timecode in [.SSA_PREFIX]. */ private const val SSA_PREFIX_END_TIMECODE_OFFSET = 21 /** * The value by which to divide a time in microseconds to convert it to the unit of the last value * in an SSA timecode (1/100ths of a second). */ private const val SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR: Long = 10000 /** The format of an SSA timecode. */ private const val SSA_TIMECODE_FORMAT = "%01d:%02d:%02d:%02d" /** * A template for the prefix that must be added to each VTT sample. * * * The display time of each subtitle is passed as `timeUs` to [ ][TrackOutput.sampleMetadata]. The start and end timecodes in this template are relative to * `timeUs`. Hence the start timecode is always zero. The 12 byte end timecode starting at * [.VTT_PREFIX_END_TIMECODE_OFFSET] is set to a placeholder value, and must be replaced * with the duration of the subtitle. * * * Equivalent to the UTF-8 string: "WEBVTT\n\n00:00:00.000 --> 00:00:00.000\n". */ private val VTT_PREFIX = byteArrayOf( 87, 69, 66, 86, 84, 84, 10, 10, 48, 48, 58, 48, 48, 58, 48, 48, 46, 48, 48, 48, 32, 45, 45, 62, 32, 48, 48, 58, 48, 48, 58, 48, 48, 46, 48, 48, 48, 10 ) /** The byte offset of the end timecode in [.VTT_PREFIX]. */ private const val VTT_PREFIX_END_TIMECODE_OFFSET = 25 /** * The value by which to divide a time in microseconds to convert it to the unit of the last value * in a VTT timecode (milliseconds). */ private const val VTT_TIMECODE_LAST_VALUE_SCALING_FACTOR: Long = 1000 /** The format of a VTT timecode. */ private const val VTT_TIMECODE_FORMAT = "%02d:%02d:%02d.%03d" /** The length in bytes of a WAVEFORMATEX structure. */ private const val WAVE_FORMAT_SIZE = 18 /** Format tag indicating a WAVEFORMATEXTENSIBLE structure. */ private const val WAVE_FORMAT_EXTENSIBLE = 0xFFFE /** Format tag for PCM. */ private const val WAVE_FORMAT_PCM = 1 /** Sub format for PCM. */ private val WAVE_SUBFORMAT_PCM = UUID(0x0100000000001000L, -0x7fffff55ffc7648fL) /** Some HTC devices signal rotation in track names. */ private val TRACK_NAME_TO_ROTATION_DEGREES: Map init { val trackNameToRotationDegrees: MutableMap = HashMap() trackNameToRotationDegrees["htc_video_rotA-000"] = 0 trackNameToRotationDegrees["htc_video_rotA-090"] = 90 trackNameToRotationDegrees["htc_video_rotA-180"] = 180 trackNameToRotationDegrees["htc_video_rotA-270"] = 270 TRACK_NAME_TO_ROTATION_DEGREES = Collections.unmodifiableMap(trackNameToRotationDegrees) } /** * Overwrites the end timecode in `subtitleData` with the correctly formatted time derived * from `durationUs`. * * * See documentation on [.SSA_DIALOGUE_FORMAT] and [.SUBRIP_PREFIX] for why we use * the duration as the end timecode. * * @param codecId The subtitle codec; must be [.CODEC_ID_SUBRIP], [.CODEC_ID_ASS], * [.CODEC_ID_SSA] or [.CODEC_ID_VTT]. * @param durationUs The duration of the sample, in microseconds. * @param subtitleData The subtitle sample in which to overwrite the end timecode (output * parameter). */ private fun setSubtitleEndTime(codecId: String, durationUs: Long, subtitleData: ByteArray) { val endTimecode: ByteArray val endTimecodeOffset: Int when (codecId) { CODEC_ID_SUBRIP -> { endTimecode = formatSubtitleTimecode( durationUs, SUBRIP_TIMECODE_FORMAT, SUBRIP_TIMECODE_LAST_VALUE_SCALING_FACTOR ) endTimecodeOffset = SUBRIP_PREFIX_END_TIMECODE_OFFSET } CODEC_ID_ASS, CODEC_ID_SSA -> { endTimecode = formatSubtitleTimecode( durationUs, SSA_TIMECODE_FORMAT, SSA_TIMECODE_LAST_VALUE_SCALING_FACTOR ) endTimecodeOffset = SSA_PREFIX_END_TIMECODE_OFFSET } CODEC_ID_VTT -> { endTimecode = formatSubtitleTimecode( durationUs, VTT_TIMECODE_FORMAT, VTT_TIMECODE_LAST_VALUE_SCALING_FACTOR ) endTimecodeOffset = VTT_PREFIX_END_TIMECODE_OFFSET } else -> throw IllegalArgumentException() } System.arraycopy(endTimecode, 0, subtitleData, endTimecodeOffset, endTimecode.size) } /** * Formats `timeUs` using `timecodeFormat`, and sets it as the end timecode in `subtitleSampleData`. */ private fun formatSubtitleTimecode( timeUs: Long, timecodeFormat: String, lastTimecodeValueScalingFactor: Long ): ByteArray { var timeUs = timeUs checkArgument(timeUs != C.TIME_UNSET) val timeCodeData: ByteArray val hours = (timeUs / (3600 * C.MICROS_PER_SECOND)).toInt() timeUs -= (hours * 3600L * C.MICROS_PER_SECOND) val minutes = (timeUs / (60 * C.MICROS_PER_SECOND)).toInt() timeUs -= (minutes * 60L * C.MICROS_PER_SECOND) val seconds = (timeUs / C.MICROS_PER_SECOND).toInt() timeUs -= (seconds * C.MICROS_PER_SECOND) val lastValue = (timeUs / lastTimecodeValueScalingFactor).toInt() timeCodeData = Util.getUtf8Bytes( String.format(Locale.US, timecodeFormat, hours, minutes, seconds, lastValue) ) return timeCodeData } private fun isCodecSupported(codecId: String): Boolean { return when (codecId) { CODEC_ID_VP8, CODEC_ID_VP9, CODEC_ID_AV1, CODEC_ID_MPEG2, CODEC_ID_MPEG4_SP, CODEC_ID_MPEG4_ASP, CODEC_ID_MPEG4_AP, CODEC_ID_H264, CODEC_ID_H265, CODEC_ID_FOURCC, CODEC_ID_THEORA, CODEC_ID_OPUS, CODEC_ID_VORBIS, CODEC_ID_AAC, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_AC3, CODEC_ID_E_AC3, CODEC_ID_TRUEHD, CODEC_ID_DTS, CODEC_ID_DTS_EXPRESS, CODEC_ID_DTS_LOSSLESS, CODEC_ID_FLAC, CODEC_ID_ACM, CODEC_ID_PCM_INT_LIT, CODEC_ID_PCM_INT_BIG, CODEC_ID_PCM_FLOAT, CODEC_ID_SUBRIP, CODEC_ID_ASS, CODEC_ID_SSA, CODEC_ID_VTT, CODEC_ID_VOBSUB, CODEC_ID_PGS, CODEC_ID_DVBSUB -> true else -> false } } /** * Returns an array that can store (at least) `length` elements, which will be either a new * array or `array` if it's not null and large enough. */ private fun ensureArrayCapacity(array: IntArray?, length: Int): IntArray { return if (array == null) { IntArray(length) } else if (array.size >= length) { array } else { // Double the size to avoid allocating constantly if the required length increases gradually. IntArray( max((array.size * 2).toDouble(), length.toDouble()) .toInt() ) } } } class MatroskaSeekMap( private val perTrackCues: SparseArray>, private val durationUs: Long, private val primarySeekTrackNumber: Int, segmentContentPosition: Long, segmentContentSize: Long ) : TrackAwareSeekMap, ChunkIndexProvider { private val chunkIndex: ChunkIndex? = buildChunkIndex( perTrackCues, durationUs, primarySeekTrackNumber, segmentContentPosition, segmentContentSize ) override fun isSeekable(): Boolean { // The media is seekable overall only if the primary seek track has cue points. return isSeekable(primarySeekTrackNumber) } override fun isSeekable(trackId: Int): Boolean { val cuePoints = perTrackCues[trackId] return !cuePoints.isNullOrEmpty() } override fun getDurationUs(): Long = durationUs override fun getSeekPoints(timeUs: Long): SeekPoints = chunkIndex?.getSeekPoints(timeUs) ?: SeekPoints(SeekPoint.START) override fun getSeekPoints(timeUs: Long, trackId: Int): SeekPoints { var cuePoints = perTrackCues[trackId] if ((cuePoints.isNullOrEmpty()) && trackId != primarySeekTrackNumber) { cuePoints = perTrackCues[primarySeekTrackNumber] } if (cuePoints.isNullOrEmpty()) { return SeekPoints(SeekPoint.START) } val bestIndex = Util.binarySearchFloor( cuePoints, CuePointData(timeUs, C.INDEX_UNSET.toLong(), C.INDEX_UNSET.toLong()), /* inclusive= */ true, /* stayInBounds= */ false ) return if (bestIndex != -1) { val bestCue = cuePoints[bestIndex] val firstPoint = SeekPoint(bestCue.timeUs, bestCue.clusterPosition) if (bestCue.timeUs < timeUs && bestIndex + 1 < cuePoints.size) { val nextCue = cuePoints[bestIndex + 1] val secondPoint = SeekPoint(nextCue.timeUs, nextCue.clusterPosition) SeekPoints(firstPoint, secondPoint) } else { SeekPoints(firstPoint) } } else { val firstCue = cuePoints[0] SeekPoints(SeekPoint(firstCue.timeUs, firstCue.clusterPosition)) } } override fun getChunkIndex(): ChunkIndex? = chunkIndex private companion object { private fun buildChunkIndex( perTrackCues: SparseArray>, durationUs: Long, primarySeekTrackNumber: Int, segmentContentPosition: Long, segmentContentSize: Long ): ChunkIndex? { val primaryTrackCuePoints = perTrackCues[primarySeekTrackNumber] ?: return null if (primaryTrackCuePoints.isEmpty()) { return null } val cuePointsSize = primaryTrackCuePoints.size var sizes = IntArray(cuePointsSize) var offsets = LongArray(cuePointsSize) var durationsUs = LongArray(cuePointsSize) var timesUs = LongArray(cuePointsSize) for (i in 0 until cuePointsSize) { val cue = primaryTrackCuePoints[i] timesUs[i] = cue.timeUs offsets[i] = cue.clusterPosition } for (i in 0 until cuePointsSize - 1) { sizes[i] = (offsets[i + 1] - offsets[i]).toInt() durationsUs[i] = timesUs[i + 1] - timesUs[i] } // Start from the last cue point and move backward until a valid duration is found. var lastValidIndex = cuePointsSize - 1 while (lastValidIndex > 0 && timesUs[lastValidIndex] >= durationUs) { lastValidIndex-- } // Calculate sizes and durations for the last valid index sizes[lastValidIndex] = (segmentContentPosition + segmentContentSize - offsets[lastValidIndex]).toInt() durationsUs[lastValidIndex] = durationUs - timesUs[lastValidIndex] // If trailing cue points were found, truncate the arrays to the last valid index. if (lastValidIndex < cuePointsSize - 1) { Log.w(TAG, "Discarding trailing cue points with timestamps greater than total duration.") sizes = sizes.copyOf(lastValidIndex + 1) offsets = offsets.copyOf(lastValidIndex + 1) durationsUs = durationsUs.copyOf(lastValidIndex + 1) timesUs = timesUs.copyOf(lastValidIndex + 1) } return ChunkIndex(sizes, offsets, durationsUs, timesUs) } } class CuePointData( /** The timestamp of the cue point, in microseconds. */ val timeUs: Long, /** The absolute byte offset of the start of the cluster containing this cue point. */ val clusterPosition: Long, /** * The relative byte offset of the cue point's data block within its cluster. * *

Note: For seeking, use {@link #clusterPosition} to prevent A/V desync. */ val relativePosition: Long ) : Comparable { override fun compareTo(other: CuePointData): Int { return timeUs.compareTo(other.timeUs) } override fun equals(other: Any?): Boolean { if (this === other) { return true } if (other !is CuePointData) { return false } return this.timeUs == other.timeUs && this.clusterPosition == other.clusterPosition && this.relativePosition == other.relativePosition } override fun hashCode(): Int { return Objects.hash(timeUs, clusterPosition, relativePosition) } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/PriorityAdapter.kt ================================================ package com.lagradost.cloudstream3.ui.player.source_priority import android.view.LayoutInflater import android.view.ViewGroup import com.lagradost.cloudstream3.databinding.PlayerPrioritizeItemBinding import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState data class SourcePriority( val data: T, val name: String, var priority: Int ) class PriorityAdapter() : NoStateAdapter>() { override fun onCreateContent(parent: ViewGroup): ViewHolderState { return ViewHolderState( PlayerPrioritizeItemBinding.inflate( LayoutInflater.from(parent.context), parent, false ) ) } override fun onBindContent( holder: ViewHolderState, item: SourcePriority, position: Int ) { val binding = holder.view as? PlayerPrioritizeItemBinding ?: return binding.priorityText.text = item.name fun updatePriority() { binding.priorityNumber.text = item.priority.toString() } updatePriority() binding.addButton.setOnClickListener { // If someone clicks til the integer limit then they deserve to crash. item.priority++ updatePriority() } binding.subtractButton.setOnClickListener { item.priority-- updatePriority() } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/ProfilesAdapter.kt ================================================ package com.lagradost.cloudstream3.ui.player.source_priority import android.content.res.ColorStateList import android.graphics.Typeface import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView import androidx.core.content.ContextCompat import androidx.core.view.isVisible import androidx.palette.graphics.Palette import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.PlayerQualityProfileItemBinding import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.drawableToBitmap import com.lagradost.cloudstream3.utils.setText class ProfilesAdapter( val usedProfile: Int?, val clickCallback: (oldIndex: Int?, newIndex: Int) -> Unit, ) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> a.id == b.id })) { companion object { private val art = arrayOf( R.drawable.profile_bg_teal, R.drawable.profile_bg_blue, R.drawable.profile_bg_dark_blue, R.drawable.profile_bg_purple, R.drawable.profile_bg_pink, R.drawable.profile_bg_red, R.drawable.profile_bg_orange, ) } override fun onCreateContent(parent: ViewGroup): ViewHolderState { return ViewHolderState( PlayerQualityProfileItemBinding.inflate( LayoutInflater.from(parent.context), parent, false ) ) } override fun onClearView(holder: ViewHolderState) { when (val binding = holder.view) { is PlayerQualityProfileItemBinding -> { clearImage(binding.profileImageBackground) } } } override fun onBindContent( holder: ViewHolderState, item: QualityDataHelper.QualityProfile, position: Int ) { val binding = holder.view as? PlayerQualityProfileItemBinding ?: return val priorityText: TextView = binding.profileText val profileBg: ImageView = binding.profileImageBackground val wifiText: TextView = binding.textIsWifi val dataText: TextView = binding.textIsMobileData val downloadText: TextView = binding.textIsDownloadData val outline: View = binding.outline val cardView: View = binding.cardView val itemView = holder.itemView priorityText.setText(item.name) dataText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.Data) wifiText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.WiFi) downloadText.isVisible = item.types.contains(QualityDataHelper.QualityProfileType.Download) fun setCurrentItem() { val prevIndex = currentItem // Prevent UI bug when re-selecting the item quickly if (prevIndex == position) { return } currentItem = position clickCallback.invoke(prevIndex, position) } outline.isVisible = currentItem == position val drawableResId = art[position % art.size] profileBg.loadImage(drawableResId) val drawable = ContextCompat.getDrawable(itemView.context, drawableResId) if (drawable != null) { // Convert Drawable to Bitmap val bitmap = drawableToBitmap(drawable) if (bitmap != null) { // Use Palette to extract colors from the bitmap Palette.from(bitmap).generate { palette -> val color = palette?.getDarkVibrantColor( ContextCompat.getColor( itemView.context, R.color.dubColorBg ) ) if (color != null) { wifiText.backgroundTintList = ColorStateList.valueOf(color) dataText.backgroundTintList = ColorStateList.valueOf(color) downloadText.backgroundTintList = ColorStateList.valueOf(color) } } } } val textStyle = if (item.id == usedProfile) { Typeface.BOLD } else { Typeface.NORMAL } priorityText.setTypeface(null, textStyle) cardView.setOnClickListener { setCurrentItem() } } private var currentItem: Int? = null fun getCurrentProfile(): QualityDataHelper.QualityProfile? { return currentItem?.let { index -> immutableCurrentList.getOrNull(index) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityDataHelper.kt ================================================ package com.lagradost.cloudstream3.ui.player.source_priority import androidx.annotation.StringRes import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.Qualities import kotlin.math.abs object QualityDataHelper { private const val VIDEO_SOURCE_PRIORITY = "video_source_priority" private const val VIDEO_PROFILE_NAME = "video_profile_name" private const val VIDEO_QUALITY_PRIORITY = "video_quality_priority" // Old key only supporting one type per profile @Deprecated("Changed to support multiple types per profile") private const val VIDEO_PROFILE_TYPE = "video_profile_type" // New key supporting more than one type per profile private const val VIDEO_PROFILE_TYPES = "video_profile_types_2" private const val DEFAULT_SOURCE_PRIORITY = 1 /** * Automatically skip loading links once this priority is reached **/ const val AUTO_SKIP_PRIORITY = 10 /** * Must be higher than amount of QualityProfileTypes **/ private const val PROFILE_COUNT = 7 /** * Unique guarantees that there will always be one of this type in the profile list. **/ enum class QualityProfileType(@StringRes val stringRes: Int, val unique: Boolean) { None(R.string.none, false), WiFi(R.string.wifi, true), Data(R.string.mobile_data, true), Download(R.string.download, true) } data class QualityProfile( val name: UiText, val id: Int, val types: Set ) fun getSourcePriority(profile: Int, name: String?): Int { if (name == null) return DEFAULT_SOURCE_PRIORITY return getKey( "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile", name, DEFAULT_SOURCE_PRIORITY ) ?: DEFAULT_SOURCE_PRIORITY } fun getAllSourcePriorityNames(profile: Int): List { val folder = "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile" return getKeys(folder)?.map { key -> key.substringAfter("$folder/") } ?: emptyList() } fun setSourcePriority(profile: Int, name: String, priority: Int) { val folder = "$currentAccount/$VIDEO_SOURCE_PRIORITY/$profile" // Prevent unnecessary keys if (priority == DEFAULT_SOURCE_PRIORITY) { removeKey(folder, name) } else { setKey(folder, name, priority) } } fun setProfileName(profile: Int, name: String?) { val path = "$currentAccount/$VIDEO_PROFILE_NAME/$profile" if (name == null) { removeKey(path) } else { setKey(path, name.trim()) } } fun getProfileName(profile: Int): UiText { return getKey("$currentAccount/$VIDEO_PROFILE_NAME/$profile")?.let { txt(it) } ?: txt(R.string.profile_number, profile) } fun getQualityPriority(profile: Int, quality: Qualities): Int { return getKey( "$currentAccount/$VIDEO_QUALITY_PRIORITY/$profile", quality.value.toString(), quality.defaultPriority ) ?: quality.defaultPriority } fun setQualityPriority(profile: Int, quality: Qualities, priority: Int) { setKey( "$currentAccount/$VIDEO_QUALITY_PRIORITY/$profile", quality.value.toString(), priority ) } @Suppress("DEPRECATION") fun getQualityProfileTypes(profile: Int): Set { val newKey = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile" // Use arrays for to make with work with setKey properly (weird crashes otherwise) val newProfiles = getKey>(newKey)?.toSet() // Migrate to new profile key if (newProfiles == null) { val oldProfile = getKey("$currentAccount/$VIDEO_PROFILE_TYPE/$profile") val newSet = oldProfile?.let { arrayOf(it) } ?: arrayOf() setKey(newKey, newSet) return newSet.toSet() } else { return newProfiles } } fun addQualityProfileType(profile: Int, type: QualityProfileType) { val path = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile" val currentTypes = getQualityProfileTypes(profile) if (type != QualityProfileType.None) { setKey(path, (currentTypes + type).toTypedArray()) } } fun removeQualityProfileType(profile: Int, type: QualityProfileType) { val path = "$currentAccount/$VIDEO_PROFILE_TYPES/$profile" val currentTypes = getQualityProfileTypes(profile) if (type != QualityProfileType.None) { setKey(path, (currentTypes - type).toTypedArray()) } } /** * Gets all quality profiles, always includes one profile with WiFi and Data * Must under all circumstances at least return one profile **/ fun getProfiles(): List { val availableTypes = QualityProfileType.entries.toMutableList() val profiles = (1..PROFILE_COUNT).map { profileNumber -> // Get the real type val types = getQualityProfileTypes(profileNumber) val uniqueTypes = types.mapNotNull { type -> // This makes it impossible to get more than one of each type if (type.unique && !availableTypes.remove(type)) { null } else { type } }.toSet() QualityProfile( getProfileName(profileNumber), profileNumber, uniqueTypes ) }.toMutableList() /** * If no profile of this type exists: insert it on the earliest profile **/ fun insertType( list: MutableList, type: QualityProfileType ) { if (list.any { it.types.contains(type) }) return synchronized(list) { val firstItem = list.firstOrNull() ?: return val fixedTypes = firstItem.types + type val fixedItem = firstItem.copy(types = fixedTypes) list.set(0, fixedItem) } } QualityProfileType.entries.forEach { if (it.unique) insertType(profiles, it) } debugAssert({ !QualityProfileType.entries.all { type -> !type.unique || profiles.any { it.types.contains(type) } } }, { "All unique quality types do not exist" }) debugAssert({ profiles.isEmpty() }, { "No profiles!" }) return profiles } fun getLinkPriority( qualityProfile: Int, linkData: ExtractorLink? ): Int { val qualityPriority = getQualityPriority( qualityProfile, closestQuality(linkData?.quality) ) val sourcePriority = getSourcePriority(qualityProfile, linkData?.source) return qualityPriority + sourcePriority } private fun closestQuality(target: Int?): Qualities { if (target == null) return Qualities.Unknown return Qualities.entries.minBy { abs(it.value - target) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/QualityProfileDialog.kt ================================================ package com.lagradost.cloudstream3.ui.player.source_priority import android.app.Dialog import androidx.annotation.StyleRes import androidx.core.view.isVisible import androidx.fragment.app.FragmentActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.PlayerQualityProfileDialogBinding import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getAllSourcePriorityNames import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfileName import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getProfiles import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.setText /** Simplified ExtractorLink for the quality profile dialog */ data class LinkSource( val source: String ) { constructor(extractorLink: ExtractorLink) : this(extractorLink.source) } class QualityProfileDialog private constructor( val activity: FragmentActivity, @StyleRes val themeRes: Int, private val links: List, private val usedProfile: Int?, private val profileSelectionCallback: ((QualityDataHelper.QualityProfile) -> Unit)?, private val useProfileSelection: Boolean ) : Dialog(activity, themeRes) { constructor( activity: FragmentActivity, @StyleRes themeRes: Int, links: List, usedProfile: Int, profileSelectionCallback: ((QualityDataHelper.QualityProfile) -> Unit), ) : this(activity, themeRes, links, usedProfile, profileSelectionCallback, true) constructor( activity: FragmentActivity, @StyleRes themeRes: Int, links: List ) : this(activity, themeRes, links, null, null, false) companion object { // Run on IO as this may be a heavy operation suspend fun getAllDefaultSources(): List = ioWork { getProfiles().flatMap { getAllSourcePriorityNames(it.id) }.distinct().map { LinkSource(it) } } } override fun show() { val binding = PlayerQualityProfileDialogBinding.inflate(this.layoutInflater, null, false) setContentView(binding.root) fixSystemBarsPadding(binding.root) binding.apply { fun getCurrentProfile(): QualityDataHelper.QualityProfile? { return (profilesRecyclerview.adapter as? ProfilesAdapter)?.getCurrentProfile() } fun refreshProfiles() { if (usedProfile != null) { currentlySelectedProfileText.setText(getProfileName(usedProfile)) } (profilesRecyclerview.adapter as? ProfilesAdapter)?.submitList(getProfiles()) } profilesRecyclerview.adapter = ProfilesAdapter( usedProfile, ) { oldIndex: Int?, newIndex: Int -> profilesRecyclerview.adapter?.notifyItemChanged(newIndex) selectedItemHolder.alpha = 1f if (oldIndex != null) { profilesRecyclerview.adapter?.notifyItemChanged(oldIndex) } } refreshProfiles() editBtt.setOnClickListener { getCurrentProfile()?.let { profile -> SourcePriorityDialog(context, themeRes, links, profile) { refreshProfiles() }.show() } } setDefaultBtt.setOnClickListener { val currentProfile = getCurrentProfile() ?: return@setOnClickListener val choices = QualityDataHelper.QualityProfileType.entries.filter { it != QualityDataHelper.QualityProfileType.None } val choiceNames = choices.map { txt(it.stringRes).asString(context) } val selectedIndices = choices.mapIndexed { index, type -> index to type } .filter { currentProfile.types.contains(it.second) }.map { it.first } activity.showMultiDialog( choiceNames, selectedIndices, txt(R.string.set_default).asString(context), {}, { index -> val pickedChoices = index.mapNotNull { choices.getOrNull(it) } pickedChoices.forEach { pickedChoice -> // Remove previous picks if (pickedChoice.unique) { getProfiles().filter { it.types.contains(pickedChoice) }.forEach { QualityDataHelper.removeQualityProfileType(it.id, pickedChoice) } } QualityDataHelper.addQualityProfileType(currentProfile.id, pickedChoice) } refreshProfiles() }) } cancelBtt.isVisible = useProfileSelection useBtt.isVisible = useProfileSelection applyBtt.isVisible = !useProfileSelection if (useProfileSelection) { cancelBtt.setOnClickListener { this@QualityProfileDialog.dismissSafe() } useBtt.setOnClickListener { getCurrentProfile()?.let { profileSelectionCallback?.invoke(it) this@QualityProfileDialog.dismissSafe() } } } else { applyBtt.setOnClickListener { this@QualityProfileDialog.dismissSafe() } } } super.show() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/player/source_priority/SourcePriorityDialog.kt ================================================ package com.lagradost.cloudstream3.ui.player.source_priority import android.app.Dialog import android.content.Context import android.view.LayoutInflater import androidx.annotation.StyleRes import androidx.appcompat.app.AlertDialog import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.PlayerSelectSourcePriorityBinding import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding class SourcePriorityDialog( val ctx: Context, @StyleRes themeRes: Int, val links: List, private val profile: QualityDataHelper.QualityProfile, /** * Notify that the profile overview should be updated, for example if the name has been updated * Should not be called excessively. **/ private val updatedCallback: () -> Unit ) : Dialog(ctx, themeRes) { override fun show() { val binding = PlayerSelectSourcePriorityBinding.inflate(LayoutInflater.from(ctx), null, false) setContentView(binding.root) fixSystemBarsPadding(binding.root) val sourcesRecyclerView = binding.sortSources val qualitiesRecyclerView = binding.sortQualities val profileText = binding.profileTextEditable val saveBtt = binding.saveBtt val exitBtt = binding.closeBtt val helpBtt = binding.helpBtt profileText.setText(QualityDataHelper.getProfileName(profile.id).asString(context)) profileText.hint = txt(R.string.profile_number, profile.id).asString(context) sourcesRecyclerView.adapter = PriorityAdapter( ).apply { submitList(links.map { link -> SourcePriority( null, link.source, QualityDataHelper.getSourcePriority(profile.id, link.source) ) }.distinctBy { it.name }.sortedBy { -it.priority }) } qualitiesRecyclerView.adapter = PriorityAdapter( ).apply { submitList(Qualities.entries.mapNotNull { SourcePriority( it, Qualities.getStringByIntFull(it.value).ifBlank { return@mapNotNull null }, QualityDataHelper.getQualityPriority(profile.id, it) ) }.sortedBy { -it.priority }) } @Suppress("UNCHECKED_CAST") // We know the types saveBtt.setOnClickListener { val qualityAdapter = qualitiesRecyclerView.adapter as? PriorityAdapter val sourcesAdapter = sourcesRecyclerView.adapter as? PriorityAdapter val qualities = qualityAdapter?.immutableCurrentList ?: emptyList() val sources = sourcesAdapter?.immutableCurrentList ?: emptyList() qualities.forEach { QualityDataHelper.setQualityPriority(profile.id, it.data, it.priority) } sources.forEach { QualityDataHelper.setSourcePriority(profile.id, it.name, it.priority) } qualityAdapter?.submitList(qualities.sortedBy { -it.priority }) sourcesAdapter?.submitList(sources.sortedBy { -it.priority }) val savedProfileName = profileText.text.toString() if (savedProfileName.isBlank()) { QualityDataHelper.setProfileName(profile.id, null) } else { QualityDataHelper.setProfileName(profile.id, savedProfileName) } updatedCallback.invoke() } exitBtt.setOnClickListener { this.dismissSafe() } helpBtt.setOnClickListener { AlertDialog.Builder(context, R.style.AlertDialogCustom).apply { setMessage(R.string.quality_profile_help) }.show() } super.show() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/quicksearch/QuickSearchFragment.kt ================================================ package com.lagradost.cloudstream3.ui.quicksearch import android.app.Activity import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.widget.ImageView import androidx.appcompat.widget.SearchView import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetDialog import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.QuickSearchBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.home.HomeFragment import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.loadHomepageList import com.lagradost.cloudstream3.ui.home.HomeViewModel import com.lagradost.cloudstream3.ui.home.ParentItemAdapter import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchClickCallback import com.lagradost.cloudstream3.ui.search.SearchHelper import com.lagradost.cloudstream3.ui.search.SearchViewModel import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilmQuality import com.lagradost.cloudstream3.utils.AppContextUtils.isRecyclerScrollable import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import java.util.concurrent.locks.ReentrantLock class QuickSearchFragment : BaseFragment( BaseFragment.BindingCreator.Inflate(QuickSearchBinding::inflate) ) { companion object { const val AUTOSEARCH_KEY = "autosearch" const val PROVIDER_KEY = "providers" fun pushSearch( autoSearch: String? = null, providers: Array? = null ) { pushSearch(activity, autoSearch, providers) } fun pushSearch( activity: Activity?, autoSearch: String? = null, providers: Array? = null ) { activity.navigate(R.id.global_to_navigation_quick_search, Bundle().apply { providers?.let { putStringArray(PROVIDER_KEY, it) } autoSearch?.let { putString( AUTOSEARCH_KEY, it.trim() .removeSuffix("(DUB)") .removeSuffix("(SUB)") .removeSuffix("(Dub)") .removeSuffix("(Sub)").trim() ) } }) } var clickCallback: ((SearchClickCallback) -> Unit)? = null } private var providers: Set? = null private lateinit var searchViewModel: SearchViewModel private var bottomSheetDialog: BottomSheetDialog? = null override fun fixLayout(view: View) { fixSystemBarsPadding(view) // Fix grid HomeFragment.currentSpan = view.context.getSpanCount() binding?.quickSearchAutofitResults?.spanCount = HomeFragment.currentSpan HomeFragment.configEvent.invoke() } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { activity?.window?.setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE ) searchViewModel = ViewModelProvider(this)[SearchViewModel::class.java] bottomSheetDialog?.ownShow() return super.onCreateView(inflater, container, savedInstanceState) } override fun onDestroy() { super.onDestroy() clickCallback = null } fun search(context: Context?, query: String, isQuickSearch: Boolean): Boolean { (providers ?: context?.filterProviderByPreferredMedia(hasHomePageIsRequired = false) ?.map { it.name }?.toSet())?.let { active -> searchViewModel.searchAndCancel( query = query, ignoreSettings = false, providersActive = active, isQuickSearch = isQuickSearch ) return true } return false } override fun onBindingCreated(binding: QuickSearchBinding) { arguments?.getStringArray(PROVIDER_KEY)?.let { providers = it.toSet() } val isSingleProvider = providers?.size == 1 val isSingleProviderQuickSearch = if (isSingleProvider) { getApiFromNameNull(providers?.first())?.hasQuickSearch ?: false } else false val firstProvider = providers?.firstOrNull() if (isSingleProvider && firstProvider != null) { binding.quickSearchAutofitResults.apply { setRecycledViewPool(SearchAdapter.sharedPool) adapter = SearchAdapter( this, ) { callback -> SearchHelper.handleSearchClickCallback(callback) } } binding.quickSearchAutofitResults.addOnScrollListener(object : RecyclerView.OnScrollListener() { var expandCount = 0 override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { super.onScrollStateChanged(recyclerView, newState) val adapter = recyclerView.adapter if (adapter !is SearchAdapter) return val count = adapter.itemCount val currentHasNext = adapter.hasNext if (!recyclerView.isRecyclerScrollable() && currentHasNext && expandCount != count) { expandCount = count ioSafe { searchViewModel.expandAndReturn(firstProvider) } } } }) try { binding.quickSearch.queryHint = getString(R.string.search_hint_site).format(firstProvider) } catch (e: Exception) { logError(e) } } else { binding.quickSearchMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) binding.quickSearchMasterRecycler.adapter = ParentItemAdapter( id = "quickSearchMasterRecycler".hashCode(), { callback -> SearchHelper.handleSearchClickCallback(callback) //when (callback.action) { //SEARCH_ACTION_LOAD -> { // clickCallback?.invoke(callback) //} // else -> SearchHelper.handleSearchClickCallback(activity, callback) //} }, { item -> bottomSheetDialog = activity?.loadHomepageList(item, dismissCallback = { bottomSheetDialog = null }, expandCallback = { searchViewModel.expandAndReturn(it) }) }, expandCallback = { name -> ioSafe { searchViewModel.expandAndReturn(name) } }) binding.quickSearchMasterRecycler.layoutManager = GridLayoutManager(context, 1) } binding.quickSearchAutofitResults.isVisible = isSingleProvider binding.quickSearchMasterRecycler.isGone = isSingleProvider val listLock = ReentrantLock() observe(searchViewModel.currentSearch) { list -> try { // https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist listLock.lock() (binding.quickSearchMasterRecycler.adapter as? ParentItemAdapter)?.apply { val newItems = list.map { ongoing -> val dataList = ongoing.value.list val dataListFiltered = context?.filterSearchResultByFilmQuality(dataList) ?: dataList val homePageList = HomePageList( ongoing.key, dataListFiltered ) val expandableList = HomeViewModel.ExpandableHomepageList( homePageList, ongoing.value.currentPage, ongoing.value.hasNext ) expandableList } submitList(newItems) //notifyDataSetChanged() } } catch (e: Exception) { logError(e) } finally { listLock.unlock() } } val searchExitIcon = binding.quickSearch.findViewById(androidx.appcompat.R.id.search_close_btn) binding.quickSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { if (search(context, query, false)) hideKeyboard(binding.quickSearch) return true } override fun onQueryTextChange(newText: String): Boolean { if (isSingleProviderQuickSearch) search(context, newText, true) return true } }) binding.quickSearchLoadingBar.alpha = 0f observe(searchViewModel.searchResponse) { when (it) { is Resource.Success -> { it.value.let { data -> val adapter = (binding.quickSearchAutofitResults.adapter as? SearchAdapter) adapter?.submitList( context?.filterSearchResultByFilmQuality(data.list) ?: data.list ) adapter?.hasNext = data.hasNext } searchExitIcon?.alpha = 1f binding.quickSearchLoadingBar.alpha = 0f } is Resource.Failure -> { // Toast.makeText(activity, "Server error", Toast.LENGTH_LONG).show() searchExitIcon?.alpha = 1f binding.quickSearchLoadingBar.alpha = 0f } is Resource.Loading -> { searchExitIcon?.alpha = 0f binding.quickSearchLoadingBar.alpha = 1f } } } if (isLayout(PHONE or EMULATOR)) { binding.quickSearchBack.apply { isVisible = true setOnClickListener { activity?.popCurrentPage() } } } if (isLayout(TV)) { binding.quickSearch.requestFocus() } arguments?.getString(AUTOSEARCH_KEY)?.let { binding.quickSearch.setQuery(it, true) arguments?.remove(AUTOSEARCH_KEY) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/result/ActorAdaptor.kt ================================================ package com.lagradost.cloudstream3.ui.result import android.app.SearchManager import android.content.Intent import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import com.lagradost.cloudstream3.ActorData import com.lagradost.cloudstream3.ActorRole import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.CastItemBinding import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.ImageLoader.loadImage class ActorAdaptor( private var nextFocusUpId: Int? = null, private val focusCallback: (View?) -> Unit = {} ) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> a.actor.name == b.actor.name })) { companion object { val sharedPool = newSharedPool { setMaxRecycledViews(CONTENT, 10) } } // Easier to store it here than to store it in the ActorData val inverted: HashMap = hashMapOf() override fun onCreateContent(parent: ViewGroup): ViewHolderState { return ViewHolderState( CastItemBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) } override fun onClearView(holder: ViewHolderState) { when (val binding = holder.view) { is CastItemBinding -> { clearImage(binding.actorImage) } } } override fun onBindContent(holder: ViewHolderState, item: ActorData, position: Int) { when (val binding = holder.view) { is CastItemBinding -> { val itemView = binding.root val isInverted = inverted.getOrDefault(item, false) val (mainImg, vaImage) = if (!isInverted || item.voiceActor?.image.isNullOrBlank()) { Pair(item.actor.image, item.voiceActor?.image) } else { Pair(item.voiceActor?.image, item.actor.image) } // Fix tv focus escaping the recyclerview if (position == 0) { itemView.nextFocusLeftId = R.id.result_cast_items } else if ((position - 1) == itemCount) { itemView.nextFocusRightId = R.id.result_cast_items } nextFocusUpId?.let { itemView.nextFocusUpId = it } itemView.setOnFocusChangeListener { v, hasFocus -> if (hasFocus) { focusCallback(v) } } itemView.setOnClickListener { inverted[item] = !isInverted this.onUpdateContent(holder, getItem(position), position) } itemView.setOnLongClickListener { if (isLayout(PHONE)) { Intent(Intent.ACTION_WEB_SEARCH).apply { putExtra(SearchManager.QUERY, item.actor.name) }.also { intent -> itemView.context.packageManager?.let { pm -> if (intent.resolveActivity(pm) != null) { itemView.context.startActivity(intent) } } } } true } binding.apply { actorImage.loadImage(mainImg) actorName.text = item.actor.name item.role?.let { actorExtra.context?.getString( when (it) { ActorRole.Main -> { R.string.actor_main } ActorRole.Supporting -> { R.string.actor_supporting } ActorRole.Background -> { R.string.actor_background } } )?.let { text -> actorExtra.isVisible = true actorExtra.text = text } } ?: item.roleString?.let { actorExtra.isVisible = true actorExtra.text = it } ?: run { actorExtra.isVisible = false } if (item.voiceActor == null) { voiceActorImageHolder.isVisible = false voiceActorName.isVisible = false } else { voiceActorName.text = item.voiceActor?.name if (!vaImage.isNullOrEmpty()) voiceActorImageHolder.isVisible = true voiceActorImage.loadImage(vaImage) } } } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/result/EpisodeAdapter.kt ================================================ package com.lagradost.cloudstream3.ui.result import android.content.Context import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.setPadding import androidx.preference.PreferenceManager import coil3.dispose import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.databinding.ResultEpisodeBinding import com.lagradost.cloudstream3.databinding.ResultEpisodeLargeBinding import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt import java.text.DateFormat import java.text.SimpleDateFormat import java.util.Date import java.util.Locale /** * Ids >= 1000 are reserved for VideoClickActions * @see VideoClickActionHolder */ const val ACTION_PLAY_EPISODE_IN_PLAYER = 1 const val ACTION_CHROME_CAST_EPISODE = 4 const val ACTION_CHROME_CAST_MIRROR = 5 const val ACTION_DOWNLOAD_EPISODE = 6 const val ACTION_DOWNLOAD_MIRROR = 7 const val ACTION_RELOAD_EPISODE = 8 const val ACTION_SHOW_OPTIONS = 10 const val ACTION_CLICK_DEFAULT = 11 const val ACTION_SHOW_TOAST = 12 const val ACTION_SHOW_DESCRIPTION = 15 const val ACTION_DOWNLOAD_EPISODE_SUBTITLE = 13 const val ACTION_DOWNLOAD_EPISODE_SUBTITLE_MIRROR = 14 const val ACTION_MARK_AS_WATCHED = 18 const val TV_EP_SIZE = 400 const val ACTION_MARK_WATCHED_UP_TO_THIS_EPISODE = 19 data class EpisodeClickEvent(val position: Int?, val action: Int, val data: ResultEpisode) { constructor(action: Int, data: ResultEpisode) : this(null, action, data) } class EpisodeAdapter( private val hasDownloadSupport: Boolean, private val clickCallback: (EpisodeClickEvent) -> Unit, private val downloadClickCallback: (DownloadClickEvent) -> Unit, ) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> a.id == b.id }, contentSame = { a, b -> a == b })) { companion object { const val HAS_POSTER: Int = 0 const val HAS_NO_POSTER: Int = 1 fun getPlayerAction(context: Context): Int { val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val playerPref = settingsManager.getString(context.getString(R.string.player_default_key), "") return VideoClickActionHolder.uniqueIdToId(playerPref) ?: ACTION_PLAY_EPISODE_IN_PLAYER } val sharedPool = newSharedPool { setMaxRecycledViews(HAS_POSTER or CONTENT, 10) setMaxRecycledViews(HAS_NO_POSTER or CONTENT, 10) } } override fun onClearView(holder: ViewHolderState) { if (holder.itemView.hasFocus()) { holder.itemView.clearFocus() } when (val binding = holder.view) { is ResultEpisodeLargeBinding -> { clearImage(binding.episodePoster) } } super.onClearView(holder) } override fun customContentViewType(item: ResultEpisode): Int = if (item.poster.isNullOrBlank() && item.description.isNullOrBlank()) HAS_NO_POSTER else HAS_POSTER override fun onCreateCustomContent(parent: ViewGroup, viewType: Int): ViewHolderState { return when (viewType) { HAS_NO_POSTER -> { ViewHolderState( ResultEpisodeBinding.inflate( LayoutInflater.from(parent.context), parent, false ) ) } HAS_POSTER -> { ViewHolderState( ResultEpisodeLargeBinding.inflate( LayoutInflater.from(parent.context), parent, false ) ) } else -> throw NotImplementedError() } } override fun onBindContent(holder: ViewHolderState, item: ResultEpisode, position: Int) { val itemView = holder.itemView when (val binding = holder.view) { is ResultEpisodeLargeBinding -> { val setWidth = if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT binding.apply { episodeLinHolder.layoutParams.width = setWidth episodeHolderLarge.layoutParams.width = setWidth episodeHolder.layoutParams.width = setWidth if (isLayout(PHONE or EMULATOR) && CommonActivity.appliedTheme == R.style.AmoledMode) { episodeHolderLarge.radius = 0.0f episodeHolder.setPadding(0) } downloadButton.isVisible = hasDownloadSupport downloadButton.setDefaultClickListener( DownloadObjects.DownloadEpisodeCached( name = item.name, poster = item.poster, episode = item.episode, season = item.season, id = item.id, parentId = item.parentId, score = item.score, description = item.description, cacheTime = System.currentTimeMillis(), ), null ) { when (it.action) { DOWNLOAD_ACTION_DOWNLOAD -> { clickCallback.invoke( EpisodeClickEvent( position, ACTION_DOWNLOAD_EPISODE, item ) ) } DOWNLOAD_ACTION_LONG_CLICK -> { clickCallback.invoke( EpisodeClickEvent( position, ACTION_DOWNLOAD_MIRROR, item ) ) } else -> { downloadClickCallback.invoke(it) } } } val status = VideoDownloadManager.downloadStatus[item.id] downloadButton.resetView() downloadButton.setPersistentId(item.id) downloadButton.setStatus(status) val name = if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" episodeFiller.isVisible = item.isFiller == true episodeText.text = name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name episodeText.isSelected = true // is needed for text repeating if (item.videoWatchState == VideoWatchState.Watched) { // This cannot be done in getDisplayPosition() as when you have not watched something // the duration and position is 0 //episodeProgress.max = 1 //episodeProgress.progress = 1 episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) episodeProgress.isVisible = false } else { val displayPos = item.getDisplayPosition() val durationSec = (item.duration / 1000).toInt() val progressSec = (displayPos / 1000).toInt() if (displayPos >= item.duration && displayPos > 0) { episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) episodeProgress.isVisible = false } else { episodePlayIcon.setImageResource(R.drawable.netflix_play) episodeProgress.apply { max = durationSec progress = progressSec isVisible = displayPos > 0L } } } val posterVisible = !item.poster.isNullOrBlank() if (posterVisible) { val isUpcoming = item.airDate != null && unixTimeMS < item.airDate episodePoster.loadImage(item.poster) { if (isUpcoming) { error { // If the poster has an url, but it is faulty then // we use the episodeUpcomingIcon if it is an upcoming episode main { // Make sure it is on the main thread episodeUpcomingIcon.isVisible = true } null // We only care about the runnable } } } } else { // Clear the image episodePoster.dispose() } episodePoster.isVisible = posterVisible val rating10p = item.score?.toFloat(10) if (rating10p != null && rating10p > 0.1) { episodeRating.text = episodeRating.context?.getString(R.string.rated_format) ?.format(rating10p) // TODO Change rated_format to use card.score.toString() } else { episodeRating.text = "" } episodeRating.isGone = episodeRating.text.isNullOrBlank() episodeDescript.apply { text = item.description.html() isGone = text.isNullOrBlank() var isExpanded = false setOnClickListener { if (isLayout(TV)) { clickCallback.invoke( EpisodeClickEvent( position, ACTION_SHOW_DESCRIPTION, item ) ) } else { isExpanded = !isExpanded maxLines = if (isExpanded) { Integer.MAX_VALUE } else 4 } } } if (item.airDate != null) { val isUpcoming = unixTimeMS < item.airDate if (isUpcoming) { episodeProgress.isVisible = false episodePlayIcon.isVisible = false episodeUpcomingIcon.isVisible = !posterVisible episodeDate.setText( txt( R.string.episode_upcoming_format, secondsToReadable( item.airDate.minus(unixTimeMS).div(1000).toInt(), "" ) ) ) } else { episodePlayIcon.isVisible = true episodeUpcomingIcon.isVisible = false val formattedAirDate = SimpleDateFormat.getDateInstance( DateFormat.LONG, Locale.getDefault() ).apply { }.format(Date(item.airDate)) episodeDate.setText(txt(formattedAirDate)) } } else { episodeUpcomingIcon.isVisible = false episodePlayIcon.isVisible = true episodeDate.isVisible = false } episodeRuntime.setText( txt( item.runTime?.times(60L)?.toInt()?.let { secondsToReadable(it, "") } ) ) if (isLayout(EMULATOR or PHONE)) { episodePoster.setOnClickListener { clickCallback.invoke( EpisodeClickEvent( position, ACTION_CLICK_DEFAULT, item ) ) } episodePoster.setOnLongClickListener { clickCallback.invoke( EpisodeClickEvent( position, ACTION_SHOW_TOAST, item ) ) return@setOnLongClickListener true } } } itemView.setOnClickListener { clickCallback.invoke(EpisodeClickEvent(position, ACTION_CLICK_DEFAULT, item)) } if (isLayout(TV)) { itemView.isFocusable = true itemView.isFocusableInTouchMode = true //itemView.touchscreenBlocksFocus = false } itemView.setOnLongClickListener { clickCallback.invoke(EpisodeClickEvent(position, ACTION_SHOW_OPTIONS, item)) return@setOnLongClickListener true } } is ResultEpisodeBinding -> { binding.episodeHolder.layoutParams.apply { width = if (isLayout(TV or EMULATOR)) TV_EP_SIZE.toPx else ViewGroup.LayoutParams.MATCH_PARENT } binding.apply { downloadButton.isVisible = hasDownloadSupport downloadButton.setDefaultClickListener( DownloadObjects.DownloadEpisodeCached( name = item.name, poster = item.poster, episode = item.episode, season = item.season, id = item.id, parentId = item.parentId, score = item.score, description = item.description, cacheTime = System.currentTimeMillis(), ), null ) { when (it.action) { DOWNLOAD_ACTION_DOWNLOAD -> { clickCallback.invoke( EpisodeClickEvent( position, ACTION_DOWNLOAD_EPISODE, item ) ) } DOWNLOAD_ACTION_LONG_CLICK -> { clickCallback.invoke( EpisodeClickEvent( position, ACTION_DOWNLOAD_MIRROR, item ) ) } else -> { downloadClickCallback.invoke(it) } } } val status = VideoDownloadManager.downloadStatus[item.id] downloadButton.resetView() downloadButton.setPersistentId(item.id) downloadButton.setStatus(status) val name = if (item.name == null) "${episodeText.context.getString(R.string.episode)} ${item.episode}" else "${item.episode}. ${item.name}" episodeFiller.isVisible = item.isFiller == true episodeText.text = name//if(card.isFiller == true) episodeText.context.getString(R.string.filler).format(name) else name episodeText.isSelected = true // is needed for text repeating if (item.videoWatchState == VideoWatchState.Watched) { episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) episodeProgress.isVisible = false } else { val displayPos = item.getDisplayPosition() val durationSec = (item.duration / 1000).toInt() val progressSec = (displayPos / 1000).toInt() if (displayPos >= item.duration && displayPos > 0) { episodePlayIcon.setImageResource(R.drawable.ic_baseline_check_24) episodeProgress.isVisible = false } else { episodePlayIcon.setImageResource(R.drawable.play_button_transparent) episodeProgress.apply { max = durationSec progress = progressSec isVisible = displayPos > 0L } } } itemView.setOnClickListener { clickCallback.invoke( EpisodeClickEvent( position, ACTION_CLICK_DEFAULT, item ) ) } if (isLayout(TV)) { itemView.isFocusable = true itemView.isFocusableInTouchMode = true //itemView.touchscreenBlocksFocus = false } itemView.setOnLongClickListener { clickCallback.invoke(EpisodeClickEvent(position, ACTION_SHOW_OPTIONS, item)) return@setOnLongClickListener true } //binding.resultEpisodeDownload.isVisible = hasDownloadSupport //binding.resultEpisodeProgressDownloaded.isVisible = hasDownloadSupport } } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/result/ImageAdapter.kt ================================================ package com.lagradost.cloudstream3.ui.result import android.view.LayoutInflater import android.view.ViewGroup import com.lagradost.cloudstream3.databinding.ResultMiniImageBinding import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.ImageLoader.loadImage const val IMAGE_CLICK = 0 const val IMAGE_LONG_CLICK = 1 class ImageAdapter( val clickCallback: ((Int) -> Unit)? = null, val nextFocusUp: Int? = null, val nextFocusDown: Int? = null, ) : NoStateAdapter( diffCallback = BaseDiffCallback( itemSame = Int::equals, contentSame = Int::equals ) ) { companion object { val sharedPool = newSharedPool { setMaxRecycledViews(CONTENT, 10) } } override fun onCreateContent(parent: ViewGroup): ViewHolderState { return ViewHolderState( ResultMiniImageBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) } override fun onClearView(holder: ViewHolderState) { val binding = holder.view as? ResultMiniImageBinding ?: return clearImage(binding.root) } override fun onBindContent(holder: ViewHolderState, item: Int, position: Int) { val binding = holder.view as? ResultMiniImageBinding ?: return binding.root.apply { loadImage(item) if (nextFocusDown != null) { this.nextFocusDownId = nextFocusDown } if (nextFocusUp != null) { this.nextFocusUpId = nextFocusUp } if (clickCallback != null) { if (isLayout(TV)) { isClickable = true isLongClickable = true isFocusable = true isFocusableInTouchMode = true } setOnClickListener { clickCallback.invoke(IMAGE_CLICK) } setOnLongClickListener { clickCallback.invoke(IMAGE_LONG_CLICK) return@setOnLongClickListener true } } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/result/LinearListLayout.kt ================================================ package com.lagradost.cloudstream3.ui.result import android.content.Context import android.view.View import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.FocusDirection import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout const val FOCUS_SELF = View.NO_ID - 1 const val FOCUS_INHERIT = FOCUS_SELF - 1 fun RecyclerView?.setLinearListLayout( isHorizontal: Boolean = true, nextLeft: Int = FOCUS_INHERIT, nextRight: Int = FOCUS_INHERIT, nextUp: Int = FOCUS_INHERIT, nextDown: Int = FOCUS_INHERIT ) { if (this == null) return val ctx = this.context ?: return this.layoutManager = (this.layoutManager as? LinearListLayout ?: LinearListLayout(ctx)).apply { if (isHorizontal) setHorizontal() else setVertical() nextFocusLeft = if (nextLeft == FOCUS_INHERIT) this@setLinearListLayout.nextFocusLeftId else nextLeft nextFocusRight = if (nextRight == FOCUS_INHERIT) this@setLinearListLayout.nextFocusRightId else nextRight nextFocusUp = if (nextUp == FOCUS_INHERIT) this@setLinearListLayout.nextFocusUpId else nextUp nextFocusDown = if (nextDown == FOCUS_INHERIT) this@setLinearListLayout.nextFocusDownId else nextDown } } open class LinearListLayout(context: Context?) : LinearLayoutManager(context) { var nextFocusLeft: Int = View.NO_ID var nextFocusRight: Int = View.NO_ID var nextFocusUp: Int = View.NO_ID var nextFocusDown: Int = View.NO_ID fun setHorizontal() { orientation = HORIZONTAL } fun setVertical() { orientation = VERTICAL } private fun getCorrectParent(focused: View?): View? { if (focused == null) return null var current: View? = focused val last: ArrayList = arrayListOf(focused) while (current != null && current !is RecyclerView) { current = (current.parent as? View?)?.also { last.add(it) } } return last.getOrNull(last.count() - 2) } private fun getPosition(view: View?): Int? { return (view?.layoutParams as? RecyclerView.LayoutParams?)?.absoluteAdapterPosition } private fun getViewFromPos(pos: Int): View? { for (i in 0 until childCount) { val child = getChildAt(i) if ((child?.layoutParams as? RecyclerView.LayoutParams?)?.absoluteAdapterPosition == pos) { return child } } return null //return recyclerView.children.firstOrNull { child -> (child.layoutParams as? RecyclerView.LayoutParams?)?.absoluteAdapterPosition == pos) } } /* private fun scrollTo(position: Int) { val linearSmoothScroller = LinearSmoothScroller(recyclerView.context) linearSmoothScroller.targetPosition = position startSmoothScroll(linearSmoothScroller) }*/ /** from the current focus go to a direction */ private fun getNextDirection(focused: View?, direction: FocusDirection): View? { val id = when (direction) { FocusDirection.Start -> if (isLayoutRTL) nextFocusRight else nextFocusLeft FocusDirection.End -> if (isLayoutRTL) nextFocusLeft else nextFocusRight FocusDirection.Up -> nextFocusUp FocusDirection.Down -> nextFocusDown } return when (id) { View.NO_ID -> null FOCUS_SELF -> focused else -> CommonActivity.continueGetNextFocus( activity ?: focused, focused ?: return null, direction, id ) } } fun redirectRecycleToFirstItem(focused: View): View? { return when (focused) { is RecyclerView -> { (focused.layoutManager as? LinearListLayout)?.let { focusedLayoutManager -> val firstPosition = focusedLayoutManager.findFirstVisibleItemPosition() val firstView = focusedLayoutManager.findViewByPosition(firstPosition) firstView } ?: focused } else -> focused } } override fun onInterceptFocusSearch(focused: View, direction: Int): View? { val dir = if (orientation == HORIZONTAL) { if (direction == View.FOCUS_DOWN) getNextDirection( focused, FocusDirection.Down )?.let { newFocus -> return redirectRecycleToFirstItem(newFocus) } if (direction == View.FOCUS_UP) getNextDirection( focused, FocusDirection.Up )?.let { newFocus -> return redirectRecycleToFirstItem(newFocus) } if (direction == View.FOCUS_DOWN || direction == View.FOCUS_UP) { // This scrolls the recyclerview before doing focus search, which // allows the focus search to work better. // Without this the recyclerview focus location on the screen // would change when scrolling between recyclerviews. (focused.parent as? RecyclerView)?.focusSearch(direction) return null } var ret = if (direction == View.FOCUS_RIGHT) 1 else -1 // only flip on horizontal layout if (isLayoutRTL) { ret = -ret } ret } else { if (direction == View.FOCUS_RIGHT) getNextDirection( focused, FocusDirection.End )?.let { newFocus -> return newFocus } if (direction == View.FOCUS_LEFT) getNextDirection( focused, FocusDirection.Start )?.let { newFocus -> return newFocus } if (direction == View.FOCUS_RIGHT || direction == View.FOCUS_LEFT) { (focused.parent as? RecyclerView)?.focusSearch(direction) return null } //if (direction == View.FOCUS_RIGHT || direction == View.FOCUS_LEFT) return null if (direction == View.FOCUS_DOWN) 1 else -1 } try { val position = getPosition(getCorrectParent(focused)) ?: return null val lookFor = dir + position // if out of bounds then refocus as specified return if (lookFor >= itemCount) { getNextDirection( focused, if (orientation == HORIZONTAL) FocusDirection.End else FocusDirection.Down ) } else if (lookFor < 0) { getNextDirection( focused, if (orientation == HORIZONTAL) FocusDirection.Start else FocusDirection.Up ) } else { getViewFromPos(lookFor) ?: run { scrollToPosition(lookFor) null } } } catch (e: Exception) { logError(e) return null } } override fun requestChildRectangleOnScreen( parent: RecyclerView, child: View, rect: android.graphics.Rect, immediate: Boolean, focusedChildVisible: Boolean ): Boolean { if (isLayout(TV) && orientation == HORIZONTAL) { val dx = when { isLayoutRTL -> getDecoratedRight(child) - (parent.width - parent.paddingRight) else -> getDecoratedLeft(child) - parent.paddingLeft } return if (dx != 0) { when { immediate -> parent.scrollBy(dx, 0) else -> parent.smoothScrollBy(dx, 0) } true } else { false } } else { return super.requestChildRectangleOnScreen( parent, child, rect, immediate, focusedChildVisible ) } } /*override fun onRequestChildFocus( parent: RecyclerView, state: RecyclerView.State, child: View, focused: View? ): Boolean { return super.onRequestChildFocus(parent, state, child, focused) getPosition(getCorrectParent(focused ?: return true))?.let { val startView = findFirstVisibleChildClosestToStart(true,true) val endView = findFirstVisibleChildClosestToEnd(true,true) val start = getPosition(startView) val end = getPosition(endView) fill(parent,LayoutState()) val helper = mOrientationHelper ?: return false val laidOutArea: Int = abs( helper.getDecoratedEnd(startView) - helper.getDecoratedStart(endView) ) val itemRange: Int = abs( (start - end) ) + 1 val avgSizePerRow = laidOutArea.toFloat() / itemRange return Math.round( itemsBefore * avgSizePerRow + ((orientation.getStartAfterPadding() - orientation.getDecoratedStart(startChild))) ) recyclerView.scrollToPosition(it) } return true*/ //return super.onRequestChildFocus(parent, state, child, focused) /* if (focused == null || focused == child) { return super.onRequestChildFocus(parent, state, child, focused) } try { val pos = getPosition(getCorrectParent(focused) ?: return true) scrollToPosition(pos) } catch (e: Exception) { logError(e) } return true }*/ } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragment.kt ================================================ package com.lagradost.cloudstream3.ui.result import android.os.Bundle import android.widget.ImageView import android.widget.TextView import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.preference.PreferenceManager import coil3.dispose import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.SeasonData import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.ui.result.EpisodeAdapter.Companion.getPlayerAction import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.getVideoWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UiImage const val START_ACTION_RESUME_LATEST = 1 const val START_ACTION_LOAD_EP = 2 /** * Future proofed way to mark episodes as watched **/ enum class VideoWatchState { /** Default value when no key is set */ None, Watched } data class ResultEpisode( val headerName: String, val name: String?, val poster: String?, val episode: Int, val seasonIndex: Int?, // this is the "season" index used season names val season: Int?, // this is the display val data: String, val apiName: String, val id: Int, val index: Int, val position: Long, // time in MS val duration: Long, // duration in MS val score: Score?, val description: String?, val isFiller: Boolean?, val tvType: TvType, val parentId: Int, /** * Conveys if the episode itself is marked as watched **/ val videoWatchState: VideoWatchState, /** Sum of all previous season episode counts + episode */ val totalEpisodeIndex: Int? = null, val airDate: Long? = null, val runTime: Int? = null, val seasonData: SeasonData? = null, ) fun ResultEpisode.getRealPosition(): Long { if (duration <= 0) return 0 val percentage = position * 100 / duration if (percentage <= 5 || percentage >= 95) return 0 return position } fun ResultEpisode.getDisplayPosition(): Long { if (duration <= 0) return 0 val percentage = position * 100 / duration if (percentage <= 1) return 0 if (percentage <= 5) return 5 * duration / 100 if (percentage >= 95) return duration return position } fun buildResultEpisode( headerName: String, name: String? = null, poster: String? = null, episode: Int, seasonIndex: Int? = null, season: Int? = null, data: String, apiName: String, id: Int, index: Int, rating: Score? = null, description: String? = null, isFiller: Boolean? = null, tvType: TvType, parentId: Int, totalEpisodeIndex: Int? = null, airDate: Long? = null, runTime: Int? = null, seasonData: SeasonData? = null, ): ResultEpisode { val posDur = getViewPos(id) val videoWatchState = getVideoWatchState(id) ?: VideoWatchState.None return ResultEpisode( headerName = headerName, name = name, poster = poster, episode = episode, seasonIndex = seasonIndex, season = season, data = data, apiName = apiName, id = id, index = index, position = posDur?.position ?: 0, duration = posDur?.duration ?: 0, score = rating, description = description, isFiller = isFiller, tvType = tvType, parentId = parentId, videoWatchState = videoWatchState, totalEpisodeIndex = totalEpisodeIndex, airDate = airDate, runTime = runTime, seasonData = seasonData ) } /** 0f-1f */ fun ResultEpisode.getWatchProgress(): Float { return (getDisplayPosition() / 1000).toFloat() / (duration / 1000).toFloat() } object ResultFragment { private const val URL_BUNDLE = "url" private const val NAME_BUNDLE = "name" private const val API_NAME_BUNDLE = "apiName" private const val SEASON_BUNDLE = "season" private const val EPISODE_BUNDLE = "episode" private const val START_ACTION_BUNDLE = "startAction" private const val START_VALUE_BUNDLE = "startValue" private const val RESTART_BUNDLE = "restart" fun newInstance( card: SearchResponse, startAction: Int = 0, startValue: Int? = null ): Bundle { return Bundle().apply { putString(URL_BUNDLE, card.url) putString(API_NAME_BUNDLE, card.apiName) putString(NAME_BUNDLE, card.name) if (card is DataStoreHelper.ResumeWatchingResult) { if (card.season != null) putInt(SEASON_BUNDLE, card.season) if (card.episode != null) putInt(EPISODE_BUNDLE, card.episode) } putInt(START_ACTION_BUNDLE, startAction) if (startValue != null) putInt(START_VALUE_BUNDLE, startValue) putBoolean(RESTART_BUNDLE, true) } } fun newInstance( url: String, apiName: String, name: String, startAction: Int = 0, startValue: Int = 0 ): Bundle { return Bundle().apply { putString(URL_BUNDLE, url) putString(API_NAME_BUNDLE, apiName) putString(NAME_BUNDLE, name) putInt(START_ACTION_BUNDLE, startAction) putInt(START_VALUE_BUNDLE, startValue) putBoolean(RESTART_BUNDLE, true) } } fun updateUI(id: Int? = null) { // updateUIListener?.invoke() updateUIEvent.invoke(id) } val updateUIEvent = Event() //private var updateUIListener: (() -> Unit)? = null //protected open val resultLayout = R.layout.fragment_result_swipe /* override var layout = R.layout.fragment_result_swipe override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View? { return super.onCreateView(inflater, container, savedInstanceState) //return inflater.inflate(resultLayout, container, false) } override fun onDestroyView() { updateUIListener = null super.onDestroyView() } override fun onResume() { afterPluginsLoadedEvent += ::reloadViewModel super.onResume() activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) } override fun onDestroy() { afterPluginsLoadedEvent -= ::reloadViewModel super.onDestroy() } private fun updateUI() { syncModel.updateUserData() viewModel.reloadEpisodes() }*/ data class StoredData( val url: String, val apiName: String, val name: String, val showFillers: Boolean, val dubStatus: DubStatus, val start: AutoResume?, val playerAction: Int, val restart: Boolean, ) fun bindLogo( url: String?, headers: Map?, logoView: ImageView, titleView: TextView ) { // Cancel it, as we want to remove the listener onSuccess race condition logoView.dispose() if (url.isNullOrBlank()) { logoView.isVisible = false titleView.isVisible = true return } logoView.isVisible = true titleView.isVisible = false logoView.loadImage( imageData = UiImage.Image(url, headers = headers), builder = { listener( onSuccess = { _, _ -> logoView.isVisible = true titleView.isVisible = false }, onError = { _, _ -> logoView.isVisible = false titleView.isVisible = true }, onCancel = { // If we manually cancel, then it should not do anything } ) } ) } fun Fragment.getStoredData(): StoredData? { val context = this.context ?: this.activity ?: return null val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val url = arguments?.getString(URL_BUNDLE) ?: return null val apiName = arguments?.getString(API_NAME_BUNDLE) ?: return null val name = arguments?.getString(NAME_BUNDLE) ?: return null val showFillers = settingsManager.getBoolean(context.getString(R.string.show_fillers_key), false) val dubStatus = if (context.getApiDubstatusSettings() .contains(DubStatus.Dubbed) ) DubStatus.Dubbed else DubStatus.Subbed val startAction = arguments?.getInt(START_ACTION_BUNDLE) val playerAction = getPlayerAction(context) val restart = arguments?.getBoolean(RESTART_BUNDLE) ?: false if (restart) { arguments?.putBoolean(RESTART_BUNDLE, false) } val start = startAction?.let { action -> val startValue = arguments?.getInt(START_VALUE_BUNDLE) val resumeEpisode = arguments?.getInt(EPISODE_BUNDLE) val resumeSeason = arguments?.getInt(SEASON_BUNDLE) arguments?.remove(START_VALUE_BUNDLE) arguments?.remove(START_ACTION_BUNDLE) AutoResume( startAction = action, id = startValue, episode = resumeEpisode, season = resumeSeason ) } return StoredData(url, apiName, name, showFillers, dubStatus, start, playerAction, restart) } /*private fun reloadViewModel(forceReload: Boolean) { if (!viewModel.hasLoaded() || forceReload) { val storedData = getStoredData(activity ?: context ?: return) ?: return viewModel.load( activity, storedData.url ?: return, storedData.apiName, storedData.showFillers, storedData.dubStatus, storedData.start ) } } @SuppressLint("SetTextI18n") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) updateUIListener = ::updateUI val restart = arguments?.getBoolean(RESTART_BUNDLE) ?: false if (restart) { arguments?.putBoolean(RESTART_BUNDLE, false) } activity?.window?.decorView?.clearFocus() hideKeyboard() context?.updateHasTrailers() activity?.loadCache() /* val backParameter = result_back.layoutParams as FrameLayout.LayoutParams backParameter.setMargins( backParameter.leftMargin, backParameter.topMargin + requireContext().getStatusBarHeight(), backParameter.rightMargin, backParameter.bottomMargin ) result_back.layoutParams = backParameter*/ val storedData = (activity ?: context)?.let { getStoredData(it) } // This is to band-aid FireTV navigation val isTv = isTvSettings() result_season_button?.isFocusableInTouchMode = isTv result_episode_select?.isFocusableInTouchMode = isTv result_dub_select?.isFocusableInTouchMode = isTv if (storedData?.url != null) { if (restart || !viewModel.hasLoaded()) { //viewModel.clear() viewModel.load( activity, storedData.url, storedData.apiName, storedData.showFillers, storedData.dubStatus, storedData.start ) } } }*/ } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentPhone.kt ================================================ package com.lagradost.cloudstream3.ui.result import android.annotation.SuppressLint import android.app.Dialog import android.content.Intent import android.content.res.ColorStateList import android.content.res.Configuration import android.graphics.Rect import android.os.Build import android.os.Bundle import android.text.Editable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.animation.AlphaAnimation import android.view.animation.Animation import android.view.animation.DecelerateInterpolator import android.widget.AbsListView import android.widget.ArrayAdapter import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.widget.NestedScrollView import androidx.core.widget.doOnTextChanged import androidx.lifecycle.ViewModelProvider import com.discord.panels.OverlappingPanelsLayout import com.discord.panels.PanelState import com.discord.panels.PanelsChildGestureRegionObserver import com.google.android.gms.cast.framework.CastButtonFactory import com.google.android.gms.cast.framework.CastContext import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.base64Encode import com.lagradost.cloudstream3.databinding.FragmentResultBinding import com.lagradost.cloudstream3.databinding.FragmentResultSwipeBinding import com.lagradost.cloudstream3.databinding.ResultRecommendationsBinding import com.lagradost.cloudstream3.databinding.ResultSyncBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.services.SubscriptionWorkManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_DOWNLOAD import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_LONG_CLICK import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup import com.lagradost.cloudstream3.ui.player.CSPlayerEvent import com.lagradost.cloudstream3.ui.player.FullScreenPlayer import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo import com.lagradost.cloudstream3.ui.result.ResultFragment.getStoredData import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchHelper import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.isCastApiAvailable import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache import com.lagradost.cloudstream3.utils.AppContextUtils.openBrowser import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.openBatteryOptimizationSettings import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogInstant import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.populateChips import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes import com.lagradost.cloudstream3.utils.UIHelper.setListViewHeightBasedOnItems import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.getImageFromDrawable import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setTextHtml import com.lagradost.cloudstream3.utils.txt import java.net.URLEncoder import kotlin.math.roundToInt open class ResultFragmentPhone : FullScreenPlayer() { private val gestureRegionsListener = object : PanelsChildGestureRegionObserver.GestureRegionsListener { override fun onGestureRegionsUpdate(gestureRegions: List) { binding?.resultOverlappingPanels?.setChildGestureRegions(gestureRegions) } } protected lateinit var viewModel: ResultViewModel2 protected lateinit var syncModel: SyncViewModel protected var binding: FragmentResultSwipeBinding? = null protected var resultBinding: FragmentResultBinding? = null protected var recommendationBinding: ResultRecommendationsBinding? = null protected var syncBinding: ResultSyncBinding? = null override var layout = R.layout.fragment_result_swipe override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { viewModel = ViewModelProvider(this)[ResultViewModel2::class.java] syncModel = ViewModelProvider(this)[SyncViewModel::class.java] updateUIEvent += ::updateUI val root = super.onCreateView(inflater, container, savedInstanceState) ?: return null FragmentResultSwipeBinding.bind(root).let { bind -> resultBinding = bind.fragmentResult recommendationBinding = bind.resultRecommendations syncBinding = bind.resultSync binding = bind } return root } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) PanelsChildGestureRegionObserver.Provider.get().apply { resultBinding?.resultCastItems?.let { register(it) } } } var currentTrailers: List> = emptyList() var currentTrailerIndex = 0 override fun nextMirror() { currentTrailerIndex++ loadTrailer() } override fun hasNextMirror(): Boolean { return currentTrailerIndex + 1 < currentTrailers.size } override fun playerError(exception: Throwable) { if (player.getIsPlaying()) { // because we don't want random toasts in player super.playerError(exception) } else { nextMirror() } } private fun loadTrailer(index: Int? = null) { val isSuccess = currentTrailers.getOrNull(index ?: currentTrailerIndex) ?.let { (extractedTrailerLink, _) -> context?.let { ctx -> player.onPause() player.loadPlayer( ctx, false, extractedTrailerLink, null, startPosition = 0L, subtitles = emptySet(), subtitle = null, autoPlay = false, preview = false ) true } ?: run { false } } ?: run { false } //result_trailer_thumbnail?.setImageBitmap(result_poster_background?.drawable?.toBitmap()) // result_trailer_loading?.isVisible = isSuccess val turnVis = !isSuccess && !isFullScreenPlayer resultBinding?.apply { // If we load a trailer, then cancel the big logo and only show the small title if (isSuccess) { // This is still a bit of a race condition, but it should work if we have the // trailers observe after the page observe! bindLogo( url = null, headers = null, logoView = backgroundPosterWatermarkBadge, titleView = resultTitle ) } resultSmallscreenHolder.isVisible = turnVis resultPosterBackgroundHolder.apply { val fadeIn: Animation = AlphaAnimation(alpha, if (turnVis) 1.0f else 0.0f).apply { interpolator = DecelerateInterpolator() duration = 200 fillAfter = true } clearAnimation() startAnimation(fadeIn) } // We don't want the trailer to be focusable if it's not visible resultSmallscreenHolder.descendantFocusability = if (isSuccess) { ViewGroup.FOCUS_AFTER_DESCENDANTS } else { ViewGroup.FOCUS_BLOCK_DESCENDANTS } binding?.resultFullscreenHolder?.isVisible = !isSuccess && isFullScreenPlayer } //player_view?.apply { //alpha = 0.0f //ObjectAnimator.ofFloat(player_view, "alpha", 1f).apply { // duration = 200 // start() //} //val fadeIn: Animation = AlphaAnimation(0.0f, 1f).apply { // interpolator = DecelerateInterpolator() // duration = 2000 // fillAfter = true //} //startAnimation(fadeIn) //} } private fun setTrailers(trailers: List>?) { context?.updateHasTrailers() if (!LoadResponse.isTrailersEnabled) return currentTrailers = trailers?.sortedBy { -it.first.quality } ?: emptyList() loadTrailer() } override fun onDestroyView() { PanelsChildGestureRegionObserver.Provider.get().let { obs -> resultBinding?.resultCastItems?.let { obs.unregister(it) } obs.removeGestureRegionsUpdateListener(gestureRegionsListener) } updateUIEvent -= ::updateUI binding = null resultBinding?.resultScroll?.setOnClickListener(null) resultBinding = null syncBinding = null recommendationBinding = null activity?.detachBackPressedCallback(this@ResultFragmentPhone.toString()) super.onDestroyView() } var loadingDialog: Dialog? = null var popupDialog: Dialog? = null /** * Sets next focus to allow navigation up and down between 2 views * if either of them is null nothing happens. **/ private fun setFocusUpAndDown(upper: View?, down: View?) { if (upper == null || down == null) return upper.nextFocusDownId = down.id down.nextFocusUpId = upper.id } var selectSeason: String? = null var selectEpisodeRange: String? = null var selectSort: EpisodeSortType? = null private fun setUrl(url: String?) { if (url == null) { binding?.resultOpenInBrowser?.isVisible = false return } val valid = url.startsWith("http") binding?.resultOpenInBrowser?.apply { isVisible = valid setOnClickListener { context?.openBrowser(url) } } resultBinding?.resultReloadConnectionOpenInBrowser?.setOnClickListener { view?.context?.openBrowser(url) } resultBinding?.resultMetaSite?.setOnClickListener { view?.context?.openBrowser(url) } } private fun reloadViewModel(forceReload: Boolean) { if (!viewModel.hasLoaded() || forceReload) { val storedData = getStoredData() ?: return viewModel.load( activity, storedData.url, storedData.apiName, storedData.showFillers, storedData.dubStatus, storedData.start ) } } override fun onResume() { afterPluginsLoadedEvent += ::reloadViewModel activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) super.onResume() PanelsChildGestureRegionObserver.Provider.get() .addGestureRegionsUpdateListener(gestureRegionsListener) } override fun onStop() { afterPluginsLoadedEvent -= ::reloadViewModel super.onStop() } private fun updateUI(id: Int?) { syncModel.updateUserData() viewModel.reloadEpisodes() } override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) view?.let { fixSystemBarsPadding(it) } } @SuppressLint("SetTextI18n") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) // ===== setup ===== fixSystemBarsPadding(view) val storedData = getStoredData() ?: return activity?.window?.decorView?.clearFocus() activity?.loadCache() context?.updateHasTrailers() hideKeyboard() if (storedData.restart || !viewModel.hasLoaded()) viewModel.load( activity, storedData.url, storedData.apiName, storedData.showFillers, storedData.dubStatus, storedData.start ) setUrl(storedData.url) syncModel.addFromUrl(storedData.url) val api = APIHolder.getApiFromNameNull(storedData.apiName) // This may not be 100% reliable, and may delay for small period // before resultCastItems will be scrollable again, but this does work // most of the time. binding?.resultOverlappingPanels?.registerEndPanelStateListeners( object : OverlappingPanelsLayout.PanelStateListener { override fun onPanelStateChange(panelState: PanelState) { PanelsChildGestureRegionObserver.Provider.get().apply { resultBinding?.resultCastItems?.let { register(it) } } } } ) // ===== ===== ===== binding?.resultSearch?.isGone = storedData.name.isBlank() binding?.resultSearch?.setOnClickListener { QuickSearchFragment.pushSearch(activity, storedData.name) } resultBinding?.apply { resultReloadConnectionerror.setOnClickListener { viewModel.load( activity, storedData.url, storedData.apiName, storedData.showFillers, storedData.dubStatus, storedData.start ) } resultCastItems.setLinearListLayout( isHorizontal = true, nextLeft = FOCUS_SELF, nextRight = FOCUS_SELF ) /*resultCastItems.layoutManager = object : LinearListLayout(view.context) { override fun onRequestChildFocus( parent: RecyclerView, state: RecyclerView.State, child: View, focused: View? ): Boolean { // Make the cast always focus the first visible item when focused // from somewhere else. Otherwise it jumps to the last item. return if (parent.focusedChild == null) { scrollToPosition(this.findFirstCompletelyVisibleItemPosition()) true } else { super.onRequestChildFocus(parent, state, child, focused) } } }.apply { this.orientation = RecyclerView.HORIZONTAL }*/ resultCastItems.setRecycledViewPool(ActorAdaptor.sharedPool) resultCastItems.adapter = ActorAdaptor() resultEpisodes.setRecycledViewPool(EpisodeAdapter.sharedPool) resultEpisodes.adapter = EpisodeAdapter( api?.hasDownloadSupport == true, { episodeClick -> viewModel.handleAction(episodeClick) }, { downloadClickEvent -> DownloadButtonSetup.handleDownloadClick(downloadClickEvent) } ) observeNullable(viewModel.selectedSorting) { resultSortButton.setText(it) } observe(viewModel.sortSelections) { sort -> resultBinding?.resultSortButton?.setOnClickListener { view -> view?.context?.let { ctx -> val names = sort .mapNotNull { (text, r) -> r to (text.asStringNull(ctx) ?: return@mapNotNull null) } activity?.showDialog( names.map { it.second }, viewModel.selectedSortingIndex.value ?: -1, ctx.getString(R.string.sort_by), false, {}) { itemId -> viewModel.setSort(names[itemId].first) } } } } resultScroll.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { _, _, scrollY, _, oldScrollY -> val dy = scrollY - oldScrollY if (dy > 0) { //check for scroll down binding?.resultBookmarkFab?.shrink() } else if (dy < -5) { binding?.resultBookmarkFab?.extend() } if (!isFullScreenPlayer && player.getIsPlaying()) { if (scrollY > (resultBinding?.fragmentTrailer?.playerBackground?.height ?: scrollY) ) { player.handleEvent(CSPlayerEvent.Pause) } } }) } binding?.apply { resultOverlappingPanels.setStartPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) resultOverlappingPanels.setEndPanelLockState(OverlappingPanelsLayout.LockState.CLOSE) resultBack.setOnClickListener { activity?.popCurrentPage() } activity?.attachBackPressedCallback(this@ResultFragmentPhone.toString()) { if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { runDefault() } else resultOverlappingPanels.closePanels() } resultMiniSync.setOnClickListener { if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { resultOverlappingPanels.openStartPanel() } else resultOverlappingPanels.closePanels() } /* resultMiniSync.setRecycledViewPool(ImageAdapter.sharedPool) resultMiniSync.adapter = ImageAdapter( nextFocusDown = R.id.result_sync_set_score, clickCallback = { action -> if (action == IMAGE_CLICK || action == IMAGE_LONG_CLICK) { if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { resultOverlappingPanels.openStartPanel() } else resultOverlappingPanels.closePanels() } }) */ resultSubscribe.setOnClickListener { viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? -> if (newStatus == null) return@toggleSubscriptionStatus val message = if (newStatus) { // Kinda icky to have this here, but it works. SubscriptionWorkManager.enqueuePeriodicWork(context) R.string.subscription_new } else { R.string.subscription_deleted } val name = (viewModel.page.value as? Resource.Success)?.value?.title ?: com.lagradost.cloudstream3.utils.txt(R.string.no_data) .asStringNull(context) ?: "" showToast( com.lagradost.cloudstream3.utils.txt(message, name), Toast.LENGTH_SHORT ) } context?.let { openBatteryOptimizationSettings(it) } } resultFavorite.setOnClickListener { viewModel.toggleFavoriteStatus(context) { newStatus: Boolean? -> if (newStatus == null) return@toggleFavoriteStatus val message = if (newStatus) { R.string.favorite_added } else { R.string.favorite_removed } val name = (viewModel.page.value as? Resource.Success)?.value?.title ?: com.lagradost.cloudstream3.utils.txt(R.string.no_data) .asStringNull(context) ?: "" showToast( com.lagradost.cloudstream3.utils.txt(message, name), Toast.LENGTH_SHORT ) } } mediaRouteButton.apply { val chromecastSupport = api?.hasChromecastSupport == true alpha = if (chromecastSupport) 1f else 0.3f if (!chromecastSupport) { setOnClickListener { showToast( R.string.no_chromecast_support_toast, Toast.LENGTH_LONG ) } } activity?.let { act -> if (act.isCastApiAvailable()) { try { CastButtonFactory.setUpMediaRouteButton(act, this) CastContext.getSharedInstance(act.applicationContext) { it.run() }.addOnCompleteListener { isGone = !it.isSuccessful } // this shit leaks for some reason //castContext.addCastStateListener { state -> // media_route_button?.isGone = state == CastState.NO_DEVICES_AVAILABLE //} } catch (e: Exception) { logError(e) } } } } } playerBinding?.apply { playerOpenSource.setOnClickListener { currentTrailers.getOrNull(currentTrailerIndex)?.let { (_, ogTrailerLink) -> context?.openBrowser(ogTrailerLink) } } } recommendationBinding?.apply { resultRecommendationsList.apply { spanCount = 3 setRecycledViewPool(SearchAdapter.sharedPool) adapter = SearchAdapter( this, ) { callback -> SearchHelper.handleSearchClickCallback(callback) } } } /* result_bookmark_button?.setOnClickListener { it.popupMenuNoIcons( items = WatchType.values() .map { watchType -> Pair(watchType.internalId, watchType.stringRes) }, //.map { watchType -> Triple(watchType.internalId, watchType.iconRes, watchType.stringRes) }, ) { viewModel.updateWatchStatus(WatchType.fromInternalId(this.itemId)) } }*/ observeNullable(viewModel.resumeWatching) { resume -> resultBinding?.apply { if (resume == null) { resultResumeParent.isVisible = false resultPlayParent.isVisible = true resultResumeProgressHolder.isVisible = false return@observeNullable } resultResumeParent.isVisible = true resume.progress?.let { progress -> resultNextSeriesButton.isVisible = false resultResumeSeriesTitle.apply { isVisible = !resume.isMovie text = if (resume.isMovie) null else context?.getNameFull( resume.result.name, resume.result.episode, resume.result.season ) } if (resume.isMovie) { resultPlayParent.isGone = true resultResumeSeriesProgressText.isVisible = true resultResumeSeriesProgressText.setText(progress.progressLeft) } resultResumeSeriesProgress.apply { isVisible = true this.max = progress.maxProgress this.progress = progress.progress } resultResumeProgressHolder.isVisible = true } ?: run { resultResumeProgressHolder.isVisible = false if (!resume.isMovie) { resultNextSeriesButton.isVisible = true resultNextSeriesButton.text = context?.getNameFull( resume.result.name, resume.result.episode, resume.result.season ) } resultResumeSeriesProgress.isVisible = false resultResumeSeriesTitle.isVisible = false resultResumeSeriesProgressText.isVisible = false } resultResumeSeriesButton.setOnClickListener { resumeAction(storedData, resume) } resultNextSeriesButton.setOnClickListener { resumeAction(storedData, resume) } } } observeNullable(viewModel.subscribeStatus) { isSubscribed -> binding?.resultSubscribe?.isVisible = isSubscribed != null if (isSubscribed == null) return@observeNullable val drawable = if (isSubscribed) { R.drawable.ic_baseline_notifications_active_24 } else { R.drawable.baseline_notifications_none_24 } binding?.resultSubscribe?.setImageResource(drawable) } observeNullable(viewModel.favoriteStatus) { isFavorite -> binding?.resultFavorite?.isVisible = isFavorite != null if (isFavorite == null) return@observeNullable val drawable = if (isFavorite) { R.drawable.ic_baseline_favorite_24 } else { R.drawable.ic_baseline_favorite_border_24 } binding?.resultFavorite?.setImageResource(drawable) } observeNullable(viewModel.episodes) { episodes -> resultBinding?.apply { // no failure? resultEpisodeLoading.isVisible = episodes is Resource.Loading resultEpisodes.isVisible = episodes is Resource.Success resultBatchDownloadButton.isVisible = episodes is Resource.Success && episodes.value.isNotEmpty() if (episodes is Resource.Success) { (resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value) // Show quality dialog with all sources resultBatchDownloadButton.setOnLongClickListener { ioSafe { val defaultSources = QualityProfileDialog.getAllDefaultSources() val activity = activity ?: return@ioSafe activity.runOnUiThread { QualityProfileDialog( activity, R.style.DialogFullscreenPlayer, defaultSources, ).show() } } true } resultBatchDownloadButton.setOnClickListener { view -> val episodeStart = episodes.value.firstOrNull()?.episode ?: return@setOnClickListener val episodeEnd = episodes.value.lastOrNull()?.episode ?: return@setOnClickListener val episodeRange = if (episodeStart == episodeEnd) { episodeStart.toString() } else { txt( R.string.episodes_range, episodeStart, episodeEnd ).asString(view.context) } val rangeMessage = txt( R.string.download_episode_range, episodeRange ).asString(view.context) AlertDialog.Builder(view.context, R.style.AlertDialogCustom) .setTitle(R.string.download_all) .setMessage(rangeMessage) .setPositiveButton(R.string.yes) { _, _ -> ioSafe { episodes.value.forEach { episode -> viewModel.handleAction( EpisodeClickEvent( ACTION_DOWNLOAD_EPISODE, episode ) ) // Join to make the episodes ordered .join() } } } .setNegativeButton(R.string.cancel) { _, _ -> }.show() } } } } observeNullable(viewModel.movie) { data -> resultBinding?.apply { resultPlayMovie.isVisible = data is Resource.Success downloadButton.isVisible = data is Resource.Success && viewModel.currentRepo?.api?.hasDownloadSupport == true (data as? Resource.Success)?.value?.let { (text, ep) -> resultPlayMovie.setText(text) resultPlayMovie.setOnClickListener { viewModel.handleAction( EpisodeClickEvent(ACTION_CLICK_DEFAULT, ep) ) } resultPlayMovie.setOnLongClickListener { viewModel.handleAction( EpisodeClickEvent(ACTION_SHOW_OPTIONS, ep) ) return@setOnLongClickListener true } resultResumeSeriesButton.setOnLongClickListener { viewModel.handleAction( EpisodeClickEvent(ACTION_SHOW_OPTIONS, ep) ) return@setOnLongClickListener true } val status = VideoDownloadManager.downloadStatus[ep.id] downloadButton.setStatus(status) downloadButton.setDefaultClickListener( DownloadObjects.DownloadEpisodeCached( name = ep.name, poster = ep.poster, episode = 0, season = null, id = ep.id, parentId = ep.id, score = ep.score, description = ep.description, cacheTime = System.currentTimeMillis(), ), null ) { click -> context?.let { openBatteryOptimizationSettings(it) } when (click.action) { DOWNLOAD_ACTION_DOWNLOAD -> { viewModel.handleAction( EpisodeClickEvent(ACTION_DOWNLOAD_EPISODE, ep) ) } DOWNLOAD_ACTION_LONG_CLICK -> { viewModel.handleAction( EpisodeClickEvent( ACTION_DOWNLOAD_MIRROR, ep ) ) } else -> DownloadButtonSetup.handleDownloadClick(click) } } } } } observe(viewModel.page) { data -> if (data == null) return@observe resultBinding?.apply { PanelsChildGestureRegionObserver.Provider.get().apply { register(resultCastItems) } (data as? Resource.Success)?.value?.let { d -> resultVpn.setText(d.vpnText) resultInfo.setText(d.metaText) resultNoEpisodes.setText(d.noEpisodesFoundText) resultTitle.setText(d.titleText) resultMetaSite.setText(d.apiName) resultMetaType.setText(d.typeText) resultMetaYear.setText(d.yearText) resultMetaDuration.setText(d.durationText) resultMetaRating.setText(d.ratingText) resultMetaStatus.setText(d.onGoingText) resultMetaContentRating.setText(d.contentRatingText) resultCastText.setText(d.actorsText) resultNextAiring.setText(d.nextAiringEpisode) resultNextAiringTime.setText(d.nextAiringDate) resultPoster.loadImage(d.posterImage, headers = d.posterHeaders) { error { getImageFromDrawable( context ?: return@error null, R.drawable.default_cover ) } } resultPosterBackground.loadImage( d.posterBackgroundImage, headers = d.posterHeaders ) { error { getImageFromDrawable( context ?: return@error null, R.drawable.default_cover ) } } bindLogo( url = d.logoUrl, headers = d.posterHeaders, titleView = resultTitle, logoView = backgroundPosterWatermarkBadge ) var isExpanded = false resultDescription.apply { setTextHtml(d.plotText) setOnClickListener { isExpanded = !isExpanded maxLines = if (isExpanded) { Integer.MAX_VALUE } else 10 } } populateChips(resultTag, d.tags) resultComingSoon.isVisible = d.comingSoon resultDataHolder.isGone = d.comingSoon val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context) val showCast = prefs.getBoolean( root.context.getString(R.string.show_cast_in_details_key), true ) resultCastItems.isGone = !showCast || d.actors.isNullOrEmpty() (resultCastItems.adapter as? ActorAdaptor)?.submitList(if (showCast) d.actors else emptyList()) if (d.contentRatingText == null) { // If there is no rating to display, we don't want an empty gap resultMetaContentRating.width = 0 } if (syncModel.addSyncs(d.syncData)) { syncModel.updateMetaAndUser() syncModel.updateSynced() } else { syncModel.addFromUrl(d.url) } binding?.apply { resultSearch.isGone = d.title.isBlank() resultSearch.setOnClickListener { QuickSearchFragment.pushSearch(activity, d.title) } resultShare.setOnClickListener { try { val i = Intent(Intent.ACTION_SEND) val nameBase64 = base64Encode(d.apiName.toString().toByteArray(Charsets.UTF_8)) val urlBase64 = base64Encode(d.url.toByteArray(Charsets.UTF_8)) val encodedUri = URLEncoder.encode( "$APP_STRING_SHARE:$nameBase64?$urlBase64", "UTF-8" ) val redirectUrl = "https://recloudstream.github.io/csredirect?redirectto=$encodedUri" i.type = "text/plain" i.putExtra(Intent.EXTRA_SUBJECT, d.title) i.putExtra(Intent.EXTRA_TEXT, redirectUrl) startActivity(Intent.createChooser(i, d.title)) } catch (e: Exception) { logError(e) } } setUrl(d.url) resultBookmarkFab.apply { isVisible = true extend() } } } (data as? Resource.Failure)?.let { data -> resultErrorText.text = storedData.url.plus("\n") + data.errorString } binding?.resultBookmarkFab?.isVisible = data is Resource.Success resultFinishLoading.isVisible = data is Resource.Success resultLoading.isVisible = data is Resource.Loading resultLoadingError.isVisible = data is Resource.Failure resultErrorText.isVisible = data is Resource.Failure resultReloadConnectionOpenInBrowser.isVisible = data is Resource.Failure resultTitle.setOnLongClickListener { clipboardHelper( com.lagradost.cloudstream3.utils.txt(R.string.title), resultTitle.text ) true } } } observeNullable(viewModel.episodesCountText) { count -> resultBinding?.resultEpisodesText.setText(count) } observeNullable(viewModel.selectPopup) { popup -> if (popup == null) { popupDialog?.dismissSafe(activity) popupDialog = null return@observeNullable } popupDialog?.dismissSafe(activity) popupDialog = activity?.let { act -> val options = popup.getOptions(act) val title = popup.getTitle(act) act.showBottomDialogInstant( options, title, { popupDialog = null popup.callback(null) }, { popupDialog = null popup.callback(it) } ) } } observe(viewModel.trailers) { trailers -> setTrailers(trailers.flatMap { it.mirros }) // I dont care about subtitles yet! } observe(syncModel.synced) { list -> syncBinding?.resultSyncNames?.text = list.filter { it.isSynced && it.hasAccount }.joinToString { it.name } val newList = list.filter { it.isSynced && it.hasAccount } binding?.resultMiniSync?.isVisible = newList.isNotEmpty() //(binding?.resultMiniSync?.adapter as? ImageAdapter)?.submitList(newList.mapNotNull { it.icon }) } var currentSyncProgress = 0 fun setSyncMaxEpisodes(totalEpisodes: Int?) { syncBinding?.resultSyncEpisodes?.max = (totalEpisodes ?: 0) * 1000 safe { val ctx = syncBinding?.resultSyncEpisodes?.context syncBinding?.resultSyncMaxEpisodes?.text = totalEpisodes?.let { episodes -> ctx?.getString(R.string.sync_total_episodes_some)?.format(episodes) } ?: run { ctx?.getString(R.string.sync_total_episodes_none) } } } observe(syncModel.metadata) { meta -> when (meta) { is Resource.Success -> { val d = meta.value syncBinding?.resultSyncEpisodes?.progress = currentSyncProgress * 1000 setSyncMaxEpisodes(d.totalEpisodes) viewModel.setMeta(d, syncModel.getSyncs()) } is Resource.Loading -> { syncBinding?.resultSyncMaxEpisodes?.text = syncBinding?.resultSyncMaxEpisodes?.context?.getString(R.string.sync_total_episodes_none) } else -> {} } } observe(syncModel.userData) { status -> var closed = false syncBinding?.apply { when (status) { is Resource.Failure -> { resultSyncLoadingShimmer.stopShimmer() resultSyncLoadingShimmer.isVisible = false resultSyncHolder.isVisible = false closed = true } is Resource.Loading -> { resultSyncLoadingShimmer.startShimmer() resultSyncLoadingShimmer.isVisible = true resultSyncHolder.isVisible = false } is Resource.Success -> { resultSyncLoadingShimmer.stopShimmer() resultSyncLoadingShimmer.isVisible = false resultSyncHolder.isVisible = true val d = status.value val desiredScore = d.score?.toFloat(1) ?: 0.0f val totalSteps = (resultSyncRating.valueTo / resultSyncRating.stepSize) val desiredStep = (totalSteps * desiredScore).roundToInt() resultSyncRating.value = desiredStep * resultSyncRating.stepSize resultSyncCheck.setItemChecked(d.status.internalId + 1, true) val watchedEpisodes = d.watchedEpisodes ?: 0 currentSyncProgress = watchedEpisodes d.maxEpisodes?.let { // don't directly call it because we don't want to override metadata observe setSyncMaxEpisodes(it) } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { resultSyncEpisodes.setProgress(watchedEpisodes * 1000, true) } else { resultSyncEpisodes.progress = watchedEpisodes * 1000 } resultSyncCurrentEpisodes.text = Editable.Factory.getInstance()?.newEditable(watchedEpisodes.toString()) safe { // format might fail val text = d.score?.toFloat(10)?.roundToInt()?.let { context?.getString(R.string.sync_score_format)?.format(it) } ?: "?" resultSyncScoreText.text = text } } null -> { closed = false } } } binding?.resultOverlappingPanels?.setStartPanelLockState(if (closed) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) } observe(viewModel.recommendations) { recommendations -> setRecommendations(recommendations, null) } context?.let { ctx -> val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) /* -1 -> None 0 -> Watching 1 -> Completed 2 -> OnHold 3 -> Dropped 4 -> PlanToWatch 5 -> ReWatching */ val items = listOf( R.string.none, R.string.type_watching, R.string.type_completed, R.string.type_on_hold, R.string.type_dropped, R.string.type_plan_to_watch, R.string.type_re_watching ).map { ctx.getString(it) } arrayAdapter.addAll(items) syncBinding?.apply { resultSyncCheck.choiceMode = AbsListView.CHOICE_MODE_SINGLE resultSyncCheck.adapter = arrayAdapter setListViewHeightBasedOnItems(resultSyncCheck) resultSyncCheck.setOnItemClickListener { _, _, which, _ -> syncModel.setStatus(which - 1) } resultSyncRating.addOnChangeListener { it, value, fromUser -> if (fromUser) syncModel.setScore(Score.from(value, it.valueTo.roundToInt())) } resultSyncAddEpisode.setOnClickListener { syncModel.setEpisodesDelta(1) } resultSyncSubEpisode.setOnClickListener { syncModel.setEpisodesDelta(-1) } resultSyncCurrentEpisodes.doOnTextChanged { text, _, before, count -> if (count == before) return@doOnTextChanged text?.toString()?.toIntOrNull()?.let { ep -> syncModel.setEpisodes(ep) } } } } syncBinding?.resultSyncSetScore?.setOnClickListener { syncModel.publishUserData() } observe(viewModel.watchStatus) { watchType -> binding?.resultBookmarkFab?.apply { setText(watchType.stringRes) if (watchType == WatchType.NONE) { context?.colorFromAttribute(R.attr.white) } else { context?.colorFromAttribute(R.attr.colorPrimary) }?.let { val colorState = ColorStateList.valueOf(it) iconTint = colorState setTextColor(colorState) } setOnClickListener { fab -> activity?.showBottomDialog( WatchType.entries.map { fab.context.getString(it.stringRes) }.toList(), watchType.ordinal, fab.context.getString(R.string.action_add_to_bookmarks), showApply = false, {}) { viewModel.updateWatchStatus(WatchType.entries[it], context) } } } } observeNullable(viewModel.loadedLinks) { load -> if (load == null) { loadingDialog?.dismissSafe(activity) loadingDialog = null return@observeNullable } if (loadingDialog?.isShowing != true) { loadingDialog?.dismissSafe(activity) loadingDialog = null } loadingDialog = loadingDialog ?: context?.let { ctx -> val builder = BottomSheetDialog(ctx) builder.setContentView(R.layout.bottom_loading) builder.setOnDismissListener { loadingDialog = null viewModel.cancelLinks() } builder.setCanceledOnTouchOutside(true) builder.show() builder } loadingDialog?.findViewById(R.id.overlay_loading_skip_button)?.apply { if (load.linksLoaded <= 0) { isInvisible = true } else { setOnClickListener { viewModel.skipLoading() } isVisible = true text = "${context.getString(R.string.skip_loading)} (${load.linksLoaded})" } } } observeNullable(viewModel.selectedSeason) { text -> resultBinding?.apply { resultSeasonButton.setText(text) selectSeason = text?.asStringNull(resultSeasonButton.context) // If the season button is visible the result season button will be next focus down if (resultSeasonButton.isVisible && resultResumeParent.isVisible) { setFocusUpAndDown(resultResumeSeriesButton, resultSeasonButton) } } } observeNullable(viewModel.selectedDubStatus) { status -> resultBinding?.apply { resultDubSelect.setText(status) if (resultDubSelect.isVisible && !resultSeasonButton.isVisible && !resultEpisodeSelect.isVisible && resultResumeParent.isVisible) { setFocusUpAndDown(resultResumeSeriesButton, resultDubSelect) } } } observeNullable(viewModel.selectedRange) { range -> resultBinding?.apply { resultEpisodeSelect.setText(range) selectEpisodeRange = range?.asStringNull(resultEpisodeSelect.context) // If Season button is invisible then the bookmark button next focus is episode select if (resultEpisodeSelect.isVisible && !resultSeasonButton.isVisible && resultResumeParent.isVisible) { setFocusUpAndDown(resultResumeSeriesButton, resultEpisodeSelect) } } } // val preferDub = context?.getApiDubstatusSettings()?.all { it == DubStatus.Dubbed } == true observe(viewModel.dubSubSelections) { range -> resultBinding?.resultDubSelect?.setOnClickListener { view -> view?.context?.let { ctx -> view.popupMenuNoIconsAndNoStringRes( range .mapNotNull { (text, status) -> Pair( status.ordinal, text?.asStringNull(ctx) ?: return@mapNotNull null ) }) { viewModel.changeDubStatus(DubStatus.entries[itemId]) } } } } observe(viewModel.rangeSelections) { range -> resultBinding?.resultEpisodeSelect?.setOnClickListener { view -> view?.context?.let { ctx -> val names = range .mapNotNull { (text, r) -> r to (text?.asStringNull(ctx) ?: return@mapNotNull null) } activity?.showDialog( names.map { it.second }, names.indexOfFirst { it.second == selectEpisodeRange }, ctx.getString(R.string.episodes), false, {}) { itemId -> viewModel.changeRange(names[itemId].first) } } } } observe(viewModel.seasonSelections) { seasonList -> resultBinding?.resultSeasonButton?.setOnClickListener { view -> view?.context?.let { ctx -> val names = seasonList .mapNotNull { (text, r) -> r to (text?.asStringNull(ctx) ?: return@mapNotNull null) } activity?.showDialog( names.map { it.second }, names.indexOfFirst { it.second == selectSeason }, ctx.getString(R.string.season), false, {}) { itemId -> viewModel.changeSeason(names[itemId].first) } //view.popupMenuNoIconsAndNoStringRes(names.mapIndexed { index, (_, name) -> // index to name //}) { // viewModel.changeSeason(names[itemId].first) //} } } } } private fun resumeAction( storedData: ResultFragment.StoredData, resume: ResumeWatchingStatus ) { viewModel.handleAction( EpisodeClickEvent( storedData.playerAction, //?: ACTION_PLAY_EPISODE_IN_PLAYER, resume.result ) ) } override fun onPause() { super.onPause() PanelsChildGestureRegionObserver.Provider.get() .addGestureRegionsUpdateListener(gestureRegionsListener) } private fun setRecommendations(rec: List?, validApiName: String?) { val isInvalid = rec.isNullOrEmpty() val matchAgainst = validApiName ?: rec?.firstOrNull()?.apiName recommendationBinding?.apply { root.isGone = isInvalid root.post { rec?.let { list -> (resultRecommendationsList.adapter as? SearchAdapter)?.submitList(list.filter { it.apiName == matchAgainst }) } } } binding?.apply { resultRecommendationsBtt.isGone = isInvalid resultRecommendationsBtt.setOnClickListener { val nextFocusDown = if (resultOverlappingPanels.getSelectedPanel().ordinal == 1) { resultOverlappingPanels.openEndPanel() R.id.result_recommendations } else { resultOverlappingPanels.closePanels() R.id.result_description } resultBinding?.apply { resultRecommendationsBtt.nextFocusDownId = nextFocusDown resultSearch.nextFocusDownId = nextFocusDown resultOpenInBrowser.nextFocusDownId = nextFocusDown resultShare.nextFocusDownId = nextFocusDown } } resultOverlappingPanels.setEndPanelLockState(if (isInvalid) OverlappingPanelsLayout.LockState.CLOSE else OverlappingPanelsLayout.LockState.UNLOCKED) rec?.map { it.apiName }?.distinct()?.let { apiNames -> // very dirty selection recommendationBinding?.resultRecommendationsFilterButton?.apply { isVisible = apiNames.size > 1 text = matchAgainst setOnClickListener { _ -> activity?.showBottomDialog( apiNames, apiNames.indexOf(matchAgainst), getString(R.string.home_change_provider_img_des), false, {} ) { setRecommendations(rec, apiNames[it]) } } } } ?: run { recommendationBinding?.resultRecommendationsFilterButton?.isVisible = false } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultFragmentTv.kt ================================================ package com.lagradost.cloudstream3.ui.result import android.animation.Animator import android.annotation.SuppressLint import android.app.Dialog import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.animation.DecelerateInterpolator import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.widget.NestedScrollView import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.databinding.FragmentResultTvBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.services.SubscriptionWorkManager import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup import com.lagradost.cloudstream3.ui.player.ExtractorLinkGenerator import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.ui.player.NEXT_WATCH_EPISODE_PERCENTAGE import com.lagradost.cloudstream3.ui.quicksearch.QuickSearchFragment import com.lagradost.cloudstream3.ui.result.ResultFragment.bindLogo import com.lagradost.cloudstream3.ui.result.ResultFragment.getStoredData import com.lagradost.cloudstream3.ui.result.ResultFragment.updateUIEvent import com.lagradost.cloudstream3.ui.search.SEARCH_ACTION_FOCUSED import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchHelper import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl import com.lagradost.cloudstream3.utils.AppContextUtils.loadCache import com.lagradost.cloudstream3.utils.AppContextUtils.updateHasTrailers import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogInstant import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.populateChips import com.lagradost.cloudstream3.utils.UIHelper.setNavigationBarColorCompat import com.lagradost.cloudstream3.utils.getImageFromDrawable import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.setTextHtml import com.lagradost.cloudstream3.utils.txt class ResultFragmentTv : BaseFragment( BindingCreator.Inflate(FragmentResultTvBinding::inflate) ) { private lateinit var viewModel: ResultViewModel2 override fun onDestroyView() { updateUIEvent -= ::updateUI activity?.detachBackPressedCallback(this@ResultFragmentTv.toString()) super.onDestroyView() } override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { viewModel = ViewModelProvider(this)[ResultViewModel2::class.java] viewModel.EPISODE_RANGE_SIZE = 50 updateUIEvent += ::updateUI return super.onCreateView(inflater, container, savedInstanceState) } private fun updateUI(id: Int?) { viewModel.reloadEpisodes() } private var currentRecommendations: List = emptyList() private fun handleSelection(data: Any) { when (data) { is EpisodeRange -> { viewModel.changeRange(data) } is Int -> { viewModel.changeSeason(data) } is DubStatus -> { viewModel.changeDubStatus(data) } is String -> { setRecommendations(currentRecommendations, data) } } } private fun RecyclerView?.select(index: Int) { (this?.adapter as? SelectAdaptor?)?.select(index, this) } private fun RecyclerView?.update(data: List) { (this?.adapter as? SelectAdaptor?)?.submitList(data) this?.isVisible = data.size > 1 } private fun RecyclerView?.setAdapter() { this?.adapter = SelectAdaptor { data -> handleSelection(data) } } // private fun hasNoFocus(): Boolean { // val focus = activity?.currentFocus // if (focus == null || !focus.isVisible) return true // return focus == binding?.resultRoot // } /** * Force focus any play button. * Note that this will steal any focus if the episode loading is too slow (unlikely). */ private fun focusPlayButton() { binding?.resultPlayMovieButton?.requestFocus() binding?.resultPlaySeriesButton?.requestFocus() binding?.resultResumeSeriesButton?.requestFocus() } private fun setRecommendations(rec: List?, validApiName: String?) { currentRecommendations = rec ?: emptyList() val isInvalid = rec.isNullOrEmpty() binding?.apply { resultRecommendationsList.isGone = isInvalid resultRecommendationsHolder.isGone = isInvalid val matchAgainst = validApiName ?: rec?.firstOrNull()?.apiName (resultRecommendationsList.adapter as? SearchAdapter)?.submitList(rec?.filter { it.apiName == matchAgainst } ?: emptyList()) rec?.map { it.apiName }?.distinct()?.let { apiNames -> // very dirty selection resultRecommendationsFilterSelection.isVisible = apiNames.size > 1 resultRecommendationsFilterSelection.update(apiNames.map { txt( it ) to it }) resultRecommendationsFilterSelection.select(apiNames.indexOf(matchAgainst)) } ?: run { resultRecommendationsFilterSelection.isVisible = false } } } var loadingDialog: Dialog? = null var popupDialog: Dialog? = null private fun reloadViewModel(forceReload: Boolean) { if (!viewModel.hasLoaded() || forceReload) { val storedData = getStoredData() ?: return viewModel.load( activity, storedData.url, storedData.apiName, storedData.showFillers, storedData.dubStatus, storedData.start ) } } override fun onResume() { activity?.setNavigationBarColorCompat(R.attr.primaryBlackBackground) afterPluginsLoadedEvent += ::reloadViewModel super.onResume() } override fun onStop() { afterPluginsLoadedEvent -= ::reloadViewModel super.onStop() } private fun View.fade(turnVisible: Boolean) { if (turnVisible) { isVisible = true } this.animate().alpha(if (turnVisible) 0.97f else 0.0f).apply { duration = 200 interpolator = DecelerateInterpolator() setListener(object : Animator.AnimatorListener { override fun onAnimationStart(animation: Animator) { } override fun onAnimationEnd(animation: Animator) { this@fade.isVisible = turnVisible } override fun onAnimationCancel(animation: Animator) { } override fun onAnimationRepeat(animation: Animator) { } }) } this.animate().translationX(if (turnVisible) 0f else if (isRtl()) -100.0f else 100f).apply { duration = 200 interpolator = DecelerateInterpolator() } } private fun toggleEpisodes(show: Boolean) { binding?.apply { if (show) { activity?.attachBackPressedCallback(this@ResultFragmentTv.toString()) { toggleEpisodes(false) } } else { activity?.detachBackPressedCallback(this@ResultFragmentTv.toString()) } episodesShadow.fade(show) episodeHolderTv.fade(show) if (episodesShadow.isRtl()) { episodesShadowBackground.scaleX = -1f } else { episodesShadowBackground.scaleX = 1f } } } override fun fixLayout(view: View) { fixSystemBarsPadding(view, padTop = false) } @SuppressLint("SetTextI18n") override fun onBindingCreated(binding: FragmentResultTvBinding) { // ===== setup ===== val storedData = getStoredData() ?: return activity?.window?.decorView?.clearFocus() activity?.loadCache() hideKeyboard() if (storedData.restart || !viewModel.hasLoaded()) viewModel.load( activity, storedData.url, storedData.apiName, storedData.showFillers, storedData.dubStatus, storedData.start ) // ===== ===== ===== var comingSoon = false binding.apply { //episodesShadow.rotationX = 180.0f//if(episodesShadow.isRtl()) 180.0f else 0.0f // parallax on background resultFinishLoading.setOnScrollChangeListener(NestedScrollView.OnScrollChangeListener { view, _, scrollY, _, oldScrollY -> backgroundPosterHolder.translationY = -scrollY.toFloat() * 0.8f }) redirectToPlay.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) return@setOnFocusChangeListener toggleEpisodes(false) binding.apply { val views = listOf( resultPlayMovieButton, resultPlaySeriesButton, resultResumeSeriesButton, resultPlayTrailerButton, resultBookmarkButton, resultFavoriteButton, resultSubscribeButton, resultSearchButton ) for (requestView in views) { if (!requestView.isVisible) continue if (requestView.requestFocus()) break } } } redirectToEpisodes.setOnFocusChangeListener { _, hasFocus -> if (!hasFocus) return@setOnFocusChangeListener toggleEpisodes(true) binding.apply { val views = listOf( resultDubSelection, resultSeasonSelection, resultRangeSelection, resultEpisodes, resultPlayTrailerButton, ) for (requestView in views) { if (!requestView.isShown) continue if (requestView.requestFocus()) break // View.FOCUS_RIGHT } } } mapOf( resultPlayMovieButton to resultPlayMovieText, resultPlaySeriesButton to resultPlaySeriesText, resultResumeSeriesButton to resultResumeSeriesText, resultPlayTrailerButton to resultPlayTrailerText, resultBookmarkButton to resultBookmarkText, resultFavoriteButton to resultFavoriteText, resultSubscribeButton to resultSubscribeText, resultSearchButton to resultSearchText, resultEpisodesShowButton to resultEpisodesShowText ).forEach { (button, text) -> button.setOnFocusChangeListener { view, hasFocus -> if (!hasFocus) { text.isSelected = false if (view.id == R.id.result_episodes_show_button) toggleEpisodes(false) return@setOnFocusChangeListener } text.isSelected = true if (button.tag == context?.getString(R.string.tv_no_focus_tag)) { resultFinishLoading.scrollTo(0, 0) } when (button.id) { R.id.result_episodes_show_button -> { toggleEpisodes(true) } else -> { toggleEpisodes(false) } } } } resultEpisodesShowButton.setOnClickListener { // toggle, to make it more touch accessible just in case someone thinks that a // tv layout is better but is using a touch device toggleEpisodes(!episodeHolderTv.isVisible) } resultEpisodes.setLinearListLayout( isHorizontal = false, nextUp = FOCUS_SELF, nextDown = FOCUS_SELF, nextRight = FOCUS_SELF, ) resultDubSelection.setLinearListLayout( isHorizontal = false, nextUp = FOCUS_SELF, nextDown = FOCUS_SELF, ) resultRangeSelection.setLinearListLayout( isHorizontal = false, nextUp = FOCUS_SELF, nextDown = FOCUS_SELF, ) resultSeasonSelection.setLinearListLayout( isHorizontal = false, nextUp = FOCUS_SELF, nextDown = FOCUS_SELF, ) /*.layoutManager = LinearListLayout(resultEpisodes.context, resultEpisodes.isRtl()).apply { setVertical() }*/ resultReloadConnectionerror.setOnClickListener { viewModel.load( activity, storedData.url, storedData.apiName, storedData.showFillers, storedData.dubStatus, storedData.start ) } resultMetaSite.isFocusable = false resultSeasonSelection.setAdapter() resultRangeSelection.setAdapter() resultDubSelection.setAdapter() resultRecommendationsFilterSelection.setAdapter() resultCastItems.setOnFocusChangeListener { _, hasFocus -> // Always escape focus if (hasFocus) binding.resultBookmarkButton.requestFocus() } //resultBack.setOnClickListener { // activity?.popCurrentPage() //} resultRecommendationsList.spanCount = 8 resultRecommendationsList.setRecycledViewPool(SearchAdapter.sharedPool) resultRecommendationsList.adapter = SearchAdapter( resultRecommendationsList, ) { callback -> if (callback.action == SEARCH_ACTION_FOCUSED) { toggleEpisodes(false) } else SearchHelper.handleSearchClickCallback(callback) } resultEpisodes.setRecycledViewPool(EpisodeAdapter.sharedPool) resultEpisodes.adapter = EpisodeAdapter( false, { episodeClick -> viewModel.handleAction(episodeClick) }, { downloadClickEvent -> DownloadButtonSetup.handleDownloadClick(downloadClickEvent) } ) resultCastItems.layoutManager = object : LinearListLayout(root.context) { override fun onRequestChildFocus( parent: RecyclerView, state: RecyclerView.State, child: View, focused: View? ): Boolean { // Make the cast always focus the first visible item when focused // from somewhere else. Otherwise it jumps to the last item. return if (parent.focusedChild == null) { scrollToPosition(this.findFirstCompletelyVisibleItemPosition()) true } else { super.onRequestChildFocus(parent, state, child, focused) } } }.apply { setHorizontal() } val aboveCast = listOf( binding.resultEpisodesShow, binding.resultBookmark, binding.resultFavorite, binding.resultSubscribe, ).firstOrNull { it.isVisible } resultCastItems.setRecycledViewPool(ActorAdaptor.sharedPool) resultCastItems.adapter = ActorAdaptor(aboveCast?.id) { toggleEpisodes(false) } if (isLayout(EMULATOR)) { episodesShadow.setOnClickListener { toggleEpisodes(false) } } } observeNullable(viewModel.resumeWatching) { resume -> binding.apply { if (resume == null) { return@observeNullable } resultResumeSeries.isVisible = true resultPlayMovie.isVisible = false resultPlaySeries.isVisible = false // show progress no matter if series or movie resume.progress?.let { progress -> resultResumeSeriesTitle.apply { isVisible = !resume.isMovie text = if (resume.isMovie) null else context?.getNameFull( resume.result.name, resume.result.episode, resume.result.season ) } resultResumeSeriesProgressText.setText(progress.progressLeft) resultResumeSeriesProgress.apply { isVisible = true this.max = progress.maxProgress this.progress = progress.progress } resultResumeProgressHolder.isVisible = true } ?: run { resultResumeProgressHolder.isVisible = false } focusPlayButton() // Stops last button right focus if it is a movie if (resume.isMovie) resultSearchButton.nextFocusRightId = R.id.result_search_Button resultResumeSeriesText.text = when { resume.isMovie -> context?.getString(R.string.resume) resume.result.season != null -> "${getString(R.string.season_short)}${resume.result.season}:${ getString( R.string.episode_short ) }${resume.result.episode}" else -> "${getString(R.string.episode)} ${resume.result.episode}" } resultResumeSeriesButton.setOnClickListener { viewModel.handleAction( EpisodeClickEvent( storedData.playerAction, //?: ACTION_PLAY_EPISODE_IN_PLAYER, resume.result ) ) } resultResumeSeriesButton.setOnLongClickListener { viewModel.handleAction( EpisodeClickEvent(ACTION_SHOW_OPTIONS, resume.result) ) return@setOnLongClickListener true } } } observe(viewModel.trailers) { trailersLinks -> context?.updateHasTrailers() if (!LoadResponse.isTrailersEnabled) return@observe val extractedTrailerLinks = trailersLinks.flatMap { it.mirros } .map { (extractedTrailerLink, _) -> extractedTrailerLink } binding.apply { resultPlayTrailer.isGone = extractedTrailerLinks.isEmpty() resultPlayTrailerButton.setOnClickListener { if (extractedTrailerLinks.isEmpty()) return@setOnClickListener activity.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( ExtractorLinkGenerator( extractedTrailerLinks, emptyList() ) ) ) } } } observe(viewModel.watchStatus) { watchType -> binding.apply { resultBookmarkText.setText(watchType.stringRes) resultBookmarkButton.apply { val drawable = if (watchType.stringRes == R.string.type_none) { R.drawable.outline_bookmark_add_24 } else R.drawable.ic_baseline_bookmark_24 setIconResource(drawable) setOnClickListener { view -> activity?.showBottomDialog( WatchType.entries.map { view.context.getString(it.stringRes) }.toList(), watchType.ordinal, view.context.getString(R.string.action_add_to_bookmarks), showApply = false, {}) { viewModel.updateWatchStatus(WatchType.entries[it], context) } } } } } observeNullable(viewModel.favoriteStatus) { isFavorite -> binding.resultFavorite.isVisible = isFavorite != null binding.resultFavoriteButton.apply { if (isFavorite == null) return@observeNullable val drawable = if (isFavorite) { R.drawable.ic_baseline_favorite_24 } else R.drawable.ic_baseline_favorite_border_24 setIconResource(drawable) setOnClickListener { viewModel.toggleFavoriteStatus(context) { newStatus: Boolean? -> if (newStatus == null) return@toggleFavoriteStatus val message = if (newStatus) { R.string.favorite_added } else R.string.favorite_removed val name = (viewModel.page.value as? Resource.Success)?.value?.title ?: txt(R.string.no_data) .asStringNull(context) ?: "" CommonActivity.showToast( txt( message, name ), Toast.LENGTH_SHORT ) } } } binding.resultFavoriteText.apply { val text = if (isFavorite == true) { R.string.unfavorite } else R.string.favorite setText(text) } } observeNullable(viewModel.subscribeStatus) { isSubscribed -> binding.resultSubscribe.isVisible = isSubscribed != null && isLayout(EMULATOR) binding.resultSubscribeButton.apply { if (isSubscribed == null) return@observeNullable val drawable = if (isSubscribed) { R.drawable.ic_baseline_notifications_active_24 } else R.drawable.baseline_notifications_none_24 setIconResource(drawable) setOnClickListener { viewModel.toggleSubscriptionStatus(context) { newStatus: Boolean? -> if (newStatus == null) return@toggleSubscriptionStatus val message = if (newStatus) { // Kinda icky to have this here, but it works. SubscriptionWorkManager.enqueuePeriodicWork(context) R.string.subscription_new } else R.string.subscription_deleted val name = (viewModel.page.value as? Resource.Success)?.value?.title ?: txt(R.string.no_data) .asStringNull(context) ?: "" CommonActivity.showToast( txt( message, name ), Toast.LENGTH_SHORT ) } } binding.resultSubscribeText.apply { val text = if (isSubscribed) { R.string.action_unsubscribe } else R.string.action_subscribe setText(text) } } } observeNullable(viewModel.movie) { data -> if (data == null) { return@observeNullable } binding.apply { (data as? Resource.Success)?.value?.let { (_, ep) -> resultPlayMovieButton.setOnClickListener { viewModel.handleAction( EpisodeClickEvent(ACTION_CLICK_DEFAULT, ep) ) } resultPlayMovieButton.setOnLongClickListener { viewModel.handleAction( EpisodeClickEvent(ACTION_SHOW_OPTIONS, ep) ) return@setOnLongClickListener true } resultPlayMovie.isVisible = !comingSoon && resultResumeSeries.isGone if (comingSoon) { resultBookmarkButton.requestFocus() } else resultPlayMovieButton.requestFocus() // Stops last button right focus resultSearchButton.nextFocusRightId = R.id.result_search_Button } } } observeNullable(viewModel.selectPopup) { popup -> if (popup == null) { popupDialog?.dismissSafe(activity) popupDialog = null return@observeNullable } popupDialog?.dismissSafe(activity) popupDialog = activity?.let { act -> val options = popup.getOptions(act) val title = popup.getTitle(act) act.showBottomDialogInstant( options, title, { popupDialog = null popup.callback(null) }, { popupDialog = null popup.callback(it) } ) } } observeNullable(viewModel.loadedLinks) { load -> if (load == null) { loadingDialog?.dismissSafe(activity) loadingDialog = null return@observeNullable } if (loadingDialog?.isShowing != true) { loadingDialog?.dismissSafe(activity) loadingDialog = null } loadingDialog = loadingDialog ?: context?.let { ctx -> val builder = BottomSheetDialog(ctx) builder.setContentView(R.layout.bottom_loading) builder.setOnDismissListener { loadingDialog = null viewModel.cancelLinks() } builder.setCanceledOnTouchOutside(true) builder.show() builder } loadingDialog?.findViewById(R.id.overlay_loading_skip_button)?.apply { if (load.linksLoaded <= 0) { isInvisible = true } else { setOnClickListener { viewModel.skipLoading() } isVisible = true text = "${context.getString(R.string.skip_loading)} (${load.linksLoaded})" } } } observeNullable(viewModel.episodesCountText) { count -> binding.resultEpisodesText.setText(count) } observe(viewModel.selectedRangeIndex) { selected -> binding.resultRangeSelection.select(selected) } observe(viewModel.selectedSeasonIndex) { selected -> binding.resultSeasonSelection.select(selected) } observe(viewModel.selectedDubStatusIndex) { selected -> binding.resultDubSelection.select(selected) } observe(viewModel.rangeSelections) { binding.resultRangeSelection.update(it) } observe(viewModel.dubSubSelections) { binding.resultDubSelection.update(it) } observe(viewModel.seasonSelections) { binding.resultSeasonSelection.update(it) } observe(viewModel.recommendations) { recommendations -> setRecommendations(recommendations, null) } if (isLayout(TV)) { observe(viewModel.episodeSynopsis) { description -> context?.let { ctx -> val builder: AlertDialog.Builder = AlertDialog.Builder(ctx, R.style.AlertDialogCustom) builder.setMessage(description.html()) .setTitle(R.string.synopsis) .setOnDismissListener { viewModel.releaseEpisodeSynopsis() } .show() } } } // Used to request focus the first time the episodes are loaded. var hasLoadedEpisodesOnce = false observeNullable(viewModel.episodes) { episodes -> if (episodes == null) return@observeNullable binding.apply { if (comingSoon) resultBookmarkButton.requestFocus() // resultEpisodeLoading.isVisible = episodes is Resource.Loading if (episodes is Resource.Success) { val lastWatchedIndex = episodes.value.indexOfLast { ep -> ep.getWatchProgress() >= NEXT_WATCH_EPISODE_PERCENTAGE.toFloat() / 100.0f || ep.videoWatchState == VideoWatchState.Watched } val firstUnwatched = episodes.value.getOrElse(lastWatchedIndex + 1) { episodes.value.firstOrNull() } if (firstUnwatched != null) { resultPlaySeriesText.text = when { firstUnwatched.season != null -> "${getString(R.string.season_short)}${firstUnwatched.season}:${ getString( R.string.episode_short ) }${firstUnwatched.episode}" else -> "${getString(R.string.episode)} ${firstUnwatched.episode}" } resultPlaySeriesButton.setOnClickListener { viewModel.handleAction( EpisodeClickEvent( ACTION_CLICK_DEFAULT, firstUnwatched ) ) } resultPlaySeriesButton.setOnLongClickListener { viewModel.handleAction( EpisodeClickEvent(ACTION_SHOW_OPTIONS, firstUnwatched) ) return@setOnLongClickListener true } if (!hasLoadedEpisodesOnce) { hasLoadedEpisodesOnce = true resultPlaySeries.isVisible = resultResumeSeries.isGone && !comingSoon resultEpisodesShow.isVisible = true && !comingSoon resultPlaySeriesButton.requestFocus() } } (resultEpisodes.adapter as? EpisodeAdapter)?.submitList(episodes.value) } } } observeNullable(viewModel.page) { data -> if (data == null) return@observeNullable binding.apply { when (data) { is Resource.Success -> { val d = data.value resultVpn.setText(d.vpnText) resultInfo.setText(d.metaText) resultNoEpisodes.setText(d.noEpisodesFoundText) resultTitle.setText(d.titleText) resultMetaSite.setText(d.apiName) resultMetaType.setText(d.typeText) resultMetaYear.setText(d.yearText) resultMetaDuration.setText(d.durationText) resultMetaRating.setText(d.ratingText) resultMetaStatus.setText(d.onGoingText) resultMetaContentRating.setText(d.contentRatingText) resultCastText.setText(d.actorsText) resultNextAiring.setText(d.nextAiringEpisode) resultNextAiringTime.setText(d.nextAiringDate) resultPoster.loadImage(d.posterImage) var isExpanded = false resultDescription.apply { setTextHtml(d.plotText) setOnClickListener { if (isLayout(EMULATOR)) { isExpanded = !isExpanded maxLines = if (isExpanded) { Integer.MAX_VALUE } else 10 } else { context?.let { ctx -> val builder: AlertDialog.Builder = AlertDialog.Builder(ctx, R.style.AlertDialogCustom) builder.setMessage(d.plotText.asString(ctx).html()) .setTitle(d.plotHeaderText.asString(ctx)) .show() } } } } val error = listOf( R.drawable.profile_bg_dark_blue, R.drawable.profile_bg_blue, R.drawable.profile_bg_orange, R.drawable.profile_bg_pink, R.drawable.profile_bg_purple, R.drawable.profile_bg_red, R.drawable.profile_bg_teal ).random() backgroundPoster.loadImage(d.posterBackgroundImage) { error { getImageFromDrawable(context ?: return@error null, error) } } bindLogo( url = d.logoUrl, headers = d.posterHeaders, titleView = resultTitle, logoView = backgroundPosterWatermarkBadgeHolder ) comingSoon = d.comingSoon resultTvComingSoon.isVisible = d.comingSoon populateChips(resultTag, d.tags) val prefs = androidx.preference.PreferenceManager.getDefaultSharedPreferences(root.context) val showCast = prefs.getBoolean(root.context.getString(R.string.show_cast_in_details_key), true) resultCastItems.isGone = !showCast || d.actors.isNullOrEmpty() (resultCastItems.adapter as? ActorAdaptor)?.submitList(if (showCast) d.actors else emptyList()) if (d.contentRatingText == null) { // If there is no rating to display, we don't want an empty gap resultMetaContentRating.width = 0 } resultSearchButton.setOnClickListener { QuickSearchFragment.pushSearch(activity, d.title) } } is Resource.Loading -> {} is Resource.Failure -> { resultErrorText.text = storedData.url.plus("\n") + data.errorString } } resultFinishLoading.isVisible = data is Resource.Success resultLoading.isVisible = data is Resource.Loading resultLoadingError.isVisible = data is Resource.Failure //resultReloadConnectionOpenInBrowser.isVisible = data is Resource.Failure } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultTrailerPlayer.kt ================================================ package com.lagradost.cloudstream3.ui.result import android.animation.ValueAnimator import android.content.Context import android.content.res.Configuration import android.os.Build import android.os.Bundle import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.ViewCompat import com.lagradost.cloudstream3.CommonActivity.screenHeight import com.lagradost.cloudstream3.CommonActivity.screenWidth import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.player.CSPlayerEvent import com.lagradost.cloudstream3.ui.player.PlayerEventSource import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding open class ResultTrailerPlayer : ResultFragmentPhone() { override var lockRotation = false override var isFullScreenPlayer = false override var hasPipModeSupport = false companion object { const val TAG = "RESULT_TRAILER" } private var playerWidthHeight: Pair? = null override fun nextEpisode() {} override fun prevEpisode() {} override fun playerPositionChanged(position: Long, duration : Long) {} override fun nextMirror() {} override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) uiReset() fixPlayerSize() } private fun fixPlayerSize() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { binding?.apply { if (isFullScreenPlayer) { // Remove listener ViewCompat.setOnApplyWindowInsetsListener(root, null) root.overlay.clear() // Clear the cutout overlay root.setPadding(0, 0, 0, 0) // Reset padding for full screen } else { // Reapply padding when not in full screen fixSystemBarsPadding(root) ViewCompat.requestApplyInsets(root) } } } playerWidthHeight?.let { (w, h) -> if(w <= 0 || h <= 0) return@let val orientation = context?.resources?.configuration?.orientation ?: return val sw = if (orientation == Configuration.ORIENTATION_LANDSCAPE) { screenWidth } else { screenHeight } //result_trailer_loading?.isVisible = false resultBinding?.resultSmallscreenHolder?.isVisible = !isFullScreenPlayer binding?.resultFullscreenHolder?.isVisible = isFullScreenPlayer val to = sw * h / w resultBinding?.fragmentTrailer?.playerBackground?.apply { isVisible = true layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, if (isFullScreenPlayer) FrameLayout.LayoutParams.MATCH_PARENT else to ) } playerBinding?.playerIntroPlay?.apply { layoutParams = FrameLayout.LayoutParams( FrameLayout.LayoutParams.MATCH_PARENT, resultBinding?.resultTopHolder?.measuredHeight ?: FrameLayout.LayoutParams.MATCH_PARENT ) } if (playerBinding?.playerIntroPlay?.isGone == true) { resultBinding?.resultTopHolder?.apply { val anim = ValueAnimator.ofInt( measuredHeight, if (isFullScreenPlayer) ViewGroup.LayoutParams.MATCH_PARENT else to ) anim.addUpdateListener { valueAnimator -> val `val` = valueAnimator.animatedValue as Int val layoutParams: ViewGroup.LayoutParams = layoutParams layoutParams.height = `val` setLayoutParams(layoutParams) } anim.duration = 200 anim.start() } } } } override fun playerDimensionsLoaded(width: Int, height : Int) { playerWidthHeight = width to height fixPlayerSize() } override fun showMirrorsDialogue() {} override fun showTracksDialogue() {} override fun openOnlineSubPicker( context: Context, loadResponse: LoadResponse?, dismissCallback: () -> Unit ) { } override fun subtitlesChanged() {} override fun embeddedSubtitlesFetched(subtitles: List) {} override fun onTracksInfoChanged() {} override fun exitedPipMode() {} private fun updateFullscreen(fullscreen: Boolean) { isFullScreenPlayer = fullscreen lockRotation = fullscreen playerBinding?.playerFullscreen?.setImageResource(if (fullscreen) R.drawable.baseline_fullscreen_exit_24 else R.drawable.baseline_fullscreen_24) if (fullscreen) { enterFullscreen() binding?.apply { resultTopBar.isVisible = false resultFullscreenHolder.isVisible = true resultMainHolder.isVisible = false } resultBinding?.fragmentTrailer?.playerBackground?.let { view -> (view.parent as ViewGroup?)?.removeView(view) binding?.resultFullscreenHolder?.addView(view) } } else { binding?.apply { resultTopBar.isVisible = true resultFullscreenHolder.isVisible = false resultMainHolder.isVisible = true resultBinding?.fragmentTrailer?.playerBackground?.let { view -> (view.parent as ViewGroup?)?.removeView(view) resultBinding?.resultSmallscreenHolder?.addView(view) } } exitFullscreen() } fixPlayerSize() uiReset() if (isFullScreenPlayer) { activity?.attachBackPressedCallback("ResultTrailerPlayer") { updateFullscreen(false) } } else activity?.detachBackPressedCallback("ResultTrailerPlayer") } override fun updateUIVisibility() { super.updateUIVisibility() playerBinding?.playerGoBackHolder?.isVisible = false } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) playerBinding?.playerFullscreen?.setOnClickListener { updateFullscreen(!isFullScreenPlayer) } updateFullscreen(isFullScreenPlayer) uiReset() playerBinding?.playerIntroPlay?.setOnClickListener { playerBinding?.playerIntroPlay?.isGone = true player.handleEvent(CSPlayerEvent.Play, PlayerEventSource.UI) updateUIVisibility() fixPlayerSize() } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/result/ResultViewModel2.kt ================================================ package com.lagradost.cloudstream3.ui.result import android.app.Activity import android.content.* import android.util.Log import android.widget.Toast import androidx.annotation.MainThread import androidx.appcompat.app.AlertDialog import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.actions.AlwaysAskAction import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.APIHolder.unixTime import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.getCastSession import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.LoadResponse.Companion.addTrailer import com.lagradost.cloudstream3.LoadResponse.Companion.getAniListId import com.lagradost.cloudstream3.LoadResponse.Companion.getKitsuId import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId import com.lagradost.cloudstream3.LoadResponse.Companion.isMovie import com.lagradost.cloudstream3.LoadResponse.Companion.readIdFromString import com.lagradost.cloudstream3.metaproviders.SyncRedirector import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.debugException import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.safeApiCall import com.lagradost.cloudstream3.runAllAsync import com.lagradost.cloudstream3.sortUrls import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.secondsToReadable import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.syncproviders.providers.Kitsu import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager import com.lagradost.cloudstream3.ui.player.GeneratorPlayer import com.lagradost.cloudstream3.ui.player.IGenerator import com.lagradost.cloudstream3.ui.player.LOADTYPE_ALL import com.lagradost.cloudstream3.ui.player.LOADTYPE_CHROMECAST import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP_DOWNLOAD import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.result.EpisodeAdapter.Companion.getPlayerAction import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.isConnectedToChromecast import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.CastHelper.startCast import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.ioWork import com.lagradost.cloudstream3.utils.Coroutines.ioWorkSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.DataStore import com.lagradost.cloudstream3.utils.DataStore.editor import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.DataStoreHelper.deleteBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllFavorites import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllSubscriptions import com.lagradost.cloudstream3.utils.DataStoreHelper.getBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getDub import com.lagradost.cloudstream3.utils.DataStoreHelper.getFavoritesData import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultEpisode import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultSeason import com.lagradost.cloudstream3.utils.DataStoreHelper.getResultWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getSubscribedData import com.lagradost.cloudstream3.utils.DataStoreHelper.getVideoWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.getViewPos import com.lagradost.cloudstream3.utils.DataStoreHelper.removeFavoritesData import com.lagradost.cloudstream3.utils.DataStoreHelper.removeSubscribedData import com.lagradost.cloudstream3.utils.DataStoreHelper.setBookmarkedData import com.lagradost.cloudstream3.utils.DataStoreHelper.setDub import com.lagradost.cloudstream3.utils.DataStoreHelper.setFavoritesData import com.lagradost.cloudstream3.utils.DataStoreHelper.setResultEpisode import com.lagradost.cloudstream3.utils.DataStoreHelper.setResultSeason import com.lagradost.cloudstream3.utils.DataStoreHelper.setResultWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.setSubscribedData import com.lagradost.cloudstream3.utils.DataStoreHelper.setVideoWatchState import com.lagradost.cloudstream3.utils.DataStoreHelper.updateSubscribedData import com.lagradost.cloudstream3.utils.Editor import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.FillerEpisodeCheck import com.lagradost.cloudstream3.utils.INFER_TYPE import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.VIDEO_WATCH_STATE import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadEpisodeMetadata import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle import com.lagradost.cloudstream3.utils.loadExtractor import com.lagradost.cloudstream3.utils.newExtractorLink import com.lagradost.cloudstream3.utils.txt import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.isActive import kotlinx.coroutines.job import kotlinx.coroutines.launch import java.util.concurrent.TimeUnit /** This starts at 1 */ data class EpisodeRange( // used to index data val startIndex: Int, val length: Int, // used to display data val startEpisode: Int, val endEpisode: Int, ) data class AutoResume( val season: Int?, val episode: Int?, val id: Int?, val startAction: Int, ) data class ResultData( val url: String, val tags: List, val actors: List?, val actorsText: UiText?, val comingSoon: Boolean, val backgroundPosterUrl: String?, val title: String, var syncData: Map, val posterImage: String?, val posterBackgroundImage: String?, val logoUrl: String?, val plotText: UiText, val apiName: UiText, val ratingText: UiText?, val contentRatingText: UiText?, val vpnText: UiText?, val metaText: UiText?, val durationText: UiText?, val onGoingText: UiText?, val noEpisodesFoundText: UiText?, val titleText: UiText, val typeText: UiText, val yearText: UiText?, val nextAiringDate: UiText?, val nextAiringEpisode: UiText?, val plotHeaderText: UiText, val posterHeaders: Map? = null, ) data class CheckDuplicateData( val name: String, val year: Int?, val syncData: Map? ) enum class LibraryListType { BOOKMARKS, FAVORITES, SUBSCRIPTIONS } enum class EpisodeSortType { NUMBER_ASC, NUMBER_DESC, RATING_HIGH_LOW, RATING_LOW_HIGH, DATE_NEWEST, DATE_OLDEST } fun txt(status: DubStatus?): UiText? { return txt( when (status) { DubStatus.Dubbed -> R.string.app_dubbed_text DubStatus.Subbed -> R.string.app_subbed_text else -> null } ) } fun LoadResponse.toResultData(repo: APIRepository): ResultData { debugAssert({ repo.name != apiName }) { "Api returned wrong apiName" } val hasActorImages = actors?.firstOrNull()?.actor?.image?.isNotBlank() == true var nextAiringEpisode: UiText? = null var nextAiringDate: UiText? = null if (this is EpisodeResponse) { val airing = this.nextAiring if (airing != null && airing.unixTime > unixTime) { val seconds = airing.unixTime - unixTime val days = TimeUnit.SECONDS.toDays(seconds) val hours: Long = TimeUnit.SECONDS.toHours(seconds) - days * 24 val minute = TimeUnit.SECONDS.toMinutes(seconds) - TimeUnit.SECONDS.toHours(seconds) * 60 nextAiringDate = when { days > 0 -> { txt( R.string.next_episode_time_day_format, days, hours, minute ) } hours > 0 -> txt( R.string.next_episode_time_hour_format, hours, minute ) minute > 0 -> txt( R.string.next_episode_time_min_format, minute ) else -> null }?.also { nextAiringEpisode = when (airing.season) { null -> txt(R.string.next_episode_format, airing.episode) else -> txt(R.string.next_season_episode_format, airing.season, airing.episode) } } } } val dur = duration return ResultData( syncData = syncData, plotHeaderText = txt( when (this.type) { TvType.Torrent -> R.string.torrent_plot else -> R.string.result_plot } ), nextAiringDate = nextAiringDate, nextAiringEpisode = nextAiringEpisode, posterImage = posterUrl ?: backgroundPosterUrl, posterHeaders = posterHeaders, posterBackgroundImage = backgroundPosterUrl ?: posterUrl, titleText = txt(name), url = url, tags = tags ?: emptyList(), comingSoon = comingSoon, actors = if (hasActorImages) actors else null, actorsText = if (hasActorImages || actors.isNullOrEmpty()) null else txt( R.string.cast_format, actors?.joinToString { it.actor.name }), plotText = if (plot.isNullOrBlank()) txt(if (this is TorrentLoadResponse) R.string.torrent_no_plot else R.string.normal_no_plot) else txt( plot!! ), backgroundPosterUrl = backgroundPosterUrl, logoUrl = logoUrl, title = name, typeText = txt( when (type) { TvType.TvSeries -> R.string.tv_series_singular TvType.Anime -> R.string.anime_singular TvType.OVA -> R.string.ova_singular TvType.AnimeMovie -> R.string.movies_singular TvType.Cartoon -> R.string.cartoons_singular TvType.Documentary -> R.string.documentaries_singular TvType.Movie -> R.string.movies_singular TvType.Torrent -> R.string.torrent_singular TvType.AsianDrama -> R.string.asian_drama_singular TvType.Live -> R.string.live_singular TvType.Others -> R.string.other_singular TvType.NSFW -> R.string.nsfw_singular TvType.Music -> R.string.music_singlar TvType.AudioBook -> R.string.audio_book_singular TvType.CustomMedia -> R.string.custom_media_singluar TvType.Audio -> R.string.audio_singluar TvType.Podcast -> R.string.podcast_singluar } ), yearText = txt(year?.toString()), apiName = txt(apiName), ratingText = score?.toStringNull(0.1, 10, 1, false, '.') ?.let { txt(R.string.rating_format, it) }, contentRatingText = txt(contentRating), vpnText = txt( when (repo.vpnStatus) { VPNStatus.None -> null VPNStatus.Torrent -> R.string.vpn_torrent VPNStatus.MightBeNeeded -> R.string.vpn_might_be_needed } ), metaText = if (repo.providerType == ProviderType.MetaProvider) txt(R.string.provider_info_meta) else null, durationText = if (dur == null || dur <= 0) null else txt( secondsToReadable(dur * 60, "0 mins") ), onGoingText = if (this is EpisodeResponse) { txt( when (showStatus) { ShowStatus.Ongoing -> R.string.status_ongoing ShowStatus.Completed -> R.string.status_completed else -> null } ) } else null, noEpisodesFoundText = if ((this is TvSeriesLoadResponse && this.episodes.isEmpty()) || (this is AnimeLoadResponse && !this.episodes.any { it.value.isNotEmpty() })) txt( R.string.no_episodes_found ) else null ) } data class ExtractorSubtitleLink( val name: String, override val url: String, override val referer: String, override val headers: Map = mapOf() ) : IDownloadableMinimum fun LoadResponse.getId(): Int { // this fixes an issue with outdated api as getLoadResponseIdFromUrl might be fucked return (if (this is ResultViewModel2.LoadResponseFromSearch) this.id else null) ?: getLoadResponseIdFromUrl(uniqueUrl, apiName) } private fun getLoadResponseIdFromUrl(url: String, apiName: String): Int { return url.replace(getApiFromNameNull(apiName)?.mainUrl ?: "", "").replace("/", "") .hashCode() } data class LinkProgress( val linksLoaded: Int, val subsLoaded: Int, ) data class ResumeProgress( val progress: Int, val maxProgress: Int, val progressLeft: UiText, ) data class ResumeWatchingStatus( val progress: ResumeProgress?, val isMovie: Boolean, val result: ResultEpisode, ) data class LinkLoadingResult( val links: List, val subs: List, val syncData: HashMap ) sealed class SelectPopup { data class SelectText( val text: UiText, val options: List, val callback: (Int?) -> Unit ) : SelectPopup() data class SelectArray( val text: UiText, val options: List>, val callback: (Int?) -> Unit ) : SelectPopup() } fun SelectPopup.callback(index: Int?) { val ret = transformResult(index) return when (this) { is SelectPopup.SelectArray -> callback(ret) is SelectPopup.SelectText -> callback(ret) } } fun SelectPopup.transformResult(input: Int?): Int? { if (input == null) return null return when (this) { is SelectPopup.SelectArray -> options.getOrNull(input)?.second is SelectPopup.SelectText -> input } } fun SelectPopup.getTitle(context: Context): String { return when (this) { is SelectPopup.SelectArray -> text.asString(context) is SelectPopup.SelectText -> text.asString(context) } } fun SelectPopup.getOptions(context: Context): List { return when (this) { is SelectPopup.SelectArray -> { this.options.map { it.first.asString(context) } } is SelectPopup.SelectText -> options.map { it.asString(context) } } } data class ExtractedTrailerData( var mirros: List>,//Pair of extracted trailer link and original trailer link var subtitles: List = emptyList(), ) class ResultViewModel2 : ViewModel() { private var currentResponse: LoadResponse? = null var EPISODE_RANGE_SIZE: Int = 20 fun clear() { currentResponse = null _page.postValue(null) } data class EpisodeIndexer( val dubStatus: DubStatus, val season: Int, ) /** map>> */ private var currentEpisodes: Map> = mapOf() private var currentRanges: Map> = mapOf() private var currentSeasons: List = listOf() private var currentDubStatus: List = listOf() private var currentMeta: SyncAPI.SyncResult? = null private var currentSync: Map? = null private var currentIndex: EpisodeIndexer? = null private var currentSorting: EpisodeSortType? = null private var currentRange: EpisodeRange? = null private var currentShowFillers: Boolean = false var currentRepo: APIRepository? = null private var currentId: Int? = null private var fillers: Map = emptyMap() private var generator: IGenerator? = null private var preferDubStatus: DubStatus? = null private var preferStartEpisode: Int? = null private var preferStartSeason: Int? = null //private val currentIsMovie get() = currentResponse?.isEpisodeBased() == false //private val currentHeaderName get() = currentResponse?.name private val _page: MutableLiveData?> = MutableLiveData(null) val page: LiveData?> = _page private val _episodes: MutableLiveData>?> = MutableLiveData(Resource.Loading()) val episodes: LiveData>?> = _episodes private val _movie: MutableLiveData>?> = MutableLiveData(null) val movie: LiveData>?> = _movie private val _episodesCountText: MutableLiveData = MutableLiveData(null) val episodesCountText: LiveData = _episodesCountText private val _trailers: MutableLiveData> = MutableLiveData(mutableListOf()) val trailers: LiveData> = _trailers private val _dubSubSelections: MutableLiveData>> = MutableLiveData(emptyList()) val dubSubSelections: LiveData>> = _dubSubSelections private val _rangeSelections: MutableLiveData>> = MutableLiveData(emptyList()) val rangeSelections: LiveData>> = _rangeSelections private val _seasonSelections: MutableLiveData>> = MutableLiveData(emptyList()) val seasonSelections: LiveData>> = _seasonSelections private val _recommendations: MutableLiveData> = MutableLiveData(emptyList()) val recommendations: LiveData> = _recommendations private val _selectedRange: MutableLiveData = MutableLiveData(null) val selectedRange: LiveData = _selectedRange private val _selectedSorting: MutableLiveData = MutableLiveData(null) val selectedSorting: LiveData = _selectedSorting private val _selectedSortingIndex: MutableLiveData = MutableLiveData(-1) val selectedSortingIndex: LiveData = _selectedSortingIndex private val _sortSelections: MutableLiveData>> = MutableLiveData(emptyList()) val sortSelections: LiveData>> = _sortSelections private val _selectedSeason: MutableLiveData = MutableLiveData(null) val selectedSeason: LiveData = _selectedSeason private val _selectedDubStatus: MutableLiveData = MutableLiveData(null) val selectedDubStatus: LiveData = _selectedDubStatus private val _selectedRangeIndex: MutableLiveData = MutableLiveData(-1) val selectedRangeIndex: LiveData = _selectedRangeIndex private val _selectedSeasonIndex: MutableLiveData = MutableLiveData(-1) val selectedSeasonIndex: LiveData = _selectedSeasonIndex private val _selectedDubStatusIndex: MutableLiveData = MutableLiveData(-1) val selectedDubStatusIndex: LiveData = _selectedDubStatusIndex private val _loadedLinks: MutableLiveData = MutableLiveData(null) val loadedLinks: LiveData = _loadedLinks private val _resumeWatching: MutableLiveData = MutableLiveData(null) val resumeWatching: LiveData = _resumeWatching private val _episodeSynopsis: MutableLiveData = MutableLiveData(null) val episodeSynopsis: LiveData = _episodeSynopsis private val _subscribeStatus: MutableLiveData = MutableLiveData(null) val subscribeStatus: LiveData = _subscribeStatus private val _favoriteStatus: MutableLiveData = MutableLiveData(null) val favoriteStatus: LiveData = _favoriteStatus companion object { const val TAG = "RVM2" //private const val EPISODE_RANGE_SIZE = 20 //private const val EPISODE_RANGE_OVERLOAD = 30 private fun List?.getSeason(season: Int?): SeasonData? { if (season == null) return null return this?.firstOrNull { it.season == season } } fun seasonToTxt(seasonData: SeasonData?, season: Int?): UiText? { if (season == 0) { return txt(R.string.no_season) } // If displaySeason is null then only show the name! return if (seasonData?.name != null && seasonData.displaySeason == null) { txt(seasonData.name) } else { val suffix = seasonData?.name?.let { " $it" } ?: "" txt( R.string.season_format, txt(R.string.season), seasonData?.displaySeason ?: season, suffix ) } } private fun List?.getSeasonTxt(season: Int?): UiText? = seasonToTxt(getSeason(season), season) private fun filterName(name: String?): String? { if (name == null) return null Regex("^[eE]pisode [0-9]*(.*)").find(name)?.groupValues?.get(1)?.let { if (it.isEmpty()) return null } return name } fun singleMap(ep: ResultEpisode): Map> = mapOf( EpisodeIndexer(DubStatus.None, 0) to listOf( ep ) ) private fun getRanges( allEpisodes: Map>, EPISODE_RANGE_SIZE: Int ): Map> { return allEpisodes.keys.mapNotNull { index -> val episodes = allEpisodes[index] ?: return@mapNotNull null // this should never happened // fast case val EPISODE_RANGE_OVERLOAD = EPISODE_RANGE_SIZE + 10 if (episodes.size <= EPISODE_RANGE_OVERLOAD) { return@mapNotNull index to listOf( EpisodeRange( 0, episodes.size, episodes.minOf { it.episode }, episodes.maxOf { it.episode }) ) } if (episodes.isEmpty()) { return@mapNotNull null } val list = mutableListOf() val currentEpisode = episodes.first() var currentIndex = 0 val maxIndex = episodes.size var targetEpisode = 0 var currentMin = currentEpisode.episode var currentMax = currentEpisode.episode while (currentIndex < maxIndex) { val startIndex = currentIndex targetEpisode += EPISODE_RANGE_SIZE while (currentIndex < maxIndex && episodes[currentIndex].episode <= targetEpisode) { val episodeNumber = episodes[currentIndex].episode if (episodeNumber < currentMin) { currentMin = episodeNumber } if (episodeNumber > currentMax) { currentMax = episodeNumber } ++currentIndex } val length = currentIndex - startIndex if (length <= 0) continue list.add( EpisodeRange( startIndex, length, currentMin, currentMax ) ) currentMin = Int.MAX_VALUE currentMax = Int.MIN_VALUE } /*var currentMin = Int.MAX_VALUE var currentMax = Int.MIN_VALUE var currentStartIndex = 0 var currentLength = 0 for (ep in episodes) { val episodeNumber = ep.episode if (episodeNumber < currentMin) { currentMin = episodeNumber } else if (episodeNumber > currentMax) { currentMax = episodeNumber } if (++currentLength >= EPISODE_RANGE_SIZE) { list.add( EpisodeRange( currentStartIndex, currentLength, currentMin, currentMax ) ) currentMin = Int.MAX_VALUE currentMax = Int.MIN_VALUE currentStartIndex += currentLength currentLength = 0 } } if (currentLength > 0) { list.add( EpisodeRange( currentStartIndex, currentLength, currentMin, currentMax ) ) }*/ index to list }.toMap() } } private val _watchStatus: MutableLiveData = MutableLiveData(WatchType.NONE) val watchStatus: LiveData get() = _watchStatus private val _selectPopup: MutableLiveData = MutableLiveData(null) val selectPopup: LiveData = _selectPopup fun updateWatchStatus( status: WatchType, context: Context?, loadResponse: LoadResponse? = null, statusChangedCallback: ((statusChanged: Boolean) -> Unit)? = null ) { val (response, currentId) = loadResponse?.let { load -> (load to load.getId()) } ?: ((currentResponse ?: return) to (currentId ?: return)) val currentStatus = getResultWatchState(currentId) // If the current status is "NONE" and the new status is not "NONE", // fetch the bookmarked data to check for duplicates, otherwise set this // to an empty list, so that we don't show the duplicate warning dialog, // but we still want to update the current bookmark and refresh the data anyway. val bookmarkedData = if (currentStatus == WatchType.NONE && status != WatchType.NONE) { getAllBookmarkedData() } else emptyList() checkAndWarnDuplicates( context, LibraryListType.BOOKMARKS, CheckDuplicateData( name = response.name, year = response.year, syncData = response.syncData, ), bookmarkedData ) { shouldContinue: Boolean, duplicateIds: List -> if (!shouldContinue) return@checkAndWarnDuplicates if (duplicateIds.isNotEmpty()) { duplicateIds.forEach { duplicateId -> deleteBookmarkedData(duplicateId) } } setResultWatchState(currentId, status.internalId) // We don't need to store if WatchType.NONE. // The key is removed in setResultWatchState, we don't want to // re-add it again here if it was just removed. if (status != WatchType.NONE) { val current = getBookmarkedData(currentId) setBookmarkedData( currentId, DataStoreHelper.BookmarkedData( current?.bookmarkedTime ?: unixTimeMS, currentId, unixTimeMS, response.name, response.url, response.apiName, response.type, response.posterUrl, response.year, response.syncData, plot = response.plot, tags = response.tags, score = response.score ) ) } if (currentStatus != status) { MainActivity.bookmarksUpdatedEvent(true) MainActivity.reloadLibraryEvent(true) } _watchStatus.postValue(status) statusChangedCallback?.invoke(true) } } private fun startChromecast( activity: Activity?, result: ResultEpisode, isVisible: Boolean = true ) { if (activity == null) return loadLinks( result, isVisible = isVisible, sourceTypes = LOADTYPE_CHROMECAST, isCasting = true ) { data -> startChromecast(activity, result, data.links, data.subs, 0) } } /** * Toggles the subscription status of an item. * * @param context The context to use for operations. * @param statusChangedCallback A callback that is invoked when the subscription status changes. * It provides the new subscription status (true if subscribed, false if unsubscribed, null if action was canceled). */ fun toggleSubscriptionStatus( context: Context?, statusChangedCallback: ((newStatus: Boolean?) -> Unit)? = null ) { val isSubscribed = _subscribeStatus.value ?: return val response = currentResponse ?: return val currentId = currentId ?: return // This might be a bit confusing, but even if the loadresponse is not a EpisodeResponse // _subscribeStatus might be true. if (isSubscribed) { removeSubscribedData(currentId) statusChangedCallback?.invoke(false) _subscribeStatus.postValue(if (response is EpisodeResponse) false else null) MainActivity.reloadLibraryEvent(true) } else { if (response !is EpisodeResponse) { return } checkAndWarnDuplicates( context, LibraryListType.SUBSCRIPTIONS, CheckDuplicateData( name = response.name, year = response.year, syncData = response.syncData, ), getAllSubscriptions(), ) { shouldContinue: Boolean, duplicateIds: List -> if (!shouldContinue) { statusChangedCallback?.invoke(null) return@checkAndWarnDuplicates } if (duplicateIds.isNotEmpty()) { duplicateIds.forEach { duplicateId -> removeSubscribedData(duplicateId) } } val current = getSubscribedData(currentId) setSubscribedData( currentId, DataStoreHelper.SubscribedData( current?.subscribedTime ?: unixTimeMS, response.getLatestEpisodes(), currentId, unixTimeMS, response.name, response.url, response.apiName, response.type, response.posterUrl, response.year, response.syncData, plot = response.plot, score = response.score, tags = response.tags ) ) _subscribeStatus.postValue(true) statusChangedCallback?.invoke(true) MainActivity.reloadLibraryEvent(true) } } } private fun getMeta( episode: ResultEpisode, titleName: String, apiName: String, currentPoster: String?, currentIsMovie: Boolean, tvType: TvType, ): DownloadObjects.DownloadEpisodeMetadata { return DownloadObjects.DownloadEpisodeMetadata( episode.id, episode.parentId, sanitizeFilename(titleName), apiName, episode.poster ?: currentPoster, episode.name, if (currentIsMovie) null else episode.season, if (currentIsMovie) null else episode.episode, tvType, ) } /** * Toggles the favorite status of an item. * * @param context The context to use. * @param statusChangedCallback A callback that is invoked when the favorite status changes. * It provides the new favorite status (true if added to favorites, false if removed, null if action was canceled). */ fun toggleFavoriteStatus( context: Context?, statusChangedCallback: ((newStatus: Boolean?) -> Unit)? = null ) { val isFavorite = _favoriteStatus.value ?: return val response = currentResponse ?: return val currentId = currentId ?: return if (isFavorite) { removeFavoritesData(currentId) statusChangedCallback?.invoke(false) _favoriteStatus.postValue(false) MainActivity.reloadLibraryEvent(true) } else { checkAndWarnDuplicates( context, LibraryListType.FAVORITES, CheckDuplicateData( name = response.name, year = response.year, syncData = response.syncData, ), getAllFavorites(), ) { shouldContinue: Boolean, duplicateIds: List -> if (!shouldContinue) { statusChangedCallback?.invoke(null) return@checkAndWarnDuplicates } if (duplicateIds.isNotEmpty()) { duplicateIds.forEach { duplicateId -> removeFavoritesData(duplicateId) } } val current = getFavoritesData(currentId) setFavoritesData( currentId, DataStoreHelper.FavoritesData( current?.favoritesTime ?: unixTimeMS, currentId, unixTimeMS, response.name, response.url, response.apiName, response.type, response.posterUrl, response.year, response.syncData, plot = response.plot, score = response.score, tags = response.tags ) ) _favoriteStatus.postValue(true) statusChangedCallback?.invoke(true) MainActivity.reloadLibraryEvent(true) } } } @MainThread private fun checkAndWarnDuplicates( context: Context?, listType: LibraryListType, checkDuplicateData: CheckDuplicateData, data: List, checkDuplicatesCallback: (shouldContinue: Boolean, duplicateIds: List) -> Unit ) { val whitespaceRegex = "\\s+".toRegex() fun normalizeString(input: String): String { /** * Trim the input string and replace consecutive spaces with a single space. * This covers some edge-cases where the title does not match exactly across providers, * and one provider has the title with an extra whitespace. This is minor enough that * it should still match in this case. */ return input.trim().replace(whitespaceRegex, " ") } val syncData = checkDuplicateData.syncData val imdbId = getImdbIdFromSyncData(syncData) val tmdbId = getTMDbIdFromSyncData(syncData) val malId = syncData?.get(AccountManager.malApi.idPrefix) val aniListId = syncData?.get(AccountManager.aniListApi.idPrefix) val normalizedName = normalizeString(checkDuplicateData.name) val year = checkDuplicateData.year val duplicateEntries = data.filter { it: DataStoreHelper.LibrarySearchResponse -> val librarySyncData = it.syncData val yearCheck = year == it.year || year == null || it.year == null val checks = listOf( { imdbId != null && getImdbIdFromSyncData(librarySyncData) == imdbId }, { tmdbId != null && getTMDbIdFromSyncData(librarySyncData) == tmdbId }, { malId != null && librarySyncData?.get(AccountManager.malApi.idPrefix) == malId }, { aniListId != null && librarySyncData?.get(AccountManager.aniListApi.idPrefix) == aniListId }, { normalizedName == normalizeString(it.name) && yearCheck } ) checks.any { it() } } if (duplicateEntries.isEmpty() || context == null) { checkDuplicatesCallback.invoke(true, emptyList()) return } val replaceMessage = if (duplicateEntries.size > 1) { R.string.duplicate_replace_all } else R.string.duplicate_replace val message = if (duplicateEntries.size == 1) { val list = when (listType) { LibraryListType.BOOKMARKS -> getResultWatchState( duplicateEntries[0].id ?: 0 ).stringRes LibraryListType.FAVORITES -> R.string.favorites_list_name LibraryListType.SUBSCRIPTIONS -> R.string.subscription_list_name } context.getString( R.string.duplicate_message_single, "${normalizeString(duplicateEntries[0].name)} (${context.getString(list)}) — ${duplicateEntries[0].apiName}" ) } else { val bulletPoints = duplicateEntries.joinToString("\n") { val list = when (listType) { LibraryListType.BOOKMARKS -> getResultWatchState(it.id ?: 0).stringRes LibraryListType.FAVORITES -> R.string.favorites_list_name LibraryListType.SUBSCRIPTIONS -> R.string.subscription_list_name } "• ${it.apiName}: ${normalizeString(it.name)} (${context.getString(list)})" } context.getString(R.string.duplicate_message_multiple, bulletPoints) } val builder: AlertDialog.Builder = AlertDialog.Builder(context) val dialogClickListener = DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { checkDuplicatesCallback.invoke(true, emptyList()) } DialogInterface.BUTTON_NEGATIVE -> { checkDuplicatesCallback.invoke(false, emptyList()) } DialogInterface.BUTTON_NEUTRAL -> { checkDuplicatesCallback.invoke(true, duplicateEntries.map { it.id }) } } } builder.setTitle(R.string.duplicate_title) .setMessage(message) .setPositiveButton(R.string.duplicate_add, dialogClickListener) .setNegativeButton(R.string.duplicate_cancel, dialogClickListener) .setNeutralButton(replaceMessage, dialogClickListener) .show().setDefaultFocus() } private fun getImdbIdFromSyncData(syncData: Map?): String? { return safe { val imdbId = readIdFromString( syncData?.get(AccountManager.simklApi.idPrefix) )[SimklSyncServices.Imdb] if (imdbId == "null") null else imdbId } } private fun getTMDbIdFromSyncData(syncData: Map?): String? { return safe { val tmdbId = readIdFromString( syncData?.get(AccountManager.simklApi.idPrefix) )[SimklSyncServices.Tmdb] if (tmdbId == "null") null else tmdbId } } private fun startChromecast( activity: Activity?, result: ResultEpisode, links: List, subs: List, startIndex: Int, ) { if (activity == null) return val response = currentResponse ?: return val eps = currentEpisodes[currentIndex ?: return] ?: return // Main needed because getCastSession needs to be on main thread main { activity.getCastSession()?.startCast( response.apiName, response.isMovie(), response.name, response.posterUrl, result.index, eps, links, subs, startTime = result.getRealPosition(), startIndex = startIndex ) } } fun cancelLinks() { currentLoadLinkJob?.cancel() currentLoadLinkJob = null _loadedLinks.postValue(null) } fun postPopup(text: UiText, options: List, callback: suspend (Int?) -> Unit) { _selectPopup.postValue( SelectPopup.SelectText( text, options ) { value -> viewModelScope.launchSafe { _selectPopup.postValue(null) callback.invoke(value) } } ) } @JvmName("postPopupArray") private fun postPopup( text: UiText, options: List>, callback: suspend (Int?) -> Unit ) { _selectPopup.postValue( SelectPopup.SelectArray( text, options, ) { value -> viewModelScope.launchSafe { _selectPopup.postValue(null) callback.invoke(value) } } ) } private fun loadLinks( result: ResultEpisode, isVisible: Boolean, sourceTypes: Set = LOADTYPE_ALL, clearCache: Boolean = false, isCasting: Boolean = false, work: suspend (CoroutineScope.(LinkLoadingResult) -> Unit) ) { currentLoadLinkJob?.cancel() currentLoadLinkJob = ioSafe { val parentJob = this.coroutineContext.job launch { val links = loadLinks( result, isVisible = isVisible, sourceTypes = sourceTypes, clearCache = clearCache, isCasting = isCasting ) // Cancel child = skip link loading // Cancel parent = dismiss dialog if (parentJob.isCancelled) { return@launch } work(links) } } } private var currentLoadLinkJob: Job? = null private fun acquireSingleLink( result: ResultEpisode, sourceTypes: Set, text: UiText, isCasting: Boolean = false, callback: (Pair) -> Unit ) { // TODO Add skip loading here loadLinks(result, isVisible = true, sourceTypes, isCasting = isCasting) { links -> // Could not find a better way to do this //val context = CloudStreamApp.context postPopup( text, links.links.map { txt("${it.name} ${Qualities.getStringByInt(it.quality)}") } /*.amap { val size = it.getVideoSize()?.let { size -> " " + formatFileSize(context, size) } ?: "" txt("${it.name} ${Qualities.getStringByInt(it.quality)}$size") }*/) { callback.invoke(links to (it ?: return@postPopup)) } } } private fun acquireSingleSubtitle( result: ResultEpisode, text: UiText, callback: (Pair) -> Unit, ) { loadLinks(result, isVisible = true) { links -> postPopup( text, links.subs.map { txt(it.name) }) { callback.invoke(links to (it ?: return@postPopup)) } } } fun skipLoading() { currentLoadLinkJob?.cancelChildren() currentLoadLinkJob = null } private suspend fun CoroutineScope.loadLinks( result: ResultEpisode, isVisible: Boolean, sourceTypes: Set = LOADTYPE_ALL, clearCache: Boolean = false, isCasting: Boolean = false ): LinkLoadingResult { val tempGenerator = RepoLinkGenerator(listOf(result)) val links: MutableSet = mutableSetOf() val subs: MutableSet = mutableSetOf() fun updatePage() { if (isVisible && isActive) { _loadedLinks.postValue(LinkProgress(links.size, subs.size)) } } try { updatePage() tempGenerator.generateLinks( clearCache, sourceTypes = sourceTypes, callback = { (link, _) -> if (link != null) { links += link updatePage() } }, subtitleCallback = { sub -> subs += sub updatePage() }, isCasting = isCasting ) } catch (e: CancellationException) { // Do nothing } catch (e: Exception) { logError(e) } finally { _loadedLinks.postValue(null) } return LinkLoadingResult( sortUrls(links), sortSubs(subs), HashMap(currentResponse?.syncData ?: emptyMap()) ) } fun handleAction(click: EpisodeClickEvent) = viewModelScope.launchSafe { handleEpisodeClickEvent(click) } fun releaseEpisodeSynopsis() { _episodeSynopsis.postValue(null) } private fun markEpisodes( editor: Editor, episodeIds: Array, watchState: VideoWatchState ) { val watchStateString = DataStore.mapper.writeValueAsString(watchState) episodeIds.forEach { if (getVideoWatchState(it.toInt()) != watchState) { editor.setKeyRaw( getFolderName("$currentAccount/$VIDEO_WATCH_STATE", it), watchStateString ) } } } private fun getEpisodesIdsBySeason(season: Int): HashMap> { val result = currentEpisodes.entries .asSequence() .filter { it.key.season <= season && it.key.dubStatus == preferDubStatus } .flatMap { entry -> entry.value.asSequence().map { entry.key.season to it.id.toString() } } .groupBy({ it.first }, { it.second }) .mapValues { (_, ids) -> ids.toTypedArray() } .toMap(HashMap()) if (season != 0) { result.remove(0) } return result } private suspend fun handleEpisodeClickEvent(click: EpisodeClickEvent) { when (click.action) { ACTION_SHOW_OPTIONS -> { val options = mutableListOf>() if (activity?.isConnectedToChromecast() == true) { options.addAll( listOf( txt(R.string.episode_action_chromecast_episode) to ACTION_CHROME_CAST_EPISODE, txt(R.string.episode_action_chromecast_mirror) to ACTION_CHROME_CAST_MIRROR, ) ) } options.add(txt(R.string.episode_action_play_in_app) to ACTION_PLAY_EPISODE_IN_PLAYER) options.addAll( listOf( txt(R.string.episode_action_auto_download) to ACTION_DOWNLOAD_EPISODE, txt(R.string.episode_action_download_mirror) to ACTION_DOWNLOAD_MIRROR, txt(R.string.episode_action_download_subtitle) to ACTION_DOWNLOAD_EPISODE_SUBTITLE_MIRROR, txt(R.string.episode_action_reload_links) to ACTION_RELOAD_EPISODE, ) ) options.addAll( VideoClickActionHolder.makeOptionMap(activity, click.data) ) // Do not add mark as watched on movies if (!listOf(TvType.Movie, TvType.AnimeMovie).contains(click.data.tvType)) { val isWatched = getVideoWatchState(click.data.id) == VideoWatchState.Watched val watchedText = if (isWatched) R.string.action_remove_from_watched else R.string.action_mark_as_watched val markUpToText = if (isWatched) R.string.action_remove_mark_watched_up_to_this_episode else R.string.action_mark_watched_up_to_this_episode options.add(txt(watchedText) to ACTION_MARK_AS_WATCHED) options.add(txt(markUpToText) to ACTION_MARK_WATCHED_UP_TO_THIS_EPISODE) } postPopup( txt( activity?.getNameFull( click.data.name, click.data.episode, click.data.season ) ?: "" ), // TODO FIX options ) { result -> handleEpisodeClickEvent( click.copy(action = result ?: return@postPopup) ) } } ACTION_CLICK_DEFAULT -> { activity?.let { ctx -> if (ctx.isConnectedToChromecast()) { handleEpisodeClickEvent( click.copy(action = ACTION_CHROME_CAST_EPISODE) ) } else { val action = getPlayerAction(ctx) handleEpisodeClickEvent( click.copy(action = action) ) } } } ACTION_SHOW_DESCRIPTION -> { _episodeSynopsis.postValue(click.data.description) } /* not implemented, not used ACTION_DOWNLOAD_EPISODE_SUBTITLE -> { loadLinks(click.data, isVisible = false, isCasting = false) { links -> downloadSubtitle(activity,links.subs,) } }*/ ACTION_DOWNLOAD_EPISODE_SUBTITLE_MIRROR -> { val response = currentResponse ?: return acquireSingleSubtitle( click.data, txt(R.string.episode_action_download_subtitle) ) { (links, index) -> downloadSubtitle( activity, links.subs[index], getMeta( click.data, response.name, response.apiName, response.posterUrl, response.isMovie(), response.type ) ) showToast( R.string.download_started, Toast.LENGTH_SHORT ) } } ACTION_SHOW_TOAST -> { showToast(R.string.play_episode_toast, Toast.LENGTH_SHORT) } ACTION_DOWNLOAD_EPISODE -> { val response = currentResponse ?: return DownloadQueueManager.addToQueue( DownloadObjects.DownloadQueueItem( click.data, response.isMovie(), response.name, response.type, response.posterUrl, response.apiName, response.getId(), response.url, ).toWrapper() ) } ACTION_DOWNLOAD_MIRROR -> { val response = currentResponse ?: return acquireSingleLink( click.data, LOADTYPE_INAPP_DOWNLOAD, txt(R.string.episode_action_download_mirror) ) { (result, index) -> DownloadQueueManager.addToQueue( DownloadObjects.DownloadQueueItem( click.data, response.isMovie(), response.name, response.type, response.posterUrl, response.apiName, response.getId(), response.url, listOf(result.links[index]), result.subs, ).toWrapper() ) showToast( R.string.download_started, Toast.LENGTH_SHORT ) } } ACTION_RELOAD_EPISODE -> { ioSafe { loadLinks( click.data, isVisible = false, LOADTYPE_INAPP, clearCache = true ) } showToast( R.string.links_reloaded_toast, Toast.LENGTH_SHORT ) } ACTION_CHROME_CAST_MIRROR -> { acquireSingleLink( click.data, LOADTYPE_CHROMECAST, txt(R.string.episode_action_chromecast_mirror), isCasting = true ) { (result, index) -> startChromecast(activity, click.data, result.links, result.subs, index) } } ACTION_CHROME_CAST_EPISODE -> { startChromecast(activity, click.data) } ACTION_PLAY_EPISODE_IN_PLAYER -> { val list = HashMap(currentResponse?.syncData ?: emptyMap()) generator?.also { it.getAll() // I know kinda shit to iterate all, but it is 100% sure to work ?.indexOfFirst { value -> value is ResultEpisode && value.id == click.data.id } ?.let { index -> if (index >= 0) it.goto(index) } } if (currentResponse?.type == TvType.CustomMedia) { generator?.generateLinks( clearCache = true, LOADTYPE_ALL, callback = {}, subtitleCallback = {}) } else { activity?.navigate( R.id.global_to_navigation_player, GeneratorPlayer.newInstance( generator ?: return, list ) ) } } ACTION_MARK_AS_WATCHED -> { val isWatched = getVideoWatchState(click.data.id) == VideoWatchState.Watched if (isWatched) { setVideoWatchState(click.data.id, VideoWatchState.None) } else { setVideoWatchState(click.data.id, VideoWatchState.Watched) } // Kinda dirty to reload all episodes :( reloadEpisodes() } ACTION_MARK_WATCHED_UP_TO_THIS_EPISODE -> ioSafe { val editor = context?.let { it1 -> editor(it1, false) } if (editor != null) { val (clickSeason, clickEpisode) = click.data.let { (it.season ?: 0) to it.episode } val watchState = if (getVideoWatchState(click.data.id) == VideoWatchState.Watched) VideoWatchState.None else VideoWatchState.Watched val seasons = getEpisodesIdsBySeason(clickSeason) seasons.keys.forEach { currentSeason -> var episodeIds = seasons[currentSeason] ?: emptyArray() if (currentSeason == clickSeason) episodeIds = episodeIds.sliceArray(0 until clickEpisode) markEpisodes(editor, episodeIds, watchState) } editor.apply() reloadEpisodes() } } else -> { val action = VideoClickActionHolder.getActionById(click.action) ?: return // Special handling for AlwaysAskAction - show player selection dialog if (action is AlwaysAskAction) { activity?.let { ctx -> // Show player selection dialog val players = VideoClickActionHolder.getPlayers(ctx) val options = mutableListOf>() // Add internal player option options.add(txt(R.string.episode_action_play_in_app) to ACTION_PLAY_EPISODE_IN_PLAYER) // Add external player options options.addAll(players.filter { it !is AlwaysAskAction }.map { player -> player.name to (VideoClickActionHolder.uniqueIdToId(player.uniqueId()) ?: ACTION_PLAY_EPISODE_IN_PLAYER) }) postPopup( txt(R.string.player_pref), options ) { selectedAction -> if (selectedAction != null) { handleEpisodeClickEvent( click.copy(action = selectedAction) ) } } } return } activity?.setKey("last_click_action", action.uniqueId()) if (action.oneSource) { acquireSingleLink( click.data, action.sourceTypes, action.name ) { (result, index) -> action.runActionSafe( activity, click.data, result, index ) } } else { loadLinks(click.data, isVisible = true, action.sourceTypes) { links -> action.runActionSafe( activity, click.data, links, null ) } } } } } private suspend fun applyMeta( resp: LoadResponse, meta: SyncAPI.SyncResult?, syncs: Map? = null ): Pair { //if (meta == null) return resp to false var updateEpisodes = false val out = resp.apply { Log.i(TAG, "applyMeta") if (meta != null) { duration = duration ?: meta.duration score = score ?: meta.publicScore tags = tags ?: meta.genres plot = if (plot.isNullOrBlank()) meta.synopsis else plot posterUrl = posterUrl ?: meta.posterUrl ?: meta.backgroundPosterUrl actors = actors ?: meta.actors if (this is EpisodeResponse) { nextAiring = nextAiring ?: meta.nextAiring } val realRecommendations = ArrayList() val apiNames = synchronized(apis) { apis.filter { it.name.contains("gogoanime", true) || it.name.contains("9anime", true) }.map { it.name } } meta.recommendations?.forEach { rec -> apiNames.forEach { name -> realRecommendations.add(rec.copy(apiName = name)) } } recommendations = recommendations?.union(realRecommendations)?.toList() ?: realRecommendations } for ((k, v) in syncs ?: emptyMap()) { syncData[k] = v } runAllAsync( { if (this !is AnimeLoadResponse) return@runAllAsync // already exist, no need to run getTracker if (this.getAniListId() != null && this.getKitsuId() != null && this.getMalId() != null) return@runAllAsync val res = APIHolder.getTracker( listOfNotNull( this.engName, this.name, this.japName ).filter { it.length > 2 } .distinct().map { // this actually would be nice if we improved a bit as 3rd season == season 3 == III ect // right now it just removes the dubbed status it.lowercase().replace(Regex("""\(?[ds]ub(bed)?\)?(\s|$)"""), "") .trim() }, TrackerType.getTypes(this.type), this.year ) val kitsuId = AccountManager.kitsuApi.getAnimeIdByTitle(this.name) val ids = arrayOf( AccountManager.malApi.idPrefix to res?.malId?.toString(), AccountManager.aniListApi.idPrefix to res?.aniId, AccountManager.kitsuApi.idPrefix to kitsuId ) if (ids.any { (id, new) -> val current = syncData[id] new != null && current != null && current != new } ) { // getTracker fucked up as it conflicts with current implementation return@runAllAsync } // set all the new data, prioritise old correct data ids.forEach { (id, new) -> new?.let { syncData[id] = syncData[id] ?: it } } // set posters, might fuck up due to headers idk posterUrl = posterUrl ?: res?.image backgroundPosterUrl = backgroundPosterUrl ?: res?.cover logoUrl = logoUrl }, { if (meta == null) return@runAllAsync addTrailer(meta.trailers) }, { if (this !is AnimeLoadResponse) return@runAllAsync val map = Kitsu.getEpisodesDetails( getMalId(), getAniListId(), isResponseRequired = false ) if (map.isNullOrEmpty()) return@runAllAsync updateEpisodes = DubStatus.entries.map { dubStatus -> val current = this.episodes[dubStatus]?.mapIndexed { index, episode -> episode.apply { this.episode = this.episode ?: (index + 1) } }?.sortedBy { it.episode ?: 0 }?.toMutableList() if (current.isNullOrEmpty()) return@map false val episodeNumbers = current.map { ep -> ep.episode!! } var updateCount = 0 map.forEach { (episode, node) -> episodeNumbers.binarySearch(episode).let { index -> current.getOrNull(index)?.let { currentEp -> current[index] = currentEp.apply { updateCount++ this.description = this.description ?: node.description?.en this.name = this.name ?: node.titles?.canonical this.episode = this.episode ?: node.num ?: episodeNumbers[index] this.posterUrl = this.posterUrl ?: node.thumbnail?.original?.url } } } } this.episodes[dubStatus] = current updateCount > 0 }.any { it } }) } return out to updateEpisodes } fun setMeta(meta: SyncAPI.SyncResult, syncs: Map?) { // I dont want to update everything if the metadata is not relevant if (currentMeta == meta && currentSync == syncs) { Log.i(TAG, "setMeta same") return } Log.i(TAG, "setMeta") viewModelScope.launchSafe { currentMeta = meta currentSync = syncs val (value, updateEpisodes) = ioWork { currentResponse?.let { resp -> return@ioWork applyMeta(resp, meta, syncs) } return@ioWork null to null } postSuccessful( value ?: return@launchSafe, currentId ?: return@launchSafe, currentRepo ?: return@launchSafe, updateEpisodes ?: return@launchSafe, false ) } } private suspend fun updateFillers(name: String) { fillers = ioWorkSafe { FillerEpisodeCheck.getFillerEpisodes(name) } ?: emptyMap() } fun changeDubStatus(status: DubStatus) { postEpisodeRange( currentIndex?.copy(dubStatus = status), currentRange, currentSorting ?: DataStoreHelper.resultsSortingMode ) } fun changeRange(range: EpisodeRange) { postEpisodeRange(currentIndex, range, currentSorting ?: DataStoreHelper.resultsSortingMode) } fun changeSeason(season: Int) { postEpisodeRange( currentIndex?.copy(season = season), currentRange, currentSorting ?: DataStoreHelper.resultsSortingMode ) } fun setSort(sortType: EpisodeSortType) { // we only update here as postEpisodeRange might change the sorting mode if it does not fit DataStoreHelper.resultsSortingMode = sortType postEpisodeRange(currentIndex, currentRange, sortType) } private fun getMovie(): ResultEpisode? { return currentEpisodes.entries.firstOrNull()?.value?.firstOrNull()?.let { ep -> val posDur = getViewPos(ep.id) ep.copy(position = posDur?.position ?: 0, duration = posDur?.duration ?: 0) } } private fun getEpisodes( indexer: EpisodeIndexer, range: EpisodeRange, ): List { return currentEpisodes[indexer]?.let { list -> val start = minOf(list.size, range.startIndex) val end = minOf(list.size, start + range.length) list.subList(start, end).map { val posDur = getViewPos(it.id) val watchState = getVideoWatchState(it.id) ?: VideoWatchState.None it.copy( position = posDur?.position ?: 0, duration = posDur?.duration ?: 0, videoWatchState = watchState ) } } ?: emptyList() } private fun getSortedEpisodes( episodes: List, sorting: EpisodeSortType ): List { return when (sorting) { EpisodeSortType.NUMBER_ASC -> episodes.sortedBy { it.episode } EpisodeSortType.NUMBER_DESC -> episodes.sortedByDescending { it.episode } EpisodeSortType.RATING_HIGH_LOW -> episodes.sortedByDescending { it.score?.toDouble() ?: 0.0 } EpisodeSortType.RATING_LOW_HIGH -> episodes.sortedBy { it.score?.toDouble() ?: 0.0 } EpisodeSortType.DATE_NEWEST -> episodes.sortedByDescending { it.airDate } EpisodeSortType.DATE_OLDEST -> episodes.sortedBy { it.airDate } } } private fun postMovie() { val response = currentResponse _episodes.postValue(null) if (response == null) { _movie.postValue(null) return } val text = txt( when (response.type) { TvType.Torrent -> R.string.play_torrent_button TvType.TvSeries -> R.string.play_full_series_button else -> { if (response.type.isLiveStream()) R.string.play_livestream_button else if (response.isMovie()) // this wont break compatibility as you only need to override isMovieType R.string.play_movie_button else null } } ) val data = getMovie() _episodes.postValue(null) if (text == null || data == null) { _movie.postValue(null) } else { _movie.postValue(Resource.Success(text to data)) } } fun reloadEpisodes() { if (currentResponse?.isMovie() == true) { postMovie() } else { _episodes.postValue( Resource.Success( getSortedEpisodes( getEpisodes( currentIndex ?: return, currentRange ?: return, ), currentSorting ?: return ) ) ) _movie.postValue(null) } postResume() } private fun postSubscription(loadResponse: LoadResponse) { val id = loadResponse.getId() val data = getSubscribedData(id) if (loadResponse.isEpisodeBased()) { updateSubscribedData(id, data, loadResponse as? EpisodeResponse) _subscribeStatus.postValue(data != null) } // lets say that we have subscribed, then we must be able to unsubscribe no matter what else if (data != null) { _subscribeStatus.postValue(true) } else _subscribeStatus.postValue(null) } private fun postFavorites(loadResponse: LoadResponse) { val id = loadResponse.getId() val isFavorite = getFavoritesData(id) != null _favoriteStatus.postValue(isFavorite) } private fun shouldEnableSort(type: EpisodeSortType, episodes: List?): Boolean { if (episodes.isNullOrEmpty()) return false return when (type) { EpisodeSortType.NUMBER_ASC, EpisodeSortType.NUMBER_DESC -> true EpisodeSortType.RATING_HIGH_LOW, EpisodeSortType.RATING_LOW_HIGH -> episodes.any { it.score != null } EpisodeSortType.DATE_NEWEST, EpisodeSortType.DATE_OLDEST -> episodes.any { it.airDate != null } } } private fun postEpisodeRange( indexer: EpisodeIndexer?, range: EpisodeRange?, sorting: EpisodeSortType? ) { if (range == null || indexer == null || sorting == null) { return } val ranges = currentRanges[indexer] if (ranges?.contains(range) != true) { // if the current ranges does not include the range then select the range with the closest matching start episode // this usually happens when dub has less episodes then sub -> the range does not exist ranges?.minByOrNull { kotlin.math.abs(it.startEpisode - range.startEpisode) } ?.let { r -> postEpisodeRange(indexer, r, sorting) return } } val isMovie = currentResponse?.isMovie() == true currentIndex = indexer currentRange = range _rangeSelections.postValue(ranges?.map { r -> val text = txt(R.string.episodes_range, r.startEpisode, r.endEpisode) text to r } ?: emptyList()) val size = currentEpisodes[indexer]?.size _episodesCountText.postValue( if (isMovie) null else txt( R.string.episode_format, size, txt(if (size == 1) R.string.episode else R.string.episodes), ) ) _selectedSeasonIndex.postValue( currentSeasons.indexOf(indexer.season) ) _selectedSeason.postValue( if (isMovie || currentSeasons.size <= 1) null else (currentResponse as? EpisodeResponse)?.seasonNames.getSeasonTxt(indexer.season) ) _selectedRangeIndex.postValue( ranges?.indexOf(range) ?: -1 ) _selectedRange.postValue( if (isMovie) null else if ((currentRanges[indexer]?.size ?: 0) > 1) { txt(R.string.episodes_range, range.startEpisode, range.endEpisode) } else { null } ) _selectedDubStatusIndex.postValue( currentDubStatus.indexOf(indexer.dubStatus) ) _selectedDubStatus.postValue( if (isMovie || currentDubStatus.size <= 1) null else txt(indexer.dubStatus) ) currentId?.let { id -> setDub(id, indexer.dubStatus) setResultSeason(id, indexer.season) setResultEpisode(id, range.startEpisode) } preferStartEpisode = range.startEpisode preferStartSeason = indexer.season preferDubStatus = indexer.dubStatus generator = if (isMovie) { getMovie()?.let { RepoLinkGenerator(listOf(it), page = currentResponse) } } else { val episodes = currentEpisodes.filter { it.key.dubStatus == indexer.dubStatus } .toList() .sortedBy { it.first.season } .flatMap { it.second } RepoLinkGenerator(episodes, page = currentResponse) } if (isMovie) { _sortSelections.postValue(emptyList()) _selectedSortingIndex.postValue(-1) _selectedSorting.postValue(null) postMovie() } else { val ret = getEpisodes(indexer, range) if (ret.size <= 1) { // we cant sort on an empty list or a list with only 1 episode _sortSelections.postValue(emptyList()) _selectedSortingIndex.postValue(-1) _selectedSorting.postValue(null) _episodes.postValue(Resource.Success(ret)) } else { val sortOptions = mutableListOf>().apply { // Episode number sorting is always available add(txt(R.string.sort_episodes_number_asc) to EpisodeSortType.NUMBER_ASC) add(txt(R.string.sort_episodes_number_desc) to EpisodeSortType.NUMBER_DESC) // Only add rating options if any episodes have ratings if (shouldEnableSort(EpisodeSortType.RATING_HIGH_LOW, ret)) { add(txt(R.string.sort_episodes_rating_high_low) to EpisodeSortType.RATING_HIGH_LOW) add(txt(R.string.sort_episodes_rating_low_high) to EpisodeSortType.RATING_LOW_HIGH) } // Only add air date options if any episodes have air dates if (shouldEnableSort(EpisodeSortType.DATE_NEWEST, ret)) { add(txt(R.string.sort_episodes_date_newest) to EpisodeSortType.DATE_NEWEST) add(txt(R.string.sort_episodes_date_oldest) to EpisodeSortType.DATE_OLDEST) } } var sortIndex = sortOptions.indexOfFirst { it.second == sorting } // correct the sorting order so if we have a selected that is not possible we just choose the default NUMBER_ASC val correctedSorting = if (sortIndex == -1) { sortIndex = 0 EpisodeSortType.NUMBER_ASC } else { sorting } currentSorting = correctedSorting _sortSelections.postValue(sortOptions) _selectedSortingIndex.postValue(sortIndex) _selectedSorting.postValue( when (correctedSorting) { EpisodeSortType.NUMBER_ASC -> txt(R.string.sort_button_episode, "↑") EpisodeSortType.NUMBER_DESC -> txt(R.string.sort_button_episode, "↓") EpisodeSortType.RATING_HIGH_LOW -> txt(R.string.sort_button_rating, "↓") EpisodeSortType.RATING_LOW_HIGH -> txt(R.string.sort_button_rating, "↑") EpisodeSortType.DATE_NEWEST -> txt(R.string.sort_button_date, "↓") EpisodeSortType.DATE_OLDEST -> txt(R.string.sort_button_date, "↑") } ) _episodes.postValue(Resource.Success(getSortedEpisodes(ret, correctedSorting))) } } } private suspend fun postSuccessful( loadResponse: LoadResponse, mainId: Int, apiRepository: APIRepository, updateEpisodes: Boolean, updateFillers: Boolean, ) { currentId = mainId currentResponse = loadResponse postPage(loadResponse, apiRepository) postSubscription(loadResponse) postFavorites(loadResponse) _watchStatus.postValue(getResultWatchState(mainId)) if (updateEpisodes) postEpisodes(loadResponse, mainId, updateFillers) } private suspend fun postEpisodes( loadResponse: LoadResponse, mainId: Int, updateFillers: Boolean ) { _episodes.postValue(Resource.Loading()) if (updateFillers && loadResponse is AnimeLoadResponse) { updateFillers(loadResponse.name) } val allEpisodes = when (loadResponse) { is AnimeLoadResponse -> { val existingEpisodes = HashSet() val episodes: MutableMap> = mutableMapOf() loadResponse.episodes.map { ep -> val idIndex = ep.key.id for ((index, i) in ep.value.withIndex()) { val episode = i.episode ?: (index + 1) val id = mainId + episode + idIndex * 1_000_000 + (i.season?.times(10_000) ?: 0) val totalIndex = i.season?.let { season -> loadResponse.getTotalEpisodeIndex( episode, season ) } if (!existingEpisodes.contains(id)) { existingEpisodes.add(id) val seasonData = loadResponse.seasonNames.getSeason(i.season) val eps = buildResultEpisode( loadResponse.name, filterName(i.name), i.posterUrl, episode, i.season, if (seasonData != null) seasonData.displaySeason else i.season, i.data, loadResponse.apiName, id, index, i.score, i.description, fillers.getOrDefault(episode, false), loadResponse.type, mainId, totalIndex, airDate = i.date, runTime = i.runTime, seasonData = seasonData, ) val season = eps.seasonIndex ?: 0 val indexer = EpisodeIndexer(ep.key, season) episodes[indexer]?.add(eps) ?: run { episodes[indexer] = mutableListOf(eps) } } } } episodes } is TvSeriesLoadResponse -> { val episodes: MutableMap> = mutableMapOf() val existingEpisodes = HashSet() for ((index, episode) in loadResponse.episodes.sortedBy { (it.season?.times(10_000) ?: 0) + (it.episode ?: 0) }.withIndex()) { val episodeIndex = episode.episode ?: (index + 1) val id = mainId + (episode.season?.times(100_000) ?: 0) + episodeIndex + 1 if (!existingEpisodes.contains(id)) { existingEpisodes.add(id) val seasonData = loadResponse.seasonNames.getSeason(episode.season) val totalIndex = episode.season?.let { season -> loadResponse.getTotalEpisodeIndex( episodeIndex, season ) } val ep = buildResultEpisode( loadResponse.name, filterName(episode.name), episode.posterUrl, episodeIndex, episode.season, if (seasonData != null) seasonData.displaySeason else episode.season, episode.data, loadResponse.apiName, id, index, episode.score, episode.description, null, loadResponse.type, mainId, totalIndex, airDate = episode.date, runTime = episode.runTime, seasonData = seasonData, ) val season = ep.seasonIndex ?: 0 val indexer = EpisodeIndexer(DubStatus.None, season) episodes[indexer]?.add(ep) ?: kotlin.run { episodes[indexer] = mutableListOf(ep) } } } episodes } is MovieLoadResponse -> { singleMap( buildResultEpisode( loadResponse.name, loadResponse.name, null, 0, null, null, loadResponse.dataUrl, loadResponse.apiName, (mainId), // HAS SAME ID 0, null, null, null, loadResponse.type, mainId, null, ) ) } is LiveStreamLoadResponse -> { singleMap( buildResultEpisode( loadResponse.name, loadResponse.name, null, 0, null, null, loadResponse.dataUrl, loadResponse.apiName, (mainId), // HAS SAME ID 0, null, null, null, loadResponse.type, mainId, null ) ) } is TorrentLoadResponse -> { singleMap( buildResultEpisode( loadResponse.name, loadResponse.name, null, 0, null, null, loadResponse.torrent ?: loadResponse.magnet ?: "", loadResponse.apiName, (mainId), // HAS SAME ID 0, null, null, null, loadResponse.type, mainId, null ) ) } else -> { mapOf() } } val seasonsSelection = mutableSetOf() val dubSelection = mutableSetOf() allEpisodes.keys.forEach { key -> seasonsSelection += key.season dubSelection += key.dubStatus } currentDubStatus = dubSelection.toList() currentSeasons = seasonsSelection.toList() _dubSubSelections.postValue(dubSelection.map { txt(it) to it }) if (loadResponse is EpisodeResponse) { _seasonSelections.postValue(seasonsSelection.map { seasonNumber -> loadResponse.seasonNames.getSeasonTxt(seasonNumber) to seasonNumber }) } currentEpisodes = allEpisodes val ranges = getRanges(allEpisodes, EPISODE_RANGE_SIZE) currentRanges = ranges // this takes the indexer most preferable by the user given the current sorting val min = ranges.keys.minByOrNull { index -> kotlin.math.abs( index.season - (preferStartSeason ?: 1) ) + if (index.dubStatus == preferDubStatus) 0 else 100000 } // this takes the range most preferable by the user given the current sorting val ranger = ranges[min] val range = ranger?.firstOrNull { it.startEpisode >= (preferStartEpisode ?: 0) } ?: ranger?.lastOrNull() postEpisodeRange(min, range, DataStoreHelper.resultsSortingMode) postResume() } private fun postResume() { _resumeWatching.postValue(resume()) } private fun resume(): ResumeWatchingStatus? { val correctId = currentId ?: return null val resume = getLastWatched(correctId) val resumeParentId = resume?.parentId if (resumeParentId != correctId) return null // is null or smth went wrong with getLastWatched val resumeId = resume.episodeId ?: return null// invalid episode id val response = currentResponse ?: return null // kinda ugly ik val episode = currentEpisodes.values.flatten().firstOrNull { it.id == resumeId } ?: return null val isMovie = response.isMovie() val progress = getViewPos(resume.episodeId)?.let { viewPos -> ResumeProgress( progress = (viewPos.position / 1000).toInt(), maxProgress = (viewPos.duration / 1000).toInt(), txt( R.string.resume_remaining, secondsToReadable( ((viewPos.duration - viewPos.position) / 1_000).toInt(), "0 mins" ) ) ) } return ResumeWatchingStatus(progress = progress, isMovie = isMovie, result = episode) } private fun loadTrailers(loadResponse: LoadResponse) = ioSafe { _trailers.postValue( getTrailers( loadResponse, 3 ) ) // we dont want to fetch too many trailers } private suspend fun getTrailers( loadResponse: LoadResponse, limit: Int = 0 ): List = coroutineScope { val returnlist = ArrayList() loadResponse.trailers.windowed(limit, limit, true).takeWhile { list -> list.amap { trailerData -> try { val links = arrayListOf>() val subs = arrayListOf() if (!loadExtractor( trailerData.extractorUrl, trailerData.referer, { subs.add(it) }, { links.add(Pair(it,trailerData.extractorUrl))}) && trailerData.raw ) { arrayListOf( Pair( newExtractorLink( "", "Trailer", trailerData.extractorUrl, type = INFER_TYPE ) { this.referer = trailerData.referer ?: "" this.quality = Qualities.Unknown.value this.headers = trailerData.headers },trailerData.extractorUrl) ) to arrayListOf() } else { links to subs } } catch (e: Throwable) { logError(e) null } }.filterNotNull().map { (links, subs) -> ExtractedTrailerData(links, subs) }.let { returnlist.addAll(it) } returnlist.size < limit } return@coroutineScope returnlist } // this instantly updates the metadata on the page private fun postPage(loadResponse: LoadResponse, apiRepository: APIRepository) { _recommendations.postValue(loadResponse.recommendations ?: emptyList()) _page.postValue(Resource.Success(loadResponse.toResultData(apiRepository))) } fun hasLoaded() = currentResponse != null private fun handleAutoStart(activity: Activity?, autostart: AutoResume?) = viewModelScope.launchSafe { if (autostart == null || activity == null) return@launchSafe when (autostart.startAction) { START_ACTION_RESUME_LATEST -> { currentEpisodes[currentIndex]?.let { currentRange -> for (ep in currentRange) { if (ep.getWatchProgress() > 0.9) continue handleAction( EpisodeClickEvent( getPlayerAction(activity), ep ) ) break } } } START_ACTION_LOAD_EP -> { val all = currentEpisodes.values.flatten() val episode = autostart.id?.let { id -> all.firstOrNull { it.id == id } } ?: autostart.episode?.let { ep -> currentEpisodes[currentIndex]?.firstOrNull { it.episode == ep && it.season == autostart.episode } ?: all.firstOrNull { it.episode == ep && it.season == autostart.episode } } ?: return@launchSafe handleAction( EpisodeClickEvent( getPlayerAction(activity), episode ) ) } } } data class LoadResponseFromSearch( override var name: String, override var url: String, override var apiName: String, override var type: TvType, override var posterUrl: String?, override var year: Int? = null, override var plot: String? = null, override var score: Score? = null, override var tags: List? = null, override var duration: Int? = null, override var trailers: MutableList = mutableListOf(), override var recommendations: List? = null, override var actors: List? = null, override var comingSoon: Boolean = false, override var syncData: MutableMap = mutableMapOf(), override var posterHeaders: Map? = null, override var backgroundPosterUrl: String? = null, override var logoUrl: String? = null, override var contentRating: String? = null, override var uniqueUrl: String = url, val id: Int?, ) : LoadResponse fun loadSmall(searchResponse: SearchResponse) = ioSafe { val url = searchResponse.url _page.postValue(Resource.Loading(url)) _episodes.postValue(Resource.Loading()) val api = APIHolder.getApiFromNameNull(searchResponse.apiName) ?: APIHolder.getApiFromUrlNull( searchResponse.url ) ?: APIRepository.noneApi val repo = APIRepository(api) val response = LoadResponseFromSearch( name = searchResponse.name, url = searchResponse.url, apiName = api.name, type = searchResponse.type ?: TvType.Others, posterUrl = searchResponse.posterUrl, id = searchResponse.id ).apply { if (searchResponse is SyncAPI.LibraryItem) { this.plot = searchResponse.plot this.score = searchResponse.personalRating ?: searchResponse.score this.tags = searchResponse.tags } if (searchResponse is DataStoreHelper.BookmarkedData) { this.plot = searchResponse.plot this.score = searchResponse.score this.tags = searchResponse.tags } } val mainId = response.getId() postSuccessful( loadResponse = response, mainId = mainId, apiRepository = repo, updateEpisodes = false, updateFillers = false ) } fun load( activity: Activity?, url: String, apiName: String, showFillers: Boolean, dubStatus: DubStatus, autostart: AutoResume?, loadTrailers: Boolean = true, ) = ioSafe { _page.postValue(Resource.Loading(url)) _episodes.postValue(Resource.Loading()) preferDubStatus = dubStatus currentShowFillers = showFillers // set api val api = APIHolder.getApiFromNameNull(apiName) ?: APIHolder.getApiFromUrlNull(url) if (api == null) { _page.postValue( Resource.Failure( false, "This provider does not exist" ) ) return@ioSafe } // validate url val validUrlResource = safeApiCall { SyncRedirector.redirect( url, api ) } if (validUrlResource !is Resource.Success) { if (validUrlResource is Resource.Failure) { _page.postValue(validUrlResource) } return@ioSafe } val validUrl = validUrlResource.value val repo = APIRepository(api) currentRepo = repo when (val data = repo.load(validUrl)) { is Resource.Failure -> { _page.postValue(data) } is Resource.Success -> { if (!isActive) return@ioSafe val loadResponse = ioWork { applyMeta(data.value, currentMeta, currentSync).first } if (!isActive) return@ioSafe val mainId = loadResponse.getId() preferDubStatus = getDub(mainId) ?: preferDubStatus preferStartEpisode = getResultEpisode(mainId) preferStartSeason = getResultSeason(mainId) ?: 1 setKey( DOWNLOAD_HEADER_CACHE, mainId.toString(), DownloadObjects.DownloadHeaderCached( apiName = apiName, url = validUrl, type = loadResponse.type, name = loadResponse.name, poster = loadResponse.posterUrl, id = mainId, cacheTime = System.currentTimeMillis(), ) ) if (loadTrailers) loadTrailers(data.value) postSuccessful( data.value, mainId, updateEpisodes = true, updateFillers = showFillers, apiRepository = repo ) if (!isActive) return@ioSafe handleAutoStart(activity, autostart) } is Resource.Loading -> { debugException { "Invalid load result" } } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/result/SelectAdaptor.kt ================================================ package com.lagradost.cloudstream3.ui.result import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.databinding.ResultSelectionBinding import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.setText typealias SelectData = Pair class SelectAdaptor(val callback: (Any) -> Unit) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> a.second == b.second }, contentSame = { a, b -> a == b })) { private var selectedIndex: Int = -1 override fun onCreateContent(parent: ViewGroup): ViewHolderState { return ViewHolderState( ResultSelectionBinding.inflate( LayoutInflater.from(parent.context), parent, false ) ) } override fun onBindContent(holder: ViewHolderState, item: SelectData, position: Int) { when (val binding = holder.view) { is ResultSelectionBinding -> { binding.root.apply { if (isLayout(TV)) { isFocusable = true isFocusableInTouchMode = true } isSelected = position == selectedIndex setText(item.first) setOnClickListener { callback.invoke(item.second) } } } } } override fun onViewDetachedFromWindow(holder: ViewHolderState) { if (holder.itemView.hasFocus()) { holder.itemView.clearFocus() } } fun select(newIndex: Int, recyclerView: RecyclerView?) { if (recyclerView == null) return if (newIndex == selectedIndex) return val oldIndex = selectedIndex selectedIndex = newIndex notifyItemChanged(selectedIndex) notifyItemChanged(oldIndex) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/result/SyncViewModel.kt ================================================ package com.lagradost.cloudstream3.ui.result import android.util.Log import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.throwAbleToResource import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.kitsuApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.SyncWatchType import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.SyncUtil import java.util.* data class CurrentSynced( val name: String, val idPrefix: String, val isSynced: Boolean, val hasAccount: Boolean, val icon: Int?, ) class SyncViewModel : ViewModel() { companion object { const val TAG = "SYNCVM" } private val repos = AccountManager.syncApis private val _metaResponse: MutableLiveData?> = MutableLiveData(null) val metadata: LiveData?> = _metaResponse private val _userDataResponse: MutableLiveData?> = MutableLiveData(null) val userData: LiveData?> = _userDataResponse // prefix, id private val syncs = mutableMapOf() //private val _syncIds: MutableLiveData> = // MutableLiveData(mutableMapOf()) //val syncIds: LiveData> get() = _syncIds fun getSyncs(): Map { return syncs } private val _currentSynced: MutableLiveData> = MutableLiveData(getMissing()) // pair of name idPrefix isSynced val synced: LiveData> = _currentSynced private fun getMissing(): List { return repos.map { CurrentSynced( it.name, it.idPrefix, syncs.containsKey(it.idPrefix), it.authUser() != null, it.icon, ) } } fun updateSynced() { Log.i(TAG, "updateSynced") _currentSynced.postValue(getMissing()) } private fun addSync(idPrefix: String, id: String): Boolean { if (syncs[idPrefix] == id) return false Log.i(TAG, "addSync $idPrefix = $id") syncs[idPrefix] = id //_syncIds.postValue(syncs) return true } fun addSyncs(map: Map?): Boolean { var isValid = false map?.forEach { (prefix, id) -> isValid = addSync(prefix, id) || isValid } return isValid } private fun setMalId(id: String?): Boolean { return addSync(malApi.idPrefix, id ?: return false) } private fun setAniListId(id: String?): Boolean { return addSync(aniListApi.idPrefix, id ?: return false) } var hasAddedFromUrl: HashSet = hashSetOf() fun addFromUrl(url: String?) = ioSafe { Log.i(TAG, "addFromUrl = $url") if (url == null || hasAddedFromUrl.contains(url)) return@ioSafe if (!url.startsWith("http")) return@ioSafe SyncUtil.getIdsFromUrl(url)?.let { (malId, aniListId) -> hasAddedFromUrl.add(url) setMalId(malId) setAniListId(aniListId) updateSynced() if (malId != null || aniListId != null) { Log.i(TAG, "addFromUrl->updateMetaAndUser $malId $aniListId") updateMetaAndUser() } } } fun setEpisodesDelta(delta: Int) { Log.i(TAG, "setEpisodesDelta = $delta") val user = userData.value if (user is Resource.Success) { user.value.watchedEpisodes?.plus( delta )?.let { episode -> setEpisodes(episode) } } } fun setEpisodes(episodes: Int) { Log.i(TAG, "setEpisodes = $episodes") if (episodes < 0) return val meta = metadata.value if (meta is Resource.Success) { meta.value.totalEpisodes?.let { max -> if (episodes > max) { setEpisodes(max) return } } } val user = userData.value if (user is Resource.Success) { user.value.watchedEpisodes = episodes _userDataResponse.postValue(Resource.Success(user.value)) } } fun setScore(score: Score?) { Log.i(TAG, "setScore = $score") val user = userData.value if (user is Resource.Success) { user.value.score = score _userDataResponse.postValue(Resource.Success(user.value)) } } fun setStatus(which: Int) { Log.i(TAG, "setStatus = $which") if (which < -1 || which > 5) return // validate input val user = userData.value if (user is Resource.Success) { user.value.status = SyncWatchType.fromInternalId(which) _userDataResponse.postValue(Resource.Success(user.value)) } } fun publishUserData() = ioSafe { Log.i(TAG, "publishUserData") val user = userData.value if (user is Resource.Success) { syncs.forEach { (prefix, id) -> repos.firstOrNull { it.idPrefix == prefix }?.updateStatus(id, user.value) } } updateUserData() } fun modifyMaxEpisode(episodeNum: Int) { Log.i(TAG, "modifyMaxEpisode = $episodeNum") modifyData { status -> status.watchedEpisodes = maxOf( episodeNum, status.watchedEpisodes ?: return@modifyData null ) status } } /// modifies the current sync data, return null if you don't want to change it private fun modifyData(update: ((SyncAPI.AbstractSyncStatus) -> (SyncAPI.AbstractSyncStatus?))) = ioSafe { syncs.amap { (prefix, id) -> repos.firstOrNull { it.idPrefix == prefix }?.let { repo -> val result = update(repo.status(id).getOrNull() ?: return@let null) ?: return@let null Log.i(TAG, "modifyData ${repo.name} => $result") repo.updateStatus(id, result) } } } fun updateUserData() = ioSafe { Log.i(TAG, "updateUserData") _userDataResponse.postValue(Resource.Loading()) val status = syncs.firstNotNullOfOrNull { (prefix, id) -> repos.firstOrNull { it.idPrefix == prefix } ?.status(id)?.getOrNull() } if (status == null) { _userDataResponse.postValue(Resource.Failure(false, "No data")) } else { _userDataResponse.postValue(Resource.Success(status)) } } private fun updateMetadata() = ioSafe { Log.i(TAG, "updateMetadata") _metaResponse.postValue(Resource.Loading()) var lastError: Resource = Resource.Failure(false, "No data") val current = ArrayList(syncs.toList()) // shitty way to sort anilist first, as it has trailers while mal does not if (syncs.containsKey(aniListApi.idPrefix)) { try { // swap can throw error Collections.swap( current, current.indexOfFirst { it.first == aniListApi.idPrefix }, 0 ) } catch (t: Throwable) { logError(t) } } current.forEach { (prefix, id) -> repos.firstOrNull { it.idPrefix == prefix }?.let { repo -> Log.i(TAG, "updateMetadata loading ${repo.idPrefix}") val result = repo.load(id) val resultValue = result.getOrNull() val resultError = result.exceptionOrNull() if (resultValue != null) { _metaResponse.postValue(Resource.Success(resultValue)) return@ioSafe } else if (resultError != null) { /*Log.e( TAG, "updateMetadata error $id at ${repo.idPrefix} ${result.errorString}" )*/ lastError = throwAbleToResource(resultError) } } } _metaResponse.postValue(lastError) setEpisodesDelta(0) } fun syncName(syncName: String): String? { // fix because of bad old data :pensive: val realName = when (syncName) { "MAL" -> malApi.idPrefix "Kitsu" -> kitsuApi.idPrefix "Simkl" -> simklApi.idPrefix "AniList" -> aniListApi.idPrefix else -> syncName } return repos.firstOrNull { it.idPrefix == realName }?.idPrefix } fun setSync(syncName: String, syncId: String) { syncs.clear() syncs[syncName] = syncId } fun clear() { syncs.clear() _metaResponse.postValue(null) _currentSynced.postValue(getMissing()) _userDataResponse.postValue(null) } fun updateMetaAndUser() { _userDataResponse.postValue(Resource.Loading()) _metaResponse.postValue(Resource.Loading()) Log.i(TAG, "updateMetaAndUser") updateMetadata() updateUserData() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchAdaptor.kt ================================================ package com.lagradost.cloudstream3.ui.search import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.databinding.SearchResultGridBinding import com.lagradost.cloudstream3.databinding.SearchResultGridExpandedBinding import com.lagradost.cloudstream3.ui.AutofitRecyclerView import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.utils.UIHelper.isBottomLayout import kotlin.math.roundToInt /** Click */ const val SEARCH_ACTION_LOAD = 0 /** Long press */ const val SEARCH_ACTION_SHOW_METADATA = 1 const val SEARCH_ACTION_PLAY_FILE = 2 const val SEARCH_ACTION_FOCUSED = 4 class SearchClickCallback( val action: Int, val view: View, val position: Int, val card: SearchResponse ) class SearchAdapter( private val resView: AutofitRecyclerView, private val isHorizontal:Boolean = false, private val clickCallback: (SearchClickCallback) -> Unit, ) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> if (a.id != null || b.id != null) { a.id == b.id } else { a.name == b.name } })) { companion object { val sharedPool = newSharedPool { setMaxRecycledViews(CONTENT, 10) } } var hasNext: Boolean = false private val coverRatio = if(isHorizontal) 1.8 else 0.68 private val coverHeight: Int get() = (resView.itemWidth / coverRatio).roundToInt() override fun onCreateContent(parent: ViewGroup): ViewHolderState { val inflater = LayoutInflater.from(parent.context) val layout = if (parent.context.isBottomLayout()) SearchResultGridExpandedBinding.inflate( inflater, parent, false ) else SearchResultGridBinding.inflate( inflater, parent, false ) return ViewHolderState(layout) } override fun onClearView(holder: ViewHolderState) { clearImage( when (val binding = holder.view) { is SearchResultGridExpandedBinding -> binding.imageView is SearchResultGridBinding -> binding.imageView else -> null } ) } override fun onBindContent(holder: ViewHolderState, item: SearchResponse, position: Int) { val imageView = when (val binding = holder.view) { is SearchResultGridExpandedBinding -> binding.imageView is SearchResultGridBinding -> binding.imageView else -> null } if (imageView != null) { val params = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, coverHeight ) if (imageView.layoutParams.width != params.width || imageView.layoutParams.height != params.height) { imageView.layoutParams = params } } SearchResultBuilder.bind(clickCallback, item, position, holder.view.root) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchFragment.kt ================================================ package com.lagradost.cloudstream3.ui.search import android.app.Activity import android.content.Intent import android.content.DialogInterface import android.speech.RecognizerIntent import android.speech.SpeechRecognizer import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.widget.AbsListView import android.widget.ArrayAdapter import android.widget.ImageView import android.widget.ListView import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.activity.result.contract.ActivityResultContracts import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.AnimeSearchResponse import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.MainActivity.Companion.afterPluginsLoadedEvent import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.FragmentSearchBinding import com.lagradost.cloudstream3.databinding.HomeSelectMainpageBinding import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.BaseAdapter import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.home.HomeFragment import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.bindChips import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.currentSpan import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.loadHomepageList import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.updateChips import com.lagradost.cloudstream3.ui.home.HomeViewModel import com.lagradost.cloudstream3.ui.home.ParentItemAdapter import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia import com.lagradost.cloudstream3.utils.AppContextUtils.filterSearchResultByFilmQuality import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.AppContextUtils.getApiSettings import com.lagradost.cloudstream3.utils.AppContextUtils.ownHide import com.lagradost.cloudstream3.utils.AppContextUtils.ownShow import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.attachBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.detachBackPressedCallback import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.getSpanCount import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import java.util.Locale import java.util.concurrent.locks.ReentrantLock class SearchFragment : BaseFragment( BaseFragment.BindingCreator.Bind(FragmentSearchBinding::bind) ) { companion object { fun List.filterSearchResponse(): List { return this.filter { response -> if (response is AnimeSearchResponse) { val status = response.dubStatus (status.isNullOrEmpty()) || (status.any { APIRepository.dubStatusActive.contains(it) }) } else { true } } } const val SEARCH_QUERY = "search_query" fun newInstance(query: String): Bundle { return Bundle().apply { if (query.isNotBlank()) putString(SEARCH_QUERY, query) } } } private val searchViewModel: SearchViewModel by activityViewModels() private var bottomSheetDialog: BottomSheetDialog? = null private val speechRecognizerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> if (result.resultCode == Activity.RESULT_OK) { val data: Intent? = result.data val matches = data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) if (!matches.isNullOrEmpty()) { val recognizedText = matches[0] binding?.mainSearch?.setQuery(recognizedText, true) } } } override fun pickLayout(): Int? = if (isLayout(TV or EMULATOR)) R.layout.fragment_search_tv else R.layout.fragment_search override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, ): View? { activity?.window?.setSoftInputMode( WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE ) bottomSheetDialog?.ownShow() return super.onCreateView(inflater, container, savedInstanceState) } override fun onDestroyView() { hideKeyboard() bottomSheetDialog?.ownHide() activity?.detachBackPressedCallback("SearchFragment") super.onDestroyView() } override fun onResume() { super.onResume() afterPluginsLoadedEvent += ::reloadRepos } override fun onStop() { super.onStop() afterPluginsLoadedEvent -= ::reloadRepos } var selectedSearchTypes = mutableListOf() var selectedApis = mutableSetOf() /** * Will filter all providers by preferred media and selectedSearchTypes. * If that results in no available providers then only filter * providers by preferred media **/ fun search(query: String?) { if (query == null) return // don't resume state from prev search (binding?.searchMasterRecycler?.adapter as? BaseAdapter<*, *>)?.clearState() context?.let { ctx -> val default = enumValues().sorted().filter { it != TvType.NSFW } .map { it.ordinal.toString() }.toSet() val preferredTypes = (PreferenceManager.getDefaultSharedPreferences(ctx) .getStringSet(this.getString(R.string.prefer_media_type_key), default) ?.ifEmpty { default } ?: default) .mapNotNull { it.toIntOrNull() ?: return@mapNotNull null } val settings = ctx.getApiSettings() val notFilteredBySelectedTypes = selectedApis.filter { name -> settings.contains(name) }.map { name -> name to getApiFromNameNull(name)?.supportedTypes }.filter { (_, types) -> types?.any { preferredTypes.contains(it.ordinal) } == true } searchViewModel.searchAndCancel( query = query, providersActive = notFilteredBySelectedTypes.filter { (_, types) -> types?.any { selectedSearchTypes.contains(it) } == true }.ifEmpty { notFilteredBySelectedTypes }.map { it.first }.toSet() ) } } // Null if defined as a variable // This needs to be run after view created private fun reloadRepos(success: Boolean = false) = main { searchViewModel.reloadRepos() context?.filterProviderByPreferredMedia()?.let { validAPIs -> bindChips( binding?.tvtypesChipsScroll?.tvtypesChips, selectedSearchTypes, validAPIs.flatMap { api -> api.supportedTypes }.distinct() ) { list -> if (selectedSearchTypes.toSet() != list.toSet()) { DataStoreHelper.searchPreferenceTags = list selectedSearchTypes.clear() selectedSearchTypes.addAll(list) search(binding?.mainSearch?.query?.toString()) } } } } override fun fixLayout(view: View) { fixSystemBarsPadding( view, padBottom = isLandscape(), padLeft = isLayout(TV or EMULATOR) ) // Fix grid currentSpan = view.context.getSpanCount() binding?.searchAutofitResults?.spanCount = currentSpan HomeFragment.configEvent.invoke() } override fun onBindingCreated( binding: FragmentSearchBinding, savedInstanceState: Bundle? ) { reloadRepos() binding.apply { val adapter = SearchAdapter( searchAutofitResults, ) { callback -> SearchHelper.handleSearchClickCallback(callback) } searchRoot.findViewById(androidx.appcompat.R.id.search_src_text)?.tag = "tv_no_focus_tag" searchAutofitResults.setRecycledViewPool(SearchAdapter.sharedPool) searchAutofitResults.adapter = adapter searchLoadingBar.alpha = 0f } binding.voiceSearch.setOnClickListener { searchView -> searchView?.context?.let { ctx -> try { if (!SpeechRecognizer.isRecognitionAvailable(ctx)) { showToast(R.string.speech_recognition_unavailable) } else { val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply { putExtra( RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM ) putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault()) putExtra( RecognizerIntent.EXTRA_PROMPT, ctx.getString(R.string.begin_speaking) ) } speechRecognizerLauncher.launch(intent) } } catch (_: Throwable) { // launch may throw showToast(R.string.speech_recognition_unavailable) } } } val searchExitIcon = binding.mainSearch.findViewById(androidx.appcompat.R.id.search_close_btn) selectedApis = DataStoreHelper.searchPreferenceProviders.toMutableSet() binding.searchFilter.setOnClickListener { searchView -> searchView?.context?.let { ctx -> val validAPIs = ctx.filterProviderByPreferredMedia(hasHomePageIsRequired = false) var currentValidApis = listOf() val currentSelectedApis = if (selectedApis.isEmpty()) validAPIs.map { it.name } .toMutableSet() else selectedApis val builder = BottomSheetDialog(ctx) builder.behavior.state = BottomSheetBehavior.STATE_EXPANDED val selectMainpageBinding: HomeSelectMainpageBinding = HomeSelectMainpageBinding.inflate( builder.layoutInflater, null, false ) builder.setContentView(selectMainpageBinding.root) builder.show() builder.let { dialog -> val previousSelectedApis = selectedApis.toSet() val previousSelectedSearchTypes = selectedSearchTypes.toSet() val isMultiLang = ctx.getApiProviderLangSettings().let { set -> set.size > 1 || set.contains(AllLanguagesName) } val cancelBtt = dialog.findViewById(R.id.cancel_btt) val applyBtt = dialog.findViewById(R.id.apply_btt) val listView = dialog.findViewById(R.id.listview1) val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) listView?.adapter = arrayAdapter listView?.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE listView?.setOnItemClickListener { _, _, i, _ -> if (currentValidApis.isNotEmpty()) { val api = currentValidApis[i].name if (currentSelectedApis.contains(api)) { listView.setItemChecked(i, false) currentSelectedApis -= api } else { listView.setItemChecked(i, true) currentSelectedApis += api } } } fun updateList(types: List) { DataStoreHelper.searchPreferenceTags = types arrayAdapter.clear() currentValidApis = validAPIs.filter { api -> api.supportedTypes.any { types.contains(it) } }.sortedBy { it.name.lowercase() } val names = currentValidApis.map { if (isMultiLang) "${ SubtitleHelper.getFlagFromIso( it.lang )?.plus(" ") ?: "" }${it.name}" else it.name } for ((index, api) in currentValidApis.map { it.name }.withIndex()) { listView?.setItemChecked(index, currentSelectedApis.contains(api)) } //arrayAdapter.notifyDataSetChanged() arrayAdapter.addAll(names) arrayAdapter.notifyDataSetChanged() } bindChips( selectMainpageBinding.tvtypesChipsScroll.tvtypesChips, selectedSearchTypes, validAPIs.flatMap { api -> api.supportedTypes }.distinct() ) { list -> updateList(list) // refresh selected chips in main chips if (selectedSearchTypes.toSet() != list.toSet()) { selectedSearchTypes.clear() selectedSearchTypes.addAll(list) updateChips( binding.tvtypesChipsScroll.tvtypesChips, selectedSearchTypes ) } } cancelBtt?.setOnClickListener { dialog.dismissSafe() } cancelBtt?.setOnClickListener { dialog.dismissSafe() } applyBtt?.setOnClickListener { //if (currentApiName != selectedApiName) { // currentApiName?.let(callback) //} dialog.dismissSafe() } dialog.setOnDismissListener { DataStoreHelper.searchPreferenceProviders = currentSelectedApis.toList() selectedApis = currentSelectedApis // run search when dialog is close if (previousSelectedApis != selectedApis.toSet() || previousSelectedSearchTypes != selectedSearchTypes.toSet()) { search(binding.mainSearch.query.toString()) } } updateList(selectedSearchTypes.toList()) } } } val settingsManager = context?.let { PreferenceManager.getDefaultSharedPreferences(it) } val isAdvancedSearch = settingsManager?.getBoolean("advanced_search", true) ?: true val isSearchSuggestionsEnabled = settingsManager?.getBoolean("search_suggestions_enabled", true) ?: true selectedSearchTypes = DataStoreHelper.searchPreferenceTags.toMutableList() if (!isLayout(PHONE)) { binding.searchFilter.isFocusable = true binding.searchFilter.isFocusableInTouchMode = true } // Hide suggestions when search view loses focus (phone only) if (isLayout(PHONE)) { binding.mainSearch.setOnQueryTextFocusChangeListener { _, hasFocus -> if (!hasFocus) { searchViewModel.clearSuggestions() } } } binding.mainSearch.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String): Boolean { search(query) searchViewModel.clearSuggestions() binding.mainSearch.let { hideKeyboard(it) } return true } override fun onQueryTextChange(newText: String): Boolean { //searchViewModel.quickSearch(newText) val showHistory = newText.isBlank() if (showHistory) { searchViewModel.clearSearch() searchViewModel.updateHistory() searchViewModel.clearSuggestions() } else { // Fetch suggestions when user is typing (if enabled) if (isSearchSuggestionsEnabled) { searchViewModel.fetchSuggestions(newText) } } binding.apply { searchHistoryRecycler.isVisible = showHistory searchMasterRecycler.isVisible = !showHistory && isAdvancedSearch searchAutofitResults.isVisible = !showHistory && !isAdvancedSearch // Hide suggestions when showing history or showing search results searchSuggestionsRecycler.isVisible = !showHistory && isSearchSuggestionsEnabled } return true } }) observe(searchViewModel.searchResponse) { when (it) { is Resource.Success -> { it.value.let { data -> val list = data.list if (list.isNotEmpty()) { (binding.searchAutofitResults.adapter as? SearchAdapter)?.submitList( list ) } } searchExitIcon?.alpha = 1f binding.searchLoadingBar.alpha = 0f } is Resource.Failure -> { // Toast.makeText(activity, "Server error", Toast.LENGTH_LONG).show() searchExitIcon?.alpha = 1f binding.searchLoadingBar.alpha = 0f } is Resource.Loading -> { searchExitIcon?.alpha = 0f binding.searchLoadingBar.alpha = 1f } } } val listLock = ReentrantLock() observe(searchViewModel.currentSearch) { list -> try { // https://stackoverflow.com/questions/6866238/concurrent-modification-exception-adding-to-an-arraylist listLock.lock() val pinnedOrder = DataStoreHelper.pinnedProviders.reversedArray() val sortedList = list.toList().sortedWith(compareBy { (providerName, _) -> val index = pinnedOrder.indexOf(providerName) if (index == -1) Int.MAX_VALUE else index }) (binding.searchMasterRecycler.adapter as? ParentItemAdapter)?.apply { val newItems = sortedList.map { (providerName, providerData) -> val dataList = providerData.list val dataListFiltered = context?.filterSearchResultByFilmQuality(dataList) ?: dataList val homePageList = HomePageList( providerName, dataListFiltered ) HomeViewModel.ExpandableHomepageList( homePageList, providerData.currentPage, providerData.hasNext ) } submitList(newItems) //notifyDataSetChanged() } } catch (e: Exception) { logError(e) } finally { listLock.unlock() } } /*main_search.setOnQueryTextFocusChangeListener { _, b -> if (b) { // https://stackoverflow.com/questions/12022715/unable-to-show-keyboard-automatically-in-the-searchview showInputMethod(view.findFocus()) } }*/ //main_search.onActionViewExpanded()*/ val masterAdapter = ParentItemAdapter(id = "masterAdapter".hashCode(), { callback -> SearchHelper.handleSearchClickCallback(callback) }, { item -> bottomSheetDialog = activity?.loadHomepageList(item, dismissCallback = { bottomSheetDialog = null }, expandCallback = { name -> searchViewModel.expandAndReturn(name) }) }, expandCallback = { name -> ioSafe { searchViewModel.expandAndReturn(name) } }) val historyAdapter = SearchHistoryAdaptor { click -> val searchItem = click.item when (click.clickAction) { SEARCH_HISTORY_OPEN -> { if (searchItem == null) return@SearchHistoryAdaptor searchViewModel.clearSearch() if (searchItem.type.isNotEmpty()) updateChips( binding.tvtypesChipsScroll.tvtypesChips, searchItem.type.toMutableList() ) binding.mainSearch.setQuery(searchItem.searchText, true) } SEARCH_HISTORY_REMOVE -> { if (searchItem == null) return@SearchHistoryAdaptor removeKey("$currentAccount/$SEARCH_HISTORY_KEY", searchItem.key) searchViewModel.updateHistory() } SEARCH_HISTORY_CLEAR -> { // Show confirmation dialog (from footer button) activity?.let { ctx -> val builder: AlertDialog.Builder = AlertDialog.Builder(ctx) val dialogClickListener = DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { removeKeys("$currentAccount/$SEARCH_HISTORY_KEY") searchViewModel.updateHistory() } DialogInterface.BUTTON_NEGATIVE -> { } } } try { builder.setTitle(R.string.clear_history).setMessage( ctx.getString(R.string.delete_message).format( ctx.getString(R.string.history) ) ) .setPositiveButton(R.string.sort_clear, dialogClickListener) .setNegativeButton(R.string.cancel, dialogClickListener) .show().setDefaultFocus() } catch (e: Exception) { logError(e) } } } else -> { // wth are you doing??? } } } val suggestionAdapter = SearchSuggestionAdapter { callback -> when (callback.clickAction) { SEARCH_SUGGESTION_CLICK -> { // Search directly binding.mainSearch.setQuery(callback.suggestion, true) searchViewModel.clearSuggestions() } SEARCH_SUGGESTION_FILL -> { // Fill the search box without searching binding.mainSearch.setQuery(callback.suggestion, false) } SEARCH_SUGGESTION_CLEAR -> { // Clear suggestions (from footer button) searchViewModel.clearSuggestions() } } } binding.apply { searchHistoryRecycler.adapter = historyAdapter searchHistoryRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) //searchHistoryRecycler.layoutManager = GridLayoutManager(context, 1) // Setup suggestions RecyclerView searchSuggestionsRecycler.adapter = suggestionAdapter searchSuggestionsRecycler.layoutManager = LinearLayoutManager(context) searchMasterRecycler.setRecycledViewPool(ParentItemAdapter.sharedPool) searchMasterRecycler.adapter = masterAdapter //searchMasterRecycler.setLinearListLayout(isHorizontal = false, nextRight = FOCUS_SELF) searchMasterRecycler.layoutManager = GridLayoutManager(context, 1) // Automatically search the specified query, this allows the app search to launch from intent var sq = arguments?.getString(SEARCH_QUERY) ?: savedInstanceState?.getString(SEARCH_QUERY) if (sq.isNullOrBlank()) { sq = MainActivity.nextSearchQuery } sq?.let { query -> if (query.isBlank()) return@let mainSearch.setQuery(query, true) // Clear the query as to not make it request the same query every time the page is opened arguments?.remove(SEARCH_QUERY) savedInstanceState?.remove(SEARCH_QUERY) MainActivity.nextSearchQuery = null } } observe(searchViewModel.currentHistory) { list -> (binding.searchHistoryRecycler.adapter as? SearchHistoryAdaptor?)?.submitList(list) // Scroll to top to show newest items (list is sorted by newest first) if (list.isNotEmpty()) { binding.searchHistoryRecycler.scrollToPosition(0) } } // Observe search suggestions observe(searchViewModel.searchSuggestions) { suggestions -> val hasSuggestions = suggestions.isNotEmpty() binding.searchSuggestionsRecycler.isVisible = hasSuggestions (binding.searchSuggestionsRecycler.adapter as? SearchSuggestionAdapter?)?.submitList(suggestions) // On non-phone layouts, redirect focus and handle back button if (!isLayout(PHONE)) { if (hasSuggestions) { binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_suggestions_recycler // Attach back button callback to clear suggestions activity?.attachBackPressedCallback("SearchFragment") { searchViewModel.clearSuggestions() } } else { // Reset to default focus target (history) binding.tvtypesChipsScroll.tvtypesChips.root.nextFocusDownId = R.id.search_history_recycler // Detach back button callback when no suggestions activity?.detachBackPressedCallback("SearchFragment") } } } searchViewModel.updateHistory() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHelper.kt ================================================ package com.lagradost.cloudstream3.ui.search import android.widget.Toast import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.download.DOWNLOAD_ACTION_PLAY_FILE import com.lagradost.cloudstream3.ui.download.DownloadButtonSetup.handleDownloadClick import com.lagradost.cloudstream3.ui.download.DownloadClickEvent import com.lagradost.cloudstream3.ui.result.START_ACTION_LOAD_EP import com.lagradost.cloudstream3.utils.AppContextUtils.loadSearchResult import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.downloader.DownloadObjects object SearchHelper { fun handleSearchClickCallback(callback: SearchClickCallback) { val card = callback.card when (callback.action) { SEARCH_ACTION_LOAD -> { loadSearchResult(card) } SEARCH_ACTION_PLAY_FILE -> { if (card is DataStoreHelper.ResumeWatchingResult) { val id = card.id if (id == null) { showToast(R.string.error_invalid_id, Toast.LENGTH_SHORT) } else { if (card.isFromDownload) { handleDownloadClick( DownloadClickEvent( DOWNLOAD_ACTION_PLAY_FILE, DownloadObjects.DownloadEpisodeCached( name = card.name, poster = card.posterUrl, episode = card.episode ?: 0, season = card.season, id = id, parentId = card.parentId ?: return, score = null, description = null, cacheTime = System.currentTimeMillis(), ) ) ) } else { loadSearchResult(card, START_ACTION_LOAD_EP, id) } } } else { handleSearchClickCallback( SearchClickCallback(SEARCH_ACTION_LOAD, callback.view, -1, callback.card) ) } } SEARCH_ACTION_SHOW_METADATA -> { (activity as? MainActivity?)?.apply { loadPopup(callback.card) } ?: kotlin.run { showToast(callback.card.name, Toast.LENGTH_SHORT) } } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchHistoryAdaptor.kt ================================================ package com.lagradost.cloudstream3.ui.search import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.isGone import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.SearchHistoryFooterBinding import com.lagradost.cloudstream3.databinding.SearchHistoryItemBinding import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout data class SearchHistoryItem( @JsonProperty("searchedAt") val searchedAt: Long, @JsonProperty("searchText") val searchText: String, @JsonProperty("type") val type: List, @JsonProperty("key") val key: String, ) data class SearchHistoryCallback( val item: SearchHistoryItem?, val clickAction: Int, ) const val SEARCH_HISTORY_OPEN = 0 const val SEARCH_HISTORY_REMOVE = 1 const val SEARCH_HISTORY_CLEAR = 2 class SearchHistoryAdaptor( private val clickCallback: (SearchHistoryCallback) -> Unit, ) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a,b -> a.searchedAt == b.searchedAt && a.searchText == b.searchText })) { // Add footer for all layouts override val footers = 1 override fun submitList(list: Collection?, commitCallback: Runnable?) { super.submitList(list, commitCallback) // Notify footer to rebind when list changes to update visibility if (footers > 0) { notifyItemChanged(itemCount - 1) } } override fun onCreateContent(parent: ViewGroup): ViewHolderState { return ViewHolderState( SearchHistoryItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), ) } override fun onBindContent( holder: ViewHolderState, item: SearchHistoryItem, position: Int ) { val binding = holder.view as? SearchHistoryItemBinding ?: return binding.apply { homeHistoryTitle.text = item.searchText homeHistoryRemove.setOnClickListener { clickCallback.invoke(SearchHistoryCallback(item, SEARCH_HISTORY_REMOVE)) } homeHistoryTab.setOnClickListener { clickCallback.invoke(SearchHistoryCallback(item, SEARCH_HISTORY_OPEN)) } } } override fun onCreateFooter(parent: ViewGroup): ViewHolderState { return ViewHolderState( SearchHistoryFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) } override fun onBindFooter(holder: ViewHolderState) { val binding = holder.view as? SearchHistoryFooterBinding ?: return // Hide footer when list is empty binding.searchClearCallHistory.apply { isGone = immutableCurrentList.isEmpty() if (isLayout(TV or EMULATOR)) { isFocusable = true isFocusableInTouchMode = true } setOnClickListener { clickCallback.invoke(SearchHistoryCallback(null, SEARCH_HISTORY_CLEAR)) } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchResultBuilder.kt ================================================ package com.lagradost.cloudstream3.ui.search import android.annotation.SuppressLint import android.content.Context import android.content.res.ColorStateList import android.view.View import android.widget.ImageView import android.widget.ProgressBar import android.widget.TextView import androidx.cardview.widget.CardView import androidx.core.view.isVisible import androidx.palette.graphics.Palette import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.AnimeSearchResponse import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.LiveSearchResponse import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.isMovieType import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.getNameFull import com.lagradost.cloudstream3.utils.AppContextUtils.getShortSeasonText import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.DataStoreHelper.fixVisual import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.getImageFromDrawable object SearchResultBuilder { private val showCache: MutableMap = mutableMapOf() fun updateCache(context: Context?) { if (context == null) return val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) for (k in context.resources.getStringArray(R.array.poster_ui_options_values)) { showCache[k] = settingsManager.getBoolean(k, showCache[k] ?: true) } } @SuppressLint("StringFormatInvalid") fun bind( clickCallback: (SearchClickCallback) -> Unit, card: SearchResponse, position: Int, itemView: View, nextFocusUp: Int? = null, nextFocusDown: Int? = null, colorCallback: ((Palette) -> Unit)? = null ) { val cardView: ImageView = itemView.findViewById(R.id.imageView) val cardText: TextView? = itemView.findViewById(R.id.imageText) val textIsDub: TextView? = itemView.findViewById(R.id.text_is_dub) val textIsSub: TextView? = itemView.findViewById(R.id.text_is_sub) val textFlag: TextView? = itemView.findViewById(R.id.text_flag) val rating: TextView? = itemView.findViewById(R.id.text_rating) val textQuality: TextView? = itemView.findViewById(R.id.text_quality) val shadow: View? = itemView.findViewById(R.id.title_shadow) val bg: CardView = itemView.findViewById(R.id.background_card) val bar: ProgressBar? = itemView.findViewById(R.id.watchProgress) val playImg: ImageView? = itemView.findViewById(R.id.search_item_download_play) val episodeText: TextView? = itemView.findViewById(R.id.episode_text) // Do logic bar?.isVisible = false playImg?.isVisible = false textIsDub?.isVisible = false textIsSub?.isVisible = false textFlag?.isVisible = false rating?.isVisible = false episodeText?.isVisible = false val showSub = showCache[textIsDub?.context?.getString(R.string.show_sub_key)] ?: false val showDub = showCache[textIsDub?.context?.getString(R.string.show_dub_key)] ?: false val showTitle = showCache[cardText?.context?.getString(R.string.show_title_key)] ?: false val showEpisodeText = showCache[cardText?.context?.getString(R.string.show_episode_text_key)] ?: false val showHd = showCache[textQuality?.context?.getString(R.string.show_hd_key)] ?: false val showRatingView = showCache[textQuality?.context?.getString(R.string.show_rating_key)] ?: false if (card is SyncAPI.LibraryItem) { val ratingText = card.personalRating?.toStringNull(0.1, 10, 1) val showRating = !ratingText.isNullOrBlank() rating?.isVisible = showRating if (showRating) { rating?.text = ratingText } } else if (showRatingView) { val ratingText = card.score?.toStringNull(0.1, 10, 1) val showRating = !ratingText.isNullOrBlank() rating?.isVisible = showRating if (showRating) { rating?.text = ratingText } } shadow?.isVisible = showTitle when (card.quality) { SearchQuality.BlueRay -> R.string.quality_blueray SearchQuality.Cam -> R.string.quality_cam SearchQuality.CamRip -> R.string.quality_cam_rip SearchQuality.DVD -> R.string.quality_dvd SearchQuality.HD -> R.string.quality_hd SearchQuality.HQ -> R.string.quality_hq SearchQuality.HdCam -> R.string.quality_cam_hd SearchQuality.Telecine -> R.string.quality_tc SearchQuality.Telesync -> R.string.quality_ts SearchQuality.WorkPrint -> R.string.quality_workprint SearchQuality.SD -> R.string.quality_sd SearchQuality.FourK -> R.string.quality_4k SearchQuality.UHD -> R.string.quality_uhd SearchQuality.SDR -> R.string.quality_sdr SearchQuality.HDR -> R.string.quality_hdr SearchQuality.WebRip -> R.string.quality_webrip null -> null }?.let { textRes -> textQuality?.setText(textRes) textQuality?.isVisible = showHd } ?: run { textQuality?.isVisible = false } cardText?.text = card.name cardText?.isVisible = showTitle cardView.isVisible = true if (!card.posterUrl.isNullOrEmpty()) { cardView.loadImage(card.posterUrl, card.posterHeaders) { error { getImageFromDrawable(itemView.context, R.drawable.default_cover) } } } else cardView.loadImage(R.drawable.default_cover) fun click(view: View?) { clickCallback.invoke( SearchClickCallback( if (card is DataStoreHelper.ResumeWatchingResult) SEARCH_ACTION_PLAY_FILE else SEARCH_ACTION_LOAD, view ?: return, position, card ) ) } fun longClick(view: View?) { clickCallback.invoke( SearchClickCallback( SEARCH_ACTION_SHOW_METADATA, view ?: return, position, card ) ) } fun focus(view: View?, focus: Boolean) { if (focus) { clickCallback.invoke( SearchClickCallback( SEARCH_ACTION_FOCUSED, view ?: return, position, card ) ) } } bg.isFocusable = false bg.isFocusableInTouchMode = false if (!isLayout(TV)) { bg.setOnClickListener { click(it) } bg.setOnLongClickListener { longClick(it) return@setOnLongClickListener true } } // // // itemView.setOnClickListener { click(it) } if (nextFocusUp != null) { itemView.nextFocusUpId = nextFocusUp } if (nextFocusDown != null) { itemView.nextFocusDownId = nextFocusDown } /*when (nextFocusBehavior) { true -> itemView.nextFocusLeftId = bg.id false -> itemView.nextFocusRightId = bg.id null -> { bg.nextFocusRightId = -1 bg.nextFocusLeftId = -1 } }*/ /*if (nextFocusUp != null) { bg.nextFocusUpId = nextFocusUp } if (nextFocusDown != null) { bg.nextFocusDownId = nextFocusDown } */ if (isLayout(TV)) { // bg.isFocusable = true // bg.isFocusableInTouchMode = true // bg.touchscreenBlocksFocus = false itemView.isFocusableInTouchMode = true itemView.isFocusable = true } /**/ itemView.setOnLongClickListener { longClick(it) return@setOnLongClickListener true } /*bg.setOnFocusChangeListener { view, b -> focus(view, b) }*/ itemView.setOnFocusChangeListener { view, b -> focus(view, b) } when (card) { is LiveSearchResponse -> { SubtitleHelper.getFlagFromIso(card.lang)?.let { flagEmoji -> textFlag?.apply { isVisible = true text = flagEmoji } } } is DataStoreHelper.ResumeWatchingResult -> { val pos = card.watchPos?.fixVisual() if (pos != null) { bar?.max = (pos.duration / 1000).toInt() bar?.progress = (pos.position / 1000).toInt() bar?.visibility = View.VISIBLE } playImg?.visibility = View.VISIBLE if (card.type?.isMovieType() == false && showEpisodeText) { episodeText?.context?.getShortSeasonText(card.episode, card.season)?.let {text-> episodeText.text = text episodeText.isVisible = true } } } is AnimeSearchResponse -> { val dubStatus = card.dubStatus if (!dubStatus.isNullOrEmpty()) { if (dubStatus.contains(DubStatus.Dubbed)) { textIsDub?.isVisible = showDub } if (dubStatus.contains(DubStatus.Subbed)) { textIsSub?.isVisible = showSub } } val dubEpisodes = card.episodes[DubStatus.Dubbed] val subEpisodes = card.episodes[DubStatus.Subbed] textIsDub?.apply { val dubText = context.getString(R.string.app_dubbed_text) text = if (dubEpisodes != null && dubEpisodes > 0) { context.getString(R.string.app_dub_sub_episode_text_format) .format(dubText, dubEpisodes) } else { dubText } } textIsSub?.apply { val subText = context.getString(R.string.app_subbed_text) text = if (subEpisodes != null && subEpisodes > 0) { context.getString(R.string.app_dub_sub_episode_text_format) .format(subText, subEpisodes) } else { subText } } } } // This is the logic for making the rounded corners more round on the top and bottom element // a bit dirty to do memory allocation, but it makes it more extensible and is easier to reason about // then a large if statement // Requires that the ordering here is the same as in the xml val boxes = arrayListOf() for (view in arrayOf(textIsDub, textIsSub, rating)) { if (view?.isVisible == true) { boxes.add(view) } } if (boxes.size == 1) { boxes[0].setBackgroundResource(R.drawable.bg_color_both) } else if (boxes.size > 1) { boxes[0].setBackgroundResource(R.drawable.bg_color_top) for (i in 1 until boxes.size) { boxes[i].setBackgroundResource(R.drawable.bg_color_center) } boxes[boxes.size - 1].setBackgroundResource(R.drawable.bg_color_bottom) } textIsDub?.apply { backgroundTintList = ColorStateList.valueOf(context.colorFromAttribute(R.attr.textColor)) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionAdapter.kt ================================================ package com.lagradost.cloudstream3.ui.search import android.view.LayoutInflater import android.view.ViewGroup import androidx.core.view.isGone import com.lagradost.cloudstream3.databinding.SearchSuggestionFooterBinding import com.lagradost.cloudstream3.databinding.SearchSuggestionItemBinding import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout const val SEARCH_SUGGESTION_CLICK = 0 const val SEARCH_SUGGESTION_FILL = 1 const val SEARCH_SUGGESTION_CLEAR = 2 data class SearchSuggestionCallback( val suggestion: String, val clickAction: Int, ) class SearchSuggestionAdapter( private val clickCallback: (SearchSuggestionCallback) -> Unit, ) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> a == b })) { // Add footer for all layouts override val footers = 1 override fun submitList(list: Collection?, commitCallback: Runnable?) { super.submitList(list, commitCallback) // Notify footer to rebind when list changes to update visibility if (footers > 0) { notifyItemChanged(itemCount - 1) } } override fun onCreateContent(parent: ViewGroup): ViewHolderState { return ViewHolderState( SearchSuggestionItemBinding.inflate(LayoutInflater.from(parent.context), parent, false), ) } override fun onBindContent( holder: ViewHolderState, item: String, position: Int ) { val binding = holder.view as? SearchSuggestionItemBinding ?: return binding.apply { suggestionText.text = item // Click on the whole item to search suggestionItem.setOnClickListener { clickCallback.invoke(SearchSuggestionCallback(item, SEARCH_SUGGESTION_CLICK)) } // Click on the arrow to fill the search box without searching suggestionFill.setOnClickListener { clickCallback.invoke(SearchSuggestionCallback(item, SEARCH_SUGGESTION_FILL)) } } } override fun onCreateFooter(parent: ViewGroup): ViewHolderState { return ViewHolderState( SearchSuggestionFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false) ) } override fun onBindFooter(holder: ViewHolderState) { val binding = holder.view as? SearchSuggestionFooterBinding ?: return binding.clearSuggestionsButton.apply { isGone = immutableCurrentList.isEmpty() if (isLayout(TV or EMULATOR)) { isFocusable = true isFocusableInTouchMode = true } setOnClickListener { clickCallback.invoke(SearchSuggestionCallback("", SEARCH_SUGGESTION_CLEAR)) } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchSuggestionApi.kt ================================================ package com.lagradost.cloudstream3.ui.search import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.nicehttp.NiceResponse /** * API for fetching search suggestions from external sources. * Uses TheMovieDB API to provide movie/show/anime related suggestions. */ object SearchSuggestionApi { private const val TMDB_API_URL = "https://api.themoviedb.org/3/search/multi" private const val TMDB_API_KEY = "e6333b32409e02a4a6eba6fb7ff866bb" data class TmdbSearchResult( @JsonProperty("results") val results: List? ) data class TmdbSearchItem( @JsonProperty("media_type") val mediaType: String?, @JsonProperty("title") val title: String?, @JsonProperty("name") val name: String?, @JsonProperty("original_title") val originalTitle: String?, @JsonProperty("original_name") val originalName: String? ) /** * Fetches search suggestions from TheMovieDB multi search API. * Returns suggestions for movies, TV series, and anime. * * @param query The search query to get suggestions for * @return List of suggestion strings, empty list on failure */ suspend fun getSuggestions(query: String): List { if (query.isBlank() || query.length < 2) return emptyList() return try { val response = app.get( TMDB_API_URL, params = mapOf( "api_key" to TMDB_API_KEY, "query" to query, "language" to "en-US" ), cacheTime = 60 * 24 // Cache for 1 day (cacheUnit default is Minutes) ) parseSuggestions(response) } catch (e: Exception) { logError(e) emptyList() } } /** * Parses the TMDB search response and extracts movie/TV show titles. * Filters to only include movies, TV shows, and anime. */ private fun parseSuggestions(response: NiceResponse): List { return try { val parsed = response.parsed() parsed.results ?.filter { it.mediaType == "movie" || it.mediaType == "tv" } ?.mapNotNull { it.title ?: it.name } ?.distinct() ?.take(10) ?: emptyList() } catch (e: Exception) { logError(e) emptyList() } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/search/SearchViewModel.kt ================================================ package com.lagradost.cloudstream3.ui.search import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.Resource import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.mvvm.debugWarning import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.home.HomeViewModel import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.DataStoreHelper.currentAccount import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext data class ExpandableSearchList( var list: List, var currentPage: Int, var hasNext: Boolean, ) const val SEARCH_HISTORY_KEY = "search_history" class SearchViewModel : ViewModel() { private val _searchResponse: MutableLiveData> = MutableLiveData() val searchResponse: LiveData> get() = _searchResponse private val _currentSearch: MutableLiveData> = MutableLiveData() val currentSearch: LiveData> get() = _currentSearch private val _currentHistory: MutableLiveData> = MutableLiveData() val currentHistory: LiveData> get() = _currentHistory private val _searchSuggestions: MutableLiveData> = MutableLiveData() val searchSuggestions: LiveData> get() = _searchSuggestions private var suggestionJob: Job? = null private var repos = synchronized(apis) { apis.map { APIRepository(it) } } fun clearSearch() { _searchResponse.postValue(Resource.Success(ExpandableSearchList(emptyList(), 0, false))) _currentSearch.postValue(emptyMap()) expandableSearches.clear() } var lastQuery: String? = null /** Save which providers can searched again and which search result page they are on. * Maps provider name to search list. * @see [HomeViewModel.expandable] */ private val expandableSearches: MutableMap = mutableMapOf() private var currentSearchIndex = 0 private var onGoingSearch: Job? = null fun reloadRepos() { repos = synchronized(apis) { apis.map { APIRepository(it) } } } fun searchAndCancel( query: String, providersActive: Set = setOf(), ignoreSettings: Boolean = false, isQuickSearch: Boolean = false, ) { currentSearchIndex++ onGoingSearch?.cancel() onGoingSearch = search(query, providersActive, ignoreSettings, isQuickSearch) } fun updateHistory() = ioSafe { val items = getKeys("$currentAccount/$SEARCH_HISTORY_KEY")?.mapNotNull { getKey(it) }?.sortedByDescending { it.searchedAt } ?: emptyList() _currentHistory.postValue(items) } /** * Fetches search suggestions with debouncing. * Waits 300ms before making the API call to avoid too many requests. * * @param query The search query to get suggestions for */ fun fetchSuggestions(query: String) { suggestionJob?.cancel() if (query.isBlank() || query.length < 2) { _searchSuggestions.postValue(emptyList()) return } suggestionJob = ioSafe { delay(300) // Debounce val suggestions = SearchSuggestionApi.getSuggestions(query) _searchSuggestions.postValue(suggestions) } } /** * Clears the current search suggestions. */ fun clearSuggestions() { suggestionJob?.cancel() _searchSuggestions.postValue(emptyList()) } private val lock: MutableSet = mutableSetOf() // ExpandableHomepageList because the home adapter is reused in the search fragment suspend fun expandAndReturn(name: String): HomeViewModel.ExpandableHomepageList? { if (lock.contains(name)) return null val query = lastQuery ?: return null val repo = repos.find { it.name == name } ?: return null lock += name expandableSearches[name]?.let { current -> debugAssert({ !current.hasNext }) { "Expand called when not needed" } val nextPage = current.currentPage + 1 val next = repo.search(query, nextPage) if (next is Resource.Success) { val nextValue = next.value expandableSearches[name]?.apply { this.hasNext = nextValue.hasNext this.currentPage = nextPage debugWarning({ nextValue.items.any { outer -> this.list.any { it.url == outer.url } } }) { "Expanded search contained an item that was previously already in the list.\nQuery = $query, ${nextValue.items} = ${this.list}" } // just to be sure we are not adding the same shit for some reason // Avoids weird behavior in the recyclerview by recreating the list this.list = (this.list + nextValue.items).distinctBy { it.url } } ?: debugWarning { "Expanded an item not in search load named $name, current list is ${expandableSearches.keys}" } } else { current.hasNext = false } _searchResponse.postValue(Resource.Success(bundleSearch(expandableSearches))) _currentSearch.postValue(expandableSearches) } lock -= name val item = expandableSearches[name] ?: return null return HomeViewModel.ExpandableHomepageList( HomePageList(name, item.list), item.currentPage, item.hasNext ) } private fun bundleSearch(lists: MutableMap): ExpandableSearchList { if (lists.size == 1) { return lists.values.first() } val list = ArrayList() val nestedList = lists.map { it.value.list } // I do it this way to move the relevant search results to the top var index = 0 while (true) { var added = 0 for (sublist in nestedList) { if (sublist.size > index) { list.add(sublist[index]) added++ } } if (added == 0) break index++ } return ExpandableSearchList(list, 1, false) } private fun search( query: String, providersActive: Set, ignoreSettings: Boolean = false, isQuickSearch: Boolean = false, ) = viewModelScope.launchSafe { val currentIndex = currentSearchIndex if (query.length <= 1) { clearSearch() return@launchSafe } if (!isQuickSearch) { val key = query.hashCode().toString() setKey( "$currentAccount/$SEARCH_HISTORY_KEY", key, SearchHistoryItem( searchedAt = System.currentTimeMillis(), searchText = query, type = emptyList(), // TODO implement tv type key = key, ) ) } _searchResponse.postValue(Resource.Loading()) _currentSearch.postValue(emptyMap()) expandableSearches.clear() lastQuery = query withContext(Dispatchers.IO) { // This interrupts UI otherwise repos.filter { a -> (ignoreSettings || (providersActive.isEmpty() || providersActive.contains(a.name))) && (!isQuickSearch || a.hasQuickSearch) }.amap { a -> // Parallel val search = if (isQuickSearch) a.quickSearch(query) else a.search(query, 1) if (currentSearchIndex != currentIndex) return@amap if (search is Resource.Success) { val searchValue = search.value expandableSearches[a.name] = ExpandableSearchList(searchValue.items, 1, searchValue.hasNext) } _currentSearch.postValue(expandableSearches) } if (currentSearchIndex != currentIndex) return@withContext // this should prevent rewrite of existing data bug _currentSearch.postValue(expandableSearches) val list = bundleSearch(expandableSearches) _searchResponse.postValue(Resource.Success(list)) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/search/SyncSearchViewModel.kt ================================================ package com.lagradost.cloudstream3.ui.search import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.TvType //TODO Relevance of this class since it's not used class SyncSearchViewModel { data class SyncSearchResultSearchResponse( override val name: String, override val url: String, override val apiName: String, override var type: TvType?, override var posterUrl: String?, override var id: Int?, override var quality: SearchQuality? = null, override var posterHeaders: Map? = null, override var score: Score? = null, ) : SearchResponse } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/AccountAdapter.kt ================================================ package com.lagradost.cloudstream3.ui.settings import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.view.isVisible import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.AccountSingleBinding import com.lagradost.cloudstream3.syncproviders.AuthData import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.utils.ImageLoader.loadImage class AccountClickCallback(val action: Int, val view: View, val card: AuthData) class AccountAdapter( private val clickCallback: (AccountClickCallback) -> Unit ) : NoStateAdapter( diffCallback = BaseDiffCallback(itemSame = { a, b -> a.user.id == b.user.id }) ) { override fun onCreateContent(parent: ViewGroup): ViewHolderState { return ViewHolderState( AccountSingleBinding.inflate( LayoutInflater.from(parent.context), parent, false ) ) } override fun onClearView(holder: ViewHolderState) { val binding = holder.view as? AccountSingleBinding ?: return clearImage(binding.accountProfilePicture) } override fun onBindContent(holder: ViewHolderState, item: AuthData, position: Int) { val binding = holder.view as? AccountSingleBinding ?: return binding.apply { accountName.text = item.user.name ?: "${binding.accountName.context.getString(R.string.account)} ${position + 1}" accountProfilePicture.isVisible = true accountProfilePicture.loadImage( item.user.profilePicture, headers = item.user.profilePictureHeaders ) root.setOnClickListener { clickCallback.invoke(AccountClickCallback(0, root, item)) } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/Globals.kt ================================================ package com.lagradost.cloudstream3.ui.settings import android.app.UiModeManager import android.content.Context import android.content.res.Configuration import android.content.res.Resources import android.os.Build import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R object Globals { var beneneCount = 0 const val PHONE : Int = 0b001 const val TV : Int = 0b010 const val EMULATOR : Int = 0b100 private const val INVALID = -1 private var layoutId = INVALID private fun Context.getLayoutInt(): Int { val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) return settingsManager.getInt(this.getString(R.string.app_layout_key), -1) } private fun Context.isAutoTv(): Boolean { val uiModeManager = getSystemService(Context.UI_MODE_SERVICE) as UiModeManager? // AFT = Fire TV val model = Build.MODEL.lowercase() return uiModeManager?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION || Build.MODEL.contains( "AFT" ) || model.contains("firestick") || model.contains("fire tv") || model.contains("chromecast") } private fun Context.layoutIntCorrected(): Int { return when(getLayoutInt()) { -1 -> if (isAutoTv()) TV else PHONE 0 -> PHONE 1 -> TV 2 -> EMULATOR else -> PHONE } } fun Context.updateTv() { layoutId = layoutIntCorrected() } /** Returns true if the current orientation is landscape. */ fun isLandscape(): Boolean = isLayout(TV or EMULATOR) || Resources.getSystem().configuration.orientation == Configuration.ORIENTATION_LANDSCAPE /** Returns true if the layout is any of the flags, * so isLayout(TV or EMULATOR) is a valid statement for checking if the layout is in the emulator * or tv. Auto will become the "TV" or the "PHONE" layout. * * Valid flags are: PHONE, TV, EMULATOR * */ fun isLayout(flags: Int) : Boolean { return (layoutId and flags) != 0 } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/LogcatAdapter.kt ================================================ package com.lagradost.cloudstream3.ui.settings import android.view.LayoutInflater import android.view.ViewGroup import com.lagradost.cloudstream3.databinding.ItemLogcatBinding import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState class LogcatAdapter() : NoStateAdapter( diffCallback = BaseDiffCallback( itemSame = String::equals, contentSame = String::equals ) ) { override fun onCreateContent(parent: ViewGroup): ViewHolderState { return ViewHolderState( ItemLogcatBinding.inflate( LayoutInflater.from(parent.context), parent, false ) ) } override fun onBindContent(holder: ViewHolderState, item: String, position: Int) { (holder.view as? ItemLogcatBinding)?.apply { logText.text = item } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsAccount.kt ================================================ package com.lagradost.cloudstream3.ui.settings import android.annotation.SuppressLint import android.graphics.Bitmap import android.os.Bundle import android.os.CountDownTimer import android.view.View import android.view.View.FOCUS_DOWN import android.view.inputmethod.EditorInfo import android.widget.TextView import androidx.annotation.UiThread import androidx.appcompat.app.AlertDialog import androidx.core.content.edit import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.fragment.app.FragmentActivity import androidx.preference.PreferenceManager import androidx.preference.SwitchPreference import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.ErrorLoadingException import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.AccountManagmentBinding import com.lagradost.cloudstream3.databinding.AccountSwitchBinding import com.lagradost.cloudstream3.databinding.AddAccountInputBinding import com.lagradost.cloudstream3.databinding.DeviceAuthBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.aniListApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.malApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.kitsuApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.openSubtitlesApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.simklApi import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.subDlApi import com.lagradost.cloudstream3.syncproviders.AuthLoginResponse import com.lagradost.cloudstream3.syncproviders.AuthRepo import com.lagradost.cloudstream3.syncproviders.AuthUser import com.lagradost.cloudstream3.syncproviders.SubtitleRepo import com.lagradost.cloudstream3.syncproviders.SyncRepo import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.BiometricAuthenticator.BiometricCallback import com.lagradost.cloudstream3.utils.BiometricAuthenticator.authCallback import com.lagradost.cloudstream3.utils.BiometricAuthenticator.biometricPrompt import com.lagradost.cloudstream3.utils.BiometricAuthenticator.deviceHasPasswordPinLock import com.lagradost.cloudstream3.utils.BiometricAuthenticator.isAuthEnabled import com.lagradost.cloudstream3.utils.BiometricAuthenticator.promptInfo import com.lagradost.cloudstream3.utils.BiometricAuthenticator.startBiometricAuthentication import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialogText import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt import qrcode.QRCode class SettingsAccount : BasePreferenceFragmentCompat(), BiometricCallback { companion object { /** Used by nginx plugin too */ @SuppressLint("StringFormatInvalid") fun showLoginInfo( activity: FragmentActivity?, api: AuthRepo, info: AuthUser?, index: Int, ) { if (activity == null) return val binding: AccountManagmentBinding = AccountManagmentBinding.inflate(activity.layoutInflater, null, false) val builder = AlertDialog.Builder(activity, R.style.AlertDialogCustom) .setView(binding.root) val dialog = builder.show() binding.accountMainProfilePictureHolder.isVisible = !info?.profilePicture.isNullOrEmpty() binding.accountMainProfilePicture.loadImage(info?.profilePicture) binding.accountLogout.isVisible = info != null binding.accountLogout.setOnClickListener { if (info != null) { ioSafe { api.logout(info) } } dialog.dismissSafe(activity) } dialog.findViewById(R.id.account_name)?.text = if (info != null) { info.name ?: "%s %d".format( activity.getString(R.string.account), index + 1 ) } else { activity.getString(R.string.no_account) } binding.accountSite.text = api.name binding.accountSwitchAccount.setOnClickListener { dialog.dismissSafe(activity) showAccountSwitch(activity, api) } if (isLayout(TV or EMULATOR)) { binding.accountSwitchAccount.requestFocus() } } private fun showAccountSwitch(activity: FragmentActivity, api: AuthRepo) { val accounts = api.accounts val binding: AccountSwitchBinding = AccountSwitchBinding.inflate(activity.layoutInflater, null, false) val builder = AlertDialog.Builder(activity, R.style.AlertDialogCustom) .setView(binding.root) val dialog = builder.show() binding.accountAdd.setOnClickListener { addAccount(activity, api) dialog?.dismissSafe(activity) } binding.accountNone.setOnClickListener { api.accountId = -1 dialog?.dismissSafe(activity) } val adapter = AccountAdapter { dialog?.dismissSafe(activity) api.accountId = it.card.user.id }.apply { submitList(accounts.toList()) } val list = dialog.findViewById(R.id.account_list) list?.adapter = adapter } @UiThread fun showPin(activity: FragmentActivity, api: AuthRepo) { val binding: DeviceAuthBinding = DeviceAuthBinding.inflate(activity.layoutInflater, null, false) val builder = AlertDialog.Builder(activity) .setView(binding.root) builder.apply { setNegativeButton(R.string.cancel) { _, _ -> } if (api.hasOAuth2) { setPositiveButton(R.string.auth_locally) { _, _ -> api.openOAuth2PageWithToast() } } } val dialog = builder.create() ioSafe { val pinCodeData = try { api.pinRequest() } catch (e: ErrorLoadingException) { if (e.message != null) { showToast(e.message) null } else { throw e } } catch (t: Throwable) { logError(t) null } if (pinCodeData == null) { if (api.hasOAuth2) { showToast(R.string.device_pin_error_message) api.openOAuth2PageWithToast() } else { showToast( txt( R.string.authenticated_user_fail, api.name ) ) } return@ioSafe } /*val logoBytes = ContextCompat.getDrawable( activity, R.drawable.cloud_2_solid )?.toBitmapOrNull()?.let { bitmap -> val csLogo = ByteArrayOutputStream() bitmap.compress(Bitmap.CompressFormat.PNG, 100, csLogo) csLogo.toByteArray() }*/ val qrCodeImage = QRCode.ofRoundedSquares() .withColor(activity.colorFromAttribute(R.attr.textColor)) .withBackgroundColor(activity.colorFromAttribute(R.attr.primaryBlackBackground)) //.withLogo(logoBytes, 200.toPx, 200.toPx) //For later if logo needed anytime .build(pinCodeData.verificationUrl) .render().nativeImage() as Bitmap activity.runOnUiThread { dialog.show() binding.apply { devicePinCode.setText(txt(pinCodeData.userCode)) deviceAuthMessage.setText( txt( R.string.device_pin_url_message, pinCodeData.verificationUrl ) ) deviceAuthQrcode.loadImage(qrCodeImage) } val expirationMillis = pinCodeData.expiresIn.times(1000).toLong() object : CountDownTimer(expirationMillis, 1000) { override fun onTick(millisUntilFinished: Long) { val secondsUntilFinished = millisUntilFinished.div(1000).toInt() binding.deviceAuthValidationCounter.setText( txt( R.string.device_pin_counter_text, secondsUntilFinished.div(60), secondsUntilFinished.rem(60) ) ) ioSafe { if (secondsUntilFinished.rem(pinCodeData.interval) == 0 && api.login( pinCodeData ) ) { showToast( txt( R.string.authenticated_user, api.name ) ) dialog.dismissSafe(activity) cancel() } } } override fun onFinish() { showToast(R.string.device_pin_expired_message) dialog.dismissSafe(activity) } }.start() } } } fun showAppLogin(activity: FragmentActivity, api: AuthRepo) { val binding: AddAccountInputBinding = AddAccountInputBinding.inflate(activity.layoutInflater, null, false) val builder = AlertDialog.Builder(activity, R.style.AlertDialogCustom) .setView(binding.root) val dialog = builder.show() val req = api.inAppLoginRequirement ?: throw ErrorLoadingException("Missing LoginRequirement") val visibilityMap = listOf( binding.loginEmailInput to req.email, binding.loginPasswordInput to req.password, binding.loginServerInput to req.server, binding.loginUsernameInput to req.username ) if (isLayout(TV or EMULATOR)) { visibilityMap.forEach { (input, isVisible) -> input.isVisible = isVisible // Band-aid for weird FireTV behavior causing crashes because keyboard covers the screen input.setOnEditorActionListener { textView, actionId, _ -> if (actionId == EditorInfo.IME_ACTION_NEXT) { val view = textView.focusSearch(FOCUS_DOWN) return@setOnEditorActionListener view?.requestFocus( FOCUS_DOWN ) == true } return@setOnEditorActionListener true } } } else { visibilityMap.forEach { (input, isVisible) -> input.isVisible = isVisible } } binding.createAccount.isGone = api.createAccountUrl.isNullOrBlank() binding.createAccount.setOnClickListener { openBrowser( api.createAccountUrl ?: return@setOnClickListener, activity ) dialog.dismissSafe() } val displayedItems = listOf( binding.loginUsernameInput, binding.loginEmailInput, binding.loginServerInput, binding.loginPasswordInput ).filter { it.isVisible } displayedItems.foldRight(displayedItems.firstOrNull()) { item, previous -> item.id.let { previous?.nextFocusDownId = it } previous?.id?.let { item.nextFocusUpId = it } item } displayedItems.firstOrNull()?.let { binding.createAccount.nextFocusDownId = it.id it.nextFocusUpId = binding.createAccount.id } binding.applyBtt.id.let { displayedItems.lastOrNull()?.nextFocusDownId = it } binding.text1.text = api.name binding.applyBtt.setOnClickListener { val loginData = AuthLoginResponse( username = if (req.username) binding.loginUsernameInput.text?.toString() else null, password = if (req.password) binding.loginPasswordInput.text?.toString() else null, email = if (req.email) binding.loginEmailInput.text?.toString() else null, server = if (req.server) binding.loginServerInput.text?.toString() else null, ) ioSafe { try { if (api.login(loginData)) { showToast( txt( R.string.authenticated_user, api.name ) ) dialog.dismissSafe(activity) } else { showToast( txt( R.string.authenticated_user_fail, api.name ) ) } } catch (t: Throwable) { if (t is ErrorLoadingException && t.message != null) { showToast(t.message) return@ioSafe } showToast( txt( R.string.authenticated_user_fail, api.name ) ) } } } binding.cancelBtt.setOnClickListener { dialog.dismissSafe(activity) } } @UiThread fun addAccount(activity: FragmentActivity, api: AuthRepo) { try { if (api.hasPin && !isLayout(PHONE)) { showPin(activity, api) } else if (api.hasOAuth2) { api.openOAuth2PageWithToast() } else if (api.hasInApp) { showAppLogin(activity, api) } else { throw NotImplementedError("The api ${api.name} has no login") } } catch (t: Throwable) { showToast(txt(R.string.authenticated_user_fail, api.name)) logError(t) } } } private fun updateAuthPreference(enabled: Boolean) { val biometricKey = getString(R.string.biometric_key) PreferenceManager.getDefaultSharedPreferences(context ?: return).edit { putBoolean(biometricKey, enabled) } findPreference(biometricKey)?.isChecked = enabled } override fun onAuthenticationError() { updateAuthPreference(!isAuthEnabled(context ?: return)) } override fun onAuthenticationSuccess() { if (isAuthEnabled(context ?: return)) { updateAuthPreference(true) BackupUtils.backup(activity) activity?.showBottomDialogText( getString(R.string.biometric_setting), getString(R.string.biometric_warning).html() ) { onDialogDismissedEvent } } else { updateAuthPreference(false) } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_account) setPaddingBottom() setToolBarScrollFlags() } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_account, rootKey) //Hides the security category on TV as it's only Biometric for now getPref(R.string.pref_category_security_key)?.hideOn(TV or EMULATOR) getPref(R.string.biometric_key)?.hideOn(TV or EMULATOR)?.setOnPreferenceClickListener { val ctx = context ?: return@setOnPreferenceClickListener false if (deviceHasPasswordPinLock(ctx)) { startBiometricAuthentication( activity ?: return@setOnPreferenceClickListener false, R.string.biometric_authentication_title, false ) promptInfo?.let { authCallback = this biometricPrompt?.authenticate(it) } } false } val syncApis = listOf( R.string.mal_key to SyncRepo(malApi), R.string.kitsu_key to SyncRepo(kitsuApi), R.string.anilist_key to SyncRepo(aniListApi), R.string.simkl_key to SyncRepo(simklApi), R.string.opensubtitles_key to SubtitleRepo(openSubtitlesApi), R.string.subdl_key to SubtitleRepo(subDlApi), ) for ((key, api) in syncApis) { getPref(key)?.apply { title = api.name setOnPreferenceClickListener { val activity = activity ?: return@setOnPreferenceClickListener false val info = api.authUser() val index = api.accounts.indexOfFirst { account -> account.user.id == info?.id } if (api.accounts.isNotEmpty()) { showLoginInfo(activity, api, info, index) } else { addAccount(activity, api) } return@setOnPreferenceClickListener true } } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsFragment.kt ================================================ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.util.Log import android.view.View import android.widget.ImageView import androidx.annotation.StringRes import androidx.core.view.children import androidx.core.view.updateLayoutParams import androidx.fragment.app.Fragment import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.MaterialToolbar import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.MainSettingsBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.AuthRepo import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.errorProfilePic import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.getImageFromDrawable import com.lagradost.cloudstream3.utils.txt import java.io.File import java.text.DateFormat import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone class SettingsFragment : BaseFragment( BaseFragment.BindingCreator.Inflate(MainSettingsBinding::inflate) ) { companion object { fun PreferenceFragmentCompat?.getPref(id: Int): Preference? { if (this == null) return null return try { findPreference(getString(id)) } catch (e: Exception) { logError(e) null } } /** * Hide many Preferences on selected layouts. **/ fun PreferenceFragmentCompat?.hidePrefs(ids: List, layoutFlags: Int) { if (this == null) return try { ids.forEach { getPref(it)?.isVisible = !isLayout(layoutFlags) } } catch (e: Exception) { logError(e) } } /** * Hide the [Preference] on selected layouts. * @return [Preference] if visible otherwise null. * * [hideOn] is usually followed by some actions on the preference which are mostly * unnecessary when the preference is disabled for the said layout thus returning null. **/ fun Preference?.hideOn(layoutFlags: Int): Preference? { if (this == null) return null this.isVisible = !isLayout(layoutFlags) return if(this.isVisible) this else null } /** * On TV you cannot properly scroll to the bottom of settings, this fixes that. * */ fun PreferenceFragmentCompat.setPaddingBottom() { if (isLayout(TV or EMULATOR)) { listView?.setPadding(0, 0, 0, 100.toPx) } } fun PreferenceFragmentCompat.setToolBarScrollFlags() { if (isLayout(TV or EMULATOR)) { val settingsAppbar = view?.findViewById(R.id.settings_toolbar) settingsAppbar?.updateLayoutParams { scrollFlags = AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL } } } fun Fragment?.setToolBarScrollFlags() { if (isLayout(TV or EMULATOR)) { val settingsAppbar = this?.view?.findViewById(R.id.settings_toolbar) settingsAppbar?.updateLayoutParams { scrollFlags = AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL } } } fun Fragment?.setUpToolbar(title: String) { if (this == null) return val settingsToolbar = view?.findViewById(R.id.settings_toolbar) ?: return settingsToolbar.apply { setTitle(title) if (isLayout(PHONE or EMULATOR)) { setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) setNavigationOnClickListener { activity?.onBackPressedDispatcher?.onBackPressed() } } } } fun Fragment?.setUpToolbar(@StringRes title: Int) { if (this == null) return val settingsToolbar = view?.findViewById(R.id.settings_toolbar) ?: return settingsToolbar.apply { setTitle(title) if (isLayout(PHONE or EMULATOR)) { setNavigationIcon(R.drawable.ic_baseline_arrow_back_24) children.firstOrNull { it is ImageView }?.tag = getString(R.string.tv_no_focus_tag) setNavigationOnClickListener { safe { activity?.onBackPressedDispatcher?.onBackPressed() } } } } } fun Fragment.setSystemBarsPadding() { view?.let { fixSystemBarsPadding( it, padLeft = isLayout(TV or EMULATOR), padBottom = isLandscape() ) } } fun getFolderSize(dir: File): Long { var size: Long = 0 dir.listFiles()?.let { for (file in it) { size += if (file.isFile) { // System.out.println(file.getName() + " " + file.length()); file.length() } else getFolderSize(file) } } return size } } override fun fixLayout(view: View) { fixSystemBarsPadding( view, padBottom = isLandscape(), padLeft = isLayout(TV or EMULATOR) ) } override fun onBindingCreated(binding: MainSettingsBinding) { fun navigate(id: Int) { activity?.navigate(id, Bundle()) } /** used to debug leaks showToast(activity,"${VideoDownloadManager.downloadStatusEvent.size} : ${VideoDownloadManager.downloadProgressEvent.size}") **/ fun hasProfilePictureFromAccountManagers(accountManagers: Array): Boolean { for (syncApi in accountManagers) { val login = syncApi.authUser() val pic = login?.profilePicture ?: continue binding.settingsProfilePic.let { imageView -> imageView.loadImage(pic) { // Fallback to random error drawable error { getImageFromDrawable(context ?: return@error null, errorProfilePic) } } } binding.settingsProfileText.text = login.name return true // sync profile exists } return false // not syncing } // display local account information if not syncing if (!hasProfilePictureFromAccountManagers(AccountManager.allApis)) { val activity = activity ?: return val currentAccount = try { DataStoreHelper.accounts.firstOrNull { it.keyIndex == DataStoreHelper.selectedKeyIndex } ?: activity.let { DataStoreHelper.getDefaultAccount(activity) } } catch (t: IllegalStateException) { Log.e("AccountManager", "Activity not found", t) null } binding.settingsProfilePic.loadImage(currentAccount?.image) binding.settingsProfileText.text = currentAccount?.name } binding.apply { listOf( settingsGeneral to R.id.action_navigation_global_to_navigation_settings_general, settingsPlayer to R.id.action_navigation_global_to_navigation_settings_player, settingsCredits to R.id.action_navigation_global_to_navigation_settings_account, settingsUi to R.id.action_navigation_global_to_navigation_settings_ui, settingsProviders to R.id.action_navigation_global_to_navigation_settings_providers, settingsUpdates to R.id.action_navigation_global_to_navigation_settings_updates, settingsExtensions to R.id.action_navigation_global_to_navigation_settings_extensions, ).forEach { (view, navigationId) -> view.apply { setOnClickListener { navigate(navigationId) } if (isLayout(TV)) { isFocusable = true isFocusableInTouchMode = true } } } // Default focus on TV if (isLayout(TV)) { settingsGeneral.requestFocus() } } val appVersion = BuildConfig.VERSION_NAME val commitInfo = getString(R.string.commit_hash) val buildTimestamp = SimpleDateFormat.getDateTimeInstance(DateFormat.LONG, DateFormat.LONG, Locale.getDefault() ).apply { timeZone = TimeZone.getTimeZone("UTC") }.format(Date(BuildConfig.BUILD_DATE)).replace("UTC", "") binding.appVersion.text = appVersion binding.buildDate.text = buildTimestamp binding.appVersionInfo.setOnLongClickListener { clipboardHelper(txt(R.string.extension_version), "$appVersion $commitInfo $buildTimestamp") true } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsGeneral.kt ================================================ package com.lagradost.cloudstream3.ui.settings import android.content.Context import android.net.Uri import android.os.Bundle import android.view.View import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.content.edit import androidx.core.os.ConfigurationCompat import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder.allProviders import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.databinding.AddRemoveSitesBinding import com.lagradost.cloudstream3.databinding.AddSiteInputBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.network.initClient import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.beneneCount import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.ui.settings.utils.getChooseFolderLauncher import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.isAppRestricted import com.lagradost.cloudstream3.utils.BatteryOptimizationChecker.showBatteryOptimizationDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.USER_PROVIDER_API import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager import java.util.Locale // Change local language settings in the app. fun getCurrentLocale(context: Context): String { val conf = context.resources.configuration return ConfigurationCompat.getLocales(conf).get(0)?.toLanguageTag() ?: "en" } /** * List of app supported languages. * Language code shall be a IETF BCP 47 conformant tag * * See locales on: * https://github.com/unicode-org/cldr-json/blob/main/cldr-json/cldr-core/availableLocales.json * https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry * https://android.googlesource.com/platform/frameworks/base/+/android-16.0.0_r2/core/res/res/values/locale_config.xml * https://iso639-3.sil.org/code_tables/639/data/all */ val appLanguages = arrayListOf( /* begin language list */ Pair("Afrikaans", "af"), Pair("Azərbaycan dili", "az"), Pair("Bahasa Indonesia", "in"), Pair("Bahasa Melayu", "ms"), Pair("Deutsch", "de"), Pair("English", "en"), Pair("Español", "es"), Pair("Esperanto", "eo"), Pair("Français", "fr"), Pair("Galego", "gl"), Pair("hrvatski", "hr"), Pair("Italiano", "it"), Pair("Latviešu valoda", "lv"), Pair("Lietuvių kalba", "lt"), Pair("Magyar", "hu"), Pair("Malti", "mt"), Pair("mmmm... monke", "qt"), Pair("Nederlands", "nl"), Pair("Norsk bokmål", "no"), Pair("Norsk nynorsk", "nn"), Pair("Polski", "pl"), Pair("Português", "pt"), Pair("Português (Brasil)", "pt-BR"), Pair("Română", "ro"), Pair("Slovenčina", "sk"), Pair("Soomaaliga", "so"), Pair("Svenska", "sv"), Pair("Tagalog", "tl"), Pair("Tiếng Việt", "vi"), Pair("Türkçe", "tr"), Pair("Wikang Filipino", "fil"), Pair("Čeština", "cs"), Pair("Ελληνικά", "el"), Pair("български", "bg"), Pair("македонски", "mk"), Pair("русский", "ru"), Pair("українська", "uk"), Pair("עברית", "iw"), Pair("اردو", "ur"), Pair("العربية", "ar"), Pair("اللهجة النجدية", "ars"), Pair("عربي شامي", "apc"), Pair("فارسی", "fa"), Pair("کوردیی ناوەندی", "ckb"), Pair("नेपाली", "ne"), Pair("हिन्दी", "hi"), Pair("অসমীয়া", "as"), Pair("বাংলা", "bn"), Pair("ଓଡ଼ିଆ", "or"), Pair("தமிழ்", "ta"), Pair("ಕನ್ನಡ", "kn"), Pair("മലയാളം", "ml"), Pair("ဗမာစာ", "my"), Pair("ትግርኛ", "ti"), Pair("አማርኛ", "am"), Pair("中文", "zh"), Pair("日本語 (にほんご)", "ja"), Pair("正體中文(臺灣)", "zh-TW"), Pair("한국어", "ko"), /* end language list */ ).sortedBy { it.first.lowercase(Locale.ROOT) } // ye, we go alphabetical, so ppl don't put their lang on top fun Pair.nameNextToFlagEmoji(): String { // fallback to [A][A] -> [?] question mak flag val flag = SubtitleHelper.getFlagFromIso(this.second) ?: "\ud83c\udde6\ud83c\udde6" return "$flag\u00a0${this.first}" // \u00a0 non-breaking space } class SettingsGeneral : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_general) setPaddingBottom() setToolBarScrollFlags() } data class CustomSite( @JsonProperty("parentJavaClass") // javaClass.simpleName val parentJavaClass: String, @JsonProperty("name") val name: String, @JsonProperty("url") val url: String, @JsonProperty("lang") val lang: String, ) private val pathPicker = getChooseFolderLauncher { uri, path -> val context = context ?: CloudStreamApp.context ?: return@getChooseFolderLauncher (path ?: uri.toString()).let { PreferenceManager.getDefaultSharedPreferences(context).edit { putString(getString(R.string.download_path_key), uri.toString()) putString(getString(R.string.download_path_key_visual), it) } } } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_general, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) fun getCurrent(): MutableList { return getKey>(USER_PROVIDER_API)?.toMutableList() ?: mutableListOf() } getPref(R.string.locale_key)?.setOnPreferenceClickListener { pref -> val current = getCurrentLocale(pref.context) val languageTagsIETF = appLanguages.map { it.second } val languageNames = appLanguages.map { it.nameNextToFlagEmoji() } val currentIndex = languageTagsIETF.indexOf(current) activity?.showDialog( languageNames, currentIndex, getString(R.string.app_language), true, { } ) { selectedLangIndex -> try { val langTagIETF = languageTagsIETF[selectedLangIndex] CommonActivity.setLocale(activity, langTagIETF) settingsManager.edit { putString(getString(R.string.locale_key), langTagIETF) } activity?.recreate() } catch (e: Exception) { logError(e) } } return@setOnPreferenceClickListener true } getPref(R.string.battery_optimisation_key)?.hideOn(TV or EMULATOR)?.setOnPreferenceClickListener { val ctx = context ?: return@setOnPreferenceClickListener false if (isAppRestricted(ctx)) { ctx.showBatteryOptimizationDialog() } else { showToast(R.string.app_unrestricted_toast) } true } fun showAdd() { val providers = synchronized(allProviders) { allProviders.distinctBy { it.javaClass }.sortedBy { it.name } } activity?.showDialog( providers.map { "${it.name} (${it.mainUrl})" }, -1, context?.getString(R.string.add_site_pref) ?: return, true, {}) { selection -> val provider = providers.getOrNull(selection) ?: return@showDialog val binding : AddSiteInputBinding = AddSiteInputBinding.inflate(layoutInflater,null,false) val builder = AlertDialog.Builder(context ?: return@showDialog, R.style.AlertDialogCustom) .setView(binding.root) val dialog = builder.create() dialog.show() binding.text2.text = provider.name binding.applyBtt.setOnClickListener { val name = binding.siteNameInput.text?.toString() val url = binding.siteUrlInput.text?.toString() val lang = binding.siteLangInput.text?.toString() val realLang = if (lang.isNullOrBlank()) provider.lang else lang if (url.isNullOrBlank() || name.isNullOrBlank()) { showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT) return@setOnClickListener } val current = getCurrent() val newSite = CustomSite(provider.javaClass.simpleName, name, url, realLang) current.add(newSite) setKey(USER_PROVIDER_API, current.toTypedArray()) // reload apis MainActivity.afterPluginsLoadedEvent.invoke(false) dialog.dismissSafe(activity) } binding.cancelBtt.setOnClickListener { dialog.dismissSafe(activity) } } } fun showDelete() { val current = getCurrent() activity?.showMultiDialog( current.map { it.name }, listOf(), context?.getString(R.string.remove_site_pref) ?: return, {}) { indexes -> current.removeAll(indexes.map { current[it] }) setKey(USER_PROVIDER_API, current.toTypedArray()) } } fun showAddOrDelete() { val binding : AddRemoveSitesBinding = AddRemoveSitesBinding.inflate(layoutInflater,null,false) val builder = AlertDialog.Builder(context ?: return, R.style.AlertDialogCustom) .setView(binding.root) val dialog = builder.create() dialog.show() binding.addSite.setOnClickListener { showAdd() dialog.dismissSafe(activity) } binding.removeSite.setOnClickListener { showDelete() dialog.dismissSafe(activity) } } getPref(R.string.override_site_key)?.setOnPreferenceClickListener { _ -> if (getCurrent().isEmpty()) { showAdd() } else { showAddOrDelete() } return@setOnPreferenceClickListener true } getPref(R.string.legal_notice_key)?.setOnPreferenceClickListener { val builder: AlertDialog.Builder = AlertDialog.Builder(it.context, R.style.AlertDialogCustom) builder.setTitle(R.string.legal_notice) builder.setMessage(R.string.legal_notice_text) builder.show() return@setOnPreferenceClickListener true } getPref(R.string.dns_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.dns_pref) val prefValues = resources.getIntArray(R.array.dns_pref_values) val currentDns = settingsManager.getInt(getString(R.string.dns_pref), 0) activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(currentDns), getString(R.string.dns_pref), true, {}) { settingsManager.edit { putInt(getString(R.string.dns_pref), prefValues[it]) } (context ?: CloudStreamApp.context)?.let { ctx -> app.initClient(ctx) } } return@setOnPreferenceClickListener true } fun getDownloadDirs(): List { return safe { context?.let { ctx -> val defaultDir = DownloadFileManagement.getDefaultDir(ctx)?.filePath() val first = listOf(defaultDir) (try { val currentDir = ctx.getBasePath().let { it.first?.filePath() ?: it.second } (first + ctx.getExternalFilesDirs("").mapNotNull { it.path } + currentDir) } catch (e: Exception) { first }).filterNotNull().distinct() } } ?: emptyList() } settingsManager.edit { putBoolean(getString(R.string.jsdelivr_proxy_key), getKey(getString(R.string.jsdelivr_proxy_key), false) ?: false) } getPref(R.string.jsdelivr_proxy_key)?.setOnPreferenceChangeListener { _, newValue -> setKey(getString(R.string.jsdelivr_proxy_key), newValue) return@setOnPreferenceChangeListener true } getPref(R.string.download_parallel_key)?.setOnPreferenceChangeListener { _, _ -> // Notify that the queue logic has been changed DownloadQueueManager.forceRefreshQueue() return@setOnPreferenceChangeListener true } getPref(R.string.download_path_key)?.setOnPreferenceClickListener { val dirs = getDownloadDirs() val currentDir = settingsManager.getString(getString(R.string.download_path_key_visual), null) ?: context?.let { ctx -> DownloadFileManagement.getDefaultDir(ctx)?.filePath() } activity?.showBottomDialog( dirs + listOf(getString(R.string.custom)), dirs.indexOf(currentDir), getString(R.string.download_path_pref), true, {}) { // Last = custom if (it == dirs.size) { try { pathPicker.launch(Uri.EMPTY) } catch (e: Exception) { logError(e) } } else { // Sets both visual and actual paths. // key = used path // visual = visual path settingsManager.edit { putString(getString(R.string.download_path_key), dirs[it]) putString(getString(R.string.download_path_key_visual), dirs[it]) } } } return@setOnPreferenceClickListener true } try { beneneCount = settingsManager.getInt(getString(R.string.benene_count), 0) getPref(R.string.benene_count)?.let { pref -> pref.summary = if (beneneCount <= 0) getString(R.string.benene_count_text_none) else getString( R.string.benene_count_text ).format( beneneCount ) pref.setOnPreferenceClickListener { try { beneneCount++ if (beneneCount%20 == 0) { activity?.navigate(R.id.action_navigation_settings_general_to_easterEggMonkeFragment) } settingsManager.edit { putInt( getString(R.string.benene_count), beneneCount ) } it.summary = getString(R.string.benene_count_text).format(beneneCount) } catch (e: Exception) { logError(e) } return@setOnPreferenceClickListener true } } } catch (e: Exception) { e.printStackTrace() } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsPlayer.kt ================================================ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.text.format.Formatter.formatShortFileSize import android.view.View import androidx.core.content.edit import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.actions.VideoClickActionHolder import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat import com.lagradost.cloudstream3.ui.player.source_priority.QualityProfileDialog import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getFolderSize import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hidePrefs import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.ui.subtitles.ChromecastSubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Qualities import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard class SettingsPlayer : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_player) setPaddingBottom() setToolBarScrollFlags() } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_player, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) //Hide specific prefs on TV/EMULATOR hidePrefs( listOf( R.string.pref_category_gestures_key, R.string.rotate_video_key, R.string.auto_rotate_video_key, R.string.speedup_key ), TV or EMULATOR ) getPref(R.string.preview_seekbar_key)?.hideOn(TV) getPref(R.string.pref_category_android_tv_key)?.hideOn(PHONE) getPref(R.string.video_buffer_length_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.video_buffer_length_names) val prefValues = resources.getIntArray(R.array.video_buffer_length_values) val currentPrefSize = settingsManager.getInt(getString(R.string.video_buffer_length_key), 0) activity?.showDialog( prefNames.toList(), prefValues.indexOf(currentPrefSize), getString(R.string.video_buffer_length_settings), true, {} ) { settingsManager.edit { putInt(getString(R.string.video_buffer_length_key), prefValues[it]) } } return@setOnPreferenceClickListener true } getPref(R.string.prefer_limit_title_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.limit_title_pref_names) val prefValues = resources.getIntArray(R.array.limit_title_pref_values) val current = settingsManager.getInt(getString(R.string.prefer_limit_title_key), 0) activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(current), getString(R.string.limit_title), true, {} ) { settingsManager.edit { putInt(getString(R.string.prefer_limit_title_key), prefValues[it]) } } return@setOnPreferenceClickListener true } getPref(R.string.software_decoding_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.software_decoding_switch) val prefValues = resources.getIntArray(R.array.software_decoding_switch_values) val current = settingsManager.getInt(getString(R.string.software_decoding_key), -1) activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(current), getString(R.string.software_decoding), true, {} ) { settingsManager.edit { putInt(getString(R.string.software_decoding_key), prefValues[it]) } } return@setOnPreferenceClickListener true } getPref(R.string.prefer_limit_show_player_info)?.setOnPreferenceClickListener { val ctx = context ?: return@setOnPreferenceClickListener false val prefNames = resources.getStringArray(R.array.title_info_pref_names) val keys = resources.getStringArray(R.array.title_info_pref_values) // Player defaults val playerDefaults = mapOf( ctx.getString(R.string.show_name_key) to true, ctx.getString(R.string.show_resolution_key) to true, ctx.getString(R.string.show_media_info_key) to false ) val selectedIndices = keys.map { key -> settingsManager.getBoolean(key, playerDefaults[key] ?: false) }.mapIndexedNotNull { index, enabled -> if (enabled) index else null } activity?.showMultiDialog( prefNames.toList(), selectedIndices, getString(R.string.limit_title_rez), {} ) { selected -> settingsManager.edit { for ((index, key) in keys.withIndex()) { putBoolean(key, selected.contains(index)) } } } true } getPref(R.string.hide_player_control_names_key)?.hideOn(TV) getPref(R.string.quality_pref_key)?.setOnPreferenceClickListener { val prefValues = Qualities.entries.map { it.value }.reversed().toMutableList() prefValues.remove(Qualities.Unknown.value) val prefNames = prefValues.map { Qualities.getStringByInt(it) } val currentQuality = settingsManager.getInt( getString(R.string.quality_pref_key), Qualities.entries.last().value ) activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(currentQuality), getString(R.string.watch_quality_pref), true, {} ) { settingsManager.edit { putInt(getString(R.string.quality_pref_key), prefValues[it]) } } return@setOnPreferenceClickListener true } getPref(R.string.quality_pref_mobile_data_key)?.setOnPreferenceClickListener { val prefValues = Qualities.entries.map { it.value }.reversed().toMutableList() prefValues.remove(Qualities.Unknown.value) val prefNames = prefValues.map { Qualities.getStringByInt(it) } val currentQuality = settingsManager.getInt( getString(R.string.quality_pref_mobile_data_key), Qualities.entries.last().value ) activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(currentQuality), getString(R.string.watch_quality_pref_data), true, {} ) { settingsManager.edit { putInt(getString(R.string.quality_pref_mobile_data_key), prefValues[it]) } } return@setOnPreferenceClickListener true } getPref(R.string.player_default_key)?.setOnPreferenceClickListener { val players = VideoClickActionHolder.getPlayers(activity) val prefNames = buildList { add(getString(R.string.player_settings_play_in_app)) addAll(players.map { it.name.asStringNull(activity) ?: it.javaClass.simpleName }) } val prefValues = buildList { add("") addAll(players.map { it.uniqueId() }) } val current = settingsManager.getString(getString(R.string.player_default_key), "") ?: "" activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(current), getString(R.string.player_pref), true, {} ) { settingsManager.edit { putString(getString(R.string.player_default_key), prefValues[it]) } } return@setOnPreferenceClickListener true } getPref(R.string.subtitle_settings_key)?.setOnPreferenceClickListener { SubtitlesFragment.push(activity, false) return@setOnPreferenceClickListener true } getPref(R.string.subtitle_settings_chromecast_key)?.setOnPreferenceClickListener { ChromecastSubtitlesFragment.push(activity, false) return@setOnPreferenceClickListener true } getPref(R.string.player_source_priority_key)?.setOnPreferenceClickListener { ioSafe { val defaultSources = QualityProfileDialog.getAllDefaultSources() val activity = activity ?: return@ioSafe activity.runOnUiThread { QualityProfileDialog( activity, R.style.DialogFullscreenPlayer, defaultSources, ).show() } } return@setOnPreferenceClickListener true } getPref(R.string.video_buffer_disk_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.video_buffer_size_names) val prefValues = resources.getIntArray(R.array.video_buffer_size_values) val currentPrefSize = settingsManager.getInt(getString(R.string.video_buffer_disk_key), 0) activity?.showDialog( prefNames.toList(), prefValues.indexOf(currentPrefSize), getString(R.string.video_buffer_disk_settings), true, {} ) { settingsManager.edit { putInt(getString(R.string.video_buffer_disk_key), prefValues[it]) } } return@setOnPreferenceClickListener true } getPref(R.string.video_buffer_size_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.video_buffer_size_names) val prefValues = resources.getIntArray(R.array.video_buffer_size_values) val currentPrefSize = settingsManager.getInt(getString(R.string.video_buffer_size_key), 0) activity?.showDialog( prefNames.toList(), prefValues.indexOf(currentPrefSize), getString(R.string.video_buffer_size_settings), true, {} ) { settingsManager.edit { putInt(getString(R.string.video_buffer_size_key), prefValues[it]) } } return@setOnPreferenceClickListener true } getPref(R.string.video_buffer_clear_key)?.let { pref -> val cacheDir = context?.cacheDir ?: return@let fun updateSummary() { try { pref.summary = formatShortFileSize(pref.context, getFolderSize(cacheDir)) } catch (e: Exception) { logError(e) } } updateSummary() pref.setOnPreferenceClickListener { try { cacheDir.deleteRecursively() updateSummary() } catch (e: Exception) { logError(e) } return@setOnPreferenceClickListener true } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsProviders.kt ================================================ package com.lagradost.cloudstream3.ui.settings import android.os.Bundle import android.view.View import androidx.core.content.edit import androidx.navigation.fragment.findNavController import androidx.navigation.NavOptions import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.ui.APIRepository import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.utils.AppContextUtils.getApiDubstatusSettings import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard class SettingsProviders : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_providers) setPaddingBottom() setToolBarScrollFlags() } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_providers, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) getPref(R.string.display_sub_key)?.setOnPreferenceClickListener { activity?.getApiDubstatusSettings()?.let { current -> val dublist = DubStatus.entries val names = dublist.map { it.name } val currentList = ArrayList() for (i in current) { currentList.add(dublist.indexOf(i)) } activity?.showMultiDialog( names, currentList, getString(R.string.display_subbed_dubbed_settings), {} ) { selectedList -> APIRepository.dubStatusActive = selectedList.map { dublist[it] }.toHashSet() settingsManager.edit { putStringSet( getString(R.string.display_sub_key), selectedList.map { names[it] }.toMutableSet() ) } } } return@setOnPreferenceClickListener true } getPref(R.string.test_providers_key)?.setOnPreferenceClickListener { // Somehow animations do not work without this. val options = NavOptions.Builder() .setEnterAnim(R.anim.enter_anim) .setExitAnim(R.anim.exit_anim) .setPopEnterAnim(R.anim.pop_enter) .setPopExitAnim(R.anim.pop_exit) .build() this@SettingsProviders.findNavController() .navigate(R.id.navigation_test_providers, null, options) true } getPref(R.string.prefer_media_type_key)?.setOnPreferenceClickListener { val names = enumValues().sorted().map { it.name } val default = enumValues().sorted().filter { it != TvType.NSFW }.map { it.ordinal } val defaultSet = default.map { it.toString() }.toSet() val currentList = try { settingsManager.getStringSet(getString(R.string.prefer_media_type_key), defaultSet) ?.map { it.toInt() } } catch (e: Throwable) { null } ?: default activity?.showMultiDialog( names, currentList, getString(R.string.preferred_media_settings), {} ) { selectedList -> settingsManager.edit { putStringSet( getString(R.string.prefer_media_type_key), selectedList.map { it.toString() }.toMutableSet() ) } DataStoreHelper.currentHomePage = null //(context ?: CloudStreamApp.context)?.let { ctx -> app.initClient(ctx) } } return@setOnPreferenceClickListener true } getPref(R.string.provider_lang_key)?.setOnPreferenceClickListener { activity?.getApiProviderLangSettings()?.let { currentLangTags -> val languagesTagName = synchronized(APIHolder.apis) { listOf( Pair(AllLanguagesName, getString(R.string.all_languages_preference)) ) + APIHolder.apis.map { Pair(it.lang, getNameNextToFlagEmoji(it.lang) ?: it.lang) } .toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() } // name ignoring flag emoji } val currentIndexList = currentLangTags.map { langTag -> languagesTagName.indexOfFirst { lang -> lang.first == langTag } } activity?.showMultiDialog( languagesTagName.map { it.second }, currentIndexList, getString(R.string.provider_lang_settings), {} ) { selectedList -> settingsManager.edit { putStringSet( getString(R.string.provider_lang_key), selectedList.map { languagesTagName[it].first }.toSet() ) } // APIRepository.providersActive = it.context.getApiSettings() } } return@setOnPreferenceClickListener true } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUI.kt ================================================ package com.lagradost.cloudstream3.ui.settings import android.os.Build import android.os.Bundle import android.view.View import androidx.core.content.edit import androidx.preference.PreferenceManager import androidx.preference.SeekBarPreference import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat import com.lagradost.cloudstream3.ui.clear import com.lagradost.cloudstream3.ui.home.HomeChildItemAdapter import com.lagradost.cloudstream3.ui.home.ParentItemAdapter import com.lagradost.cloudstream3.ui.search.SearchAdapter import com.lagradost.cloudstream3.ui.search.SearchResultBuilder import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.updateTv import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.UIHelper.toPx class SettingsUI : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_ui) setPaddingBottom() setToolBarScrollFlags() } override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_ui, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) (getPref(R.string.overscan_key)?.hideOn(PHONE or EMULATOR) as? SeekBarPreference)?.setOnPreferenceChangeListener { pref, newValue -> val padding = (newValue as? Int)?.toPx ?: return@setOnPreferenceChangeListener true (pref.context.getActivity() as? MainActivity)?.binding?.homeRoot?.setPadding(padding, padding, padding, padding) return@setOnPreferenceChangeListener true } getPref(R.string.bottom_title_key)?.setOnPreferenceChangeListener { _, _ -> HomeChildItemAdapter.sharedPool.clear() ParentItemAdapter.sharedPool.clear() SearchAdapter.sharedPool.clear() true } getPref(R.string.poster_size_key)?.setOnPreferenceChangeListener { _, newValue -> HomeChildItemAdapter.sharedPool.clear() ParentItemAdapter.sharedPool.clear() SearchAdapter.sharedPool.clear() context?.let { HomeChildItemAdapter.updatePosterSize(it, newValue as? Int) } true } getPref(R.string.poster_ui_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.poster_ui_options) val keys = resources.getStringArray(R.array.poster_ui_options_values) val prefValues = keys.map { settingsManager.getBoolean(it, true) }.mapIndexedNotNull { index, b -> if (b) { index } else null } activity?.showMultiDialog( prefNames.toList(), prefValues, getString(R.string.poster_ui_settings), {} ) { list -> settingsManager.edit { for ((i, key) in keys.withIndex()) { putBoolean(key, list.contains(i)) } } SearchResultBuilder.updateCache(it.context) } return@setOnPreferenceClickListener true } getPref(R.string.app_layout_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.app_layout) val prefValues = resources.getIntArray(R.array.app_layout_values) val currentLayout = settingsManager.getInt(getString(R.string.app_layout_key), -1) activity?.showBottomDialog( items = prefNames.toList(), selectedIndex = prefValues.indexOf(currentLayout), name = getString(R.string.app_layout), showApply = true, dismissCallback = {}, callback = { try { settingsManager.edit { putInt(getString(R.string.app_layout_key), prefValues[it]) } context?.updateTv() activity?.recreate() } catch (e: Exception) { logError(e) } } ) return@setOnPreferenceClickListener true } getPref(R.string.app_theme_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.themes_names).toMutableList() val prefValues = resources.getStringArray(R.array.themes_names_values).toMutableList() val removeIncompatible = { text: String -> val toRemove = prefValues .mapIndexed { idx, s -> if (s.startsWith(text)) idx else null } .filterNotNull() var offset = 0 toRemove.forEach { idx -> prefNames.removeAt(idx - offset) prefValues.removeAt(idx - offset) offset += 1 } } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // remove monet on android 11 and less removeIncompatible("Monet") } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { // Remove system on android 9 and less removeIncompatible("System") } val currentLayout = settingsManager.getString(getString(R.string.app_theme_key), prefValues.first()) activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(currentLayout), getString(R.string.app_theme_settings), true, {} ) { try { settingsManager.edit { putString(getString(R.string.app_theme_key), prefValues[it]) } activity?.recreate() } catch (e: Exception) { logError(e) } } return@setOnPreferenceClickListener true } getPref(R.string.primary_color_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.themes_overlay_names).toMutableList() val prefValues = resources.getStringArray(R.array.themes_overlay_names_values).toMutableList() if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { // remove monet on android 11 and less val toRemove = prefValues .mapIndexed { idx, s -> if (s.startsWith("Monet")) idx else null } .filterNotNull() var offset = 0 toRemove.forEach { idx -> prefNames.removeAt(idx - offset) prefValues.removeAt(idx - offset) offset += 1 } } val currentLayout = settingsManager.getString(getString(R.string.primary_color_key), prefValues.first()) activity?.showDialog( prefNames.toList(), prefValues.indexOf(currentLayout), getString(R.string.primary_color_settings), true, {} ) { try { settingsManager.edit { putString(getString(R.string.primary_color_key), prefValues[it]) } activity?.recreate() } catch (e: Exception) { logError(e) } } return@setOnPreferenceClickListener true } getPref(R.string.pref_filter_search_quality_key)?.setOnPreferenceClickListener { val names = enumValues().sorted().map { it.name } val currentList = settingsManager.getStringSet( getString(R.string.pref_filter_search_quality_key), setOf() )?.map { it.toInt() } ?: listOf() activity?.showMultiDialog( names, currentList, getString(R.string.pref_filter_search_quality), {} ) { selectedList -> settingsManager.edit { putStringSet( getString(R.string.pref_filter_search_quality_key), selectedList.map { it.toString() }.toMutableSet() ) } } return@setOnPreferenceClickListener true } getPref(R.string.confirm_exit_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.confirm_exit) val prefValues = resources.getIntArray(R.array.confirm_exit_values) val confirmExit = settingsManager.getInt(getString(R.string.confirm_exit_key), -1) activity?.showBottomDialog( items = prefNames.toList(), selectedIndex = prefValues.indexOf(confirmExit), name = getString(R.string.confirm_before_exiting_title), showApply = true, dismissCallback = {}, callback = { selectedOption -> settingsManager.edit { putInt(getString(R.string.confirm_exit_key), prefValues[selectedOption]) } } ) return@setOnPreferenceClickListener true } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/SettingsUpdates.kt ================================================ package com.lagradost.cloudstream3.ui.settings import android.net.Uri import android.os.Bundle import android.view.View import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.content.edit import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import com.lagradost.cloudstream3.AutoDownloadMode import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.databinding.LogcatBinding import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.network.initClient import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.services.BackupWorkManager import com.lagradost.cloudstream3.ui.BasePreferenceFragmentCompat import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.getPref import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.hideOn import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setPaddingBottom import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.ui.settings.utils.getChooseFolderLauncher import com.lagradost.cloudstream3.utils.BackupUtils import com.lagradost.cloudstream3.utils.BackupUtils.restorePrompt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.InAppUpdater.installPreReleaseIfNeeded import com.lagradost.cloudstream3.utils.InAppUpdater.runAutoUpdate import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showBottomDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager import com.lagradost.cloudstream3.utils.txt import java.io.BufferedReader import java.io.InputStreamReader import java.io.OutputStream import java.lang.System.currentTimeMillis import java.text.SimpleDateFormat import java.util.Date import java.util.Locale class SettingsUpdates : BasePreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) setUpToolbar(R.string.category_updates) setPaddingBottom() setToolBarScrollFlags() } private val pathPicker = getChooseFolderLauncher { uri, path -> val context = context ?: CloudStreamApp.context ?: return@getChooseFolderLauncher (path ?: uri.toString()).let { PreferenceManager.getDefaultSharedPreferences(context).edit { putString(getString(R.string.backup_path_key), uri.toString()) putString(getString(R.string.backup_dir_key), it) } } } @Suppress("DEPRECATION_ERROR") override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { hideKeyboard() setPreferencesFromResource(R.xml.settings_updates, rootKey) val settingsManager = PreferenceManager.getDefaultSharedPreferences(requireContext()) getPref(R.string.backup_key)?.setOnPreferenceClickListener { BackupUtils.backup(activity) return@setOnPreferenceClickListener true } getPref(R.string.automatic_backup_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.periodic_work_names) val prefValues = resources.getIntArray(R.array.periodic_work_values) val current = settingsManager.getInt(getString(R.string.automatic_backup_key), 0) activity?.showDialog( prefNames.toList(), prefValues.indexOf(current), getString(R.string.backup_frequency), true, {} ) { index -> settingsManager.edit { putInt(getString(R.string.automatic_backup_key), prefValues[index]) } BackupWorkManager.enqueuePeriodicWork( context ?: CloudStreamApp.context, prefValues[index].toLong() ) } return@setOnPreferenceClickListener true } getPref(R.string.redo_setup_key)?.setOnPreferenceClickListener { findNavController().navigate(R.id.navigation_setup_language) return@setOnPreferenceClickListener true } getPref(R.string.restore_key)?.setOnPreferenceClickListener { activity?.restorePrompt() return@setOnPreferenceClickListener true } getPref(R.string.backup_path_key)?.hideOn(EMULATOR)?.setOnPreferenceClickListener { val dirs = getBackupDirsForDisplay() val currentDir = settingsManager.getString(getString(R.string.backup_dir_key), null) ?: context?.let { ctx -> BackupUtils.getDefaultBackupDir(ctx)?.filePath() } activity?.showBottomDialog( dirs + listOf(getString(R.string.custom)), dirs.indexOf(currentDir), getString(R.string.backup_path_title), true, {} ) { // Last = custom if (it == dirs.size) { try { pathPicker.launch(Uri.EMPTY) } catch (e: Exception) { logError(e) } } else { // Sets both visual and actual paths. // path = used uri // dir = dir path settingsManager.edit { putString(getString(R.string.backup_path_key), dirs[it]) putString(getString(R.string.backup_dir_key), dirs[it]) } } } return@setOnPreferenceClickListener true } getPref(R.string.show_logcat_key)?.setOnPreferenceClickListener { pref -> val builder = AlertDialog.Builder(pref.context, R.style.AlertDialogCustom) val binding = LogcatBinding.inflate(layoutInflater, null, false) builder.setView(binding.root) val dialog = builder.create() dialog.show() val logList = mutableListOf() try { // https://developer.android.com/studio/command-line/logcat val process = Runtime.getRuntime().exec("logcat -d") val bufferedReader = BufferedReader(InputStreamReader(process.inputStream)) bufferedReader.lineSequence().forEach { logList.add(it) } } catch (e: Exception) { logError(e) // kinda ironic } val adapter = LogcatAdapter().apply { submitList(logList) } binding.logcatRecyclerView.layoutManager = LinearLayoutManager(pref.context) binding.logcatRecyclerView.adapter = adapter binding.copyBtt.setOnClickListener { clipboardHelper(txt("Logcat"), logList.joinToString("\n")) dialog.dismissSafe(activity) } binding.clearBtt.setOnClickListener { Runtime.getRuntime().exec("logcat -c") dialog.dismissSafe(activity) } binding.saveBtt.setOnClickListener { val date = SimpleDateFormat("yyyy_MM_dd_HH_mm", Locale.getDefault()).format(Date(currentTimeMillis())) var fileStream: OutputStream? = null try { fileStream = VideoDownloadManager.setupStream( it.context, "logcat_${date}", null, "txt", false ).openNew() fileStream.writer().use { writer -> writer.write(logList.joinToString("\n")) } dialog.dismissSafe(activity) } catch (t: Throwable) { logError(t) showToast(t.message) } } binding.closeBtt.setOnClickListener { dialog.dismissSafe(activity) } return@setOnPreferenceClickListener true } getPref(R.string.apk_installer_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.apk_installer_pref) val prefValues = resources.getIntArray(R.array.apk_installer_values) val currentInstaller = settingsManager.getInt(getString(R.string.apk_installer_key), 0) activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(currentInstaller), getString(R.string.apk_installer_settings), true, {} ) { num -> try { settingsManager.edit { putInt(getString(R.string.apk_installer_key), prefValues[num]) } } catch (e: Exception) { logError(e) } } return@setOnPreferenceClickListener true } getPref(R.string.manual_check_update_key)?.let { pref -> pref.summary = BuildConfig.VERSION_NAME pref.setOnPreferenceClickListener { ioSafe { if (activity?.runAutoUpdate(false) == false) { activity?.runOnUiThread { showToast( R.string.no_update_found, Toast.LENGTH_SHORT ) } } } return@setOnPreferenceClickListener true } } getPref(R.string.install_prerelease_key)?.let { pref -> pref.isVisible = BuildConfig.FLAVOR == "stable" pref.setOnPreferenceClickListener { activity?.installPreReleaseIfNeeded() return@setOnPreferenceClickListener true } } getPref(R.string.auto_download_plugins_key)?.setOnPreferenceClickListener { val prefNames = resources.getStringArray(R.array.auto_download_plugin) val prefValues = enumValues().sortedBy { x -> x.value }.map { x -> x.value } val current = settingsManager.getInt(getString(R.string.auto_download_plugins_key), 0) activity?.showBottomDialog( prefNames.toList(), prefValues.indexOf(current), getString(R.string.automatic_plugin_download_mode_title), true, {} ) { num -> settingsManager.edit { putInt(getString(R.string.auto_download_plugins_key), prefValues[num]) } (context ?: CloudStreamApp.context)?.let { ctx -> app.initClient(ctx) } } return@setOnPreferenceClickListener true } getPref(R.string.manual_update_plugins_key)?.setOnPreferenceClickListener { ioSafe { PluginManager.___DO_NOT_CALL_FROM_A_PLUGIN_manuallyReloadAndUpdatePlugins(activity ?: return@ioSafe) } return@setOnPreferenceClickListener true // Return true for the listener } } private fun getBackupDirsForDisplay(): List { return safe { context?.let { ctx -> val defaultDir = BackupUtils.getDefaultBackupDir(ctx)?.filePath() val first = listOf(defaultDir) (runCatching { first + BackupUtils.getCurrentBackupDir(ctx).let { it.first?.filePath() ?: it.second } }.getOrNull() ?: first).filterNotNull().distinct() } } ?: emptyList() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsFragment.kt ================================================ package com.lagradost.cloudstream3.ui.settings.extensions import android.content.ClipboardManager import android.content.Context import android.content.DialogInterface import android.os.Build import android.view.LayoutInflater import android.view.View import android.widget.LinearLayout import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.marginBottom import androidx.core.view.marginTop import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity.Companion.afterRepositoryLoadedEvent import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.AddRepoInputBinding import com.lagradost.cloudstream3.databinding.FragmentExtensionsBinding import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.plugins.RepositoryManager import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.utils.AppContextUtils.addRepositoryDialog import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.setText class ExtensionsFragment : BaseFragment( BaseFragment.BindingCreator.Inflate(FragmentExtensionsBinding::inflate) ) { private val extensionViewModel: ExtensionsViewModel by activityViewModels() private fun View.setLayoutWidth(weight: Int) { val param = LinearLayout.LayoutParams( 0, LinearLayout.LayoutParams.MATCH_PARENT, weight.toFloat() ) this.layoutParams = param } override fun onResume() { super.onResume() afterRepositoryLoadedEvent += ::reloadRepositories } override fun onStop() { super.onStop() afterRepositoryLoadedEvent -= ::reloadRepositories } private fun reloadRepositories(success: Boolean = true) { extensionViewModel.loadStats() extensionViewModel.loadRepositories() } override fun fixLayout(view: View) { setSystemBarsPadding() } override fun onBindingCreated(binding: FragmentExtensionsBinding) { setUpToolbar(R.string.extensions) setToolBarScrollFlags() binding.repoRecyclerView.apply { setLinearListLayout( isHorizontal = false, nextUp = R.id.settings_toolbar, // FOCUS_SELF, // back has no id so we cant :pensive: nextDown = R.id.plugin_storage_appbar, nextRight = FOCUS_SELF, nextLeft = R.id.nav_rail_view ) if (!isLayout(TV)) binding.addRepoButton.let { button -> button.post { setPadding( paddingLeft, paddingTop, paddingRight, button.measuredHeight + button.marginTop + button.marginBottom ) } } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { setOnScrollChangeListener { _, _, scrollY, _, oldScrollY -> val dy = scrollY - oldScrollY if (dy > 0) { // check for scroll down binding.addRepoButton.shrink() // hide } else if (dy < -5) { binding.addRepoButton.extend() // show } } } adapter = RepoAdapter(false, { findNavController().navigate( R.id.navigation_settings_extensions_to_navigation_settings_plugins, PluginsFragment.newInstance( it.name, it.url, false ) ) }, { repo -> // Prompt user before deleting repo main { val builder = AlertDialog.Builder(context ?: binding.root.context) val dialogClickListener = DialogInterface.OnClickListener { _, which -> when (which) { DialogInterface.BUTTON_POSITIVE -> { ioSafe { RepositoryManager.removeRepository(binding.root.context, repo) extensionViewModel.loadStats() extensionViewModel.loadRepositories() } } DialogInterface.BUTTON_NEGATIVE -> {} } } builder.setTitle(R.string.delete_repository) .setMessage( context?.getString(R.string.delete_repository_plugins) ) .setPositiveButton(R.string.delete, dialogClickListener) .setNegativeButton(R.string.cancel, dialogClickListener) .show().setDefaultFocus() } }) } observe(extensionViewModel.repositories) { binding.repoRecyclerView.isVisible = it.isNotEmpty() binding.blankRepoScreen.isVisible = it.isEmpty() (binding.repoRecyclerView.adapter as? RepoAdapter)?.submitList(it.toList()) } observeNullable(extensionViewModel.pluginStats) { value -> binding.apply { if (value == null) { pluginStorageAppbar.isVisible = false return@observeNullable } pluginStorageAppbar.isVisible = true if (value.total == 0) { pluginDownload.setLayoutWidth(1) pluginDisabled.setLayoutWidth(0) pluginNotDownloaded.setLayoutWidth(0) } else { pluginDownload.setLayoutWidth(value.downloaded) pluginDisabled.setLayoutWidth(value.disabled) pluginNotDownloaded.setLayoutWidth(value.notDownloaded) } pluginNotDownloadedTxt.setText(value.notDownloadedText) pluginDisabledTxt.setText(value.disabledText) pluginDownloadTxt.setText(value.downloadedText) } } binding.pluginStorageAppbar.setOnClickListener { findNavController().navigate( R.id.navigation_settings_extensions_to_navigation_settings_plugins, PluginsFragment.newInstance( getString(R.string.extensions), "", true ) ) } val addRepositoryClick = View.OnClickListener { val ctx = context ?: return@OnClickListener val binding = AddRepoInputBinding.inflate(LayoutInflater.from(ctx), null, false) val builder = AlertDialog.Builder(ctx, R.style.AlertDialogCustom) .setView(binding.root) val dialog = builder.create() dialog.show() (activity?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager)?.primaryClip?.getItemAt( 0 )?.text?.toString()?.let { copiedText -> if (copiedText.contains(RepoAdapter.SHAREABLE_REPO_SEPARATOR)) { // text is of format : val (name, url) = copiedText.split(RepoAdapter.SHAREABLE_REPO_SEPARATOR, limit = 2) binding.repoUrlInput.setText(url.trim()) binding.repoNameInput.setText(name.trim()) } else { binding.repoUrlInput.setText(copiedText) } } binding.applyBtt.setOnClickListener secondListener@{ val name = binding.repoNameInput.text?.toString() ioSafe { val url = binding.repoUrlInput.text?.toString() ?.let { it1 -> RepositoryManager.parseRepoUrl(it1) } if (url.isNullOrBlank()) { main { showToast(R.string.error_invalid_data, Toast.LENGTH_SHORT) } } else { val repository = RepositoryManager.parseRepository(url) // Exit if wrong repository if (repository == null) { showToast(R.string.no_repository_found_error, Toast.LENGTH_LONG) return@ioSafe } val fixedName = if (!name.isNullOrBlank()) name else repository.name val newRepo = RepositoryData(repository.iconUrl,fixedName, url) RepositoryManager.addRepository(newRepo) extensionViewModel.loadStats() extensionViewModel.loadRepositories() val plugins = RepositoryManager.getRepoPlugins(url) if (plugins.isNullOrEmpty()) { showToast(R.string.no_plugins_found_error, Toast.LENGTH_LONG) } else { this@ExtensionsFragment.activity?.addRepositoryDialog( fixedName, url, ) } } } dialog.dismissSafe(activity) } binding.cancelBtt.setOnClickListener { dialog.dismissSafe(activity) } } val isTv = isLayout(TV) binding.apply { addRepoButton.isGone = isTv addRepoButtonImageviewHolder.isVisible = isTv // Band-aid for Fire TV pluginStorageAppbar.isFocusableInTouchMode = isTv addRepoButtonImageview.isFocusableInTouchMode = isTv addRepoButton.setOnClickListener(addRepositoryClick) addRepoButtonImageview.setOnClickListener(addRepositoryClick) } reloadRepositories() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/ExtensionsViewModel.kt ================================================ package com.lagradost.cloudstream3.ui.settings.extensions import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.debugAssert import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.PluginManager.getPluginsOnline import com.lagradost.cloudstream3.plugins.RepositoryManager import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe data class RepositoryData( @JsonProperty("iconUrl") val iconUrl: String?, @JsonProperty("name") val name: String, @JsonProperty("url") val url: String ){ constructor(name: String,url: String):this(null,name,url) } const val REPOSITORIES_KEY = "REPOSITORIES_KEY" class ExtensionsViewModel : ViewModel() { data class PluginStats( val total: Int, val downloaded: Int, val disabled: Int, val notDownloaded: Int, val downloadedText: UiText, val disabledText: UiText, val notDownloadedText: UiText, ) private val _repositories = MutableLiveData>() val repositories: LiveData> = _repositories private val _pluginStats: MutableLiveData = MutableLiveData(null) val pluginStats: LiveData = _pluginStats //TODO CACHE GET REQUESTS // DO not use viewModelScope.launchSafe, it will ANR on slow internet fun loadStats() = ioSafe { val urls = (getKey>(REPOSITORIES_KEY) ?: emptyArray()) + PREBUILT_REPOSITORIES val onlinePlugins = urls.toList().amap { RepositoryManager.getRepoPlugins(it.url)?.toList() ?: emptyList() }.flatten().distinctBy { it.second.url } // Iterates over all offline plugins, compares to remote repo and returns the plugins which are outdated val outdatedPlugins = getPluginsOnline().map { savedData -> onlinePlugins.filter { onlineData -> savedData.internalName == onlineData.second.internalName } .map { onlineData -> PluginManager.OnlinePluginData(savedData, onlineData) } }.flatten().distinctBy { it.onlineData.second.url } val total = onlinePlugins.count() val disabled = outdatedPlugins.count { it.isDisabled } val downloadedTotal = outdatedPlugins.count() val downloaded = downloadedTotal - disabled val notDownloaded = total - downloadedTotal val stats = PluginStats( total, downloaded, disabled, notDownloaded, txt(R.string.plugins_downloaded, downloaded), txt(R.string.plugins_disabled, disabled), txt(R.string.plugins_not_downloaded, notDownloaded) ) debugAssert({ stats.downloaded + stats.notDownloaded + stats.disabled != stats.total }) { "downloaded(${stats.downloaded}) + notDownloaded(${stats.notDownloaded}) + disabled(${stats.disabled}) != total(${stats.total})" } _pluginStats.postValue(stats) } private fun repos() = (getKey>(REPOSITORIES_KEY) ?: emptyArray()) + PREBUILT_REPOSITORIES fun loadRepositories() { val urls = repos() _repositories.postValue(urls) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginAdapter.kt ================================================ package com.lagradost.cloudstream3.ui.settings.extensions import android.annotation.SuppressLint import android.text.format.Formatter.formatShortFileSize import android.util.Log import android.view.LayoutInflater import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.viewbinding.ViewBinding import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.RepositoryItemBinding import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.newSharedPool import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.html import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji import com.lagradost.cloudstream3.utils.UIHelper.toPx import com.lagradost.cloudstream3.utils.getImageFromDrawable import com.lagradost.cloudstream3.utils.setText import com.lagradost.cloudstream3.utils.txt import java.text.DecimalFormat import kotlin.math.floor import kotlin.math.log10 import kotlin.math.pow data class PluginViewData( val plugin: Plugin, val isDownloaded: Boolean, ) class RepositoryViewHolderState(view: ViewBinding) : ViewHolderState(view) { // Store how many times this has called recycled, this is used to correctly sync text in jobs var recycleCount = 0 } class PluginAdapter( val iconClickCallback: (Plugin) -> Unit ) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> a.plugin.second.internalName == b.plugin.second.internalName && a.plugin.first == b.plugin.first })) { override fun onCreateContent(parent: ViewGroup): ViewHolderState { val layout = if (isLayout(TV)) R.layout.repository_item_tv else R.layout.repository_item val inflated = LayoutInflater.from(parent.context).inflate(layout, parent, false) return RepositoryViewHolderState( RepositoryItemBinding.bind(inflated) // may crash ) } override fun onClearView(holder: ViewHolderState) { if (holder is RepositoryViewHolderState) { holder.recycleCount += 1 } when (val binding = holder.view) { is RepositoryItemBinding -> { clearImage(binding.entryIcon) } } } @SuppressLint("SetTextI18n") override fun onBindContent(holder: ViewHolderState, item: PluginViewData, position: Int) { val binding = holder.view as? RepositoryItemBinding ?: return val itemView = holder.itemView val metadata = item.plugin.second val disabled = metadata.status == PROVIDER_STATUS_DOWN val name = metadata.name.removeSuffix("Provider") val alpha = if (disabled) 0.6f else 1f val isLocal = !item.plugin.second.url.startsWith("http") binding.mainText.alpha = alpha binding.subText.alpha = alpha val drawableInt = if (item.isDownloaded) R.drawable.ic_baseline_delete_outline_24 else R.drawable.netflix_download binding.nsfwMarker.isVisible = metadata.tvTypes?.contains(TvType.NSFW.name) ?: false binding.actionButton.setImageResource(drawableInt) binding.actionButton.setOnClickListener { iconClickCallback.invoke(item.plugin) } itemView.setOnClickListener { if (isLocal) return@setOnClickListener val sheet = PluginDetailsFragment(item) val activity = itemView.context.getActivity() as AppCompatActivity sheet.show(activity.supportFragmentManager, "PluginDetails") } //if (itemView.context?.isTrueTvSettings() == false) { // val siteUrl = metadata.repositoryUrl // if (siteUrl != null && siteUrl.isNotBlank() && siteUrl != "NONE") { // itemView.setOnClickListener { // openBrowser(siteUrl) // } // } //} if (item.isDownloaded) { // On local plugins page the filepath is provided instead of url. val plugin = (PluginManager.urlPlugins[metadata.url] ?: (PluginManager.plugins[metadata.url])) as? com.lagradost.cloudstream3.plugins.Plugin if (plugin?.openSettings != null) { binding.actionSettings.isVisible = true binding.actionSettings.setOnClickListener { try { plugin.openSettings?.invoke(itemView.context) } catch (e: Throwable) { Log.e( "PluginAdapter", "Failed to open $name settings: ${ Log.getStackTraceString(e) }" ) } } } else { binding.actionSettings.isVisible = false } } else { binding.actionSettings.isVisible = false } val url = metadata.iconUrl?.replace( "%size%", "$iconSize" )?.replace( "%exact_size%", "$iconSizeExact" ) if (url.isNullOrBlank()) { binding.entryIcon.loadImage(R.drawable.ic_baseline_extension_24) } else { binding.entryIcon.loadImage( url ) { error(getImageFromDrawable(itemView.context, R.drawable.ic_baseline_extension_24)) } } binding.extVersion.isVisible = true binding.extVersion.text = "v${metadata.version}" if (metadata.language.isNullOrBlank()) { binding.langIcon.isVisible = false } else { binding.langIcon.isVisible = true binding.langIcon.text = getNameNextToFlagEmoji(metadata.language) ?: metadata.language } //val oldRecycleCount = (holder as? RepositoryViewHolderState)?.recycleCount binding.extVotes.isVisible = false // Disable this for now as the vote api is down, this will also significantly improve the lag // from doing all these network requests /*if (!isLocal) { ioSafe { metadata.getVotes().main { votes -> val currentRecycleCount = (holder as? RepositoryViewHolderState)?.recycleCount // Only set the text if the view is correctly rendered if (currentRecycleCount == oldRecycleCount) { binding.extVotes.setText(txt(R.string.extension_rating, prettyCount(votes))) binding.extVotes.isVisible = true } } } }*/ if (metadata.fileSize != null) { binding.extFilesize.isVisible = true binding.extFilesize.text = formatShortFileSize(itemView.context, metadata.fileSize) } else { binding.extFilesize.isVisible = false } binding.mainText.setText( if (disabled) txt( R.string.single_plugin_disabled, name ) else txt(name) ) binding.subText.isGone = metadata.description.isNullOrBlank() binding.subText.text = metadata.description.html() } companion object { // A high count as we can render in the entire list as the same time val sharedPool = newSharedPool { setMaxRecycledViews(CONTENT, 15) } private tailrec fun findClosestBase2(target: Int, current: Int = 16, max: Int = 512): Int { if (current >= max) return max if (current >= target) return current return findClosestBase2(target, current * 2, max) } // DO NOT MOVE, as running this test will result in ExceptionInInitializerError on prerelease due to static variables using Resources.getSystem() // this test function is only to show how the function works /*@Test fun testFindClosestBase2() { Assert.assertEquals(16, findClosestBase2(0)) Assert.assertEquals(256, findClosestBase2(170)) Assert.assertEquals(256, findClosestBase2(256)) Assert.assertEquals(512, findClosestBase2(257)) Assert.assertEquals(512, findClosestBase2(700)) }*/ private val iconSizeExact = 32.toPx private val iconSize by lazy { findClosestBase2(iconSizeExact, 16, 512) } fun prettyCount(number: Number): String? { val suffix = charArrayOf(' ', 'k', 'M', 'B', 'T', 'P', 'E') val numValue = number.toLong() val value = floor(log10(numValue.toDouble())).toInt() val base = value / 3 return if (value >= 3 && base < suffix.size) { DecimalFormat("#0.00").format( numValue / 10.0.pow((base * 3).toDouble()) ) + suffix[base] } else { DecimalFormat().format(numValue) } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginDetailsFragment.kt ================================================ package com.lagradost.cloudstream3.ui.settings.extensions import android.content.res.ColorStateList import android.text.format.Formatter.formatFileSize import android.util.Log import android.view.View import androidx.core.view.isVisible import com.lagradost.cloudstream3.CloudStreamApp.Companion.openBrowser import com.lagradost.cloudstream3.databinding.FragmentPluginDetailsBinding import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.VotingApi.canVote import com.lagradost.cloudstream3.plugins.VotingApi.getVotes import com.lagradost.cloudstream3.plugins.VotingApi.hasVoted import com.lagradost.cloudstream3.plugins.VotingApi.vote import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.BaseBottomSheetDialogFragment import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.getImageFromDrawable import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.toPx class PluginDetailsFragment(val data: PluginViewData) : BaseBottomSheetDialogFragment( BaseFragment.BindingCreator.Inflate(FragmentPluginDetailsBinding::inflate) ) { companion object { private tailrec fun findClosestBase2(target: Int, current: Int = 16, max: Int = 512): Int { if (current >= max) return max if (current >= target) return current return findClosestBase2(target, current * 2, max) } private val iconSizeExact = 50.toPx private val iconSize by lazy { findClosestBase2(iconSizeExact, 16, 512) } } override fun fixLayout(view: View) { fixSystemBarsPadding( view, padBottom = isLandscape(), padLeft = isLayout(TV or EMULATOR) ) } override fun onBindingCreated(binding: FragmentPluginDetailsBinding) { val metadata = data.plugin.second binding.apply { pluginIcon.loadImage(metadata.iconUrl?.replace("%size%", "$iconSize") ?.replace("%exact_size%", "$iconSizeExact")) { error { getImageFromDrawable(context ?: return@error null , R.drawable.ic_baseline_extension_24) } } pluginName.text = metadata.name.removeSuffix("Provider") pluginVersion.text = metadata.version.toString() pluginDescription.text = metadata.description ?: getString(R.string.no_data) pluginSize.text = if (metadata.fileSize == null) getString(R.string.no_data) else formatFileSize( context, metadata.fileSize ) pluginAuthor.text = if (metadata.authors.isEmpty()) getString(R.string.no_data) else metadata.authors.joinToString( ", " ) pluginStatus.text = resources.getStringArray(R.array.extension_statuses)[metadata.status] pluginTypes.text = if (metadata.tvTypes.isNullOrEmpty()) getString(R.string.no_data) else metadata.tvTypes.joinToString( ", " ) pluginLang.text = if (metadata.language == null) getString(R.string.no_data) else getNameNextToFlagEmoji(metadata.language) ?: metadata.language githubBtn.setOnClickListener { if (metadata.repositoryUrl != null) { openBrowser(metadata.repositoryUrl) } } if (!metadata.canVote()) { upvote.alpha = .6f } if (data.isDownloaded) { // On local plugins page the filepath is provided instead of url. val plugin = (PluginManager.urlPlugins[metadata.url] ?: PluginManager.plugins[metadata.url]) as? com.lagradost.cloudstream3.plugins.Plugin if (plugin?.openSettings != null && context != null) { actionSettings.isVisible = true actionSettings.setOnClickListener { try { plugin.openSettings!!.invoke(requireContext()) } catch (e: Throwable) { Log.e( "PluginAdapter", "Failed to open ${metadata.name} settings: ${ Log.getStackTraceString(e) }" ) } } } else { actionSettings.isVisible = false } } else { actionSettings.isVisible = false } upvote.setOnClickListener { ioSafe { metadata.vote().main { updateVoting(it) } } } ioSafe { metadata.getVotes().main { updateVoting(it) } } } } private fun updateVoting(value: Int) { val metadata = data.plugin.second binding?.apply { pluginVotes.text = value.toString() if (metadata.hasVoted()) { upvote.imageTintList = ColorStateList.valueOf( context?.colorFromAttribute(R.attr.colorPrimary) ?: R.color.colorPrimary ) } else { upvote.imageTintList = ColorStateList.valueOf( context?.colorFromAttribute(com.google.android.material.R.attr.colorOnSurface) ?: R.color.white ) } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsFragment.kt ================================================ package com.lagradost.cloudstream3.ui.settings.extensions import android.os.Bundle import android.view.View import androidx.appcompat.widget.SearchView import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.databinding.FragmentPluginsBinding import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.home.HomeFragment.Companion.bindChips import com.lagradost.cloudstream3.ui.result.FOCUS_SELF import com.lagradost.cloudstream3.ui.result.setLinearListLayout import com.lagradost.cloudstream3.ui.setRecycledViewPool import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji import com.lagradost.cloudstream3.utils.UIHelper.toPx const val PLUGINS_BUNDLE_NAME = "name" const val PLUGINS_BUNDLE_URL = "url" const val PLUGINS_BUNDLE_LOCAL = "isLocal" class PluginsFragment : BaseFragment( BaseFragment.BindingCreator.Inflate(FragmentPluginsBinding::inflate) ) { private val pluginViewModel: PluginsViewModel by activityViewModels() override fun onDestroyView() { pluginViewModel.clear() // clear for the next observe super.onDestroyView() } override fun fixLayout(view: View) { setSystemBarsPadding() } override fun onBindingCreated(binding: FragmentPluginsBinding) { // Since the ViewModel is getting reused the tvTypes must be cleared between uses pluginViewModel.tvTypes.clear() pluginViewModel.selectedLanguages = listOf() pluginViewModel.clear() // Filter by language set on preferred media activity?.let { val providerLangs = it.getApiProviderLangSettings().toList() if (!providerLangs.contains(AllLanguagesName)) { pluginViewModel.selectedLanguages = mutableListOf("none") + providerLangs } } val name = arguments?.getString(PLUGINS_BUNDLE_NAME) val url = arguments?.getString(PLUGINS_BUNDLE_URL) val isLocal = arguments?.getBoolean(PLUGINS_BUNDLE_LOCAL) == true // download all extensions button val downloadAllButton = binding.settingsToolbar.menu?.findItem(R.id.download_all) if (url == null || name == null) { dispatchBackPressed() return } setToolBarScrollFlags() setUpToolbar(name) binding.settingsToolbar.apply { setOnMenuItemClickListener { menuItem -> when (menuItem?.itemId) { R.id.download_all -> { PluginsViewModel.downloadAll(activity, url, pluginViewModel) } R.id.lang_filter -> { val languagesTagName = pluginViewModel.pluginLanguages .map { langTag -> Pair( langTag, getNameNextToFlagEmoji(langTag) ?: langTag ) } .sortedBy { it.second.substringAfter("\u00a0").lowercase() } // name ignoring flag emoji .toMutableList() // Move "none" to 1st position as it's special code to indicate unknown/missing language if (languagesTagName.remove(Pair("none", "none"))) { languagesTagName.add(0, Pair("none", getString(R.string.no_data))) } val currentIndexList = pluginViewModel.selectedLanguages.map { langTag -> languagesTagName.indexOfFirst { lang -> lang.first == langTag } } activity?.showMultiDialog( languagesTagName.map { it.second }, currentIndexList, getString(R.string.provider_lang_settings), {} ) { selectedList -> pluginViewModel.selectedLanguages = selectedList.map { languagesTagName[it].first } pluginViewModel.updateFilteredPlugins() } } else -> {} } return@setOnMenuItemClickListener true } val searchView = menu?.findItem(R.id.search_button)?.actionView as? SearchView // Don't go back if active query setNavigationOnClickListener { if (searchView?.isIconified == false) { searchView.isIconified = true } else { dispatchBackPressed() } } searchView?.setOnQueryTextFocusChangeListener { _, hasFocus -> if (!hasFocus) pluginViewModel.search(null) } searchView?.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextSubmit(query: String?): Boolean { pluginViewModel.search(query) return true } override fun onQueryTextChange(newText: String?): Boolean { pluginViewModel.search(newText) return true } }) } // searchView?.onActionViewCollapsed = { // pluginViewModel.search(null) // } // Because onActionViewCollapsed doesn't wanna work we need this workaround :( binding.pluginRecyclerView.apply { setLinearListLayout( isHorizontal = false, nextDown = FOCUS_SELF, nextRight = FOCUS_SELF, ) setRecycledViewPool(PluginAdapter.sharedPool) adapter = PluginAdapter { pluginViewModel.handlePluginAction(activity, url, it, isLocal) } } if (isLayout(TV or EMULATOR)) { // Scrolling down does not reveal the whole RecyclerView on TV, add to bypass that. binding.pluginRecyclerView.setPadding(0, 0, 0, 200.toPx) } observe(pluginViewModel.filteredPlugins) { (scrollToTop, list) -> (binding.pluginRecyclerView.adapter as? PluginAdapter)?.submitList(list) if (scrollToTop) { binding.pluginRecyclerView.scrollToPosition(0) } } if (isLocal) { // No download button and no categories on local downloadAllButton?.isVisible = false binding.settingsToolbar.menu?.findItem(R.id.lang_filter)?.isVisible = false pluginViewModel.updatePluginListLocal() binding.tvtypesChipsScroll.root.isVisible = false } else { pluginViewModel.updatePluginList(context, url) binding.tvtypesChipsScroll.root.isVisible = true // not needed for users but may be useful for devs downloadAllButton?.isVisible = BuildConfig.DEBUG bindChips( binding.tvtypesChipsScroll.tvtypesChips, emptyList(), TvType.entries.toList(), callback = { list -> pluginViewModel.tvTypes.clear() pluginViewModel.tvTypes.addAll(list.map { it.name }) pluginViewModel.updateFilteredPlugins() }, nextFocusDown = R.id.plugin_recycler_view, nextFocusUp = null, ) } } companion object { fun newInstance(name: String, url: String, isLocal: Boolean): Bundle { return Bundle().apply { putString(PLUGINS_BUNDLE_NAME, name) putString(PLUGINS_BUNDLE_URL, url) putBoolean(PLUGINS_BUNDLE_LOCAL, isLocal) } } // class RepoSearchView(context: Context) : android.widget.SearchView(context) { // var onActionViewCollapsed = {} // // override fun onActionViewCollapsed() { // onActionViewCollapsed() // } // } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/PluginsViewModel.kt ================================================ package com.lagradost.cloudstream3.ui.settings.extensions import android.app.Activity import android.content.Context import android.util.Log import android.widget.Toast import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.PROVIDER_STATUS_DOWN import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.amap import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.plugins.PluginManager.getPluginPath import com.lagradost.cloudstream3.plugins.RepositoryManager import com.lagradost.cloudstream3.plugins.SitePlugin import com.lagradost.cloudstream3.utils.txt import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import me.xdrop.fuzzywuzzy.FuzzySearch import java.io.File // String => repository url typealias Plugin = Pair /** * The boolean signifies if the plugin list should be scrolled to the top, used for searching. * */ typealias PluginViewDataUpdate = Pair> class PluginsViewModel : ViewModel() { /** plugins is an unaltered list of plugins */ private var plugins: List = emptyList() set(value) { // Also set all the plugin languages for easier filtering value.map { pluginViewData -> val language = pluginViewData.plugin.second.language?.lowercase() pluginLanguages.add( when { language.isNullOrBlank() -> "none" else -> language.lowercase() } ) // not sorting as most likely this is a language tag instead of name } field = value } var pluginLanguages = mutableSetOf() // set to avoid duplicates /** filteredPlugins is a subset of plugins following the current search query and tv type selection */ private var _filteredPlugins = MutableLiveData() var filteredPlugins: LiveData = _filteredPlugins val tvTypes = mutableListOf() var selectedLanguages = listOf() private var currentQuery: String? = null companion object { private val repositoryCache: MutableMap> = mutableMapOf() const val TAG = "PLG" private fun isDownloaded( context: Context, pluginName: String, repositoryUrl: String ): Boolean { return getPluginPath(context, pluginName, repositoryUrl).exists() } private suspend fun getPlugins( repositoryUrl: String, canUseCache: Boolean = true ): List { Log.i(TAG, "getPlugins = $repositoryUrl") if (canUseCache && repositoryCache.containsKey(repositoryUrl)) { repositoryCache[repositoryUrl]?.let { return it } } return RepositoryManager.getRepoPlugins(repositoryUrl) ?.also { repositoryCache[repositoryUrl] = it } ?: emptyList() } /** * @param viewModel optional, updates the plugins livedata for that viewModel if included * */ fun downloadAll(activity: Activity?, repositoryUrl: String, viewModel: PluginsViewModel?) = ioSafe { if (activity == null) return@ioSafe val plugins = getPlugins(repositoryUrl) plugins.filter { plugin -> !isDownloaded( activity, plugin.second.internalName, repositoryUrl ) }.also { list -> main { showToast( when { // No plugins at all plugins.isEmpty() -> txt( R.string.no_plugins_found_error, ) // All plugins downloaded list.isEmpty() -> txt( R.string.batch_download_nothing_to_download_format, txt(R.string.plugin) ) else -> txt( R.string.batch_download_start_format, list.size, txt(if (list.size == 1) R.string.plugin_singular else R.string.plugin) ) }, Toast.LENGTH_SHORT ) } }.amap { (repo, metadata) -> PluginManager.downloadPlugin( activity, metadata.url, metadata.internalName, repo, metadata.status != PROVIDER_STATUS_DOWN ) }.main { list -> if (list.any { it }) { showToast( txt( R.string.batch_download_finish_format, list.count { it }, txt(if (list.size == 1) R.string.plugin_singular else R.string.plugin) ), Toast.LENGTH_SHORT ) viewModel?.updatePluginListPrivate(activity, repositoryUrl) } else if (list.isNotEmpty()) { showToast(R.string.download_failed, Toast.LENGTH_SHORT) } } } } /** * @param isLocal defines if the plugin data is from local data instead of repo * Will only allow removal of plugins. Used for the local file management. * */ fun handlePluginAction( activity: Activity?, repositoryUrl: String, plugin: Plugin, isLocal: Boolean ) = ioSafe { Log.i(TAG, "handlePluginAction = $repositoryUrl, $plugin, $isLocal") if (activity == null) return@ioSafe val (repo, metadata) = plugin val file = if (isLocal) File(plugin.second.url) else getPluginPath( activity, plugin.second.internalName, plugin.first ) val (success, message) = if (file.exists()) { PluginManager.deletePlugin(file) to R.string.plugin_deleted } else { val isEnabled = plugin.second.status != PROVIDER_STATUS_DOWN val message = if (isEnabled) R.string.plugin_loaded else R.string.plugin_downloaded PluginManager.downloadPlugin( activity, metadata.url, metadata.internalName, repo, isEnabled ) to message } runOnMainThread { if (success) showToast(message, Toast.LENGTH_SHORT) else showToast(R.string.error, Toast.LENGTH_SHORT) } if (success) if (isLocal) updatePluginListLocal() else updatePluginListPrivate(activity, repositoryUrl) } private suspend fun updatePluginListPrivate(context: Context, repositoryUrl: String) { val isAdult = PreferenceManager.getDefaultSharedPreferences(context) .getStringSet(context.getString(R.string.prefer_media_type_key), emptySet()) ?.contains(TvType.NSFW.ordinal.toString()) == true val plugins = getPlugins(repositoryUrl) val list = plugins.filter { // Show all non-nsfw plugins or all if nsfw is enabled it.second.tvTypes?.contains(TvType.NSFW.name) != true || isAdult }.map { plugin -> PluginViewData(plugin, isDownloaded(context, plugin.second.internalName, plugin.first)) } this.plugins = list _filteredPlugins.postValue( false to list.filterTvTypes().filterLang().sortByQuery(currentQuery) ) } // Perhaps can be optimized? private fun List.filterTvTypes(): List { if (tvTypes.isEmpty()) return this return this.filter { (it.plugin.second.tvTypes?.any { type -> tvTypes.contains(type) } == true) || (tvTypes.contains(TvType.Others.name) && (it.plugin.second.tvTypes ?: emptyList()).isEmpty()) } } private fun List.filterLang(): List { if (selectedLanguages.isEmpty()) return this // do not filter return this.filter { if (it.plugin.second.language == null) { return@filter selectedLanguages.contains("none") } selectedLanguages.contains(it.plugin.second.language?.lowercase()) } } private fun List.sortByQuery(query: String?): List { return if (query == null) { // Return list to base state if no query this.sortedBy { it.plugin.second.name } } else { this.sortedBy { -FuzzySearch.partialRatio( it.plugin.second.name.lowercase(), query.lowercase() ) } } } fun updateFilteredPlugins() { _filteredPlugins.postValue( false to plugins.filterTvTypes().filterLang().sortByQuery(currentQuery) ) } fun clear() { currentQuery = null _filteredPlugins.postValue( false to emptyList() ) } fun updatePluginList(context: Context?, repositoryUrl: String) = viewModelScope.launchSafe { if (context == null) return@launchSafe Log.i(TAG, "updatePluginList = $repositoryUrl") updatePluginListPrivate(context, repositoryUrl) } fun search(query: String?) { currentQuery = query _filteredPlugins.postValue( true to (filteredPlugins.value?.second?.sortByQuery(query) ?: emptyList()) ) } /** * Update the list but only with the local data. Used for file management. * */ fun updatePluginListLocal() = viewModelScope.launchSafe { Log.i(TAG, "updatePluginList = local") val downloadedPlugins = (PluginManager.getPluginsOnline() + PluginManager.getPluginsLocal()) .distinctBy { it.filePath } .map { PluginViewData("" to it.toSitePlugin(), true) } plugins = downloadedPlugins _filteredPlugins.postValue( false to downloadedPlugins.filterTvTypes().filterLang().sortByQuery(currentQuery) ) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/extensions/RepoAdapter.kt ================================================ package com.lagradost.cloudstream3.ui.settings.extensions import android.view.LayoutInflater import android.view.ViewGroup import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.RepositoryItemBinding import com.lagradost.cloudstream3.databinding.RepositoryItemTvBinding import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.clipboardHelper import com.lagradost.cloudstream3.utils.getImageFromDrawable import com.lagradost.cloudstream3.utils.txt class RepoAdapter( val isSetup: Boolean, val clickCallback: RepoAdapter.(RepositoryData) -> Unit, val imageClickCallback: RepoAdapter.(RepositoryData) -> Unit, /** In setup mode the trash icons will be replaced with download icons */ ) : NoStateAdapter(diffCallback = BaseDiffCallback(itemSame = { a, b -> a.url == b.url })) { override fun onCreateContent(parent: ViewGroup): ViewHolderState { val layout = if (isLayout(TV)) RepositoryItemTvBinding.inflate( LayoutInflater.from(parent.context), parent, false ) else RepositoryItemBinding.inflate( LayoutInflater.from(parent.context), parent, false ) return ViewHolderState(layout) } override fun onClearView(holder: ViewHolderState) { when (val binding = holder.view) { is RepositoryItemBinding -> clearImage(binding.entryIcon) is RepositoryItemTvBinding -> clearImage(binding.entryIcon) } } override fun onBindContent(holder: ViewHolderState, item: RepositoryData, position: Int) { val isPrebuilt = PREBUILT_REPOSITORIES.contains(item) val drawable = if (isSetup) R.drawable.netflix_download else R.drawable.ic_baseline_delete_outline_24 when (val binding = holder.view) { is RepositoryItemTvBinding -> { binding.apply { // Only shows icon if on setup or if it isn't a prebuilt repo. // No delete buttons on prebuilt repos. if (!isPrebuilt || isSetup) { actionButton.setImageResource(drawable) } actionButton.setOnClickListener { imageClickCallback(item) } repositoryItemRoot.setOnClickListener { clickCallback(item) } mainText.text = item.name subText.text = item.url if (!item.iconUrl.isNullOrEmpty()) { entryIcon.loadImage(item.iconUrl) { error( getImageFromDrawable( binding.root.context, R.drawable.ic_github_logo ) ) } } else { entryIcon.loadImage(R.drawable.ic_github_logo) } } } is RepositoryItemBinding -> { binding.apply { // Only shows icon if on setup or if it isn't a prebuilt repo. // No delete buttons on prebuilt repos. if (!isPrebuilt || isSetup) { actionButton.setImageResource(drawable) } actionButton.setOnClickListener { imageClickCallback(item) } repositoryItemRoot.setOnClickListener { clickCallback(item) } repositoryItemRoot.setOnLongClickListener { val shareableRepoData = "${item.name}$SHAREABLE_REPO_SEPARATOR\n ${item.url}" clipboardHelper(txt(R.string.repo_copy_label), shareableRepoData) true } mainText.text = item.name subText.text = item.url if (!item.iconUrl.isNullOrEmpty()) { entryIcon.loadImage(item.iconUrl) { error( getImageFromDrawable( binding.root.context, R.drawable.ic_github_logo ) ) } } else { entryIcon.loadImage(R.drawable.ic_github_logo) } } } } } companion object { const val SHAREABLE_REPO_SEPARATOR = " : " } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestFragment.kt ================================================ package com.lagradost.cloudstream3.ui.settings.testing import android.view.View import androidx.fragment.app.activityViewModels import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentTestingBinding import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.mvvm.observe import com.lagradost.cloudstream3.mvvm.observeNullable import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setSystemBarsPadding import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setToolBarScrollFlags import com.lagradost.cloudstream3.ui.settings.SettingsFragment.Companion.setUpToolbar class TestFragment : BaseFragment( BaseFragment.BindingCreator.Inflate(FragmentTestingBinding::inflate) ) { private val testViewModel: TestViewModel by activityViewModels() override fun fixLayout(view: View) { setSystemBarsPadding() } override fun onBindingCreated(binding: FragmentTestingBinding) { setUpToolbar(R.string.category_provider_test) setToolBarScrollFlags() binding.apply { providerTestRecyclerView.adapter = TestResultAdapter() testViewModel.init() if (testViewModel.isRunningTest) { providerTest.setState(TestView.TestState.Running) } observe(testViewModel.providerProgress) { (passed, failed, total) -> providerTest.setProgress(passed, failed, total) } observeNullable(testViewModel.providerResults) { safe { val newItems = it.sortedBy { api -> api.first.name } (providerTestRecyclerView.adapter as? TestResultAdapter)?.submitList( newItems ) } } providerTest.setOnPlayButtonListener { state -> when (state) { TestView.TestState.Stopped -> testViewModel.stopTest() TestView.TestState.Running -> testViewModel.startTest() TestView.TestState.None -> testViewModel.startTest() } } if (isLayout(TV)) { providerTest.playPauseButton?.isFocusableInTouchMode = true providerTest.playPauseButton?.requestFocus() } providerTest.playPauseButton?.setOnFocusChangeListener { _, hasFocus -> if (hasFocus) { providerTestAppbar.setExpanded(true, true) } } fun focusRecyclerView() { // Hack to make it possible to focus the recyclerview. if (isLayout(TV)) { providerTestRecyclerView.requestFocus() providerTestAppbar.setExpanded(false, true) } } providerTest.setOnMainClick { testViewModel.setFilterMethod(TestViewModel.ProviderFilter.All) focusRecyclerView() } providerTest.setOnFailedClick { testViewModel.setFilterMethod(TestViewModel.ProviderFilter.Failed) focusRecyclerView() } providerTest.setOnPassedClick { testViewModel.setFilterMethod(TestViewModel.ProviderFilter.Passed) focusRecyclerView() } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestResultAdapter.kt ================================================ package com.lagradost.cloudstream3.ui.settings.testing import android.app.AlertDialog import android.view.LayoutInflater import android.view.ViewGroup import android.widget.ImageView import android.widget.TextView import android.widget.Toast import androidx.core.content.ContextCompat import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.ProviderTestItemBinding import com.lagradost.cloudstream3.mvvm.getAllMessages import com.lagradost.cloudstream3.mvvm.getStackTracePretty import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.ui.BaseDiffCallback import com.lagradost.cloudstream3.ui.NoStateAdapter import com.lagradost.cloudstream3.ui.ViewHolderState import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.runOnMainThread import com.lagradost.cloudstream3.utils.SubtitleHelper.getFlagFromIso import com.lagradost.cloudstream3.utils.TestingUtils import java.io.File class TestResultAdapter() : NoStateAdapter>( diffCallback = BaseDiffCallback( itemSame = { a, b -> a.first.name == b.first.name && a.first.mainUrl == b.first.mainUrl }, contentSame = { a, b -> a == b }) ) { companion object { private fun String.lastLine(): String? { return this.lines().lastOrNull { it.isNotBlank() } } } override fun onClearView(holder: ViewHolderState) { val binding = holder.view as? ProviderTestItemBinding ?: return clearImage(binding.actionButton) } override fun onCreateContent(parent: ViewGroup): ViewHolderState { return ViewHolderState( ProviderTestItemBinding.inflate( LayoutInflater.from(parent.context), parent, false ) ) } override fun onBindContent( holder: ViewHolderState, item: Pair, position: Int ) { val binding = holder.view as? ProviderTestItemBinding ?: return val (api, result) = item val itemView = holder.itemView val languageText: TextView = binding.langIcon val providerTitle: TextView = binding.mainText val statusText: TextView = binding.passedFailedMarker val failDescription: TextView = binding.failDescription val logButton: ImageView = binding.actionButton languageText.text = getFlagFromIso(api.lang) providerTitle.text = api.name val (resultText, resultColor) = if (result.success) { if (result.log.any { it.level == TestingUtils.Logger.LogLevel.Warning }) { R.string.test_warning to R.color.colorTestWarning } else { R.string.test_passed to R.color.colorTestPass } } else { R.string.test_failed to R.color.colorTestFail } statusText.setText(resultText) statusText.setTextColor(ContextCompat.getColor(itemView.context, resultColor)) val stackTrace = result.exception?.getStackTracePretty(false)?.ifBlank { null } val messages = result.exception?.getAllMessages()?.ifBlank { null } val resultLog = result.log.joinToString("\n") val fullLog = resultLog + (messages?.let { "\n\nError: $it" } ?: "") + (stackTrace?.let { "\n\n$it" } ?: "") failDescription.text = messages?.lastLine() ?: resultLog.lastLine() logButton.setOnClickListener { val builder: AlertDialog.Builder = AlertDialog.Builder(it.context, R.style.AlertDialogCustom) builder.setMessage(fullLog) .setTitle(R.string.test_log) // Ok button just closes the dialog .setPositiveButton(R.string.ok) { _, _ -> } api.sourcePlugin?.let { path -> val pluginFile = File(path) // Cannot delete a deleted plugin if (!pluginFile.exists()) return@let builder.setNegativeButton(R.string.delete_plugin) { _, _ -> ioSafe { val success = PluginManager.deletePlugin(pluginFile) runOnMainThread { if (success) { showToast(R.string.plugin_deleted, Toast.LENGTH_SHORT) } else { showToast(R.string.error, Toast.LENGTH_SHORT) } } } } } builder.show() } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestView.kt ================================================ package com.lagradost.cloudstream3.ui.settings.testing import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.widget.TextView import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat import androidx.core.content.withStyledAttributes import androidx.core.view.isVisible import androidx.core.widget.ContentLoadingProgressBar import com.google.android.material.button.MaterialButton import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.utils.AppContextUtils.animateProgressTo class TestView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : CardView(context, attrs) { enum class TestState(@StringRes val stringRes: Int, @DrawableRes val icon: Int) { None(R.string.start, R.drawable.ic_baseline_play_arrow_24), // Paused(R.string.resume, R.drawable.ic_baseline_play_arrow_24), Stopped(R.string.restart, R.drawable.ic_baseline_play_arrow_24), Running(R.string.stop, R.drawable.pause_to_play), } var mainSection: View? = null var testsPassedSection: View? = null var testsFailedSection: View? = null var mainSectionText: TextView? = null var mainSectionHeader: TextView? = null var testsPassedSectionText: TextView? = null var testsFailedSectionText: TextView? = null var totalProgressBar: ContentLoadingProgressBar? = null var playPauseButton: MaterialButton? = null var stateListener: (TestState) -> Unit = {} private var state = TestState.None init { LayoutInflater.from(context).inflate(R.layout.view_test, this, true) mainSection = findViewById(R.id.main_test_section) testsPassedSection = findViewById(R.id.passed_test_section) testsFailedSection = findViewById(R.id.failed_test_section) mainSectionHeader = findViewById(R.id.main_test_header) mainSectionText = findViewById(R.id.main_test_section_progress) testsPassedSectionText = findViewById(R.id.passed_test_section_progress) testsFailedSectionText = findViewById(R.id.failed_test_section_progress) totalProgressBar = findViewById(R.id.test_total_progress) playPauseButton = findViewById(R.id.tests_play_pause) attrs?.let { context.withStyledAttributes(it, R.styleable.TestView) { mainSectionHeader?.text = getString(R.styleable.TestView_header_text) } } playPauseButton?.setOnClickListener { val newState = when (state) { TestState.None -> TestState.Running TestState.Running -> TestState.Stopped TestState.Stopped -> TestState.Running } setState(newState) } } fun setOnPlayButtonListener(listener: (TestState) -> Unit) { stateListener = listener } fun setState(newState: TestState) { state = newState stateListener.invoke(newState) playPauseButton?.setText(newState.stringRes) playPauseButton?.icon = ContextCompat.getDrawable(context, newState.icon) } fun setProgress(passed: Int, failed: Int, total: Int?) { val totalProgress = passed + failed mainSectionText?.text = "$totalProgress / ${total?.toString() ?: "?"}" testsPassedSectionText?.text = passed.toString() testsFailedSectionText?.text = failed.toString() totalProgressBar?.max = (total ?: 0) * 1000 totalProgressBar?.animateProgressTo(totalProgress * 1000) totalProgressBar?.isVisible = !(totalProgress == 0 || (total ?: 0) == 0) if (totalProgress == total) { setState(TestState.Stopped) } } fun setMainHeader(@StringRes header: Int) { mainSectionHeader?.setText(header) } fun setOnMainClick(listener: OnClickListener) { mainSection?.setOnClickListener(listener) } fun setOnPassedClick(listener: OnClickListener) { testsPassedSection?.setOnClickListener(listener) } fun setOnFailedClick(listener: OnClickListener) { testsFailedSection?.setOnClickListener(listener) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/testing/TestViewModel.kt ================================================ package com.lagradost.cloudstream3.ui.settings.testing import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.utils.Coroutines.threadSafeListOf import com.lagradost.cloudstream3.utils.TestingUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancel class TestViewModel : ViewModel() { data class TestProgress( val passed: Int, val failed: Int, val total: Int ) enum class ProviderFilter { All, Passed, Failed } private val _providerProgress = MutableLiveData(null) val providerProgress: LiveData = _providerProgress private val _providerResults = MutableLiveData>>( emptyList() ) val providerResults: LiveData>> = _providerResults private var scope: CoroutineScope? = null val isRunningTest get() = scope != null private var filter = ProviderFilter.All private val providers = threadSafeListOf>() private var passed = 0 private var failed = 0 private var total = 0 private fun updateProgress() { _providerProgress.postValue(TestProgress(passed, failed, total)) postProviders() } private fun postProviders() { synchronized(providers) { val filtered = when (filter) { ProviderFilter.All -> providers ProviderFilter.Passed -> providers.filter { it.second.success } ProviderFilter.Failed -> providers.filter { !it.second.success } } _providerResults.postValue(filtered) } } fun setFilterMethod(filter: ProviderFilter) { if (this.filter == filter) return this.filter = filter postProviders() } private fun addProvider(api: MainAPI, results: TestingUtils.TestResultProvider) { synchronized(providers) { val index = providers.indexOfFirst { it.first == api } if (index == -1) { providers.add(api to results) if (results.success) passed++ else failed++ } else { providers[index] = api to results } updateProgress() } } fun init() { total = synchronized(APIHolder.allProviders) { APIHolder.allProviders.size } updateProgress() } fun startTest() { scope = CoroutineScope(Dispatchers.Default) val apis = synchronized(APIHolder.allProviders) { APIHolder.allProviders.toTypedArray() } total = apis.size failed = 0 passed = 0 providers.clear() updateProgress() TestingUtils.getDeferredProviderTests(scope ?: return, apis) { api, result -> addProvider(api, result) } } fun stopTest() { scope?.cancel() scope = null } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/settings/utils/DirectoryPicker.kt ================================================ package com.lagradost.cloudstream3.ui.settings.utils import android.content.Intent import android.net.Uri import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.Fragment import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.safefile.SafeFile fun Fragment.getChooseFolderLauncher(dirSelected: (uri: Uri?, path: String?) -> Unit) = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri -> // It lies, it can be null if file manager quits. if (uri == null) return@registerForActivityResult val context = context ?: CloudStreamApp.context ?: return@registerForActivityResult // RW perms for the path val flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION context.contentResolver.takePersistableUriPermission(uri, flags) val filePath = SafeFile.fromUri(context, uri)?.filePath() println("Selected URI path: $uri - Full path: $filePath") // store the actual URI instead of the path due to permissions. // filePath should only be used for cosmetic purposes. dirSelected(uri, filePath) } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentExtensions.kt ================================================ package com.lagradost.cloudstream3.ui.setup import android.os.Bundle import android.view.View import androidx.core.view.isVisible import androidx.navigation.fragment.findNavController import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.MainActivity.Companion.afterRepositoryLoadedEvent import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentSetupExtensionsBinding import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.plugins.RepositoryManager import com.lagradost.cloudstream3.plugins.RepositoryManager.PREBUILT_REPOSITORIES import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.settings.extensions.PluginsViewModel import com.lagradost.cloudstream3.ui.settings.extensions.RepoAdapter import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding class SetupFragmentExtensions : BaseFragment( BaseFragment.BindingCreator.Inflate(FragmentSetupExtensionsBinding::inflate) ) { companion object { const val SETUP_EXTENSION_BUNDLE_IS_SETUP = "isSetup" /** * If false then this is treated a singular screen with a done button * */ fun newInstance(isSetup: Boolean): Bundle { return Bundle().apply { putBoolean(SETUP_EXTENSION_BUNDLE_IS_SETUP, isSetup) } } } override fun onResume() { super.onResume() afterRepositoryLoadedEvent += ::setRepositories } override fun onStop() { super.onStop() afterRepositoryLoadedEvent -= ::setRepositories } override fun fixLayout(view: View) { fixSystemBarsPadding(view) } private fun setRepositories(success: Boolean = true) { main { val repositories = RepositoryManager.getRepositories() + PREBUILT_REPOSITORIES val hasRepos = repositories.isNotEmpty() binding?.repoRecyclerView?.isVisible = hasRepos binding?.blankRepoScreen?.isVisible = !hasRepos if (hasRepos) { binding?.repoRecyclerView?.adapter = RepoAdapter(true, {}, { PluginsViewModel.downloadAll(activity, it.url, null) }).apply { submitList(repositories.toList()) } } // else { // list_repositories?.setOnClickListener { // // Open webview on tv if browser fails // openBrowser(PUBLIC_REPOSITORIES_LIST, isTvSettings(), this) // } // } } } override fun onBindingCreated(binding: FragmentSetupExtensionsBinding) { val isSetup = arguments?.getBoolean(SETUP_EXTENSION_BUNDLE_IS_SETUP) ?: false safe { setRepositories() binding.apply { if (!isSetup) { nextBtt.setText(R.string.setup_done) } prevBtt.isVisible = isSetup nextBtt.setOnClickListener { // Continue setup if (isSetup) if ( // If any available languages synchronized(apis) { apis.distinctBy { it.lang }.size > 1 } ) { findNavController().navigate(R.id.action_navigation_setup_extensions_to_navigation_setup_provider_languages) } else { findNavController().navigate(R.id.action_navigation_setup_extensions_to_navigation_setup_media) } else findNavController().navigate(R.id.navigation_home) } prevBtt.setOnClickListener { findNavController().navigate(R.id.navigation_setup_language) } } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLanguage.kt ================================================ package com.lagradost.cloudstream3.ui.setup import android.view.View import android.widget.AbsListView import android.widget.ArrayAdapter import androidx.core.content.ContextCompat import androidx.core.content.edit import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity import com.lagradost.cloudstream3.databinding.FragmentSetupLanguageBinding import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.settings.appLanguages import com.lagradost.cloudstream3.ui.settings.getCurrentLocale import com.lagradost.cloudstream3.ui.settings.nameNextToFlagEmoji import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding const val HAS_DONE_SETUP_KEY = "HAS_DONE_SETUP" class SetupFragmentLanguage : BaseFragment( BaseFragment.BindingCreator.Inflate(FragmentSetupLanguageBinding::inflate) ) { override fun fixLayout(view: View) { fixSystemBarsPadding(view) } override fun onBindingCreated(binding: FragmentSetupLanguageBinding) { // We don't want a crash for all users safe { val ctx = context ?: return@safe val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) binding.apply { // Icons may crash on some weird android versions? safe { val drawable = when { BuildConfig.DEBUG -> R.drawable.cloud_2_gradient_debug BuildConfig.FLAVOR == "prerelease" -> R.drawable.cloud_2_gradient_beta else -> R.drawable.cloud_2_gradient } appIconImage.setImageDrawable(ContextCompat.getDrawable(ctx, drawable)) } val current = getCurrentLocale(ctx) val languageTagsIETF = appLanguages.map { it.second } val languageNames = appLanguages.map { it.nameNextToFlagEmoji() } val currentIndex = languageTagsIETF.indexOf(current) arrayAdapter.addAll(languageNames) listview1.adapter = arrayAdapter listview1.choiceMode = AbsListView.CHOICE_MODE_SINGLE listview1.setItemChecked(currentIndex, true) listview1.setOnItemClickListener { _, _, selectedLangIndex, _ -> val langTagIETF = languageTagsIETF[selectedLangIndex] CommonActivity.setLocale(activity, langTagIETF) settingsManager.edit { putString(getString(R.string.locale_key), langTagIETF) } } nextBtt.setOnClickListener { // If no plugins go to plugins page val nextDestination = if ( PluginManager.getPluginsOnline().isEmpty() && PluginManager.getPluginsLocal().isEmpty() //&& PREBUILT_REPOSITORIES.isNotEmpty() ) R.id.action_navigation_global_to_navigation_setup_extensions else R.id.action_navigation_setup_language_to_navigation_setup_provider_languages findNavController().navigate( nextDestination, SetupFragmentExtensions.newInstance(true) ) } skipBtt.setOnClickListener { setKey(HAS_DONE_SETUP_KEY, true) findNavController().navigate(R.id.navigation_home) } } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentLayout.kt ================================================ package com.lagradost.cloudstream3.ui.setup import android.view.View import android.widget.AbsListView import android.widget.ArrayAdapter import androidx.core.content.edit import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.FragmentSetupLayoutBinding import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding class SetupFragmentLayout : BaseFragment( BaseFragment.BindingCreator.Inflate(FragmentSetupLayoutBinding::inflate) ) { override fun fixLayout(view: View) { fixSystemBarsPadding(view) } override fun onBindingCreated(binding: FragmentSetupLayoutBinding) { safe { val ctx = context ?: return@safe val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val prefNames = resources.getStringArray(R.array.app_layout) val prefValues = resources.getIntArray(R.array.app_layout_values) val currentLayout = settingsManager.getInt(getString(R.string.app_layout_key), -1) val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) arrayAdapter.addAll(prefNames.toList()) binding.apply { listview1.adapter = arrayAdapter listview1.choiceMode = AbsListView.CHOICE_MODE_SINGLE listview1.setItemChecked( prefValues.indexOf(currentLayout), true ) listview1.setOnItemClickListener { _, _, position, _ -> settingsManager.edit { putInt(getString(R.string.app_layout_key), prefValues[position]) } activity?.recreate() } nextBtt.setOnClickListener { setKey(HAS_DONE_SETUP_KEY, true) findNavController().navigate(R.id.navigation_home) } prevBtt.setOnClickListener { findNavController().popBackStack() } } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentMedia.kt ================================================ package com.lagradost.cloudstream3.ui.setup import android.view.View import android.widget.AbsListView import android.widget.ArrayAdapter import androidx.core.content.edit import androidx.core.util.forEach import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.databinding.FragmentSetupMediaBinding import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.utils.DataStoreHelper import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding class SetupFragmentMedia : BaseFragment( BaseFragment.BindingCreator.Inflate(FragmentSetupMediaBinding::inflate) ) { override fun fixLayout(view: View) { fixSystemBarsPadding(view) } override fun onBindingCreated(binding: FragmentSetupMediaBinding) { safe { val ctx = context ?: return@safe val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) val names = enumValues().sorted().map { it.name } val selected = mutableListOf() arrayAdapter.addAll(names) binding.apply { listview1.let { it.adapter = arrayAdapter it.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE it.setOnItemClickListener { _, _, _, _ -> it.checkedItemPositions?.forEach { key, value -> if (value) { selected.add(key) } else { selected.remove(key) } } val prefValues = selected.mapNotNull { pos -> val item = it.getItemAtPosition(pos)?.toString() ?: return@mapNotNull null val itemVal = TvType.valueOf(item) itemVal.ordinal.toString() }.toSet() settingsManager.edit { putStringSet(getString(R.string.prefer_media_type_key), prefValues) } // Regenerate set homepage DataStoreHelper.currentHomePage = null } } nextBtt.setOnClickListener { findNavController().navigate(R.id.navigation_setup_media_to_navigation_setup_layout) } prevBtt.setOnClickListener { findNavController().popBackStack() } } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/setup/SetupFragmentProviderLanguage.kt ================================================ package com.lagradost.cloudstream3.ui.setup import android.view.View import android.widget.AbsListView import android.widget.ArrayAdapter import androidx.core.content.edit import androidx.core.util.forEach import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.APIHolder import com.lagradost.cloudstream3.databinding.FragmentSetupProviderLanguagesBinding import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.utils.AppContextUtils.getApiProviderLangSettings import com.lagradost.cloudstream3.utils.SubtitleHelper.getNameNextToFlagEmoji import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding class SetupFragmentProviderLanguage : BaseFragment( BaseFragment.BindingCreator.Inflate(FragmentSetupProviderLanguagesBinding::inflate) ) { override fun fixLayout(view: View) { fixSystemBarsPadding(view) } override fun onBindingCreated(binding: FragmentSetupProviderLanguagesBinding) { safe { val ctx = context ?: return@safe val settingsManager = PreferenceManager.getDefaultSharedPreferences(ctx) val arrayAdapter = ArrayAdapter(ctx, R.layout.sort_bottom_single_choice) val currentLangTags = ctx.getApiProviderLangSettings() val languagesTagName = synchronized(APIHolder.apis) { listOf( Pair(AllLanguagesName, getString(R.string.all_languages_preference)) ) + APIHolder.apis.map { Pair(it.lang, getNameNextToFlagEmoji(it.lang) ?: it.lang) } .toSet().sortedBy { it.second.substringAfter("\u00a0").lowercase() } // name ignoring flag emoji } val currentIndexList = currentLangTags.map { langTag -> languagesTagName.indexOfFirst { lang -> lang.first == langTag } }.filter { it > -1 } arrayAdapter.addAll(languagesTagName.map { it.second }) binding.apply { listview1.adapter = arrayAdapter listview1.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE currentIndexList.forEach { listview1.setItemChecked(it, true) } listview1.setOnItemClickListener { _, _, _, _ -> val selectedLanguages = mutableSetOf() listview1.checkedItemPositions?.forEach { key, value -> if (value) selectedLanguages.add(languagesTagName[key].first) } settingsManager.edit { putStringSet( ctx.getString(R.string.provider_lang_key), selectedLanguages.toSet() ) } } nextBtt.setOnClickListener { findNavController().navigate(R.id.navigation_setup_provider_languages_to_navigation_setup_media) } prevBtt.setOnClickListener { findNavController().popBackStack() } } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/ChromecastSubtitlesFragment.kt ================================================ package com.lagradost.cloudstream3.ui.subtitles import android.app.Activity import android.content.Context import android.content.res.Resources import android.graphics.Color import android.os.Bundle import android.util.DisplayMetrics import android.util.TypedValue import android.view.View import android.widget.TextView import android.widget.Toast import androidx.annotation.OptIn import androidx.media3.common.text.Cue import androidx.media3.common.util.UnstableApi import com.fasterxml.jackson.annotation.JsonProperty import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_DEPRESSED import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_DROP_SHADOW import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_NONE import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_OUTLINE import com.google.android.gms.cast.TextTrackStyle.EDGE_TYPE_RAISED import com.jaredrummler.android.colorpicker.ColorPickerDialog import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.ChromecastSubtitleSettingsBinding import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage const val CHROME_SUBTITLE_KEY = "chome_subtitle_settings" data class SaveChromeCaptionStyle( @JsonProperty("fontFamily") var fontFamily: String? = null, @JsonProperty("fontGenericFamily") var fontGenericFamily: Int? = null, @JsonProperty("backgroundColor") var backgroundColor: Int = 0x00FFFFFF, // transparent @JsonProperty("edgeColor") var edgeColor: Int = Color.BLACK, // BLACK @JsonProperty("edgeType") var edgeType: Int = EDGE_TYPE_OUTLINE, @JsonProperty("foregroundColor") var foregroundColor: Int = Color.WHITE, @JsonProperty("fontScale") var fontScale: Float = 1.05f, @JsonProperty("windowColor") var windowColor: Int = Color.TRANSPARENT, ) class ChromecastSubtitlesFragment : BaseFragment( BaseFragment.BindingCreator.Inflate(ChromecastSubtitleSettingsBinding::inflate) ) { companion object { val applyStyleEvent = Event() //fun Context.fromSaveToStyle(data: SaveChromeCaptionStyle): CaptionStyleCompat { // return CaptionStyleCompat( // data.foregroundColor, // data.backgroundColor, // data.windowColor, // data.edgeType, // data.edgeColor, // if (typeface == null) Typeface.SANS_SERIF else ResourcesCompat.getFont( // this, // typeface // ) // ) //} fun push(activity: Activity?, hide: Boolean = true) { activity.navigate(R.id.global_to_navigation_chrome_subtitles, Bundle().apply { putBoolean("hide", hide) }) } private fun getDefColor(id: Int): Int { return when (id) { 0 -> Color.WHITE 1 -> Color.BLACK 2 -> Color.TRANSPARENT 3 -> Color.TRANSPARENT else -> Color.TRANSPARENT } } fun Context.saveStyle(style: SaveChromeCaptionStyle) { this.setKey(CHROME_SUBTITLE_KEY, style) } private fun getPixels(unit: Int, size: Float): Int { val metrics: DisplayMetrics = Resources.getSystem().displayMetrics return TypedValue.applyDimension(unit, size, metrics).toInt() } fun getCurrentSavedStyle(): SaveChromeCaptionStyle { return getKey(CHROME_SUBTITLE_KEY) ?: defaultState } private val defaultState = SaveChromeCaptionStyle() } private fun onColorSelected(stuff: Pair) { setColor(stuff.first, stuff.second) if (hide) activity?.hideSystemUI() } private fun onDialogDismissed(id: Int) { if (hide) activity?.hideSystemUI() } private fun getColor(id: Int): Int { val color = when (id) { 0 -> state.foregroundColor 1 -> state.edgeColor 2 -> state.backgroundColor 3 -> state.windowColor else -> Color.TRANSPARENT } return if (color == Color.TRANSPARENT) Color.BLACK else color } private fun setColor(id: Int, color: Int?) { val realColor = color ?: getDefColor(id) when (id) { 0 -> state.foregroundColor = realColor 1 -> state.edgeColor = realColor 2 -> state.backgroundColor = realColor 3 -> state.windowColor = realColor else -> Unit } updateState() } private fun updateState() { //subtitle_text?.setStyle(fromSaveToStyle(state)) } private lateinit var state: SaveChromeCaptionStyle private var hide: Boolean = true override fun onDestroy() { super.onDestroy() onColorSelectedEvent -= ::onColorSelected } override fun fixLayout(view: View) { fixSystemBarsPadding( view, padBottom = isLandscape(), padLeft = isLayout(TV or EMULATOR) ) } override fun onBindingCreated(binding: ChromecastSubtitleSettingsBinding) { hide = arguments?.getBoolean("hide") ?: true onColorSelectedEvent += ::onColorSelected onDialogDismissedEvent += ::onDialogDismissed state = getCurrentSavedStyle() updateState() val isTvSettings = isLayout(TV or EMULATOR) fun View.setFocusableInTv() { this.isFocusableInTouchMode = isTvSettings } fun View.setup(id: Int) { setFocusableInTv() this.setOnClickListener { activity?.let { ColorPickerDialog.newBuilder() .setDialogId(id) .setShowAlphaSlider(true) .setColor(getColor(id)) .show(it) } } this.setOnLongClickListener { setColor(id, null) showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } } binding.apply { subsTextColor.setup(0) subsOutlineColor.setup(1) subsBackgroundColor.setup(2) } val dismissCallback = { if (hide) activity?.hideSystemUI() } binding.subsEdgeType.setFocusableInTv() binding.subsEdgeType.setOnClickListener { textView -> val edgeTypes = listOf( Pair( EDGE_TYPE_NONE, textView.context.getString(R.string.subtitles_none) ), Pair( EDGE_TYPE_OUTLINE, textView.context.getString(R.string.subtitles_outline) ), Pair( EDGE_TYPE_DEPRESSED, textView.context.getString(R.string.subtitles_depressed) ), Pair( EDGE_TYPE_DROP_SHADOW, textView.context.getString(R.string.subtitles_shadow) ), Pair( EDGE_TYPE_RAISED, textView.context.getString(R.string.subtitles_raised) ), ) //showBottomDialog activity?.showDialog( edgeTypes.map { it.second }, edgeTypes.map { it.first }.indexOf(state.edgeType), (textView as TextView).text.toString(), false, dismissCallback ) { index -> state.edgeType = edgeTypes.map { it.first }[index] updateState() } } binding.subsEdgeType.setOnLongClickListener { state.edgeType = defaultState.edgeType updateState() showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } binding.subsFontSize.setFocusableInTv() binding.subsFontSize.setOnClickListener { textView -> val fontSizes = listOf( Pair(0.75f, "75%"), Pair(0.80f, "80%"), Pair(0.85f, "85%"), Pair(0.90f, "90%"), Pair(0.95f, "95%"), Pair(1.00f, "100%"), Pair(1.05f, textView.context.getString(R.string.normal)), Pair(1.10f, "110%"), Pair(1.15f, "115%"), Pair(1.20f, "120%"), Pair(1.25f, "125%"), Pair(1.30f, "130%"), Pair(1.35f, "135%"), Pair(1.40f, "140%"), Pair(1.45f, "145%"), Pair(1.50f, "150%"), ) //showBottomDialog activity?.showDialog( fontSizes.map { it.second }, fontSizes.map { it.first }.indexOf(state.fontScale), (textView as TextView).text.toString(), false, dismissCallback ) { index -> state.fontScale = fontSizes.map { it.first }[index] //textView.context.updateState() // font size not changed } } binding.subsFontSize.setOnLongClickListener { _ -> state.fontScale = defaultState.fontScale //textView.context.updateState() // font size not changed showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } binding.subsFont.setFocusableInTv() binding.subsFont.setOnClickListener { textView -> val fontTypes = listOf( null to textView.context.getString(R.string.normal), "Droid Sans" to "Droid Sans", "Droid Sans Mono" to "Droid Sans Mono", "Droid Serif Regular" to "Droid Serif Regular", "Cutive Mono" to "Cutive Mono", "Short Stack" to "Short Stack", "Quintessential" to "Quintessential", "Alegreya Sans SC" to "Alegreya Sans SC", ) //showBottomDialog activity?.showDialog( fontTypes.map { it.second }, fontTypes.map { it.first }.indexOf(state.fontFamily), (textView as TextView).text.toString(), false, dismissCallback ) { index -> state.fontFamily = fontTypes.map { it.first }[index] updateState() } } binding.subsFont.setOnLongClickListener { _ -> state.fontFamily = defaultState.fontFamily updateState() showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } binding.cancelBtt.setOnClickListener { activity?.popCurrentPage() } binding.applyBtt.setOnClickListener { it.context.saveStyle(state) applyStyleEvent.invoke(state) //it.context.fromSaveToStyle(state) activity?.popCurrentPage() } setSubtitleCues(binding) } @OptIn(UnstableApi::class) private fun setSubtitleCues(binding: ChromecastSubtitleSettingsBinding) { binding.subtitleText.apply { setCues( listOf( Cue.Builder() .setTextSize( getPixels(TypedValue.COMPLEX_UNIT_SP, 25.0f).toFloat(), Cue.TEXT_SIZE_TYPE_ABSOLUTE ) .setText(context.getString(R.string.subtitles_example_text)) .build() ) ) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/ui/subtitles/SubtitlesFragment.kt ================================================ package com.lagradost.cloudstream3.ui.subtitles import android.app.Activity import android.content.Context import android.content.res.Resources import android.graphics.Color import android.graphics.Typeface import android.os.Bundle import android.text.Layout import android.text.Spannable import android.text.SpannableString import android.text.style.StyleSpan import android.util.DisplayMetrics import android.util.TypedValue import android.view.View import android.widget.TextView import android.widget.Toast import androidx.annotation.FontRes import androidx.annotation.OptIn import androidx.annotation.Px import androidx.core.content.edit import androidx.core.content.res.ResourcesCompat import androidx.media3.common.text.Cue import androidx.media3.common.util.UnstableApi import androidx.media3.ui.CaptionStyleCompat import androidx.media3.ui.SubtitleView import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty import com.jaredrummler.android.colorpicker.ColorPickerDialog import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CommonActivity.onColorSelectedEvent import com.lagradost.cloudstream3.CommonActivity.onDialogDismissedEvent import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.SubtitleSettingsBinding import com.lagradost.cloudstream3.ui.BaseDialogFragment import com.lagradost.cloudstream3.ui.BaseFragment import com.lagradost.cloudstream3.ui.player.CustomDecoder import com.lagradost.cloudstream3.ui.player.CustomDecoder.Companion.setSubtitleAlignment import com.lagradost.cloudstream3.ui.player.OutlineSpan import com.lagradost.cloudstream3.ui.player.RoundedBackgroundColorSpan import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLandscape import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.DataStore.setKey import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog import com.lagradost.cloudstream3.utils.SubtitleHelper.languages import com.lagradost.cloudstream3.utils.UIHelper.fixSystemBarsPadding import com.lagradost.cloudstream3.utils.UIHelper.hideSystemUI import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.popCurrentPage import com.lagradost.cloudstream3.utils.UIHelper.toPx import java.io.File const val SUBTITLE_KEY = "subtitle_settings" const val SUBTITLE_AUTO_SELECT_KEY = "subs_auto_select" const val SUBTITLE_DOWNLOAD_KEY = "subs_auto_download" data class SaveCaptionStyle( @JsonProperty("foregroundColor") var foregroundColor: Int, @JsonProperty("backgroundColor") var backgroundColor: Int, @JsonProperty("windowColor") var windowColor: Int, @OptIn(UnstableApi::class) @JsonProperty("edgeType") var edgeType: @CaptionStyleCompat.EdgeType Int, @JsonProperty("edgeColor") var edgeColor: Int, @FontRes @JsonProperty("typeface") var typeface: Int?, @JsonProperty("typefaceFilePath") var typefaceFilePath: String?, /**in dp**/ @JsonProperty("elevation") var elevation: Int, /**in sp**/ @JsonProperty("fixedTextSize") var fixedTextSize: Float?, @Px @JsonProperty("edgeSize") var edgeSize: Float? = null, @JsonProperty("removeCaptions") var removeCaptions: Boolean = false, @JsonProperty("removeBloat") var removeBloat: Boolean = true, /** Apply caps lock to the text **/ @JsonProperty("upperCase") var upperCase: Boolean = false, /** Apply bold to the text **/ @JsonProperty("bold") var bold: Boolean = false, /** Apply italic to the text **/ @JsonProperty("italic") var italic: Boolean = false, /** in px, background radius, aka how round the background (backgroundColor) on each row is **/ @JsonProperty("backgroundRadius") var backgroundRadius: Float? = null, /** The SSA_ALIGNMENT */ @JsonProperty("alignment") var alignment: Int? = null, ) const val DEF_SUBS_ELEVATION = 20 @OptIn(UnstableApi::class) class SubtitlesFragment : BaseDialogFragment( BaseFragment.BindingCreator.Inflate(SubtitleSettingsBinding::inflate) ) { companion object { val applyStyleEvent = Event() private val captionRegex = Regex("""(-\s?|)[\[({][\S\s]*?[])}]\s*""") fun setSubtitleViewStyle( view: SubtitleView?, data: SaveCaptionStyle, applyElevation: Boolean ) { if (view == null) return val ctx = view.context ?: return val style = ctx.fromSaveToStyle(data) view.setStyle(style) if (applyElevation) { view.setPadding( view.paddingLeft, data.elevation.toPx, view.paddingRight, view.paddingBottom ) } // we default to 25sp, this is needed as RoundedBackgroundColorSpan breaks on override sizes val size = data.fixedTextSize ?: 25.0f view.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, size) view.setBottomPaddingFraction(0.0f) /*if (size != null) { view.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, size) } else { view.setUserDefaultTextSize() }*/ } fun Cue.Builder.applyStyle(style: SaveCaptionStyle): Cue.Builder { val edgeSize = style.edgeSize /* This is old code for only applying on non null val fixedFontSize = style.fixedTextSize val absoluteFontSize = fixedFontSize?.let { getPixels(TypedValue.COMPLEX_UNIT_SP, it).toFloat() } // 1. apply override size if (absoluteFontSize != null) { setTextSize(absoluteFontSize, Cue.TEXT_SIZE_TYPE_ABSOLUTE) }*/ // 1. remove any subtitle size set by the subtitle file (like ass) // instead we use the inherit size of the subtitle view setTextSize(Cue.DIMEN_UNSET, Cue.TYPE_UNSET) // 2. apply edge text?.let { text -> val customSpan = SpannableString.valueOf(text) if (edgeSize != null) { customSpan.setSpan( OutlineSpan(edgeSize), 0, customSpan.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) } setText(customSpan) } // 3. apply bold + italic text?.let { text -> val customSpan = SpannableString.valueOf(text) val typeface = when (style.bold to style.italic) { (true to true) -> Typeface.BOLD_ITALIC (true to false) -> Typeface.BOLD (false to true) -> Typeface.ITALIC (false to false) -> Typeface.NORMAL else -> { Typeface.NORMAL } } if (typeface != Typeface.NORMAL) { val styleSpan = StyleSpan(typeface) customSpan.setSpan( styleSpan, 0, customSpan.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) } setText(customSpan) } // 4. apply radius text?.let { text -> val customSpan = SpannableString.valueOf(text) val radius = style.backgroundRadius if (radius != null && style.backgroundColor != Color.TRANSPARENT) { val styleSpan = RoundedBackgroundColorSpan( style.backgroundColor, this.textAlignment ?: Layout.Alignment.ALIGN_CENTER, 2.0F + radius * 0.5f, radius ) customSpan.setSpan( styleSpan, 0, customSpan.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) } setText(customSpan) } // 5. remove captions text?.let { text -> if (style.removeCaptions) { setText(text.replace(captionRegex, "")) } } // 6. set alignment return this.setSubtitleAlignment(style.alignment) } private fun Context.fromSaveToStyle(data: SaveCaptionStyle): CaptionStyleCompat { return CaptionStyleCompat( data.foregroundColor, // we actually override with a custom span when backgroundRadius != null if (data.backgroundRadius == null) data.backgroundColor else Color.TRANSPARENT, data.windowColor, data.edgeType, data.edgeColor, data.typefaceFilePath?.let { try { // RuntimeException: Font asset not found Typeface.createFromFile(File(it)) } catch (e: Exception) { null } } ?: data.typeface?.let { ResourcesCompat.getFont( this, it ) } ?: Typeface.SANS_SERIF ) } fun push(activity: Activity?, hide: Boolean = true) { activity.navigate(R.id.global_to_navigation_subtitles, Bundle().apply { putBoolean("hide", hide) putBoolean("popFragment", true) }) } private fun getDefColor(id: Int): Int { return when (id) { 0 -> Color.WHITE 1 -> Color.BLACK 2 -> Color.TRANSPARENT 3 -> Color.TRANSPARENT else -> Color.TRANSPARENT } } private var cachedSubtitleStyle: SaveCaptionStyle? = null fun Context.saveStyle(style: SaveCaptionStyle) { cachedSubtitleStyle = style this.setKey(SUBTITLE_KEY, style) } fun getCurrentSavedStyle(): SaveCaptionStyle { return cachedSubtitleStyle ?: (getKey(SUBTITLE_KEY) ?: SaveCaptionStyle( foregroundColor = getDefColor(0), backgroundColor = getDefColor(2), windowColor = getDefColor(3), edgeType = CaptionStyleCompat.EDGE_TYPE_OUTLINE, edgeColor = getDefColor(1), typeface = null, typefaceFilePath = null, elevation = DEF_SUBS_ELEVATION, fixedTextSize = null, )).also { cachedSubtitleStyle = it } } private fun Context.getSavedFonts(): List { val externalFiles = getExternalFilesDir(null) ?: return emptyList() val fontDir = File(externalFiles.absolutePath + "/Fonts").also { it.mkdir() } return fontDir.list()?.mapNotNull { // No idea which formats are supported, but these should be. if (it.endsWith(".ttf") || it.endsWith(".otf")) { File(fontDir.absolutePath + "/" + it) } else null } ?: listOf() } private fun getPixels(unit: Int, size: Float): Int { val metrics: DisplayMetrics = Resources.getSystem().displayMetrics return TypedValue.applyDimension(unit, size, metrics).toInt() } fun getDownloadSubsLanguageTagIETF(): List { return getKey(SUBTITLE_DOWNLOAD_KEY) ?: listOf("en") } fun getAutoSelectLanguageTagIETF(): String { return getKey(SUBTITLE_AUTO_SELECT_KEY) ?: "en" } } private fun onColorSelected(stuff: Pair) { context?.setColor(stuff.first, stuff.second) if (hide) activity?.hideSystemUI() } private fun onDialogDismissed(@Suppress("UNUSED_PARAMETER") id: Int) { if (hide) activity?.hideSystemUI() } private fun Context.setColor(id: Int, color: Int?) { val realColor = color ?: getDefColor(id) when (id) { 0 -> state.foregroundColor = realColor 1 -> state.edgeColor = realColor 2 -> state.backgroundColor = realColor 3 -> state.windowColor = realColor else -> Unit } updateState() } private fun Context.updateState() { val text = getString(R.string.subtitles_example_text) val fixedText = SpannableString.valueOf(if (state.upperCase) text.uppercase() else text) setSubtitleViewStyle(binding?.subtitleText, state, false) binding?.subtitleText?.setCues( listOf( Cue.Builder() .setText(fixedText) .applyStyle(state) .build() ) ) } private fun getColor(id: Int): Int { val color = when (id) { 0 -> state.foregroundColor 1 -> state.edgeColor 2 -> state.backgroundColor 3 -> state.windowColor else -> Color.TRANSPARENT } return if (color == Color.TRANSPARENT) Color.BLACK else color } private lateinit var state: SaveCaptionStyle private var hide: Boolean = true override fun onDestroy() { super.onDestroy() onColorSelectedEvent -= ::onColorSelected } override fun onStart() { super.onStart() dialog?.window?.setWindowAnimations(R.style.DialogFullscreenPlayer) } override fun getTheme(): Int { return R.style.DialogFullscreenPlayer } var systemBarsAddPadding = isLayout(TV or EMULATOR) override fun fixLayout(view: View) { fixSystemBarsPadding( view, padBottom = systemBarsAddPadding || isLandscape(), padLeft = systemBarsAddPadding ) } override fun onBindingCreated(binding: SubtitleSettingsBinding) { hide = arguments?.getBoolean("hide") ?: true val popFragment = arguments?.getBoolean("popFragment") ?: false onColorSelectedEvent += ::onColorSelected onDialogDismissedEvent += ::onDialogDismissed binding.subsImportText.text = getString(R.string.subs_import_text).format( context?.getExternalFilesDir(null)?.absolutePath.toString() + "/Fonts" ) state = getCurrentSavedStyle() context?.updateState() val isTvTrueSettings = isLayout(TV) fun View.setFocusableInTv() { this.isFocusableInTouchMode = isTvTrueSettings } fun View.setup(id: Int) { setFocusableInTv() this.setOnClickListener { activity?.let { ColorPickerDialog.newBuilder() .setDialogId(id) .setShowAlphaSlider(true) .setColor(getColor(id)) .show(it) } } this.setOnLongClickListener { it.context.setColor(id, null) showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } } binding.apply { subsTextColor.setup(0) subsOutlineColor.setup(1) subsBackgroundColor.setup(2) subsWindowColor.setup(3) val dismissCallback = { if (hide) activity?.hideSystemUI() } subsSubtitleElevation.setFocusableInTv() subsSubtitleElevation.setOnClickListener { textView -> // tbh this should not be a dialog if it has so many values val elevationTypes = listOf( 0 to textView.context.getString(R.string.none) ) + (1..40).map { x -> val i = x * 10 i to "${i}dp" } //showBottomDialog activity?.showDialog( elevationTypes.map { it.second }, elevationTypes.map { it.first }.indexOf(state.elevation), (textView as TextView).text.toString(), false, dismissCallback ) { index -> state.elevation = elevationTypes.map { it.first }[index] textView.context.updateState() if (hide) activity?.hideSystemUI() } } subsSubtitleElevation.setOnLongClickListener { state.elevation = DEF_SUBS_ELEVATION it.context.updateState() showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } subsBackgroundRadius.setFocusableInTv() subsBackgroundRadius.setOnClickListener { textView -> // tbh this should not be a dialog if it has so many values val radiusTypes = listOf( null to textView.context.getString(R.string.none) ) + (1..10).map { x -> val i = x * 5 i to "${i}px" } activity?.showDialog( radiusTypes.map { it.second }, radiusTypes.map { it.first }.indexOf(state.backgroundRadius?.toInt()), (textView as TextView).text.toString(), false, dismissCallback ) { index -> state.backgroundRadius = radiusTypes.map { it.first }[index]?.toFloat() textView.context.updateState() } } subsBackgroundRadius.setOnLongClickListener { state.backgroundRadius = null it.context.updateState() showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } subsSubtitleAlignment.setFocusableInTv() subsSubtitleAlignment.setOnClickListener { textView -> val alignmentTypes = listOf( null to R.string.automatic, CustomDecoder.SSA_ALIGNMENT_BOTTOM_LEFT to R.string.bottom_left, CustomDecoder.SSA_ALIGNMENT_BOTTOM_CENTER to R.string.bottom_center, CustomDecoder.SSA_ALIGNMENT_BOTTOM_RIGHT to R.string.bottom_right, CustomDecoder.SSA_ALIGNMENT_MIDDLE_LEFT to R.string.middle_left, CustomDecoder.SSA_ALIGNMENT_MIDDLE_CENTER to R.string.middle_center, CustomDecoder.SSA_ALIGNMENT_MIDDLE_RIGHT to R.string.middle_right, CustomDecoder.SSA_ALIGNMENT_TOP_LEFT to R.string.top_left, CustomDecoder.SSA_ALIGNMENT_TOP_CENTER to R.string.top_center, CustomDecoder.SSA_ALIGNMENT_TOP_RIGHT to R.string.top_right, ) activity?.showDialog( alignmentTypes.map { textView.context.getString(it.second) }, alignmentTypes.map { it.first }.indexOf(state.alignment), (textView as TextView).text.toString(), false, dismissCallback ) { index -> state.alignment = alignmentTypes.map { it.first }[index] textView.context.updateState() } } subsEdgeType.setFocusableInTv() subsEdgeType.setOnClickListener { textView -> val edgeTypes = listOf( CaptionStyleCompat.EDGE_TYPE_NONE to textView.context.getString(R.string.subtitles_none), CaptionStyleCompat.EDGE_TYPE_OUTLINE to textView.context.getString(R.string.subtitles_outline), CaptionStyleCompat.EDGE_TYPE_DEPRESSED to textView.context.getString(R.string.subtitles_depressed), CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW to textView.context.getString(R.string.subtitles_shadow), CaptionStyleCompat.EDGE_TYPE_RAISED to textView.context.getString(R.string.subtitles_raised), ) //showBottomDialog activity?.showDialog( edgeTypes.map { it.second }, edgeTypes.map { it.first }.indexOf(state.edgeType), (textView as TextView).text.toString(), false, dismissCallback ) { index -> state.edgeType = edgeTypes.map { it.first }[index] textView.context.updateState() } } subsEdgeType.setOnLongClickListener { state.edgeType = CaptionStyleCompat.EDGE_TYPE_OUTLINE it.context.updateState() showToast(R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } subsFontSize.setFocusableInTv() subsFontSize.setOnClickListener { textView -> val fontSizes = listOf( null to textView.context.getString(R.string.normal), ) + (6..60).map { i -> i.toFloat() to "${i}sp" } //showBottomDialog activity?.showDialog( fontSizes.map { it.second }, fontSizes.map { it.first }.indexOf(state.fixedTextSize), (textView as TextView).text.toString(), false, dismissCallback ) { index -> state.fixedTextSize = fontSizes.map { it.first }[index] textView.context.updateState() } } subsEdgeSize.setFocusableInTv() subsEdgeSize.setOnClickListener { textView -> val fontSizes = listOf( null to textView.context.getString(R.string.normal), ) + (1..60).map { i -> i.toFloat() to "${i}px" } //showBottomDialog activity?.showDialog( fontSizes.map { it.second }, fontSizes.map { it.first }.indexOf(state.edgeSize), (textView as TextView).text.toString(), false, dismissCallback ) { index -> state.edgeSize = fontSizes.map { it.first }[index] textView.context.updateState() } } subtitlesRemoveBloat.isChecked = state.removeBloat subtitlesRemoveBloat.setOnCheckedChangeListener { _, b -> state.removeBloat = b } subtitlesUppercase.isChecked = state.upperCase subtitlesUppercase.setOnCheckedChangeListener { _, b -> state.upperCase = b context?.updateState() } subtitlesRemoveCaptions.isChecked = state.removeCaptions subtitlesRemoveCaptions.setOnCheckedChangeListener { _, b -> state.removeCaptions = b } subtitlesBold.isChecked = state.bold subtitlesBold.setOnCheckedChangeListener { _, b -> state.bold = b context?.updateState() } subtitlesItalic.isChecked = state.italic subtitlesItalic.setOnCheckedChangeListener { _, b -> state.italic = b context?.updateState() } subsFontSize.setOnLongClickListener { _ -> state.fixedTextSize = null context?.updateState() showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } subsEdgeSize.setOnLongClickListener { _ -> state.edgeSize = null context?.updateState() showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } //Fetch current value from preference context?.let { ctx -> subtitlesFilterSubLang.isChecked = PreferenceManager.getDefaultSharedPreferences(ctx) .getBoolean(getString(R.string.filter_sub_lang_key), false) } subtitlesFilterSubLang.setOnCheckedChangeListener { _, b -> context?.let { ctx -> PreferenceManager.getDefaultSharedPreferences(ctx).edit { putBoolean(getString(R.string.filter_sub_lang_key), b) } } } subsFont.setFocusableInTv() subsFont.setOnClickListener { textView -> val fontTypes = listOf( null to textView.context.getString(R.string.normal), R.font.trebuchet_ms to "Trebuchet MS", R.font.netflix_sans to "Netflix Sans", R.font.google_sans to "Google Sans", R.font.open_sans to "Open Sans", R.font.futura to "Futura", R.font.consola to "Consola", R.font.gotham to "Gotham", R.font.lucida_grande to "Lucida Grande", R.font.stix_general to "STIX General", R.font.times_new_roman to "Times New Roman", R.font.verdana to "Verdana", R.font.ubuntu_regular to "Ubuntu", R.font.comic_sans to "Comic Sans", R.font.poppins_regular to "Poppins", ) val savedFontTypes = textView.context.getSavedFonts() val currentIndex = savedFontTypes.indexOfFirst { it.absolutePath == state.typefaceFilePath } .let { index -> if (index == -1) fontTypes.indexOfFirst { it.first == state.typeface } else index + fontTypes.size } //showBottomDialog activity?.showDialog( fontTypes.map { it.second } + savedFontTypes.map { it.name }, currentIndex, (textView as TextView).text.toString(), false, dismissCallback ) { index -> if (index < fontTypes.size) { state.typeface = fontTypes[index].first state.typefaceFilePath = null } else { state.typefaceFilePath = savedFontTypes[index - fontTypes.size].absolutePath state.typeface = null } textView.context.updateState() } } subsFont.setOnLongClickListener { textView -> state.typeface = null state.typefaceFilePath = null textView.context.updateState() showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } subsAutoSelectLanguage.setFocusableInTv() subsAutoSelectLanguage.setOnClickListener { textView -> val languagesTagName = listOf( Pair( textView.context.getString(R.string.none), textView.context.getString(R.string.none) ) ) + languages .map { Pair(it.IETF_tag, it.nameNextToFlagEmoji()) } .sortedBy { it.second.substringAfter("\u00a0").lowercase() } // name ignoring flag emoji val (langTagsIETF, langNames) = languagesTagName.unzip() activity?.showDialog( langNames, langTagsIETF.indexOf(getAutoSelectLanguageTagIETF()), (textView as TextView).text.toString(), true, dismissCallback ) { index -> setKey(SUBTITLE_AUTO_SELECT_KEY, langTagsIETF[index]) } } subsAutoSelectLanguage.setOnLongClickListener { setKey(SUBTITLE_AUTO_SELECT_KEY, "en") showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } subsDownloadLanguages.setFocusableInTv() subsDownloadLanguages.setOnClickListener { textView -> val languagesTagName = languages .map { Pair(it.IETF_tag, it.nameNextToFlagEmoji()) } .sortedBy { it.second.substringAfter("\u00a0").lowercase() } // name ignoring flag emoji val (langTagsIETF, langNames) = languagesTagName.unzip() val selectedLanguages = getDownloadSubsLanguageTagIETF() .map { langTagsIETF.indexOf(it) } .filter { it >= 0 } activity?.showMultiDialog( langNames, selectedLanguages, (textView as TextView).text.toString(), dismissCallback ) { indexList -> setKey(SUBTITLE_DOWNLOAD_KEY, indexList.map { langTagsIETF[it] }.toList()) } } subsDownloadLanguages.setOnLongClickListener { setKey(SUBTITLE_DOWNLOAD_KEY, listOf("en")) showToast(activity, R.string.subs_default_reset_toast, Toast.LENGTH_SHORT) return@setOnLongClickListener true } cancelBtt.setOnClickListener { if (popFragment) { activity?.popCurrentPage() } else { dismiss() } } applyBtt.setOnClickListener { it.context.saveStyle(state) applyStyleEvent.invoke(state) if (popFragment) { activity?.popCurrentPage() } else { dismiss() } } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/AniSkip.kt ================================================ package com.lagradost.cloudstream3.utils import android.util.Log import androidx.annotation.StringRes import com.fasterxml.jackson.databind.annotation.JsonSerialize import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.LoadResponse.Companion.getMalId import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.result.ResultEpisode import java.lang.Long.min object EpisodeSkip { private const val TAG = "EpisodeSkip" enum class SkipType(@StringRes name: Int) { Opening(R.string.skip_type_op), Ending(R.string.skip_type_ed), Recap(R.string.skip_type_recap), MixedOpening(R.string.skip_type_mixed_op), MixedEnding(R.string.skip_type_mixed_ed), Credits(R.string.skip_type_creddits), Intro(R.string.skip_type_creddits), } data class SkipStamp( val type: SkipType, val skipToNextEpisode: Boolean, val startMs: Long, val endMs: Long, ) { val uiText = if (skipToNextEpisode) txt(R.string.next_episode) else txt( R.string.skip_type_format, txt(type.name) ) } private val cachedStamps = HashMap>() private fun shouldSkipToNextEpisode(endMs: Long, episodeDurationMs: Long): Boolean { return episodeDurationMs - endMs < 20_000L // some might have outro that we don't care about tbh } suspend fun getStamps( data: LoadResponse, episode: ResultEpisode, episodeDurationMs: Long, hasNextEpisode: Boolean, ): List { cachedStamps[episode.id]?.let { list -> return list } val out = mutableListOf() Log.i(TAG, "Requesting SkipStamp from ${data.syncData}") if (data is AnimeLoadResponse && (data.type == TvType.Anime || data.type == TvType.OVA)) { data.getMalId()?.toIntOrNull()?.let { malId -> val (resultLength, stamps) = AniSkip.getResult( malId, episode.episode, episodeDurationMs ) ?: return@let null // because it also returns an expected episode length we use that just in case it is mismatched with like 2s next episode will still work val dur = min(episodeDurationMs, resultLength) stamps.mapNotNull { stamp -> val skipType = when (stamp.skipType) { "op" -> SkipType.Opening "ed" -> SkipType.Ending "recap" -> SkipType.Recap "mixed-ed" -> SkipType.MixedEnding "mixed-op" -> SkipType.MixedOpening else -> null } ?: return@mapNotNull null val end = (stamp.interval.endTime * 1000.0).toLong() val start = (stamp.interval.startTime * 1000.0).toLong() SkipStamp( type = skipType, skipToNextEpisode = hasNextEpisode && shouldSkipToNextEpisode( end, dur ), startMs = start, endMs = end ) }.let { list -> out.addAll(list) } } } if (out.isNotEmpty()) cachedStamps[episode.id] = out return out } } // taken from https://github.com/saikou-app/saikou/blob/3803f8a7a59b826ca193664d46af3a22bbc989f7/app/src/main/java/ani/saikou/others/AniSkip.kt // the following is GPLv3 code https://github.com/saikou-app/saikou/blob/main/LICENSE.md object AniSkip { private const val TAG = "AniSkip" suspend fun getResult( malId: Int, episodeNumber: Int, episodeLength: Long ): Pair>? { return try { val url = "https://api.aniskip.com/v2/skip-times/$malId/$episodeNumber?types[]=ed&types[]=mixed-ed&types[]=mixed-op&types[]=op&types[]=recap&episodeLength=${episodeLength / 1000L}" Log.i(TAG, "Requesting $url") val a = app.get(url) val res = a.parsed() Log.i(TAG, "Found ${res.found} with ${res.results?.size} results") if (res.found && !res.results.isNullOrEmpty()) (res.results[0].episodeLength * 1000).toLong() to res.results else null } catch (t: Throwable) { Log.i(TAG, "error = ${t.message}") logError(t) null } } data class AniSkipResponse( @JsonSerialize val found: Boolean, @JsonSerialize val results: List?, @JsonSerialize val message: String?, @JsonSerialize val statusCode: Int ) data class Stamp( @JsonSerialize val interval: AniSkipInterval, @JsonSerialize val skipType: String, @JsonSerialize val skipId: String, @JsonSerialize val episodeLength: Double ) data class AniSkipInterval( @JsonSerialize val startTime: Double, @JsonSerialize val endTime: Double ) } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/AppContextUtils.kt ================================================ package com.lagradost.cloudstream3.utils import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.app.Activity import android.app.Activity.RESULT_CANCELED import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context import android.content.DialogInterface import android.content.Intent import android.content.pm.PackageManager import android.database.Cursor import android.media.AudioAttributes import android.media.AudioFocusRequest import android.media.AudioManager import android.media.tv.TvContract.Channels.COLUMN_INTERNAL_PROVIDER_ID import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.os.Build import android.os.Handler import android.os.Looper import android.text.Spanned import android.util.Log import android.view.View import android.view.View.LAYOUT_DIRECTION_LTR import android.view.View.LAYOUT_DIRECTION_RTL import android.view.animation.DecelerateInterpolator import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.annotation.WorkerThread import androidx.appcompat.app.AlertDialog import androidx.core.net.toUri import androidx.core.text.HtmlCompat import androidx.core.text.toSpanned import androidx.core.widget.ContentLoadingProgressBar import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.navigation.fragment.findNavController import androidx.preference.PreferenceManager import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.tvprovider.media.tv.PreviewChannelHelper import androidx.tvprovider.media.tv.TvContractCompat import androidx.tvprovider.media.tv.WatchNextProgram import androidx.tvprovider.media.tv.WatchNextProgram.fromCursor import androidx.viewpager2.widget.ViewPager2 import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.cast.framework.CastState import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.wrappers.Wrappers import com.google.android.material.bottomsheet.BottomSheetDialog import com.lagradost.cloudstream3.APIHolder.apis import com.lagradost.cloudstream3.AllLanguagesName import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.HomePageList import com.lagradost.cloudstream3.LoadResponse import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.MainActivity.Companion.afterRepositoryLoadedEvent import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.isMovieType import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.plugins.RepositoryManager import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_RESUME_WATCHING import com.lagradost.cloudstream3.syncproviders.providers.Kitsu import com.lagradost.cloudstream3.ui.WebviewFragment import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.result.ResultFragment import com.lagradost.cloudstream3.ui.settings.Globals import com.lagradost.cloudstream3.ui.settings.extensions.PluginsFragment import com.lagradost.cloudstream3.ui.settings.extensions.RepositoryData import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStoreHelper.getAllResumeStateIds import com.lagradost.cloudstream3.utils.DataStoreHelper.getLastWatched import com.lagradost.cloudstream3.utils.FillerEpisodeCheck.toClassDir import com.lagradost.cloudstream3.utils.JsUnpacker.Companion.load import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.Cache import java.io.File import java.net.URL import java.net.URLDecoder import java.util.concurrent.Executor import java.util.concurrent.Executors object AppContextUtils { fun RecyclerView.isRecyclerScrollable(): Boolean { val layoutManager = this.layoutManager as? LinearLayoutManager? val adapter = adapter return if (layoutManager == null || adapter == null) false else layoutManager.findLastCompletelyVisibleItemPosition() < adapter.itemCount - 7 // bit more than 1 to make it more seamless } fun View.isLtr() = this.layoutDirection == LAYOUT_DIRECTION_LTR fun View.isRtl() = this.layoutDirection == LAYOUT_DIRECTION_RTL fun BottomSheetDialog?.ownHide() { this?.hide() } fun BottomSheetDialog?.ownShow() { // the reason for this is because show has a shitty animation we don't want this?.window?.setWindowAnimations(-1) this?.show() Handler(Looper.getMainLooper()).postDelayed({ this?.window?.setWindowAnimations(com.google.android.material.R.style.Animation_Design_BottomSheetDialog) }, 200) } //fun Context.deleteFavorite(data: SearchResponse) { // if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return // safe { // val existingId = // getWatchNextProgramByVideoId(data.url, this).second ?: return@safe // contentResolver.delete( // // TvContractCompat.buildWatchNextProgramUri(existingId), // null, null // ) // } //} fun String?.html(): Spanned { return getHtmlText(this ?: return "".toSpanned()) } private fun getHtmlText(text: String): Spanned { return try { // I have no idea if this can throw any error, but I dont want to try HtmlCompat.fromHtml( text, HtmlCompat.FROM_HTML_MODE_LEGACY ) } catch (e: Exception) { logError(e) text.toSpanned() } } /** Get channel ID by name */ @SuppressLint("RestrictedApi") private fun buildWatchNextProgramUri( context: Context, card: DataStoreHelper.ResumeWatchingResult, resumeWatching: DownloadObjects.ResumeWatching? ): WatchNextProgram { val isSeries = card.type?.isMovieType() == false val title = if (isSeries) { context.getNameFull(card.name, card.episode, card.season) } else { card.name } val builder = WatchNextProgram.Builder() .setEpisodeTitle(title) .setType( if (isSeries) { TvContractCompat.WatchNextPrograms.TYPE_TV_EPISODE } else TvContractCompat.WatchNextPrograms.TYPE_MOVIE ) .setWatchNextType(TvContractCompat.WatchNextPrograms.WATCH_NEXT_TYPE_CONTINUE) .setTitle(title) .setPosterArtUri(card.posterUrl?.toUri()) .setIntentUri((card.id?.let { "$APP_STRING_RESUME_WATCHING://$it" } ?: card.url).toUri()) .setInternalProviderId(card.url) .setLastEngagementTimeUtcMillis( resumeWatching?.updateTime ?: System.currentTimeMillis() ) card.watchPos?.let { builder.setDurationMillis(it.duration.toInt()) builder.setLastPlaybackPositionMillis(it.position.toInt()) } if (isSeries) card.episode?.let { builder.setEpisodeNumber(it) } return builder.build() } // https://stackoverflow.com/a/67441735/13746422 fun ViewPager2.reduceDragSensitivity(f: Int = 4) { val recyclerViewField = ViewPager2::class.java.getDeclaredField("mRecyclerView") recyclerViewField.isAccessible = true val recyclerView = recyclerViewField.get(this) as RecyclerView val touchSlopField = RecyclerView::class.java.getDeclaredField("mTouchSlop") touchSlopField.isAccessible = true val touchSlop = touchSlopField.get(recyclerView) as Int touchSlopField.set(recyclerView, touchSlop * f) // "8" was obtained experimentally } fun ContentLoadingProgressBar?.animateProgressTo(to: Int) { if (this == null) return val animation: ObjectAnimator = ObjectAnimator.ofInt( this, "progress", this.progress, to ) animation.duration = 500 animation.setAutoCancel(true) animation.interpolator = DecelerateInterpolator() animation.start() } fun Context.createNotificationChannel( channelId: String, channelName: String, description: String ) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val importance = NotificationManager.IMPORTANCE_DEFAULT val channel = NotificationChannel(channelId, channelName, importance).apply { this.description = description } // Register the channel with the system. val notificationManager: NotificationManager = this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager.createNotificationChannel(channel) } } @SuppressLint("RestrictedApi") fun getAllWatchNextPrograms(context: Context): Set { val COLUMN_WATCH_NEXT_ID_INDEX = 0 val cursor = context.contentResolver.query( TvContractCompat.WatchNextPrograms.CONTENT_URI, WatchNextProgram.PROJECTION, /* selection = */ null, /* selectionArgs = */ null, /* sortOrder = */ null ) val set = mutableSetOf() cursor?.use { if (it.moveToFirst()) { do { set.add(cursor.getLong(COLUMN_WATCH_NEXT_ID_INDEX)) } while (it.moveToNext()) } } return set } /** * Find the Watch Next program for given id. * Returns the first instance available. */ @SuppressLint("RestrictedApi") // Suppress RestrictedApi due to https://issuetracker.google.com/138150076 fun findFirstWatchNextProgram(context: Context, predicate: (Cursor) -> Boolean): Pair { val COLUMN_WATCH_NEXT_ID_INDEX = 0 // val COLUMN_WATCH_NEXT_INTERNAL_PROVIDER_ID_INDEX = 1 // val COLUMN_WATCH_NEXT_COLUMN_BROWSABLE_INDEX = 2 val cursor = context.contentResolver.query( TvContractCompat.WatchNextPrograms.CONTENT_URI, WatchNextProgram.PROJECTION, /* selection = */ null, /* selectionArgs = */ null, /* sortOrder = */ null ) cursor?.use { if (it.moveToFirst()) { do { if (predicate(cursor)) { return fromCursor(cursor) to cursor.getLong(COLUMN_WATCH_NEXT_ID_INDEX) } } while (it.moveToNext()) } } return null to null } /** * Query the Watch Next list and find the program with given videoId. * Return null if not found. */ @RequiresApi(Build.VERSION_CODES.O) @SuppressLint("Range") @Synchronized private fun getWatchNextProgramByVideoId( id: String, context: Context ): Pair { return findFirstWatchNextProgram(context) { cursor -> (cursor.getString(cursor.getColumnIndex(COLUMN_INTERNAL_PROVIDER_ID)) == id) } } /** Prevents losing data when removing and adding simultaneously */ private val continueWatchingLock = Mutex() // https://github.com/googlearchive/leanback-homescreen-channels/blob/master/app/src/main/java/com/google/android/tvhomescreenchannels/SampleTvProvider.java @SuppressLint("RestrictedApi") @Throws @WorkerThread suspend fun Context.addProgramsToContinueWatching(data: List) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val context = this continueWatchingLock.withLock { // A way to get all last watched timestamps val timeStampHashMap = HashMap() getAllResumeStateIds()?.forEach { id -> val lastWatched = getLastWatched(id) ?: return@forEach timeStampHashMap[lastWatched.parentId] = lastWatched } val currentProgramIds = data.mapNotNull { episodeInfo -> try { val customId = "${episodeInfo.id}|${episodeInfo.apiName}|${episodeInfo.url}" val (program, id) = getWatchNextProgramByVideoId(customId, context) val nextProgram = buildWatchNextProgramUri( context, episodeInfo, timeStampHashMap[episodeInfo.id] ) // If the program is already in the Watch Next row, update it if (program != null && id != null) { PreviewChannelHelper(context).updateWatchNextProgram( nextProgram, id, ) id } else { PreviewChannelHelper(context) .publishWatchNextProgram(nextProgram) } } catch (e: Exception) { logError(e) null } }.toSet() val allOldPrograms = getAllWatchNextPrograms(context) - currentProgramIds // Ensures synced watch next progress by deleting all old programs. allOldPrograms.forEach { context.contentResolver.delete( TvContractCompat.buildWatchNextProgramUri(it), null, null ) } } } fun sortSubs(subs: Set): List { return subs.sortedBy { it.name } } fun Context.getApiSettings(): HashSet { //val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val hashSet = HashSet() val activeLangs = getApiProviderLangSettings() val hasUniversal = activeLangs.contains(AllLanguagesName) hashSet.addAll(synchronized(apis) { apis.filter { hasUniversal || activeLangs.contains(it.lang) } } .map { it.name }) /*val set = settingsManager.getStringSet( this.getString(R.string.search_providers_list_key), hashSet )?.toHashSet() ?: hashSet val list = HashSet() for (name in set) { val api = getApiFromNameNull(name) ?: continue if (activeLangs.contains(api.lang)) { list.add(name) } }*/ //if (list.isEmpty()) return hashSet //return list return hashSet } fun Context.getApiDubstatusSettings(): HashSet { val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val hashSet = HashSet() hashSet.addAll(DubStatus.values()) val list = settingsManager.getStringSet( this.getString(R.string.display_sub_key), hashSet.map { it.name }.toMutableSet() ) ?: return hashSet val names = DubStatus.values().map { it.name }.toHashSet() //if(realSet.isEmpty()) return hashSet return list.filter { names.contains(it) }.map { DubStatus.valueOf(it) }.toHashSet() } fun Context.getApiProviderLangSettings(): HashSet { val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val hashSet = hashSetOf(AllLanguagesName) // def is all languages // hashSet.add("en") // def is only en val list = settingsManager.getStringSet( this.getString(R.string.provider_lang_key), hashSet ) if (list.isNullOrEmpty()) return hashSet return list.toHashSet() } fun Context.getApiTypeSettings(): HashSet { val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val hashSet = HashSet() hashSet.addAll(TvType.values()) val list = settingsManager.getStringSet( this.getString(R.string.search_types_list_key), hashSet.map { it.name }.toMutableSet() ) if (list.isNullOrEmpty()) return hashSet val names = TvType.values().map { it.name }.toHashSet() val realSet = list.filter { names.contains(it) }.map { TvType.valueOf(it) }.toHashSet() if (realSet.isEmpty()) return hashSet return realSet } fun Context.updateHasTrailers() { LoadResponse.isTrailersEnabled = getHasTrailers() } private fun Context.getHasTrailers(): Boolean { val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) return settingsManager.getBoolean(this.getString(R.string.show_trailers_key), true) } fun Context.filterProviderByPreferredMedia(hasHomePageIsRequired: Boolean = true): List { // We are getting the weirdest crash ever done: // java.lang.ClassCastException: com.lagradost.cloudstream3.TvType cannot be cast to com.lagradost.cloudstream3.TvType // Trying fixing using classloader fuckery val oldLoader = Thread.currentThread().contextClassLoader Thread.currentThread().contextClassLoader = TvType::class.java.classLoader val default = TvType.values() .sorted() .filter { it != TvType.NSFW } .map { it.ordinal } Thread.currentThread().contextClassLoader = oldLoader val defaultSet = default.map { it.toString() }.toSet() val currentPrefMedia = try { PreferenceManager.getDefaultSharedPreferences(this) .getStringSet(this.getString(R.string.prefer_media_type_key), defaultSet) ?.mapNotNull { it.toIntOrNull() ?: return@mapNotNull null } } catch (e: Throwable) { null } ?: default val langs = this.getApiProviderLangSettings() val hasUniversal = langs.contains(AllLanguagesName) val allApis = synchronized(apis) { apis.filter { api -> (hasUniversal || langs.contains(api.lang)) && (api.hasMainPage || !hasHomePageIsRequired) } } return if (currentPrefMedia.isEmpty()) { allApis } else { // Filter API depending on preferred media type allApis.filter { api -> api.supportedTypes.any { currentPrefMedia.contains(it.ordinal) } } } } fun Context.filterSearchResultByFilmQuality(data: List): List { // Filter results omitting entries with certain quality if (data.isNotEmpty()) { val filteredSearchQuality = PreferenceManager.getDefaultSharedPreferences(this) ?.getStringSet(getString(R.string.pref_filter_search_quality_key), setOf()) ?.mapNotNull { entry -> entry.toIntOrNull() ?: return@mapNotNull null } ?: listOf() if (filteredSearchQuality.isNotEmpty()) { return data.filter { item -> val searchQualVal = item.quality?.ordinal ?: -1 //Log.i("filterSearch", "QuickSearch item => ${item.toJson()}") !filteredSearchQuality.contains(searchQualVal) } } } return data } fun Context.filterHomePageListByFilmQuality(data: HomePageList): HomePageList { // Filter results omitting entries with certain quality if (data.list.isNotEmpty()) { val filteredSearchQuality = PreferenceManager.getDefaultSharedPreferences(this) ?.getStringSet(getString(R.string.pref_filter_search_quality_key), setOf()) ?.mapNotNull { entry -> entry.toIntOrNull() ?: return@mapNotNull null } ?: listOf() if (filteredSearchQuality.isNotEmpty()) { return HomePageList( name = data.name, isHorizontalImages = data.isHorizontalImages, list = data.list.filter { item -> val searchQualVal = item.quality?.ordinal ?: -1 //Log.i("filterSearch", "QuickSearch item => ${item.toJson()}") !filteredSearchQuality.contains(searchQualVal) } ) } } return data } fun Activity.loadRepository(url: String) { ioSafe { val repo = RepositoryManager.parseRepository(url) ?: return@ioSafe RepositoryManager.addRepository( RepositoryData( repo.iconUrl ?: "", repo.name, url ) ) main { showToast( getString(R.string.player_loaded_subtitles, repo.name), Toast.LENGTH_LONG ) } afterRepositoryLoadedEvent.invoke(true) addRepositoryDialog(repo.name, url) } } fun Activity.addRepositoryDialog( repositoryName: String, repositoryURL: String, ) { val repos = RepositoryManager.getRepositories() // navigate to newly added repository on pressing Open Repository fun openAddedRepo() { if (repos.isNotEmpty()) { navigate( R.id.global_to_navigation_settings_plugins, PluginsFragment.newInstance( repositoryName, repositoryURL, false, ) ) } } runOnUiThread { AlertDialog.Builder(this).apply { setTitle(repositoryName) setMessage(R.string.download_all_plugins_from_repo) setPositiveButton(R.string.open_downloaded_repo) { _, _ -> openAddedRepo() } setNegativeButton(R.string.dismiss, null) show().setDefaultFocus() } } } private fun Context.hasWebView(): Boolean { return this.packageManager.hasSystemFeature("android.software.webview") } fun openWebView(fragment: Fragment?, url: String) { if (fragment?.context?.hasWebView() == true) safe { fragment .findNavController() .navigate(R.id.navigation_webview, WebviewFragment.newInstance(url)) } } /** * If fallbackWebview is true and a fragment is supplied then it will open a webview with the url if the browser fails. * */ fun Context.openBrowser( url: String, fallbackWebview: Boolean = false, fragment: Fragment? = null, ) = (this.getActivity() ?: activity)?.runOnUiThread { try { val intent = Intent(Intent.ACTION_VIEW) intent.data = url.toUri() intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) // activityResultRegistry is used to fall back to webview if a browser is missing // On older versions the startActivity just crashes, but on newer android versions // You need to check the result to make sure it failed val activityResultRegistry = fragment?.activity?.activityResultRegistry if (activityResultRegistry != null) { activityResultRegistry.register( url, ActivityResultContracts.StartActivityForResult() ) { result -> if (result.resultCode == RESULT_CANCELED && fallbackWebview) { openWebView(fragment, url) } }.launch(intent) } else this.startActivity(intent) } catch (e: Exception) { logError(e) if (fallbackWebview) { openWebView(fragment, url) } } } fun Context.isNetworkAvailable(): Boolean { val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val network = connectivityManager.activeNetwork ?: return false val networkCapabilities = connectivityManager.getNetworkCapabilities(network) ?: return false networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } else { @Suppress("DEPRECATION") connectivityManager.activeNetworkInfo?.isConnected == true } } fun splitQuery(url: URL): Map { val queryPairs: MutableMap = LinkedHashMap() val query: String = url.query val pairs = query.split("&").toTypedArray() for (pair in pairs) { val idx = pair.indexOf("=") queryPairs[URLDecoder.decode(pair.substring(0, idx), "UTF-8")] = URLDecoder.decode(pair.substring(idx + 1), "UTF-8") } return queryPairs } /**| S1:E2 Hello World * | Episode 2. Hello world * | Hello World * | Season 1 - Episode 2 * | Episode 2 * **/ fun Context.getNameFull(name: String?, episode: Int?, season: Int?): String { val rEpisode = if (episode == 0) null else episode val rSeason = if (season == 0) null else season val seasonName = getString(R.string.season) val episodeName = getString(R.string.episode) val seasonNameShort = getString(R.string.season_short) val episodeNameShort = getString(R.string.episode_short) if (name != null) { return if (rEpisode != null && rSeason != null) { "$seasonNameShort${rSeason}:$episodeNameShort${rEpisode} $name" } else if (rEpisode != null) { "$episodeName $rEpisode. $name" } else { name } } else { if (rEpisode != null && rSeason != null) { return "$seasonName $rSeason - $episodeName $rEpisode" } else if (rSeason == null) { return "$episodeName $rEpisode" } } return "" } fun Context.getShortSeasonText(episode: Int?, season: Int?): String? { val rEpisode = if (episode == 0) null else episode val rSeason = if (season == 0) null else season val seasonNameShort = getString(R.string.season_short) val episodeNameShort = getString(R.string.episode_short) return if (rEpisode != null && rSeason != null) { "$seasonNameShort${rSeason}:$episodeNameShort${rEpisode}" } else if (rEpisode != null) { "$episodeNameShort$rEpisode" }else null } fun Activity?.loadCache() { try { cacheClass("android.net.NetworkCapabilities".load()) } catch (_: Exception) { } } //private val viewModel: ResultViewModel by activityViewModels() private fun getResultsId(): Int { return if (Globals.isLayout(Globals.TV or Globals.EMULATOR)) { R.id.global_to_navigation_results_tv } else { R.id.global_to_navigation_results_phone } } fun loadResult( url: String, apiName: String, name : String, startAction: Int = 0, startValue: Int = 0 ) { (activity as FragmentActivity?)?.loadResult(url, apiName, name, startAction, startValue) } fun FragmentActivity.loadResult( url: String, apiName: String, name : String, startAction: Int = 0, startValue: Int = 0 ) { try { val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) Kitsu.isEnabled = settingsManager.getBoolean(this.getString(R.string.show_kitsu_posters_key), true) } catch (t: Throwable) { logError(t) } this.runOnUiThread { // viewModelStore.clear() this.navigate( getResultsId(), ResultFragment.newInstance(url, apiName, name, startAction, startValue) ) } } fun loadSearchResult( card: SearchResponse, startAction: Int = 0, startValue: Int? = null, ) { activity?.loadSearchResult(card, startAction, startValue) } fun Activity?.loadSearchResult( card: SearchResponse, startAction: Int = 0, startValue: Int? = null, ) { this?.runOnUiThread { // viewModelStore.clear() this.navigate( getResultsId(), ResultFragment.newInstance(card, startAction, startValue) ) } //(this as? AppCompatActivity?)?.loadResult(card.url, card.apiName, startAction, startValue) } fun Activity.requestLocalAudioFocus(focusRequest: AudioFocusRequest?) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (focusRequest == null) { Log.e("TAG", "focusRequest was null") return } val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager audioManager.requestAudioFocus(focusRequest) } else { val audioManager: AudioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager @Suppress("DEPRECATION") audioManager.requestAudioFocus( null, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK ) } } private var currentAudioFocusRequest: AudioFocusRequest? = null private var currentAudioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null var onAudioFocusEvent = Event() private fun getAudioListener(): AudioManager.OnAudioFocusChangeListener? { if (currentAudioFocusChangeListener != null) return currentAudioFocusChangeListener currentAudioFocusChangeListener = AudioManager.OnAudioFocusChangeListener { onAudioFocusEvent.invoke( when (it) { AudioManager.AUDIOFOCUS_GAIN -> false AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE -> false AudioManager.AUDIOFOCUS_GAIN_TRANSIENT -> false else -> true } ) } return currentAudioFocusChangeListener } fun Context.isCastApiAvailable(): Boolean { val isCastApiAvailable = GoogleApiAvailability.getInstance() .isGooglePlayServicesAvailable(applicationContext) == ConnectionResult.SUCCESS try { applicationContext?.let { val task = CastContext.getSharedInstance(it) { it.run() } task.result } } catch (e: Exception) { println(e) // Track non-fatal return false } return isCastApiAvailable } fun Context.isConnectedToChromecast(): Boolean { if (isCastApiAvailable()) { val executor: Executor = Executors.newSingleThreadExecutor() val castContext = CastContext.getSharedInstance(this, executor) if (castContext.result.castState == CastState.CONNECTED) { return true } } return false } /** * Sets the focus to the negative button when in TV and Emulator layout. **/ fun AlertDialog.setDefaultFocus(buttonFocus: Int = DialogInterface.BUTTON_NEGATIVE) { if (!Globals.isLayout(Globals.TV or Globals.EMULATOR)) return this.getButton(buttonFocus).run { isFocusableInTouchMode = true requestFocus() } } fun Context.isUsingMobileData(): Boolean { val connectionManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { val activeNetwork: Network? = connectionManager.activeNetwork val networkCapabilities = connectionManager.getNetworkCapabilities(activeNetwork) networkCapabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true && !networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) } else { @Suppress("DEPRECATION") connectionManager.activeNetworkInfo?.type == ConnectivityManager.TYPE_MOBILE } } private fun Activity?.cacheClass(clazz: String?) { clazz?.let { c -> this?.cacheDir?.let { Cache( directory = File(it, c.toClassDir()), maxSize = 20L * 1024L * 1024L // 20 MiB ) } } } fun Context.isAppInstalled(uri: String): Boolean { val pm = Wrappers.packageManager(this) return try { pm.getPackageInfo(uri, 0) // PackageManager.GET_ACTIVITIES true } catch (e: PackageManager.NameNotFoundException) { false } } fun getFocusRequest(): AudioFocusRequest? { if (currentAudioFocusRequest != null) return currentAudioFocusRequest currentAudioFocusRequest = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN).run { setAudioAttributes(AudioAttributes.Builder().run { setUsage(AudioAttributes.USAGE_MEDIA) setContentType(AudioAttributes.CONTENT_TYPE_MOVIE) build() }) setAcceptsDelayedFocusGain(true) getAudioListener()?.let { setOnAudioFocusChangeListener(it) } build() } } else null return currentAudioFocusRequest } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/BackPressedCallbackHelper.kt ================================================ package com.lagradost.cloudstream3.utils import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback import java.lang.ref.WeakReference import java.util.WeakHashMap object BackPressedCallbackHelper { private val backPressedCallbacks = WeakHashMap>() class CallbackHelper( private val activityRef: WeakReference, private val callback: OnBackPressedCallback ) { fun runDefault() { val activity = activityRef.get() ?: return val wasEnabled = callback.isEnabled callback.isEnabled = false try { activity.onBackPressedDispatcher.onBackPressed() } finally { callback.isEnabled = wasEnabled } } } fun ComponentActivity.attachBackPressedCallback( id: String, callback: CallbackHelper.() -> Unit ) { val callbackMap = backPressedCallbacks.getOrPut(this) { mutableMapOf() } if (callbackMap.containsKey(id)) return // We use WeakReference to protect against potential leaks. val activityRef = WeakReference(this) val newCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { CallbackHelper(activityRef, this).callback() } } callbackMap[id] = newCallback onBackPressedDispatcher.addCallback(this, newCallback) } fun ComponentActivity.disableBackPressedCallback(id : String) { backPressedCallbacks[this]?.get(id)?.isEnabled = false } fun ComponentActivity.enableBackPressedCallback(id : String) { backPressedCallbacks[this]?.get(id)?.isEnabled = true } fun ComponentActivity.detachBackPressedCallback(id: String) { val callbackMap = backPressedCallbacks[this] ?: return callbackMap[id]?.let { callback -> callback.isEnabled = false callbackMap.remove(id) } if (callbackMap.isEmpty()) { backPressedCallbacks.remove(this) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/BackupUtils.kt ================================================ package com.lagradost.cloudstream3.utils import android.content.Context import android.net.Uri import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.WorkerThread import androidx.core.net.toUri import androidx.fragment.app.FragmentActivity import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty import com.fasterxml.jackson.module.kotlin.readValue import com.lagradost.cloudstream3.CloudStreamApp.Companion.getActivity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.plugins.PLUGINS_KEY import com.lagradost.cloudstream3.plugins.PLUGINS_KEY_LOCAL import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.providers.AniListApi.Companion.ANILIST_CACHED_LIST import com.lagradost.cloudstream3.syncproviders.providers.MALApi.Companion.MAL_CACHED_LIST import com.lagradost.cloudstream3.syncproviders.providers.KitsuApi.Companion.KITSU_CACHED_LIST import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DataStore.getDefaultSharedPrefs import com.lagradost.cloudstream3.utils.DataStore.getSharedPrefs import com.lagradost.cloudstream3.utils.DataStore.mapper import com.lagradost.cloudstream3.utils.UIHelper.checkWrite import com.lagradost.cloudstream3.utils.UIHelper.requestRW import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.setupStream import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import com.lagradost.cloudstream3.utils.downloader.DownloadQueueManager.QUEUE_KEY import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_DOWNLOAD_INFO import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_PACKAGES import com.lagradost.safefile.MediaFileContentType import com.lagradost.safefile.SafeFile import okhttp3.internal.closeQuietly import java.io.IOException import java.io.OutputStream import java.io.PrintWriter import java.lang.System.currentTimeMillis import java.text.SimpleDateFormat import java.util.Date import java.util.Locale object BackupUtils { /** * No sensitive or breaking data in the backup * */ private val nonTransferableKeys = listOf( ANILIST_CACHED_LIST, MAL_CACHED_LIST, KITSU_CACHED_LIST, // The plugins themselves are not backed up PLUGINS_KEY, PLUGINS_KEY_LOCAL, AccountManager.ACCOUNT_TOKEN, AccountManager.ACCOUNT_IDS, "biometric_key", // can lock down users if backup is shared on a incompatible device "nginx_user", // Nginx user key // No access rights after restore data from backup "download_path_key", "download_path_key_visual", "backup_path_key", "backup_dir_path_key", // When sharing backup we do not want to transfer what is essentially the password // Note that this is deprecated, and can be removed after all tokens have expired "anilist_token", "anilist_user", "mal_user", "mal_token", "mal_refresh_token", "mal_unixtime", "open_subtitles_user", "subdl_user", "simkl_token", // Downloads can not be restored from backups. // The download path URI can not be transferred. // In the future we may potentially write metadata to files in the download directory // and make it possible to restore download folders using that metadata. DOWNLOAD_EPISODE_CACHE_BACKUP, DOWNLOAD_EPISODE_CACHE, // Download headers are unintuitively used in the resume watching system. // We can therefore not prune download headers in backups. //DOWNLOAD_HEADER_CACHE_BACKUP, //DOWNLOAD_HEADER_CACHE, // This may overwrite valid local data with invalid data KEY_DOWNLOAD_INFO, // Prevent backups from automatically starting downloads KEY_RESUME_IN_QUEUE, KEY_RESUME_PACKAGES, QUEUE_KEY ) /** false if key should not be contained in backup */ private fun String.isTransferable(): Boolean { return !nonTransferableKeys.any { this.contains(it) } } private var restoreFileSelector: ActivityResultLauncher>? = null // Kinda hack, but I couldn't think of a better way data class BackupVars( @JsonProperty("_Bool") val bool: Map?, @JsonProperty("_Int") val int: Map?, @JsonProperty("_String") val string: Map?, @JsonProperty("_Float") val float: Map?, @JsonProperty("_Long") val long: Map?, @JsonProperty("_StringSet") val stringSet: Map?>?, ) data class BackupFile( @JsonProperty("datastore") val datastore: BackupVars, @JsonProperty("settings") val settings: BackupVars ) @Suppress("UNCHECKED_CAST") private fun getBackup(context: Context?): BackupFile? { if (context == null) return null val allData = context.getSharedPrefs().all.filter { it.key.isTransferable() } val allSettings = context.getDefaultSharedPrefs().all.filter { it.key.isTransferable() } val allDataSorted = BackupVars( allData.filter { it.value is Boolean } as? Map, allData.filter { it.value is Int } as? Map, allData.filter { it.value is String } as? Map, allData.filter { it.value is Float } as? Map, allData.filter { it.value is Long } as? Map, allData.filter { it.value as? Set != null } as? Map> ) val allSettingsSorted = BackupVars( allSettings.filter { it.value is Boolean } as? Map, allSettings.filter { it.value is Int } as? Map, allSettings.filter { it.value is String } as? Map, allSettings.filter { it.value is Float } as? Map, allSettings.filter { it.value is Long } as? Map, allSettings.filter { it.value as? Set != null } as? Map> ) return BackupFile( allDataSorted, allSettingsSorted ) } @WorkerThread fun restore( context: Context?, backupFile: BackupFile, restoreSettings: Boolean, restoreDataStore: Boolean ) { if (context == null) return if (restoreSettings) { context.restoreMap(backupFile.settings.bool, true) context.restoreMap(backupFile.settings.int, true) context.restoreMap(backupFile.settings.string, true) context.restoreMap(backupFile.settings.float, true) context.restoreMap(backupFile.settings.long, true) context.restoreMap(backupFile.settings.stringSet, true) } if (restoreDataStore) { context.restoreMap(backupFile.datastore.bool) context.restoreMap(backupFile.datastore.int) context.restoreMap(backupFile.datastore.string) context.restoreMap(backupFile.datastore.float) context.restoreMap(backupFile.datastore.long) context.restoreMap(backupFile.datastore.stringSet) } // Make sure the library is fresh for(api in AccountManager.syncApis) { api.requireLibraryRefresh = true } } fun backup(context: Context?) = ioSafe { if (context == null) return@ioSafe var fileStream: OutputStream? = null var printStream: PrintWriter? = null try { if (!context.checkWrite()) { showToast(R.string.backup_failed, Toast.LENGTH_LONG) context.getActivity()?.requestRW() return@ioSafe } val date = SimpleDateFormat("yyyy_MM_dd_HH_mm", Locale.getDefault()).format(Date(currentTimeMillis())) val displayName = "CS3_Backup_${date}" val backupFile = getBackup(context) val stream = setupBackupStream(context, displayName) fileStream = stream.openNew() printStream = PrintWriter(fileStream) printStream.print(mapper.writeValueAsString(backupFile)) showToast( R.string.backup_success, Toast.LENGTH_LONG ) } catch (e: Exception) { logError(e) try { showToast( txt(R.string.backup_failed_error_format, e.toString()), Toast.LENGTH_LONG ) } catch (e: Exception) { logError(e) } } finally { printStream?.closeQuietly() fileStream?.closeQuietly() } } @Throws(IOException::class) private fun setupBackupStream(context: Context, name: String, ext: String = "txt"): DownloadObjects.StreamData { return setupStream( baseFile = getCurrentBackupDir(context).first ?: getDefaultBackupDir(context) ?: throw IOException("Bad config"), name, folder = null, extension = ext, tryResume = false ) } fun FragmentActivity.setUpBackup() { try { restoreFileSelector = registerForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? -> if (uri == null) return@registerForActivityResult val activity = this ioSafe { try { val input = activity.contentResolver.openInputStream(uri) ?: return@ioSafe val restoredValue = mapper.readValue(input) restore( activity, restoredValue, restoreSettings = true, restoreDataStore = true ) activity.runOnUiThread { activity.recreate() } } catch (e: Exception) { logError(e) main { // smth can fail in .format showToast( getString(R.string.restore_failed_format).format(e.toString()) ) } } } } } catch (e: Exception) { logError(e) } } fun FragmentActivity.restorePrompt() { runOnUiThread { try { restoreFileSelector?.launch( arrayOf( "text/plain", "text/str", "text/x-unknown", "application/json", "unknown/unknown", "content/unknown", "application/octet-stream", ) ) } catch (e: Exception) { showToast(e.message) logError(e) } } } private fun Context.restoreMap( map: Map?, isEditingAppSettings: Boolean = false ) { val editor = DataStore.editor(this, isEditingAppSettings) map?.forEach { if (it.key.isTransferable()) { editor.setKeyRaw(it.key, it.value) } } editor.apply() } /** * Copy of [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.basePathToFile], [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDefaultDir] and [com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getBasePath] * modded for backup specific paths * */ fun getDefaultBackupDir(context: Context): SafeFile? { return SafeFile.fromMedia(context, MediaFileContentType.Downloads) } fun getCurrentBackupDir(context: Context): Pair { val settingsManager = PreferenceManager.getDefaultSharedPreferences(context) val basePathSetting = settingsManager.getString(context.getString(R.string.backup_path_key), null) return baseBackupPathToFile(context, basePathSetting) to basePathSetting } private fun baseBackupPathToFile(context: Context, path: String?): SafeFile? { return when { path.isNullOrBlank() -> getDefaultBackupDir(context) path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) else -> SafeFile.fromFilePath(context, path) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/BiometricAuthenticator.kt ================================================ package com.lagradost.cloudstream3.utils import android.annotation.SuppressLint import android.app.Activity import android.app.KeyguardManager import android.content.Context import android.os.Build import android.util.Log import androidx.appcompat.app.AppCompatActivity import androidx.biometric.BiometricManager import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL import androidx.biometric.BiometricPrompt import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat.getString import androidx.fragment.app.FragmentActivity import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R object BiometricAuthenticator { const val TAG = "cs3Auth" private const val MAX_FAILED_ATTEMPTS = 3 private var failedAttempts = 0 private var biometricManager: BiometricManager? = null var biometricPrompt: BiometricPrompt? = null var promptInfo: BiometricPrompt.PromptInfo? = null var authCallback: BiometricCallback? = null // listen to authentication success private fun initializeBiometrics(activity: FragmentActivity) { val executor = ContextCompat.getMainExecutor(activity) biometricManager = BiometricManager.from(activity) biometricPrompt = BiometricPrompt( activity, executor, object : BiometricPrompt.AuthenticationCallback() { override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { super.onAuthenticationError(errorCode, errString) showToast("$errString") Log.e(TAG, "$errorCode") authCallback?.onAuthenticationError() //activity.finish() } override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { super.onAuthenticationSucceeded(result) failedAttempts = 0 authCallback?.onAuthenticationSuccess() } override fun onAuthenticationFailed() { super.onAuthenticationFailed() failedAttempts++ if (failedAttempts >= MAX_FAILED_ATTEMPTS) { failedAttempts = 0 activity.finish() } } }) } @Suppress("DEPRECATION") // authentication dialog prompt builder private fun authenticationDialog( activity: Activity, title: Int, setDeviceCred: Boolean, ) { val description = activity.getString(R.string.biometric_prompt_description) if (setDeviceCred) { // For API level > 30, Newer API setAllowedAuthenticators is used if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val authFlag = DEVICE_CREDENTIAL or BIOMETRIC_WEAK or BIOMETRIC_STRONG promptInfo = BiometricPrompt.PromptInfo.Builder() .setTitle(activity.getString(title)) .setDescription(description) .setAllowedAuthenticators(authFlag) .build() } else { // for apis < 30 promptInfo = BiometricPrompt.PromptInfo.Builder() .setTitle(activity.getString(title)) .setDescription(description) .setDeviceCredentialAllowed(true) .build() } } else { // fallback for A12+ when both fingerprint & Face unlock is absent but PIN is set promptInfo = BiometricPrompt.PromptInfo.Builder() .setTitle(activity.getString(title)) .setDescription(description) .setDeviceCredentialAllowed(true) .build() } } private fun isBiometricHardWareAvailable(): Boolean { // Authentication occurs only when this is true and device is truly capable. var result = false when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.BAKLAVA -> { @SuppressLint("RestrictedApi") when (biometricManager?.canAuthenticate( DEVICE_CREDENTIAL or BIOMETRIC_STRONG or BIOMETRIC_WEAK )) { BiometricManager.BIOMETRIC_SUCCESS -> result = true BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> result = false BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> result = false BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> result = false BiometricManager.BIOMETRIC_ERROR_NOT_ENABLED_FOR_APPS -> result = false BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> result = true BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> result = true BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> result = false } } Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> { @Suppress("SwitchIntDef") when (biometricManager?.canAuthenticate( DEVICE_CREDENTIAL or BIOMETRIC_STRONG or BIOMETRIC_WEAK )) { BiometricManager.BIOMETRIC_SUCCESS -> result = true BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> result = false BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> result = false BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> result = false BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> result = true BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> result = true BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> result = false } } else -> { @Suppress("DEPRECATION", "SwitchIntDef") when (biometricManager?.canAuthenticate()) { BiometricManager.BIOMETRIC_SUCCESS -> result = true BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> result = false BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> result = false BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> result = false BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> result = true BiometricManager.BIOMETRIC_ERROR_UNSUPPORTED -> result = true BiometricManager.BIOMETRIC_STATUS_UNKNOWN -> result = false } } } return result } // checks if device is secured i.e has at least some type of lock fun deviceHasPasswordPinLock(context: Context?): Boolean { val keyMgr = context?.getSystemService(AppCompatActivity.KEYGUARD_SERVICE) as? KeyguardManager return keyMgr?.isKeyguardSecure ?: false } // function to start authentication in any fragment or activity fun startBiometricAuthentication(activity: FragmentActivity, title: Int, setDeviceCred: Boolean) { initializeBiometrics(activity) authCallback = activity as? BiometricCallback if (isBiometricHardWareAvailable()) { authCallback = activity as? BiometricCallback authenticationDialog(activity, title, setDeviceCred) promptInfo?.let { biometricPrompt?.authenticate(it) } } else { if (deviceHasPasswordPinLock(activity)) { authCallback = activity as? BiometricCallback authenticationDialog(activity, R.string.password_pin_authentication_title, true) promptInfo?.let { biometricPrompt?.authenticate(it) } } else { showToast(R.string.biometric_unsupported) } } } fun isAuthEnabled(ctx: Context):Boolean { return ctx.let { PreferenceManager.getDefaultSharedPreferences(ctx) .getBoolean(getString(ctx, R.string.biometric_key), false) } } interface BiometricCallback { fun onAuthenticationSuccess() fun onAuthenticationError() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/CastHelper.kt ================================================ package com.lagradost.cloudstream3.utils import androidx.core.net.toUri import androidx.media3.common.MimeTypes import com.google.android.gms.cast.* import com.google.android.gms.cast.framework.CastSession import com.google.android.gms.cast.framework.media.RemoteMediaClient import com.google.android.gms.common.api.PendingResult import com.google.android.gms.common.images.WebImage import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.MetadataHolder import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.AppUtils.toJson import com.lagradost.cloudstream3.utils.Coroutines.main import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject object CastHelper { fun getMediaInfo( epData: ResultEpisode, holder: MetadataHolder, index: Int, data: JSONObject?, subtitles: List ): MediaInfo { val link = holder.currentLinks[index] val movieMetadata = MediaMetadata(MediaMetadata.MEDIA_TYPE_MOVIE) movieMetadata.putString( MediaMetadata.KEY_SUBTITLE, if (holder.isMovie) "${link.name} ${Qualities.getStringByInt(link.quality)}" else (epData.name ?: "Episode ${epData.episode}") + " - ${link.name} ${Qualities.getStringByInt(link.quality)}" ) holder.title?.let { movieMetadata.putString(MediaMetadata.KEY_TITLE, it) } val srcPoster = epData.poster ?: holder.poster if (srcPoster != null) { movieMetadata.addImage(WebImage(srcPoster.toUri())) } var subIndex = 0 val tracks = subtitles.map { MediaTrack.Builder(subIndex++.toLong(), MediaTrack.TYPE_TEXT) .setName(it.name) .setSubtype(MediaTrack.SUBTYPE_SUBTITLES) .setContentId(it.url) .build() } val builder = MediaInfo.Builder(link.url) .setStreamType(MediaInfo.STREAM_TYPE_BUFFERED) .setContentType(when(link.type) { ExtractorLinkType.M3U8 -> MimeTypes.APPLICATION_M3U8 ExtractorLinkType.DASH -> MimeTypes.APPLICATION_MPD else -> MimeTypes.VIDEO_MP4 }) .setMetadata(movieMetadata) .setMediaTracks(tracks) data?.let { builder.setCustomData(data) } return builder.build() } fun awaitLinks( pending: PendingResult?, callback: (Boolean) -> Unit ) { if (pending == null) return main { val res = withContext(Dispatchers.IO) { pending.await() } when (res.status.statusCode) { CastStatusCodes.FAILED -> { callback.invoke(true) println("FAILED AND LOAD NEXT") } else -> Unit //IDK DO SMTH HERE } } } fun CastSession?.startCast( apiName: String, isMovie: Boolean, title: String?, poster: String?, currentEpisodeIndex: Int, episodes: List, currentLinks: List, subtitles: List, startIndex: Int? = null, startTime: Long? = null, ): Boolean { try { if (this == null) return false if (episodes.isEmpty()) return false if (currentEpisodeIndex >= episodes.size) return false val epData = episodes[currentEpisodeIndex] val holder = MetadataHolder( apiName, isMovie, title, poster, currentEpisodeIndex, episodes, currentLinks, subtitles ) val index = if (startIndex == null || startIndex < 0) 0 else startIndex val mediaItem = getMediaInfo(epData, holder, index, JSONObject(holder.toJson()), subtitles) awaitLinks( this.remoteMediaClient?.load( MediaLoadRequestData.Builder().setMediaInfo(mediaItem) .setCurrentTime(startTime ?: 0L).build() ) ) { if (currentLinks.size > index + 1) startCast( apiName, isMovie, title, poster, currentEpisodeIndex, episodes, currentLinks, subtitles, index + 1, startTime ) } return true } catch (e: Exception) { logError(e) return false } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/CastOptionsProvider.kt ================================================ package com.lagradost.cloudstream3.utils import android.content.Context import com.google.android.gms.cast.CastMediaControlIntent import com.google.android.gms.cast.framework.CastOptions import com.google.android.gms.cast.framework.OptionsProvider import com.google.android.gms.cast.framework.SessionProvider import com.google.android.gms.cast.framework.media.CastMediaOptions import com.google.android.gms.cast.framework.media.MediaIntentReceiver import com.google.android.gms.cast.framework.media.NotificationOptions import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.ControllerActivity import java.util.* class CastOptionsProvider : OptionsProvider { override fun getCastOptions(context: Context): CastOptions { val buttonActions = listOf( MediaIntentReceiver.ACTION_REWIND, MediaIntentReceiver.ACTION_TOGGLE_PLAYBACK, MediaIntentReceiver.ACTION_FORWARD, MediaIntentReceiver.ACTION_STOP_CASTING ) val name = ControllerActivity::class.qualifiedName!! val compatButtonAction = intArrayOf(1, 3) val notificationOptions = NotificationOptions.Builder() .setTargetActivityClassName(name) .setActions(buttonActions, compatButtonAction) .setForward30DrawableResId(R.drawable.go_forward_30) .setRewind30DrawableResId(R.drawable.go_back_30) .setSkipStepMs(30000) .build() val mediaOptions = CastMediaOptions.Builder() .setNotificationOptions(notificationOptions) .setExpandedControllerActivityClassName(name) .build() return CastOptions.Builder() .setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID) //.setReceiverApplicationId("") // C0868879 = SAMPLE, CHANGE TO A NICE ID at https://developers.google.com/cast/docs/registration .setStopReceiverApplicationWhenEndingSession(true) .setCastMediaOptions(mediaOptions) .build() } override fun getAdditionalSessionProviders(p0: Context): MutableList { return Collections.emptyList() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/ConsistentLiveData.kt ================================================ package com.lagradost.cloudstream3.utils import androidx.annotation.MainThread import androidx.lifecycle.LiveData import com.lagradost.cloudstream3.mvvm.Resource /** * This is an atomic LiveData where you can do .value instantly after doing .postValue. * * The default behavior is a footgun that will cause race conditions, * as we do not really care if it is posted as we only want the latest data (even in the binding). * * Fuck all that is LiveData, because we want this value to be accessible everywhere instantly. * */ open class ConsistentLiveData(initValue : T? = null) : LiveData(initValue) { @Volatile private var internalValue : T? = initValue override fun getValue(): T? { return internalValue } /** If someone want the old behavior then good for them */ val postedValue : T? get() = super.getValue() public override fun postValue(value : T?) { super.postValue(value) internalValue = value } @MainThread public override fun setValue(value: T?) { super.setValue(value) internalValue = value } } /** Atomic resource livedata, to make it easier to work with resources without local copies */ class ResourceLiveData(initValue : Resource? = null) : ConsistentLiveData>(initValue) { var success get() = when(val output = this.value) { is Resource.Success -> { output.value } else -> null } set(value) = this.postValue(value?.let { Resource.Success(it) } ) } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/DataStore.kt ================================================ package com.lagradost.cloudstream3.utils import android.content.Context import android.content.SharedPreferences import androidx.preference.PreferenceManager import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.json.JsonMapper import com.fasterxml.jackson.module.kotlin.kotlinModule import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeyClass import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKeyClass import com.lagradost.cloudstream3.mvvm.logError import kotlin.reflect.KClass import kotlin.reflect.KProperty import androidx.core.content.edit /** Used to display metadata about downloads and resume watching */ const val DOWNLOAD_HEADER_CACHE = "download_header_cache" const val DOWNLOAD_HEADER_CACHE_BACKUP = "BACKUP_download_header_cache" //const val WATCH_HEADER_CACHE = "watch_header_cache" const val DOWNLOAD_EPISODE_CACHE = "download_episode_cache" const val DOWNLOAD_EPISODE_CACHE_BACKUP = "BACKUP_download_episode_cache" const val VIDEO_PLAYER_BRIGHTNESS = "video_player_alpha_key" const val USER_SELECTED_HOMEPAGE_API = "home_api_used" const val USER_PROVIDER_API = "user_custom_sites" const val PREFERENCES_NAME = "rebuild_preference" // TODO degelgate by value for get & set class PreferenceDelegate( val key: String, val default: T //, private val klass: KClass ) { private val klass: KClass = default::class // simple cache to make it not get the key every time it is accessed, however this requires // that ONLY this changes the key private var cache: T? = null operator fun getValue(self: Any?, property: KProperty<*>) = cache ?: getKeyClass(key, klass.java).also { newCache -> cache = newCache } ?: default operator fun setValue( self: Any?, property: KProperty<*>, t: T? ) { cache = t if (t == null) { removeKey(key) } else { setKeyClass(key, t) } } } /** When inserting many keys use this function, this is because apply for every key is very expensive on memory */ data class Editor( val editor: SharedPreferences.Editor ) { /** Always remember to call apply after */ fun setKeyRaw(path: String, value: T) { @Suppress("UNCHECKED_CAST") if (isStringSet(value)) { editor.putStringSet(path, value as Set) } else { when (value) { is Boolean -> editor.putBoolean(path, value) is Int -> editor.putInt(path, value) is String -> editor.putString(path, value) is Float -> editor.putFloat(path, value) is Long -> editor.putLong(path, value) } } } private fun isStringSet(value: Any?): Boolean { if (value is Set<*>) { return value.filterIsInstance().size == value.size } return false } fun apply() { editor.apply() System.gc() } } object DataStore { val mapper: JsonMapper = JsonMapper.builder().addModule(kotlinModule()) .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build() private fun getPreferences(context: Context): SharedPreferences { return context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) } fun Context.getSharedPrefs(): SharedPreferences { return getPreferences(this) } fun getFolderName(folder: String, path: String): String { return "${folder}/${path}" } fun editor(context: Context, isEditingAppSettings: Boolean = false): Editor { val editor: SharedPreferences.Editor = if (isEditingAppSettings) context.getDefaultSharedPrefs() .edit() else context.getSharedPrefs().edit() return Editor(editor) } fun Context.getDefaultSharedPrefs(): SharedPreferences { return PreferenceManager.getDefaultSharedPreferences(this) } fun Context.getKeys(folder: String): List { // Ensure that the folder ends with "/" to prevent matching with other folders val fixedFolder = folder.trimEnd('/') + "/" return this.getSharedPrefs().all.keys.filter { it.startsWith(fixedFolder) } } fun Context.removeKey(folder: String, path: String) { removeKey(getFolderName(folder, path)) } fun Context.containsKey(folder: String, path: String): Boolean { return containsKey(getFolderName(folder, path)) } fun Context.containsKey(path: String): Boolean { val prefs = getSharedPrefs() return prefs.contains(path) } fun Context.removeKey(path: String) { try { val prefs = getSharedPrefs() if (prefs.contains(path)) { prefs.edit { remove(path) } } } catch (e: Exception) { logError(e) } } fun Context.removeKeys(folder: String): Int { val keys = getKeys("$folder/") try { getSharedPrefs().edit { keys.forEach { value -> remove(value) } } return keys.size } catch (e: Exception) { logError(e) return 0 } } fun Context.setKey(path: String, value: T) { try { getSharedPrefs().edit { putString(path, mapper.writeValueAsString(value)) } } catch (e: Exception) { logError(e) } } fun Context.getKey(path: String, valueType: Class): T? { try { val json: String = getSharedPrefs().getString(path, null) ?: return null return json.toKotlinObject(valueType) } catch (e: Exception) { return null } } fun Context.setKey(folder: String, path: String, value: T) { setKey(getFolderName(folder, path), value) } inline fun String.toKotlinObject(): T { return mapper.readValue(this, T::class.java) } fun String.toKotlinObject(valueType: Class): T { return mapper.readValue(this, valueType) } // GET KEY GIVEN PATH AND DEFAULT VALUE, NULL IF ERROR inline fun Context.getKey(path: String, defVal: T?): T? { try { val json: String = getSharedPrefs().getString(path, null) ?: return defVal return json.toKotlinObject() } catch (e: Exception) { return null } } inline fun Context.getKey(path: String): T? { return getKey(path, null) } inline fun Context.getKey(folder: String, path: String): T? { return getKey(getFolderName(folder, path), null) } inline fun Context.getKey(folder: String, path: String, defVal: T?): T? { return getKey(getFolderName(folder, path), defVal) ?: defVal } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/DataStoreHelper.kt ================================================ package com.lagradost.cloudstream3.utils import android.content.Context import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder.unixTimeMS import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeyClass import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKeyClass import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.DubStatus import com.lagradost.cloudstream3.EpisodeResponse import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.SearchQuality import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.syncproviders.AccountManager import com.lagradost.cloudstream3.syncproviders.SyncAPI import com.lagradost.cloudstream3.ui.WatchType import com.lagradost.cloudstream3.ui.library.ListSorting import com.lagradost.cloudstream3.ui.player.ExtractorUri import com.lagradost.cloudstream3.ui.player.NEXT_WATCH_EPISODE_PERCENTAGE import com.lagradost.cloudstream3.ui.result.EpisodeSortType import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.result.VideoWatchState import com.lagradost.cloudstream3.utils.AppContextUtils.filterProviderByPreferredMedia import com.lagradost.cloudstream3.utils.downloader.DownloadObjects import java.util.Calendar import java.util.Date import java.util.GregorianCalendar import kotlin.reflect.KClass import kotlin.reflect.KProperty const val VIDEO_POS_DUR = "video_pos_dur" const val VIDEO_WATCH_STATE = "video_watch_state" const val RESULT_WATCH_STATE = "result_watch_state" const val RESULT_WATCH_STATE_DATA = "result_watch_state_data" const val RESULT_SUBSCRIBED_STATE_DATA = "result_subscribed_state_data" const val RESULT_FAVORITES_STATE_DATA = "result_favorites_state_data" const val RESULT_RESUME_WATCHING = "result_resume_watching_2" // changed due to id changes const val RESULT_RESUME_WATCHING_OLD = "result_resume_watching" const val RESULT_RESUME_WATCHING_HAS_MIGRATED = "result_resume_watching_migrated" const val RESULT_EPISODE = "result_episode" const val RESULT_SEASON = "result_season" const val RESULT_DUB = "result_dub" const val KEY_RESULT_SORT = "result_sort" const val USER_PINNED_PROVIDERS = "user_pinned_providers" //key for pinned user set class UserPreferenceDelegate( private val key: String, private val default: T //, private val klass: KClass ) { private val klass: KClass = default::class private val realKey get() = "${DataStoreHelper.currentAccount}/$key" operator fun getValue(self: Any?, property: KProperty<*>) = getKeyClass(realKey, klass.java) ?: default operator fun setValue( self: Any?, property: KProperty<*>, t: T? ) { if (t == null) { removeKey(realKey) } else { setKeyClass(realKey, t) } } } object DataStoreHelper { // be aware, don't change the index of these as Account uses the index for the art val profileImages = arrayOf( R.drawable.profile_bg_dark_blue, R.drawable.profile_bg_blue, R.drawable.profile_bg_orange, R.drawable.profile_bg_pink, R.drawable.profile_bg_purple, R.drawable.profile_bg_red, R.drawable.profile_bg_teal ) private var searchPreferenceProvidersStrings: List by UserPreferenceDelegate( /** java moment right here, as listOf()::class.java != List(0) { "" }::class.java */ "search_pref_providers", List(0) { "" } ) private fun serializeTv(data: List): List = data.map { it.name } private fun deserializeTv(data: List): List { return data.mapNotNull { listName -> TvType.values().firstOrNull { it.name == listName } } } var searchPreferenceProviders: List get() { val ret = searchPreferenceProvidersStrings return ret.ifEmpty { context?.filterProviderByPreferredMedia()?.map { it.name } ?: emptyList() } } set(value) { searchPreferenceProvidersStrings = value } private var searchPreferenceTagsStrings: List by UserPreferenceDelegate( "search_pref_tags", listOf(TvType.Movie, TvType.TvSeries).map { it.name }) var searchPreferenceTags: List get() = deserializeTv(searchPreferenceTagsStrings) set(value) { searchPreferenceTagsStrings = serializeTv(value) } private var homePreferenceStrings: List by UserPreferenceDelegate( "home_pref_homepage", listOf(TvType.Movie, TvType.TvSeries).map { it.name }) var homePreference: List get() = deserializeTv(homePreferenceStrings) set(value) { homePreferenceStrings = serializeTv(value) } var homeBookmarkedList: IntArray by UserPreferenceDelegate( "home_bookmarked_last_list", IntArray(0) ) var playBackSpeed: Float by UserPreferenceDelegate("playback_speed", 1.0f) var resizeMode: Int by UserPreferenceDelegate("resize_mode", 0) var librarySortingMode: Int by UserPreferenceDelegate( "library_sorting_mode", ListSorting.AlphabeticalA.ordinal ) private var _resultsSortingMode: Int by UserPreferenceDelegate( "results_sorting_mode", EpisodeSortType.NUMBER_ASC.ordinal ) var resultsSortingMode: EpisodeSortType get() = EpisodeSortType.entries.getOrNull(_resultsSortingMode) ?: EpisodeSortType.NUMBER_ASC set(value) { _resultsSortingMode = value.ordinal } data class Account( @JsonProperty("keyIndex") val keyIndex: Int, @JsonProperty("name") val name: String, @JsonProperty("customImage") val customImage: String? = null, @JsonProperty("defaultImageIndex") val defaultImageIndex: Int, @JsonProperty("lockPin") val lockPin: String? = null, ) { val image get() = customImage?.let { UiImage.Image(it) } ?: profileImages.getOrNull( defaultImageIndex )?.let { UiImage.Drawable(it) } ?: UiImage.Drawable(profileImages.first()) } const val TAG = "data_store_helper" var accounts by PreferenceDelegate("$TAG/account", arrayOf()) var selectedKeyIndex by PreferenceDelegate("$TAG/account_key_index", 0) val currentAccount: String get() = selectedKeyIndex.toString() /** * Get or set the current account homepage. * Setting this does not automatically reload the homepage. */ var currentHomePage: String? get() = getKey("$currentAccount/$USER_SELECTED_HOMEPAGE_API") set(value) { val key = "$currentAccount/$USER_SELECTED_HOMEPAGE_API" if (value == null) { removeKey(key) } else { setKey(key, value) } } fun setAccount(account: Account) { val homepage = currentHomePage selectedKeyIndex = account.keyIndex AccountManager.updateAccountIds() showToast(context?.getString(R.string.logged_account, account.name) ?: account.name) MainActivity.bookmarksUpdatedEvent(true) MainActivity.reloadLibraryEvent(true) val oldAccount = accounts.find { it.keyIndex == account.keyIndex } if (oldAccount != null && currentHomePage != homepage) { // This is not a new account, and the homepage has changed, reload it MainActivity.reloadHomeEvent(true) } } fun getDefaultAccount(context: Context): Account { return accounts.let { currentAccounts -> currentAccounts.getOrNull(currentAccounts.indexOfFirst { it.keyIndex == 0 }) ?: Account( keyIndex = 0, name = context.getString(R.string.default_account), defaultImageIndex = 0 ) } } fun getAccounts(context: Context): List { return accounts.toMutableList().apply { val item = getDefaultAccount(context) remove(item) add(0, item) } } /** Gets the current selected account (or default), may return null if context is null and the user is using the default account */ fun getCurrentAccount(): Account? { return (context?.let { getAccounts(it) } ?: accounts.toList()).firstNotNullOfOrNull { account -> if (account.keyIndex == selectedKeyIndex) { account } else { null } } } data class PosDur( @JsonProperty("position") val position: Long, @JsonProperty("duration") val duration: Long ) fun PosDur.fixVisual(): PosDur { if (duration <= 0) return PosDur(0, duration) val percentage = position * 100 / duration if (percentage <= 1) return PosDur(0, duration) if (percentage <= 5) return PosDur(5 * duration / 100, duration) if (percentage >= 95) return PosDur(duration, duration) return this } fun Int.toYear(): Date = GregorianCalendar.getInstance().also { it.set(Calendar.YEAR, this) }.time /** * Used to display notifications on new episodes and posters in library. **/ abstract class LibrarySearchResponse( @JsonProperty("id") override var id: Int?, @JsonProperty("latestUpdatedTime") open val latestUpdatedTime: Long, @JsonProperty("name") override val name: String, @JsonProperty("url") override val url: String, @JsonProperty("apiName") override val apiName: String, @JsonProperty("type") override var type: TvType?, @JsonProperty("posterUrl") override var posterUrl: String?, @JsonProperty("year") open val year: Int?, @JsonProperty("syncData") open val syncData: Map?, @JsonProperty("quality") override var quality: SearchQuality?, @JsonProperty("posterHeaders") override var posterHeaders: Map?, @JsonProperty("plot") open val plot: String? = null, @JsonProperty("score") override var score: Score? = null, @JsonProperty("tags") open val tags: List? = null, ) : SearchResponse { @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) @Deprecated( "`rating` is the old scoring system, use score instead", replaceWith = ReplaceWith("score"), level = DeprecationLevel.ERROR ) var rating: Int? = null set(value) { if (value != null) { @Suppress("DEPRECATION_ERROR") score = Score.fromOld(value) } } } data class SubscribedData( @JsonProperty("subscribedTime") val subscribedTime: Long, @JsonProperty("lastSeenEpisodeCount") val lastSeenEpisodeCount: Map, override var id: Int?, override val latestUpdatedTime: Long, override val name: String, override val url: String, override val apiName: String, override var type: TvType?, override var posterUrl: String?, override val year: Int?, override val syncData: Map? = null, override var quality: SearchQuality? = null, override var posterHeaders: Map? = null, override val plot: String? = null, override var score: Score? = null, override val tags: List? = null, ) : LibrarySearchResponse( id, latestUpdatedTime, name, url, apiName, type, posterUrl, year, syncData, quality, posterHeaders, plot, score, tags ) { fun toLibraryItem(): SyncAPI.LibraryItem? { return SyncAPI.LibraryItem( name, url, id?.toString() ?: return null, null, null, null, latestUpdatedTime, apiName, type, posterUrl, posterHeaders, quality, year?.toYear(), this.id, plot = this.plot, score = this.score, tags = this.tags ) } } data class BookmarkedData( @JsonProperty("bookmarkedTime") val bookmarkedTime: Long, override var id: Int?, override val latestUpdatedTime: Long, override val name: String, override val url: String, override val apiName: String, override var type: TvType?, override var posterUrl: String?, override val year: Int?, override val syncData: Map? = null, override var quality: SearchQuality? = null, override var posterHeaders: Map? = null, override val plot: String? = null, override var score: Score? = null, override val tags: List? = null, ) : LibrarySearchResponse( id, latestUpdatedTime, name, url, apiName, type, posterUrl, year, syncData, quality, posterHeaders, plot ) { fun toLibraryItem(id: String): SyncAPI.LibraryItem { return SyncAPI.LibraryItem( name, url, id, null, null, null, latestUpdatedTime, apiName, type, posterUrl, posterHeaders, quality, year?.toYear(), this.id, plot = this.plot, score = this.score, tags = this.tags ) } } data class FavoritesData( @JsonProperty("favoritesTime") val favoritesTime: Long, override var id: Int?, override val latestUpdatedTime: Long, override val name: String, override val url: String, override val apiName: String, override var type: TvType?, override var posterUrl: String?, override val year: Int?, override val syncData: Map? = null, override var quality: SearchQuality? = null, override var posterHeaders: Map? = null, override val plot: String? = null, override var score: Score? = null, override val tags: List? = null, ) : LibrarySearchResponse( id, latestUpdatedTime, name, url, apiName, type, posterUrl, year, syncData, quality, posterHeaders, plot ) { fun toLibraryItem(): SyncAPI.LibraryItem? { return SyncAPI.LibraryItem( name, url, id?.toString() ?: return null, null, null, null, latestUpdatedTime, apiName, type, posterUrl, posterHeaders, quality, year?.toYear(), this.id, plot = this.plot, score = this.score, tags = this.tags ) } } data class ResumeWatchingResult( @JsonProperty("name") override val name: String, @JsonProperty("url") override val url: String, @JsonProperty("apiName") override val apiName: String, @JsonProperty("type") override var type: TvType? = null, @JsonProperty("posterUrl") override var posterUrl: String?, @JsonProperty("watchPos") val watchPos: PosDur?, @JsonProperty("id") override var id: Int?, @JsonProperty("parentId") val parentId: Int?, @JsonProperty("episode") val episode: Int?, @JsonProperty("season") val season: Int?, @JsonProperty("isFromDownload") val isFromDownload: Boolean, @JsonProperty("quality") override var quality: SearchQuality? = null, @JsonProperty("posterHeaders") override var posterHeaders: Map? = null, @JsonProperty("score") override var score: Score? = null, ) : SearchResponse /** * A datastore wide account for future implementations of a multiple account system **/ fun getAllWatchStateIds(): List? { val folder = "$currentAccount/$RESULT_WATCH_STATE" return getKeys(folder)?.mapNotNull { it.removePrefix("$folder/").toIntOrNull() } } fun deleteAllResumeStateIds() { val folder = "$currentAccount/$RESULT_RESUME_WATCHING" removeKeys(folder) } fun deleteBookmarkedData(id: Int?) { if (id == null) return AccountManager.localListApi.requireLibraryRefresh = true removeKey("$currentAccount/$RESULT_WATCH_STATE", id.toString()) removeKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString()) } fun getAllResumeStateIds(): List? { val folder = "$currentAccount/$RESULT_RESUME_WATCHING" return getKeys(folder)?.mapNotNull { it.removePrefix("$folder/").toIntOrNull() } } private fun getAllResumeStateIdsOld(): List? { val folder = "$currentAccount/$RESULT_RESUME_WATCHING_OLD" return getKeys(folder)?.mapNotNull { it.removePrefix("$folder/").toIntOrNull() } } fun migrateResumeWatching() { // if (getKey(RESULT_RESUME_WATCHING_HAS_MIGRATED, false) != true) { setKey(RESULT_RESUME_WATCHING_HAS_MIGRATED, true) getAllResumeStateIdsOld()?.forEach { id -> getLastWatchedOld(id)?.let { setLastWatched( it.parentId, null, it.episode, it.season, it.isFromDownload, it.updateTime ) removeLastWatchedOld(it.parentId) } } //} } fun setLastWatched( parentId: Int?, episodeId: Int?, episode: Int?, season: Int?, isFromDownload: Boolean = false, updateTime: Long? = null, ) { if (parentId == null) return setKey( "$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString(), DownloadObjects.ResumeWatching( parentId, episodeId, episode, season, updateTime ?: System.currentTimeMillis(), isFromDownload ) ) } private fun removeLastWatchedOld(parentId: Int?) { if (parentId == null) return removeKey("$currentAccount/$RESULT_RESUME_WATCHING_OLD", parentId.toString()) } fun removeLastWatched(parentId: Int?) { if (parentId == null) return removeKey("$currentAccount/$RESULT_RESUME_WATCHING", parentId.toString()) } fun getLastWatched(id: Int?): DownloadObjects.ResumeWatching? { if (id == null) return null return getKey( "$currentAccount/$RESULT_RESUME_WATCHING", id.toString(), ) } private fun getLastWatchedOld(id: Int?): DownloadObjects.ResumeWatching? { if (id == null) return null return getKey( "$currentAccount/$RESULT_RESUME_WATCHING_OLD", id.toString(), ) } fun setBookmarkedData(id: Int?, data: BookmarkedData) { if (id == null) return setKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString(), data) AccountManager.localListApi.requireLibraryRefresh = true } fun getBookmarkedData(id: Int?): BookmarkedData? { if (id == null) return null return getKey("$currentAccount/$RESULT_WATCH_STATE_DATA", id.toString()) } fun getAllBookmarkedData(): List { return getKeys("$currentAccount/$RESULT_WATCH_STATE_DATA")?.mapNotNull { getKey(it) } ?: emptyList() } fun getAllSubscriptions(): List { return getKeys("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA")?.mapNotNull { getKey(it) } ?: emptyList() } fun removeSubscribedData(id: Int?) { if (id == null) return AccountManager.localListApi.requireLibraryRefresh = true removeKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString()) } /** * Set new seen episodes and update time **/ fun updateSubscribedData(id: Int?, data: SubscribedData?, episodeResponse: EpisodeResponse?) { if (id == null || data == null || episodeResponse == null) return val newData = data.copy( latestUpdatedTime = unixTimeMS, lastSeenEpisodeCount = episodeResponse.getLatestEpisodes() ) setKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString(), newData) } fun setSubscribedData(id: Int?, data: SubscribedData) { if (id == null) return setKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString(), data) AccountManager.localListApi.requireLibraryRefresh = true } fun getSubscribedData(id: Int?): SubscribedData? { if (id == null) return null return getKey("$currentAccount/$RESULT_SUBSCRIBED_STATE_DATA", id.toString()) } fun getAllFavorites(): List { return getKeys("$currentAccount/$RESULT_FAVORITES_STATE_DATA")?.mapNotNull { getKey(it) } ?: emptyList() } fun removeFavoritesData(id: Int?) { if (id == null) return AccountManager.localListApi.requireLibraryRefresh = true removeKey("$currentAccount/$RESULT_FAVORITES_STATE_DATA", id.toString()) } fun setFavoritesData(id: Int?, data: FavoritesData) { if (id == null) return setKey("$currentAccount/$RESULT_FAVORITES_STATE_DATA", id.toString(), data) AccountManager.localListApi.requireLibraryRefresh = true } fun getFavoritesData(id: Int?): FavoritesData? { if (id == null) return null return getKey("$currentAccount/$RESULT_FAVORITES_STATE_DATA", id.toString()) } fun setViewPos(id: Int?, pos: Long, dur: Long) { if (id == null) return if (dur < 30_000) return // too short setKey("$currentAccount/$VIDEO_POS_DUR", id.toString(), PosDur(pos, dur)) } /** Sets the position, duration, and resume data of an episode/movie, * * if nextEpisode is not specified it will not be able to set the next episode as resumable if progress > NEXT_WATCH_EPISODE_PERCENTAGE * */ fun setViewPosAndResume(id: Int?, position: Long, duration: Long, currentEpisode: Any?, nextEpisode: Any?) { setViewPos(id, position, duration) if (id != null) { when (val meta = currentEpisode) { is ResultEpisode -> { if (meta.videoWatchState == VideoWatchState.Watched) { setVideoWatchState(id, VideoWatchState.None) } } } } val percentage = position * 100L / duration val nextEp = percentage >= NEXT_WATCH_EPISODE_PERCENTAGE val resumeMeta = if (nextEp) nextEpisode else currentEpisode if (resumeMeta == null && nextEp) { // remove last watched as it is the last episode and you have watched too much when (val newMeta = currentEpisode) { is ResultEpisode -> { removeLastWatched(newMeta.parentId) } is ExtractorUri -> { removeLastWatched(newMeta.parentId) } } } else { // save resume when (resumeMeta) { is ResultEpisode -> { setLastWatched( resumeMeta.parentId, resumeMeta.id, resumeMeta.episode, resumeMeta.season, isFromDownload = false ) } is ExtractorUri -> { setLastWatched( resumeMeta.parentId, resumeMeta.id, resumeMeta.episode, resumeMeta.season, isFromDownload = true ) } } } } fun getViewPos(id: Int?): PosDur? { if (id == null) return null return getKey("$currentAccount/$VIDEO_POS_DUR", id.toString(), null) } fun getVideoWatchState(id: Int?): VideoWatchState? { if (id == null) return null return getKey("$currentAccount/$VIDEO_WATCH_STATE", id.toString(), null) } fun setVideoWatchState(id: Int?, watchState: VideoWatchState) { if (id == null) return // None == No key if (watchState == VideoWatchState.None) { removeKey("$currentAccount/$VIDEO_WATCH_STATE", id.toString()) } else { setKey("$currentAccount/$VIDEO_WATCH_STATE", id.toString(), watchState) } } fun getDub(id: Int): DubStatus? { return DubStatus.entries .getOrNull(getKey("$currentAccount/$RESULT_DUB", id.toString(), -1) ?: -1) } fun setDub(id: Int, status: DubStatus) { setKey("$currentAccount/$RESULT_DUB", id.toString(), status.ordinal) } fun setResultWatchState(id: Int?, status: Int) { if (id == null) return if (status == WatchType.NONE.internalId) { deleteBookmarkedData(id) } else { setKey("$currentAccount/$RESULT_WATCH_STATE", id.toString(), status) } } fun getResultWatchState(id: Int): WatchType { return WatchType.fromInternalId( getKey( "$currentAccount/$RESULT_WATCH_STATE", id.toString(), null ) ) } fun getResultSeason(id: Int): Int? { return getKey("$currentAccount/$RESULT_SEASON", id.toString(), null) } fun setResultSeason(id: Int, value: Int?) { setKey("$currentAccount/$RESULT_SEASON", id.toString(), value) } fun getResultEpisode(id: Int): Int? { return getKey("$currentAccount/$RESULT_EPISODE", id.toString(), null) } fun setResultEpisode(id: Int, value: Int?) { setKey("$currentAccount/$RESULT_EPISODE", id.toString(), value) } fun addSync(id: Int, idPrefix: String, url: String) { setKey("${idPrefix}_sync", id.toString(), url) } fun getSync(id: Int, idPrefixes: List): List { return idPrefixes.map { idPrefix -> getKey("${idPrefix}_sync", id.toString()) } } var pinnedProviders: Array get() = getKey(USER_PINNED_PROVIDERS) ?: emptyArray() set(value) = setKey(USER_PINNED_PROVIDERS, value) } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/DownloadFileWorkManager.kt ================================================ ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/Event.kt ================================================ package com.lagradost.cloudstream3.utils class Event { private val observers = mutableSetOf<(T) -> Unit>() val size: Int get() = observers.size operator fun plusAssign(observer: (T) -> Unit) { synchronized(observers) { observers.add(observer) } } operator fun minusAssign(observer: (T) -> Unit) { synchronized(observers) { observers.remove(observer) } } operator fun invoke(value: T) { synchronized(observers) { for (observer in observers) observer(value) } } } class EmptyEvent { private val observers = mutableSetOf() val size: Int get() = observers.size operator fun plusAssign(observer: Runnable) { synchronized(observers) { observers.add(observer) } } operator fun minusAssign(observer: Runnable) { synchronized(observers) { observers.remove(observer) } } operator fun invoke() { synchronized(observers) { for (observer in observers) observer.run() } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/FillerEpisodeCheck.kt ================================================ package com.lagradost.cloudstream3.utils import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.utils.Coroutines.main import org.jsoup.Jsoup import java.lang.Thread.sleep import java.util.* import kotlin.concurrent.thread object FillerEpisodeCheck { private const val MAIN_URL = "https://www.animefillerlist.com" var list: HashMap? = null var cache: HashMap> = hashMapOf() private fun fixName(name: String): String { return name.lowercase(Locale.ROOT)/*.replace(" ", "")*/.replace("-", " ") .replace("[^a-zA-Z0-9 ]".toRegex(), "") } private suspend fun getFillerList(): Boolean { if (list != null) return true try { val result = app.get("$MAIN_URL/shows").text val documented = Jsoup.parse(result) val localHTMLList = documented.select("div#ShowList > div.Group > ul > li > a") val localList = HashMap() for (i in localHTMLList) { val name = i.text() if (name.lowercase(Locale.ROOT).contains("manga only")) continue val href = i.attr("href") if (name.isNullOrEmpty() || href.isNullOrEmpty()) { continue } val values = "(.*) \\((.*)\\)".toRegex().matchEntire(name)?.groups if (values != null) { for (index in 1 until values.size) { val localName = values[index]?.value ?: continue localList[fixName(localName)] = href } } else { localList[fixName(name)] = href } } if (localList.size > 0) { list = localList return true } } catch (e: Exception) { e.printStackTrace() } return false } fun String?.toClassDir(): String { val q = this ?: "null" val z = (6..10).random().calc() return q + "cache" + z } suspend fun getFillerEpisodes(query: String): HashMap? { try { cache[query]?.let { return it } if (!getFillerList()) return null val localList = list ?: return null // Strips these from the name val blackList = listOf( "TV Dubbed", "(Dub)", "Subbed", "(TV)", "(Uncensored)", "(Censored)", "(\\d+)" // year ) val blackListRegex = Regex( """ (${ blackList.joinToString(separator = "|").replace("(", "\\(") .replace(")", "\\)") })""" ) val realQuery = fixName(query.replace(blackListRegex, "")).replace("shippuuden", "shippuden") if (!localList.containsKey(realQuery)) return null val href = localList[realQuery]?.replace(MAIN_URL, "") ?: return null // JUST IN CASE val result = app.get("$MAIN_URL$href").text val documented = Jsoup.parse(result) val hashMap = HashMap() documented.select("table.EpisodeList > tbody > tr").forEach { val type = it.selectFirst("td.Type > span")?.text() == "Filler" val episodeNumber = it.selectFirst("td.Number")?.text()?.toIntOrNull() if (episodeNumber != null) { hashMap[episodeNumber] = type } } cache[query] = hashMap return hashMap } catch (e: Exception) { e.printStackTrace() return null } } private fun Int.calc(): Int { var counter = 10 thread { sleep((this * 0xEA60).toLong()) main { var exit = true while (exit) { counter++ if (this > 10) { exit = false } } } } return counter } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/IDisposable.kt ================================================ package com.lagradost.cloudstream3.utils interface IDisposable { fun dispose() } object IDisposableHelper { fun using(disposeObject: T, work: (T) -> Unit) { work.invoke(disposeObject) disposeObject.dispose() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/ImageModuleCoil.kt ================================================ package com.lagradost.cloudstream3.utils import android.graphics.Bitmap import android.graphics.drawable.Drawable import android.net.Uri import android.os.Build.VERSION.SDK_INT import android.util.Log import android.widget.ImageView import androidx.annotation.DrawableRes import coil3.EventListener import coil3.ImageLoader import coil3.PlatformContext import coil3.SingletonImageLoader import coil3.disk.DiskCache import coil3.dispose import coil3.load import coil3.memory.MemoryCache import coil3.network.NetworkHeaders import coil3.network.httpHeaders import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.CachePolicy import coil3.request.ErrorResult import coil3.request.ImageRequest import coil3.request.allowHardware import coil3.request.crossfade import coil3.util.DebugLogger import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.USER_AGENT import com.lagradost.cloudstream3.network.buildDefaultClient import okhttp3.HttpUrl import okio.Path.Companion.toOkioPath import java.io.File import java.nio.ByteBuffer object ImageLoader { private const val TAG = "CoilImgLoader" internal fun buildImageLoader(context: PlatformContext): ImageLoader = ImageLoader.Builder(context) .crossfade(200) .allowHardware(SDK_INT >= 28) // SDK_INT >= 28, cant use hardware bitmaps for Palette Builder .diskCachePolicy(CachePolicy.ENABLED) .networkCachePolicy(CachePolicy.ENABLED) .memoryCache { MemoryCache.Builder().maxSizePercent(context, 0.1) // Use 10 % of the app's available memory for caching .build() } .diskCache { DiskCache.Builder() .directory(context.cacheDir.resolve("cs3_image_cache").toOkioPath()) .maxSizeBytes(512L * 1024 * 1024) // 512 MB .maxSizePercent(0.04) // Use 4 % of the device's storage space for disk caching .build() } /** Pass interceptors with care, unnecessary passing tokens to servers or image hosting services causes unauthorized exceptions **/ .components { add(OkHttpNetworkFetcherFactory(callFactory = { buildDefaultClient(context) })) } .also { it.setupCoilLogger() Log.d(TAG, "buildImageLoader: Setting COIL Image Loader.") } .build() /** Use DebugLogger on debug builds which won't slow down release builds & use EventListener for Errors on release builds. **/ internal fun ImageLoader.Builder.setupCoilLogger() { if (BuildConfig.DEBUG) { logger(DebugLogger()) Log.d(TAG, "setupCoilLogger: Activated DEBUG_LOGGER FOR COIL") } else { eventListener(object : EventListener() { override fun onError(request: ImageRequest, result: ErrorResult) { super.onError(request, result) Log.e(TAG, "Error loading image: ${result.throwable}") } }) Log.d(TAG, "setupCoilLogger: Activated EVENT_LISTENER FOR COIL") } } /** we use coil's built in loader with our global synchronized instance, this way we achieve latest and complete functionality as well as stability **/ private fun ImageView.loadImageInternal( imageData: Any?, headers: Map? = null, builder: ImageRequest.Builder.() -> Unit = {} // for placeholder, error & transformations ) { // clear image to avoid loading & flickering issue at fast scrolling (e.g, an image recycler) this.dispose() if(imageData == null) return // Just in case // setImageResource is better than coil3 on resources due to attr if(imageData is Int) { this.setImageResource(imageData) return } // Use Coil's built-in load method but with our custom module & a decent USER-AGENT always // which can be overridden by extensions. this.load(imageData, SingletonImageLoader.get(context)) { this.httpHeaders(NetworkHeaders.Builder().also { headerBuilder -> headerBuilder["User-Agent"] = USER_AGENT headers?.forEach { (key, value) -> headerBuilder[key] = value } }.build()) builder() // if passed } } /** TYPE_SAFE_LOADERS **/ fun ImageView.loadImage( imageData: UiImage?, builder: ImageRequest.Builder.() -> Unit = {} ) = when (imageData) { is UiImage.Image -> loadImageInternal( imageData = imageData.url, headers = imageData.headers, builder = builder ) is UiImage.Bitmap -> loadImageInternal(imageData = imageData.bitmap, builder = builder) is UiImage.Drawable -> loadImageInternal(imageData = imageData.resId, builder = builder) null -> loadImageInternal(null, builder = builder) } fun ImageView.loadImage( imageData: String?, headers: Map? = null, builder: ImageRequest.Builder.() -> Unit = {} ) = loadImageInternal(imageData = imageData, headers = headers, builder = builder) fun ImageView.loadImage( imageData: Uri?, headers: Map? = null, builder: ImageRequest.Builder.() -> Unit = {} ) = loadImageInternal(imageData = imageData, headers = headers, builder = builder) fun ImageView.loadImage( imageData: HttpUrl?, headers: Map? = null, builder: ImageRequest.Builder.() -> Unit = {} ) = loadImageInternal(imageData = imageData, headers = headers, builder = builder) fun ImageView.loadImage( imageData: File?, builder: ImageRequest.Builder.() -> Unit = {} ) = loadImageInternal(imageData = imageData, builder = builder) fun ImageView.loadImage( @DrawableRes imageData: Int?, builder: ImageRequest.Builder.() -> Unit = {} ) = loadImageInternal(imageData = imageData, builder = builder) fun ImageView.loadImage( imageData: Drawable?, builder: ImageRequest.Builder.() -> Unit = {} ) = loadImageInternal(imageData = imageData, builder = builder) fun ImageView.loadImage( imageData: Bitmap?, builder: ImageRequest.Builder.() -> Unit = {} ) = loadImageInternal(imageData = imageData, builder = builder) fun ImageView.loadImage( imageData: ByteArray?, builder: ImageRequest.Builder.() -> Unit = {} ) = loadImageInternal(imageData = imageData, builder = builder) fun ImageView.loadImage( imageData: ByteBuffer?, builder: ImageRequest.Builder.() -> Unit = {} ) = loadImageInternal(imageData = imageData, builder = builder) } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/ImageUtil.kt ================================================ package com.lagradost.cloudstream3.utils import android.content.Context import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.drawable.BitmapDrawable import android.graphics.drawable.Drawable import androidx.annotation.DrawableRes import androidx.core.content.ContextCompat import androidx.core.graphics.createBitmap import coil3.Image import coil3.asImage /// Type safe any image, because THIS IS NOT PYTHON sealed class UiImage { data class Image( val url: String, val headers: Map? = null ) : UiImage() data class Drawable(@DrawableRes val resId: Int) : UiImage() data class Bitmap(val bitmap: android.graphics.Bitmap) : UiImage() } fun getImageFromDrawable(context: Context, drawableRes: Int): Image? { return ContextCompat.getDrawable(context, drawableRes)?.asImage() } fun drawableToBitmap(drawable: Drawable): Bitmap? { return when (drawable) { is BitmapDrawable -> drawable.bitmap else -> { val bitmap = createBitmap(drawable.intrinsicWidth, drawable.intrinsicHeight) val canvas = Canvas(bitmap) drawable.setBounds(0, 0, canvas.width, canvas.height) drawable.draw(canvas) bitmap } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/InAppUpdater.kt ================================================ package com.lagradost.cloudstream3.utils import android.app.Activity import android.content.Context import android.content.Intent import android.content.pm.PackageManager.NameNotFoundException import android.net.Uri import android.util.Log import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.content.edit import androidx.preference.PreferenceManager import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.MainActivity.Companion.deleteFileOnExit import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.services.PackageInstallerService import com.lagradost.cloudstream3.utils.AppContextUtils.setDefaultFocus import com.lagradost.cloudstream3.utils.AppUtils.parseJson import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okio.BufferedSink import okio.buffer import okio.sink import java.io.BufferedReader import java.io.File import java.io.IOException import java.io.InputStreamReader object InAppUpdater { private const val GITHUB_USER_NAME = "recloudstream" private const val GITHUB_REPO = "cloudstream" private const val PRERELEASE_PACKAGE_NAME = "com.lagradost.cloudstream3.prerelease" private const val LOG_TAG = "InAppUpdater" private data class GithubAsset( @JsonProperty("name") val name: String, @JsonProperty("size") val size: Int, // Size in bytes @JsonProperty("browser_download_url") val browserDownloadUrl: String, @JsonProperty("content_type") val contentType: String, // application/vnd.android.package-archive ) private data class GithubRelease( @JsonProperty("tag_name") val tagName: String, // Version code @JsonProperty("body") val body: String, // Description @JsonProperty("assets") val assets: List, @JsonProperty("target_commitish") val targetCommitish: String, // Branch @JsonProperty("prerelease") val prerelease: Boolean, @JsonProperty("node_id") val nodeId: String, ) private data class GithubObject( @JsonProperty("sha") val sha: String, // SHA-256 hash @JsonProperty("type") val type: String, @JsonProperty("url") val url: String, ) private data class GithubTag( @JsonProperty("object") val githubObject: GithubObject, ) private data class Update( @JsonProperty("shouldUpdate") val shouldUpdate: Boolean, @JsonProperty("updateURL") val updateURL: String?, @JsonProperty("updateVersion") val updateVersion: String?, @JsonProperty("changelog") val changelog: String?, @JsonProperty("updateNodeId") val updateNodeId: String?, ) private suspend fun Activity.getAppUpdate(installPrerelease: Boolean): Update { return try { when { // No updates on debug version BuildConfig.DEBUG -> Update(false, null, null, null, null) BuildConfig.FLAVOR == "prerelease" || installPrerelease -> getPreReleaseUpdate() else -> getReleaseUpdate() } } catch (e: Exception) { Log.e(LOG_TAG, Log.getStackTraceString(e)) Update(false, null, null, null, null) } } private suspend fun Activity.getReleaseUpdate(): Update { val url = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" val headers = mapOf("Accept" to "application/vnd.github.v3+json") val response = parseJson>( app.get(url, headers = headers).text ) val versionRegex = Regex("""(.*?((\d+)\.(\d+)\.(\d+))\.apk)""") val versionRegexLocal = Regex("""(.*?((\d+)\.(\d+)\.(\d+)).*)""") val foundList = response.filter { rel -> !rel.prerelease }.sortedWith(compareBy { release -> release.assets.firstOrNull { it.contentType == "application/vnd.android.package-archive" }?.name?.let { it1 -> versionRegex.find( it1 )?.groupValues?.let { it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() } } }).toList() val found = foundList.lastOrNull() val foundAsset = found?.assets?.getOrNull(0) val foundVersion = foundAsset?.name?.let { versionRegex.find(it) } if (foundVersion == null) { return Update(false, null, null, null, null) } val currentVersion = packageName?.let { packageManager.getPackageInfo(it, 0) } val shouldUpdate = if (foundAsset.browserDownloadUrl.isBlank()) { false } else { currentVersion?.versionName?.let { versionName -> versionRegexLocal.find(versionName)?.groupValues?.let { it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() } }?.compareTo( foundVersion.groupValues.let { it[3].toInt() * 100_000_000 + it[4].toInt() * 10_000 + it[5].toInt() })!! < 0 } return Update( shouldUpdate, foundAsset.browserDownloadUrl, foundVersion.groupValues[2], found.body, found.nodeId ) } private suspend fun Activity.getPreReleaseUpdate(): Update { val tagUrl = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/git/ref/tags/pre-release" val releaseUrl = "https://api.github.com/repos/$GITHUB_USER_NAME/$GITHUB_REPO/releases" val headers = mapOf("Accept" to "application/vnd.github.v3+json") val response = parseJson>( app.get(releaseUrl, headers = headers).text ) val found = response.lastOrNull { rel -> rel.prerelease || rel.tagName == "pre-release" } val foundAsset = found?.assets?.filter { it -> it.contentType == "application/vnd.android.package-archive" }?.getOrNull(0) if (foundAsset == null) { return Update(false, null, null, null, null) } val tagResponse = parseJson(app.get(tagUrl, headers = headers).text) val updateCommitHash = tagResponse.githubObject.sha.trim().take(7) Log.d(LOG_TAG, "Fetched GitHub tag: $updateCommitHash") return Update( getString(R.string.commit_hash) != updateCommitHash, foundAsset.browserDownloadUrl, updateCommitHash, found.body, found.nodeId ) } private val updateLock = Mutex() private suspend fun Activity.downloadUpdate(url: String): Boolean { try { Log.d(LOG_TAG, "Downloading update: $url") val appUpdateName = "CloudStream" val appUpdateSuffix = "apk" // Delete all old updates this.cacheDir.listFiles()?.filter { it.name.startsWith(appUpdateName) && it.extension == appUpdateSuffix }?.forEach { deleteFileOnExit(it) } val downloadedFile = File.createTempFile(appUpdateName, ".$appUpdateSuffix") val sink: BufferedSink = downloadedFile.sink().buffer() updateLock.withLock { sink.writeAll(app.get(url).body.source()) sink.close() openApk(this, Uri.fromFile(downloadedFile)) } return true } catch (e: Exception) { logError(e) return false } } private fun openApk(context: Context, uri: Uri) = safe { val path = uri.path ?: return@safe val contentUri = FileProvider.getUriForFile( context, BuildConfig.APPLICATION_ID + ".provider", File(path) ) val installIntent = Intent(Intent.ACTION_VIEW).apply { addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) data = contentUri } context.startActivity(installIntent) } fun Activity.installPreReleaseIfNeeded() = ioSafe { val isInstalled = try { packageManager.getPackageInfo(PRERELEASE_PACKAGE_NAME, 0) true } catch (_: NameNotFoundException) { false } if (isInstalled) { showToast(R.string.prerelease_already_installed) } else if (!runAutoUpdate(checkAutoUpdate = false, installPrerelease = true)) { showToast(R.string.prerelease_install_failed) } } /** * @param checkAutoUpdate if the update check was launched automatically * @param installPrerelease if we want to install the pre-release version */ suspend fun Activity.runAutoUpdate( checkAutoUpdate: Boolean = true, installPrerelease: Boolean = false ): Boolean { val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val autoUpdateEnabled = settingsManager.getBoolean(getString(R.string.auto_update_key), true) if (checkAutoUpdate && !autoUpdateEnabled) { return false } val update = getAppUpdate(installPrerelease) if (!update.shouldUpdate || update.updateURL == null) { return false } // Check if update should be skipped val updateNodeId = settingsManager.getString( getString(R.string.skip_update_key), "" ) // Skips the update if its an automatic update and the update is skipped // This allows updating manually if (update.updateNodeId.equals(updateNodeId) && checkAutoUpdate) { return false } runOnUiThread { safe { val currentVersion = packageName?.let { packageManager.getPackageInfo(it, 0) } val builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) builder.setTitle( getString(R.string.new_update_format).format( currentVersion?.versionName, update.updateVersion ) ) val logRegex = Regex("\\[(.*?)]\\((.*?)\\)") val sanitizedChangelog = update.changelog?.replace(logRegex) { matchResult -> matchResult.groupValues[1] } // Sanitized because it looks cluttered builder.setMessage(sanitizedChangelog) builder.apply { setPositiveButton(R.string.update) { _, _ -> // Forcefully start any delayed installations if (ApkInstaller.delayedInstaller?.startInstallation() == true) return@setPositiveButton showToast(R.string.download_started, Toast.LENGTH_LONG) // Check if the setting hasn't been changed if (settingsManager.getInt( getString(R.string.apk_installer_key), -1 ) == -1 ) { // Set to legacy installer if using MIUI if (isMiUi()) { settingsManager.edit { putInt(getString(R.string.apk_installer_key), 1) } } } val currentInstaller = settingsManager.getInt( getString(R.string.apk_installer_key), 0 ) when (currentInstaller) { // New method 0 -> { val intent = PackageInstallerService.Companion.getIntent( this@runAutoUpdate, update.updateURL ) ContextCompat.startForegroundService( this@runAutoUpdate, intent ) } // Legacy 1 -> { ioSafe { if (!downloadUpdate(update.updateURL)) { runOnUiThread { showToast( R.string.download_failed, Toast.LENGTH_LONG ) } } } } } } setNegativeButton(R.string.cancel) { _, _ -> } if (checkAutoUpdate) { setNeutralButton(R.string.skip_update) { _, _ -> settingsManager.edit { putString( getString(R.string.skip_update_key), update.updateNodeId ?: "" ) } } } } builder.show().setDefaultFocus() } } return true } private fun isMiUi(): Boolean = !getSystemProperty("ro.miui.ui.version.name").isNullOrEmpty() private fun getSystemProperty(propName: String): String? = try { val p = Runtime.getRuntime().exec("getprop $propName") BufferedReader(InputStreamReader(p.inputStream), 1024).use { it.readLine() } } catch (_: IOException) { null } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/IntentHelpers.kt ================================================ package com.lagradost.cloudstream3.utils import android.annotation.SuppressLint import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.Build inline fun Intent.getSafeParcelableExtra(key: String): T? = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) @Suppress("DEPRECATION") getParcelableExtra(key) else getParcelableExtra(key, T::class.java) @SuppressLint("UnspecifiedRegisterReceiverFlag") fun Context.registerBroadcastReceiver(receiver: BroadcastReceiver, actionFilter: IntentFilter) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // Register receiver with the context with flag to indicate internal usage registerReceiver(receiver, actionFilter, Context.RECEIVER_NOT_EXPORTED) } else { // For older versions, no special export flag is needed registerReceiver(receiver, actionFilter) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/PackageInstaller.kt ================================================ package com.lagradost.cloudstream3.utils import android.annotation.SuppressLint import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.IntentSender import android.content.pm.PackageInstaller import android.os.Build import android.util.Log import android.widget.Toast import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.services.PackageInstallerService import com.lagradost.cloudstream3.utils.Coroutines.main import java.io.InputStream const val INSTALL_ACTION = "ApkInstaller.INSTALL_ACTION" class ApkInstaller(private val service: PackageInstallerService) { companion object { /** * Used for postponed installations **/ var delayedInstaller: DelayedInstaller? = null private var isReceiverRegistered = false private const val TAG = "ApkInstaller" } inner class DelayedInstaller( private val session: PackageInstaller.Session, private val intent: IntentSender ) { fun startInstallation(): Boolean { return try { session.commit(intent) true } catch (e: Exception) { logError(e) false }.also { delayedInstaller = null } } } private val packageInstaller = service.packageManager.packageInstaller enum class InstallProgressStatus { Preparing, Downloading, Installing, Failed, } private val installActionReceiver = object : BroadcastReceiver() { @SuppressLint("UnsafeIntentLaunch") override fun onReceive(context: Context, intent: Intent) { when (intent.getIntExtra( PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE )) { PackageInstaller.STATUS_PENDING_USER_ACTION -> { val userAction = intent.getSafeParcelableExtra(Intent.EXTRA_INTENT) userAction?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) context.startActivity(userAction) } } } } fun installApk( context: Context, inputStream: InputStream, size: Long, installProgress: (bytesRead: Int) -> Unit, installProgressStatus: (InstallProgressStatus) -> Unit ) { installProgressStatus.invoke(InstallProgressStatus.Preparing) var activeSession: Int? = null try { val installParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { installParams.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) } activeSession = packageInstaller.createSession(installParams) installParams.setSize(size) val session = packageInstaller.openSession(activeSession) installProgressStatus.invoke(InstallProgressStatus.Downloading) session.openWrite(context.packageName, 0, size) .use { outputStream -> val buffer = ByteArray(4 * 1024) var bytesRead = inputStream.read(buffer) while (bytesRead >= 0) { outputStream.write(buffer, 0, bytesRead) bytesRead = inputStream.read(buffer) installProgress.invoke(bytesRead) } session.fsync(outputStream) inputStream.close() } // We must create an explicit intent or it will fail on Android 15+ val installIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { Intent(service, PackageInstallerService::class.java) .setAction(INSTALL_ACTION) } else Intent(INSTALL_ACTION) val installFlags = when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> PendingIntent.FLAG_MUTABLE Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> PendingIntent.FLAG_IMMUTABLE else -> 0 } val intentSender = PendingIntent.getBroadcast( service, activeSession, installIntent, installFlags ).intentSender // Use delayed installations on android 13 and only if "allow from unknown sources" is enabled // if the app lacks installation permission it cannot ask for the permission when it's closed. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && context.packageManager.canRequestPackageInstalls() ) { // Save for later installation since it's more jarring to have the app exit abruptly delayedInstaller = DelayedInstaller(session, intentSender) main { // Use real toast since it should show even if app is exited Toast.makeText(context, R.string.delayed_update_notice, Toast.LENGTH_LONG) .show() } } else { installProgressStatus.invoke(InstallProgressStatus.Installing) session.commit(intentSender) } } catch (e: Exception) { logError(e) service.unregisterReceiver(installActionReceiver) installProgressStatus.invoke(InstallProgressStatus.Failed) activeSession?.let { sessionId -> packageInstaller.abandonSession(sessionId) } } } init { // Might be dangerous registerInstallActionReceiver() } private fun registerInstallActionReceiver() { if (!isReceiverRegistered) { val intentFilter = IntentFilter().apply { addAction(INSTALL_ACTION) } Log.d(TAG, "Registering install action event receiver") context?.registerBroadcastReceiver(installActionReceiver, intentFilter) isReceiverRegistered = true } } fun unregisterInstallActionReceiver() { if (isReceiverRegistered) { Log.d(TAG, "Unregistering install action event receiver") try { context?.unregisterReceiver(installActionReceiver) } catch (e: Exception) { logError(e) } isReceiverRegistered = false } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/PercentageCropImageView.kt ================================================ package com.lagradost.cloudstream3.utils //Reference: https://stackoverflow.com/a/29055283 import android.content.Context import android.graphics.Matrix import android.graphics.drawable.Drawable import android.util.AttributeSet import androidx.core.content.withStyledAttributes import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError /** * A custom [AppCompatImageView] that allows precise control over the visible crop area * of an image by adjusting its horizontal and vertical center offset percentages. * * ### Key Features: * - Allows **manual vertical or horizontal cropping** via percentage offsets. * - Works seamlessly with Coil, Glide, or any image loading library. * * ### Usage (XML): * You can set the crop offset directly in XML using custom attributes: * ```xml * * ``` * - `app:cropYCenterOffsetPct` → controls how far vertically the image shifts * `0.0` = top-aligned, `0.5` = centered, `1.0` = bottom-aligned. * - `app:cropXCenterOffsetPct` → controls how far horizontally the image shifts * `0.0` = left, `0.5` = center, `1.0` = right. * * ### Programmatic Example: * ```kotlin * imageView.cropYCenterOffsetPct = 0.15f // Show slightly more (15%) of the top area * imageView.cropXCenterOffsetPct = 0.5f // Keep image centered horizontally * imageView.redraw() //Only needed if you changed cropYCenterOffsetPct/cropXCenterOffsetPct at runtime * ``` * * ### Notes: * - Must use `android:scaleType="matrix"` to enable manual matrix transformations. * - Reference: https://stackoverflow.com/a/29055283 * * @property cropYCenterOffsetPct the vertical crop percentage (0.0–1.0) * @property cropXCenterOffsetPct the horizontal crop percentage (0.0–1.0) * * @see ImageView.ScaleType.MATRIX */ class PercentageCropImageView : androidx.appcompat.widget.AppCompatImageView { private var mCropYCenterOffsetPct: Float? = null private var mCropXCenterOffsetPct: Float? = null constructor(context: Context?) : super(context!!) constructor(context: Context?, attrs: AttributeSet?) : super(context!!, attrs) { initAttrs(context, attrs) } constructor( context: Context?, attrs: AttributeSet?, defStyle: Int ) : super(context!!, attrs, defStyle) { initAttrs(context, attrs) } var cropYCenterOffsetPct: Float get() = mCropYCenterOffsetPct!! set(cropYCenterOffsetPct) { require(cropYCenterOffsetPct <= 1.0) { "Value too large: Must be <= 1.0" } mCropYCenterOffsetPct = cropYCenterOffsetPct } var cropXCenterOffsetPct: Float get() = mCropXCenterOffsetPct!! set(cropXCenterOffsetPct) { require(cropXCenterOffsetPct <= 1.0) { "Value too large: Must be <= 1.0" } mCropXCenterOffsetPct = cropXCenterOffsetPct } private fun myConfigureBounds() { if (this.scaleType == ScaleType.MATRIX) { val d = this.drawable if (d != null) { val dWidth = d.intrinsicWidth val dHeight = d.intrinsicHeight val m = Matrix() val vWidth = width - this.paddingLeft - this.paddingRight val vHeight = height - this.paddingTop - this.paddingBottom val scale: Float var dx = 0f var dy = 0f if (dWidth * vHeight > vWidth * dHeight) { val cropXCenterOffsetPct = if (mCropXCenterOffsetPct != null) mCropXCenterOffsetPct!! else 0.5f scale = vHeight.toFloat() / dHeight.toFloat() dx = (vWidth - dWidth * scale) * cropXCenterOffsetPct } else { val cropYCenterOffsetPct = if (mCropYCenterOffsetPct != null) mCropYCenterOffsetPct!! else 0f scale = vWidth.toFloat() / dWidth.toFloat() dy = (vHeight - dHeight * scale) * cropYCenterOffsetPct } m.setScale(scale, scale) m.postTranslate((dx + 0.5f).toInt().toFloat(), (dy + 0.5f).toInt().toFloat()) this.imageMatrix = m } } } // These 3 methods call configureBounds in ImageView.java class, which // adjusts the matrix in a call to center_crop (android's built-in // scaling and centering crop method). We also want to trigger // in the same place, but using our own matrix, which is then set // directly at line 588 of ImageView.java and then copied over // as the draw matrix at line 942 of ImageView.java override fun setFrame(l: Int, t: Int, r: Int, b: Int): Boolean { val changed = super.setFrame(l, t, r, b) myConfigureBounds() return changed } override fun setImageDrawable(d: Drawable?) { super.setImageDrawable(d) myConfigureBounds() } override fun setImageResource(resId: Int) { super.setImageResource(resId) myConfigureBounds() } // In case you can change the ScaleType in code you have to call redraw() //fullsizeImageView.setScaleType(ScaleType.FIT_CENTER); //fullsizeImageView.redraw(); fun redraw() { val d = this.drawable if (d != null) { // Force toggle to recalculate our bounds setImageDrawable(null) setImageDrawable(d) } } private fun initAttrs(context: Context, attrs: AttributeSet?) { attrs ?: return context.withStyledAttributes(attrs, R.styleable.PercentageCropImageView) { try { if (hasValue(R.styleable.PercentageCropImageView_cropYCenterOffsetPct)) { mCropYCenterOffsetPct = getFloat( R.styleable.PercentageCropImageView_cropYCenterOffsetPct, 0.5f ) } if (hasValue(R.styleable.PercentageCropImageView_cropXCenterOffsetPct)) { mCropXCenterOffsetPct = getFloat( R.styleable.PercentageCropImageView_cropXCenterOffsetPct, 0.5f ) } } catch (e: Exception) { logError(e) } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/PowerManagerAPI.kt ================================================ package com.lagradost.cloudstream3.utils import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.os.Build.VERSION.SDK_INT import android.os.PowerManager import android.provider.Settings import android.util.Log import androidx.appcompat.app.AlertDialog import androidx.core.content.edit import androidx.core.net.toUri import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.isLayout private const val PACKAGE_NAME = BuildConfig.APPLICATION_ID private const val TAG = "PowerManagerAPI" object BatteryOptimizationChecker { fun isAppRestricted(context: Context?): Boolean { if (SDK_INT >= 23 && context != null) { val powerManager = context.getSystemService(Context.POWER_SERVICE) as PowerManager return !powerManager.isIgnoringBatteryOptimizations(context.packageName) } return false // below Marshmallow, it's always unrestricted when app is in background } fun openBatteryOptimizationSettings(context: Context) { if (shouldShowBatteryOptimizationDialog(context)) { context.showBatteryOptimizationDialog() } } fun Context.showBatteryOptimizationDialog() { val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) try { AlertDialog.Builder(this) .setTitle(R.string.battery_dialog_title) .setIcon(R.drawable.ic_battery) .setMessage(R.string.battery_dialog_message) .setPositiveButton(R.string.ok) { _, _ -> showRequestIgnoreBatteryOptDialog() } .setNegativeButton(R.string.cancel) { _, _ -> settingsManager.edit { putBoolean(getString(R.string.battery_optimisation_key), false) } } .show() } catch (t: Throwable) { Log.e(TAG, "Error showing battery optimization dialog", t) } } private fun shouldShowBatteryOptimizationDialog(context: Context): Boolean { val isRestricted = isAppRestricted(context) val isOptimizedNotShown = PreferenceManager.getDefaultSharedPreferences(context) .getBoolean(context.getString(R.string.battery_optimisation_key), true) return isRestricted && isOptimizedNotShown && isLayout(PHONE) } private fun Context.showRequestIgnoreBatteryOptDialog() { try { val intent = Intent().apply { action = Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS data = "package:$PACKAGE_NAME".toUri() } startActivity(intent) } catch (t: Throwable) { Log.e(TAG, "Unable to invoke APP_DETAILS intent", t) if (t is ActivityNotFoundException) { showToast("Exception: Activity Not Found") } else { showToast(R.string.app_info_intent_error) } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/SingleSelectionHelper.kt ================================================ package com.lagradost.cloudstream3.utils import android.app.Activity import android.app.Dialog import android.text.Spanned import android.view.LayoutInflater import android.view.View import android.widget.AbsListView import android.widget.ArrayAdapter import android.widget.LinearLayout import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.marginLeft import androidx.core.view.marginRight import androidx.core.view.marginTop import com.google.android.material.bottomsheet.BottomSheetDialog import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.databinding.BottomInputDialogBinding import com.lagradost.cloudstream3.databinding.BottomSelectionDialogBinding import com.lagradost.cloudstream3.databinding.BottomTextDialogBinding import com.lagradost.cloudstream3.databinding.OptionsPopupTvBinding import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.ImageLoader.loadImage import com.lagradost.cloudstream3.utils.UIHelper.dismissSafe import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes object SingleSelectionHelper { fun Activity?.showOptionSelectStringRes( view: View?, poster: String?, options: List, tvOptions: List = listOf(), callback: (Pair) -> Unit ) { if (this == null) return this.showOptionSelect( view, poster, options.map { this.getString(it) }, tvOptions.map { this.getString(it) }, callback ) } private fun Activity?.showOptionSelect( view: View?, poster: String?, options: List, tvOptions: List, callback: (Pair) -> Unit ) { if (this == null) return // This was temporarily removed until better UI is made /*if (isLayout(TV or EMULATOR)) { val binding = OptionsPopupTvBinding.inflate(layoutInflater) val dialog = AlertDialog.Builder(this, R.style.AlertDialogCustom) .setView(binding.root) .create() dialog.show() binding.listview1.let { listView -> listView.choiceMode = AbsListView.CHOICE_MODE_SINGLE listView.adapter = ArrayAdapter(this, R.layout.sort_bottom_single_choice_color).apply { addAll(tvOptions) } listView.setOnItemClickListener { _, _, i, _ -> callback.invoke(Pair(true, i)) dialog.dismissSafe(this) } } binding.imageView.apply { isGone = poster.isNullOrEmpty() loadImage(poster) } } else {*/ view?.popupMenuNoIconsAndNoStringRes(options.mapIndexed { index, s -> Pair( index, s ) }) { callback(Pair(false, this.itemId)) } //} } fun Activity?.showDialog( binding: BottomSelectionDialogBinding, dialog: Dialog, items: List, selectedIndex: List, name: String, showApply: Boolean, isMultiSelect: Boolean, callback: (List) -> Unit, dismissCallback: () -> Unit, itemLayout: Int = R.layout.sort_bottom_single_choice ) { if (this == null) return val realShowApply = showApply || isMultiSelect val listView = binding.listview1 val textView = binding.text1 val applyButton = binding.applyBtt val cancelButton = binding.cancelBtt val applyHolder = binding.applyBttHolder if (isLayout(PHONE or EMULATOR) && dialog is BottomSheetDialog) { binding.dragHandle.isVisible = true listView.isNestedScrollingEnabled = true } applyHolder.isVisible = realShowApply if (!realShowApply) { val params = listView.layoutParams as LinearLayout.LayoutParams params.setMargins(listView.marginLeft, listView.marginTop, listView.marginRight, 0) listView.layoutParams = params } textView.text = name textView.isGone = name.isBlank() val arrayAdapter = ArrayAdapter(this, itemLayout) arrayAdapter.addAll(items) listView.adapter = arrayAdapter if (isMultiSelect) { listView.choiceMode = AbsListView.CHOICE_MODE_MULTIPLE } else { listView.choiceMode = AbsListView.CHOICE_MODE_SINGLE } for (select in selectedIndex) { listView.setItemChecked(select, true) } selectedIndex.minOrNull()?.let { listView.setSelection(it) } // var lastSelectedIndex = if(selectedIndex.isNotEmpty()) selectedIndex.first() else -1 dialog.setOnDismissListener { dismissCallback.invoke() } listView.setOnItemClickListener { _, _, which, _ -> // lastSelectedIndex = which if (realShowApply) { if (!isMultiSelect) { listView.setItemChecked(which, true) } } else { callback.invoke(listOf(which)) dialog.dismissSafe(this) } } if (realShowApply) { applyButton.setOnClickListener { val list = ArrayList() for (index in 0 until listView.count) { if (listView.checkedItemPositions[index]) list.add(index) } callback.invoke(list) dialog.dismissSafe(this) } cancelButton.setOnClickListener { dialog.dismissSafe(this) } } } private fun Activity?.showInputDialog( binding: BottomInputDialogBinding, dialog: Dialog, value: String, name: String, textInputType: Int?, callback: (String) -> Unit, dismissCallback: () -> Unit ) { if (this == null) return val inputView = binding.nginxTextInput val textView = binding.text1 val applyButton = binding.applyBtt val cancelButton = binding.cancelBtt val applyHolder = binding.applyBttHolder applyHolder.isVisible = true textView.text = name if (textInputType != null) { inputView.inputType = textInputType // 16 for website url input type } inputView.setText(value, TextView.BufferType.EDITABLE) applyButton.setOnClickListener { callback.invoke(inputView.text.toString()) // try to save the setting, using callback dialog.dismissSafe(this) } cancelButton.setOnClickListener { // just dismiss dialog.dismissSafe(this) } dialog.setOnDismissListener { dismissCallback.invoke() } } fun Activity?.showMultiDialog( items: List, selectedIndex: List, name: String, dismissCallback: () -> Unit, callback: (List) -> Unit, ) { if (this == null) return val binding: BottomSelectionDialogBinding = BottomSelectionDialogBinding.inflate( LayoutInflater.from(this) ) val builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) .setView(binding.root) val dialog = builder.create() dialog.show() showDialog( binding, dialog, items, selectedIndex, name, showApply = true, isMultiSelect = true, callback, dismissCallback ) } fun Activity?.showDialog( items: List, selectedIndex: Int, name: String, showApply: Boolean, dismissCallback: () -> Unit, callback: (Int) -> Unit, ) { if (this == null) return val binding: BottomSelectionDialogBinding = BottomSelectionDialogBinding.inflate( LayoutInflater.from(this) ) val builder = AlertDialog.Builder(this, R.style.AlertDialogCustom) .setView(binding.root) val dialog = builder.create() dialog.show() showDialog( binding, dialog, items, listOf(selectedIndex), name, showApply, false, { if (it.isNotEmpty()) callback.invoke(it.first()) }, dismissCallback ) } /** Only for a low amount of items */ fun Activity?.showBottomDialog( items: List, selectedIndex: Int, name: String, showApply: Boolean, dismissCallback: () -> Unit, callback: (Int) -> Unit, ) { if (this == null) return val binding: BottomSelectionDialogBinding = BottomSelectionDialogBinding.inflate( LayoutInflater.from(this) ) val builder = BottomSheetDialog(this) builder.setContentView(binding.root) builder.show() showDialog( binding, builder, items, listOf(selectedIndex), name, showApply, false, { if (it.isNotEmpty()) callback.invoke(it.first()) }, dismissCallback ) } fun Activity.showBottomDialogInstant( items: List, name: String, dismissCallback: () -> Unit, callback: (Int) -> Unit, ): BottomSheetDialog { val builder = BottomSheetDialog(this) val binding: BottomSelectionDialogBinding = BottomSelectionDialogBinding.inflate( LayoutInflater.from(this) ) //builder.setContentView(R.layout.bottom_selection_dialog_direct) builder.setContentView(binding.root) builder.show() showDialog( binding, builder, items, emptyList(), name, showApply = false, isMultiSelect = false, callback = { if (it.isNotEmpty()) callback.invoke(it.first()) }, dismissCallback = dismissCallback, itemLayout = R.layout.sort_bottom_single_choice_no_checkmark ) return builder } fun Activity.showNginxTextInputDialog( name: String, value: String, textInputType: Int?, dismissCallback: () -> Unit, callback: (String) -> Unit, ) { val builder = BottomSheetDialog(this) val binding: BottomInputDialogBinding = BottomInputDialogBinding.inflate( LayoutInflater.from(this) ) builder.setContentView(binding.root) builder.show() showInputDialog( binding, builder, value, name, textInputType, // type is a uri callback, dismissCallback ) } fun Activity.showBottomDialogText( title: String, text: Spanned, dismissCallback: () -> Unit ) { val binding = BottomTextDialogBinding.inflate(layoutInflater) val dialog = BottomSheetDialog(this) dialog.setContentView(binding.root) binding.dialogTitle.text = title binding.dialogText.text = text dialog.setOnDismissListener { dismissCallback.invoke() } dialog.show() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/SnackbarHelper.kt ================================================ package com.lagradost.cloudstream3.utils import android.app.Activity import android.view.View import androidx.annotation.MainThread import androidx.annotation.StringRes import com.google.android.material.snackbar.Snackbar import com.lagradost.api.Log import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute object SnackbarHelper { private const val TAG = "COMPACT" private var currentSnackbar: Snackbar? = null @MainThread fun showSnackbar( act: Activity?, message: UiText, duration: Int = Snackbar.LENGTH_SHORT, actionText: UiText? = null, actionCallback: (() -> Unit)? = null ) { if (act == null) return showSnackbar(act, message.asString(act), duration, actionText?.asString(act), actionCallback) } @MainThread fun showSnackbar( act: Activity?, @StringRes message: Int, duration: Int = Snackbar.LENGTH_SHORT, @StringRes actionText: Int? = null, actionCallback: (() -> Unit)? = null ) { if (act == null) return showSnackbar(act, act.getString(message), duration, actionText?.let { act.getString(it) }, actionCallback) } @MainThread fun showSnackbar( act: Activity?, message: String?, duration: Int = Snackbar.LENGTH_SHORT, actionText: String? = null, actionCallback: (() -> Unit)? = null ) { if (act == null || message == null) { Log.w(TAG, "Invalid showSnackbar: act = $act, message = $message") return } Log.i(TAG, "showSnackbar: $message") try { currentSnackbar?.dismiss() } catch (e: Exception) { logError(e) } try { val parentView = act.findViewById(android.R.id.content) val snackbar = Snackbar.make(parentView, message, duration) actionCallback?.let { snackbar.setAction(actionText) { actionCallback.invoke() } } snackbar.show() currentSnackbar = snackbar snackbar.setBackgroundTint(act.colorFromAttribute(R.attr.primaryBlackBackground)) snackbar.setTextColor(act.colorFromAttribute(R.attr.textColor)) snackbar.setActionTextColor(act.colorFromAttribute(R.attr.colorPrimary)) } catch (e: Exception) { logError(e) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/SubtitleUtils.kt ================================================ package com.lagradost.cloudstream3.utils import android.content.Context import com.lagradost.api.Log import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.basePathToFile import com.lagradost.cloudstream3.utils.downloader.DownloadObjects object SubtitleUtils { // Only these files are allowed, so no videos as subtitles private val allowedExtensions = listOf( ".vtt", ".srt", ".txt", ".ass", ".ttml", ".sbv", ".dfxp" ) fun deleteMatchingSubtitles(context: Context, info: DownloadObjects.DownloadedFileInfo) { val cleanDisplay = cleanDisplayName(info.displayName) val base = basePathToFile(context, info.basePath) val folder = base?.gotoDirectory(info.relativePath, createMissingDirectories = false) ?: return val folderFiles = folder.listFiles() ?: return for (file in folderFiles) { val name = file.name() ?: continue if (!isMatchingSubtitle(name, info.displayName, cleanDisplay)) { continue } if (file.delete() != true) { Log.e("SubtitleDeletion", "Failed to delete subtitle file: $name") } } } /** * @param name the file name of the subtitle * @param display the file name of the video * @param cleanDisplay the cleanDisplayName of the video file name */ fun isMatchingSubtitle( name: String, display: String, cleanDisplay: String ): Boolean { // Check if the file has a valid subtitle extension val hasValidExtension = allowedExtensions.any { name.contains(it, ignoreCase = true) } // We can't have the exact same file as a subtitle val isNotDisplayName = !name.equals(display, ignoreCase = true) // Check if the file name starts with a cleaned version of the display name val startsWithCleanDisplay = cleanDisplayName(name).startsWith(cleanDisplay, ignoreCase = true) return hasValidExtension && isNotDisplayName && startsWithCleanDisplay } fun cleanDisplayName(name: String): String { return name.substringBeforeLast('.').trim() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/SyncUtil.kt ================================================ package com.lagradost.cloudstream3.utils // TODO: FIX import android.util.Log import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.APIHolder.apis //import com.lagradost.cloudstream3.animeproviders.AniflixProvider import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.utils.AppUtils.parseJson import java.util.concurrent.TimeUnit object SyncUtil { private val regexs = listOf( Regex("""(9anime)\.(?:to|center|id)/watch/.*?\.([^/?]*)"""), Regex("""(gogoanime|gogoanimes)\..*?/category/([^/?]*)"""), Regex("""(twist\.moe)/a/([^/?]*)"""), ) private const val TAG = "SYNCUTIL" private const val GOGOANIME = "Gogoanime" private const val NINE_ANIME = "9anime" private const val TWIST_MOE = "Twistmoe" private val matchList = mapOf( "9anime" to NINE_ANIME, "gogoanime" to GOGOANIME, "gogoanimes" to GOGOANIME, "twist.moe" to TWIST_MOE ) suspend fun getIdsFromUrl(url: String?): Pair? { if (url == null) return null Log.i(TAG, "getIdsFromUrl $url") for (regex in regexs) { regex.find(url)?.let { match -> if (match.groupValues.size == 3) { val site = match.groupValues[1] val slug = match.groupValues[2] matchList[site]?.let { realSite -> getIdsFromSlug(slug, realSite)?.let { return it } ?: kotlin.run { if (slug.endsWith("-dub")) { println("testing non -dub slug $slug") getIdsFromSlug(slug.removeSuffix("-dub"), realSite)?.let { return it } } } } } } } return null } /** first. Mal, second. Anilist, * valid sites are: Gogoanime, Twistmoe and 9anime*/ private suspend fun getIdsFromSlug( slug: String, site: String = "Gogoanime" ): Pair? { Log.i(TAG, "getIdsFromSlug $slug $site") try { //Gogoanime, Twistmoe and 9anime val url = "https://raw.githubusercontent.com/MALSync/MAL-Sync-Backup/master/data/pages/$site/$slug.json" val response = app.get(url, cacheTime = 1, cacheUnit = TimeUnit.DAYS).text val mapped = parseJson(response) val overrideMal = mapped?.malId ?: mapped?.mal?.id ?: mapped?.anilist?.malId val overrideAnilist = mapped?.aniId ?: mapped?.anilist?.id if (overrideMal != null) { return overrideMal.toString() to overrideAnilist?.toString() } return null } catch (e: Exception) { logError(e) } return null } suspend fun getUrlsFromId(id: String, type: String = "anilist"): List { val url = "https://raw.githubusercontent.com/MALSync/MAL-Sync-Backup/master/data/$type/anime/$id.json" val response = app.get(url, cacheTime = 1, cacheUnit = TimeUnit.DAYS).parsed() val pages = response.pages ?: return emptyList() val current = pages.gogoanime.values.union(pages.nineanime.values).union(pages.twistmoe.values) .mapNotNull { it.url }.toMutableList() if (type == "anilist") { // TODO MAKE BETTER synchronized(apis) { apis.filter { it.name.contains("Aniflix", ignoreCase = true) }.forEach { current.add("${it.mainUrl}/anime/$id") } } } return current } data class SyncPage( @JsonProperty("Pages") val pages: SyncPages?, ) data class SyncPages( @JsonProperty("9anime") val nineanime: Map = emptyMap(), @JsonProperty("Gogoanime") val gogoanime: Map = emptyMap(), @JsonProperty("Twistmoe") val twistmoe: Map = emptyMap(), ) data class ProviderPage( @JsonProperty("url") val url: String?, ) data class MalSyncPage( @JsonProperty("identifier") val identifier: String?, @JsonProperty("type") val type: String?, @JsonProperty("page") val page: String?, @JsonProperty("title") val title: String?, @JsonProperty("url") val url: String?, @JsonProperty("image") val image: String?, @JsonProperty("hentai") val hentai: Boolean?, @JsonProperty("sticky") val sticky: Boolean?, @JsonProperty("active") val active: Boolean?, @JsonProperty("actor") val actor: String?, @JsonProperty("malId") val malId: Int?, @JsonProperty("aniId") val aniId: Int?, @JsonProperty("createdAt") val createdAt: String?, @JsonProperty("updatedAt") val updatedAt: String?, @JsonProperty("deletedAt") val deletedAt: String?, @JsonProperty("Mal") val mal: Mal?, @JsonProperty("Anilist") val anilist: Anilist?, @JsonProperty("malUrl") val malUrl: String? ) data class Anilist( // @JsonProperty("altTitle") val altTitle: List?, // @JsonProperty("externalLinks") val externalLinks: List?, @JsonProperty("id") val id: Int?, @JsonProperty("malId") val malId: Int?, @JsonProperty("type") val type: String?, @JsonProperty("title") val title: String?, @JsonProperty("url") val url: String?, @JsonProperty("image") val image: String?, @JsonProperty("category") val category: String?, @JsonProperty("hentai") val hentai: Boolean?, @JsonProperty("createdAt") val createdAt: String?, @JsonProperty("updatedAt") val updatedAt: String?, @JsonProperty("deletedAt") val deletedAt: String? ) data class Mal( // @JsonProperty("altTitle") val altTitle: List?, @JsonProperty("id") val id: Int?, @JsonProperty("type") val type: String?, @JsonProperty("title") val title: String?, @JsonProperty("url") val url: String?, @JsonProperty("image") val image: String?, @JsonProperty("category") val category: String?, @JsonProperty("hentai") val hentai: Boolean?, @JsonProperty("createdAt") val createdAt: String?, @JsonProperty("updatedAt") val updatedAt: String?, @JsonProperty("deletedAt") val deletedAt: String? ) } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/TestingUtils.kt ================================================ package com.lagradost.cloudstream3.utils import com.lagradost.cloudstream3.* import com.lagradost.cloudstream3.mvvm.logError import kotlinx.coroutines.* import org.junit.Assert import kotlin.random.Random object TestingUtils { open class TestResult(val success: Boolean) { companion object { val Pass = TestResult(true) val Fail = TestResult(false) } } class Logger { enum class LogLevel { Normal, Warning, Error; } data class Message(val level: LogLevel, val message: String) { override fun toString(): String { val level = when (this.level) { LogLevel.Normal -> "" LogLevel.Warning -> "Warning: " LogLevel.Error -> "Error: " } return "$level$message" } } private val messageLog = mutableListOf() fun getRawLog(): List = messageLog fun log(message: String) { messageLog.add(Message(LogLevel.Normal, message)) } fun warn(message: String) { messageLog.add(Message(LogLevel.Warning, message)) } fun error(message: String) { messageLog.add(Message(LogLevel.Error, message)) } } class TestResultList(val results: List) : TestResult(true) class TestResultLoad(val extractorData: String, val shouldLoadLinks: Boolean) : TestResult(true) class TestResultProvider( success: Boolean, val log: List, val exception: Throwable? ) : TestResult(success) @Throws(AssertionError::class, CancellationException::class) suspend fun testHomepage( api: MainAPI, logger: Logger ): TestResult { if (api.hasMainPage) { try { val f = api.mainPage.first() val homepage = api.getMainPage(1, MainPageRequest(f.name, f.data, f.horizontalImages)) when { homepage == null -> { logger.error("Provider ${api.name} did not correctly load homepage!") } homepage.items.isEmpty() -> { logger.warn("Provider ${api.name} does not contain any homepage rows!") } homepage.items.any { it.list.isEmpty() } -> { logger.warn("Provider ${api.name} does not have any items in a homepage row!") } } val homePageList = homepage?.items?.flatMap { it.list } ?: emptyList() return TestResultList(homePageList) } catch (e: Throwable) { when (e) { is NotImplementedError -> { Assert.fail("Provider marked as hasMainPage, while in reality is has not been implemented") } is CancellationException -> { throw e } else -> { e.message?.let { logger.warn("Exception thrown when loading homepage: \"$it\"") } } } } } return TestResult.Pass } @Throws(AssertionError::class, CancellationException::class) private suspend fun testSearch( api: MainAPI, testQueries: List, logger: Logger, ): TestResult { val searchResults = testQueries.firstNotNullOfOrNull { query -> try { logger.log("Searching for: $query") api.search(query, 1)?.items?.takeIf { it.isNotEmpty() } } catch (e: Throwable) { if (e is NotImplementedError) { Assert.fail("Provider has not implemented search()") } else if (e is CancellationException) { throw e } logError(e) null } } return if (searchResults.isNullOrEmpty()) { Assert.fail("Api ${api.name} did not return any search responses") TestResult.Fail // Should not be reached } else { TestResultList(searchResults) } } @Throws(AssertionError::class, CancellationException::class) private suspend fun testLoad( api: MainAPI, result: SearchResponse, logger: Logger ): TestResult { try { if (result.apiName != api.name) { logger.warn("Wrong apiName on SearchResponse: ${api.name} != ${result.apiName}") } val loadResponse = api.load(result.url) if (loadResponse == null) { logger.error("Returned null loadResponse on ${result.url} on ${api.name}") return TestResult.Fail } if (loadResponse.apiName != api.name) { logger.warn("Wrong apiName on LoadResponse: ${api.name} != ${loadResponse.apiName}") } if (!api.supportedTypes.contains(loadResponse.type)) { logger.warn("Api ${api.name} on load does not contain any of the supportedTypes: ${loadResponse.type}") } val url = when (loadResponse) { is AnimeLoadResponse -> { val gotNoEpisodes = loadResponse.episodes.keys.isEmpty() || loadResponse.episodes.keys.any { loadResponse.episodes[it].isNullOrEmpty() } if (gotNoEpisodes) { logger.error("Api ${api.name} got no episodes on ${loadResponse.url}") return TestResult.Fail } (loadResponse.episodes[loadResponse.episodes.keys.firstOrNull()])?.firstOrNull()?.data } is MovieLoadResponse -> { val gotNoEpisodes = loadResponse.dataUrl.isBlank() if (gotNoEpisodes) { logger.error("Api ${api.name} got no movie on ${loadResponse.url}") return TestResult.Fail } loadResponse.dataUrl } is TvSeriesLoadResponse -> { val gotNoEpisodes = loadResponse.episodes.isEmpty() if (gotNoEpisodes) { logger.error("Api ${api.name} got no episodes on ${loadResponse.url}") return TestResult.Fail } loadResponse.episodes.firstOrNull()?.data } is LiveStreamLoadResponse -> { loadResponse.dataUrl } else -> { logger.error("Unknown load response: ${loadResponse.javaClass.name}") return TestResult.Fail } } ?: return TestResult.Fail return TestResultLoad(url, loadResponse.type != TvType.CustomMedia) // val loadTest = testLoadResponse(api, load, logger) // if (loadTest is TestResultLoad) { // testLinkLoading(api, loadTest.extractorData, logger).success // } else { // false // } // if (!validResults) { // logger("Api ${api.name} did not load on the first search results: ${smallSearchResults.map { it.name }}") // } // return TestResult(validResults) } catch (e: Throwable) { if (e is NotImplementedError) { Assert.fail("Provider has not implemented load()") } throw e } } @Throws(AssertionError::class, CancellationException::class) private suspend fun testLinkLoading( api: MainAPI, url: String?, logger: Logger ): TestResult { Assert.assertNotNull("Api ${api.name} has invalid url on episode", url) if (url == null) return TestResult.Fail // Should never trigger var linksLoaded = 0 try { val success = api.loadLinks(url, false, {}) { link -> logger.log("Video loaded: ${link.name}") Assert.assertTrue( "Api ${api.name} returns link with invalid url ${link.url}", link.url.length > 4 ) linksLoaded++ } if (success) { logger.log("Links loaded: $linksLoaded") return TestResult(linksLoaded > 0) } else { Assert.fail("Api ${api.name} returns false on loadLinks() with $linksLoaded links loaded") } } catch (e: Throwable) { when (e) { is NotImplementedError -> { Assert.fail("Provider has not implemented loadLinks()") } else -> { logger.error("Failed link loading on ${api.name} using data: $url") throw e } } } return TestResult.Pass } fun getDeferredProviderTests( scope: CoroutineScope, providers: Array, callback: (MainAPI, TestResultProvider) -> Unit ) { providers.forEach { api -> scope.launch { val logger = Logger() val result = try { logger.log("Trying ${api.name}") // Test Homepage val homepage = testHomepage(api, logger) Assert.assertTrue("Homepage failed to load", homepage.success) val homePageList = (homepage as? TestResultList)?.results ?: emptyList() // Test Search Results val searchQueries = // Use the random 3 home page results as queries since they are guaranteed to exist (homePageList.shuffled(Random).take(3).map { it.name.split(" ").first() } + // If home page is sparse then use generic search queries listOf("over", "iron", "guy")).take(3) val searchResults = testSearch(api, searchQueries, logger) Assert.assertTrue("Failed to get search results", searchResults.success) searchResults as TestResultList // Test Load and LoadLinks // Only try the first 3 search results to prevent spamming val success = searchResults.results.take(3).any { searchResponse -> logger.log("Testing search result: ${searchResponse.url}") val loadResponse = testLoad(api, searchResponse, logger) if (loadResponse !is TestResultLoad) { false } else { if (loadResponse.shouldLoadLinks) { testLinkLoading(api, loadResponse.extractorData, logger).success } else { logger.log("Skipping link loading test") true } } } if (success) { logger.log("Success ${api.name}") TestResultProvider(true, logger.getRawLog(), null) } else { logger.error("Link loading failed") TestResultProvider(false, logger.getRawLog(), null) } } catch (e: Throwable) { TestResultProvider(false, logger.getRawLog(), e) } callback.invoke(api, result) } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/TextUtil.kt ================================================ package com.lagradost.cloudstream3.utils import android.content.Context import android.util.Log import android.widget.TextView import androidx.annotation.StringRes import androidx.core.view.isGone import androidx.core.view.isVisible import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.utils.AppContextUtils.html sealed class UiText { companion object { const val TAG = "UiText" } data class DynamicString(val value: String) : UiText() { override fun toString(): String = value override fun equals(other: Any?): Boolean { if (other !is DynamicString) return false return this.value == other.value } override fun hashCode(): Int = value.hashCode() } class StringResource( @StringRes val resId: Int, val args: List ) : UiText() { override fun toString(): String = "resId = $resId\nargs = ${args.toList().map { "(${it::class} = $it)" }}" override fun equals(other: Any?): Boolean { if (other !is StringResource) return false return this.resId == other.resId && this.args == other.args } override fun hashCode(): Int { var result = resId result = 31 * result + args.hashCode() return result } } fun asStringNull(context: Context?): String? { try { return asString(context ?: return null) } catch (e: Exception) { Log.e(TAG, "Got invalid data from $this") logError(e) return null } } fun asString(context: Context): String { return when (this) { is DynamicString -> value is StringResource -> { val str = context.getString(resId) if (args.isEmpty()) { str } else { str.format(*args.map { when (it) { is UiText -> it.asString(context) else -> it } }.toTypedArray()) } } } } } fun txt(value: String): UiText { return UiText.DynamicString(value) } @JvmName("txtNull") fun txt(value: String?): UiText? { return UiText.DynamicString(value ?: return null) } fun txt(@StringRes resId: Int, vararg args: Any): UiText { return UiText.StringResource(resId, args.toList()) } @JvmName("txtNull") fun txt(@StringRes resId: Int?, vararg args: Any?): UiText? { if (resId == null || args.any { it == null }) { return null } return UiText.StringResource(resId, args.filterNotNull().toList()) } fun TextView?.setText(text: UiText?) { if (this == null) return if (text == null) { this.isVisible = false } else { val str = text.asStringNull(context)?.let { if (this.maxLines == 1) { it.replace("\n", " ") } else { it } } this.isGone = str.isNullOrBlank() this.text = str } } fun TextView?.setTextHtml(text: UiText?) { if (this == null) return if (text == null) { this.isVisible = false } else { val str = text.asStringNull(context) this.isGone = str.isNullOrBlank() this.text = str.html() } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/TvChannelUtils.kt ================================================ package com.lagradost.cloudstream3.utils import android.annotation.SuppressLint import android.content.ComponentName import android.content.ContentUris import android.content.Context import android.content.Intent import android.util.Log import androidx.core.net.toUri import androidx.tvprovider.media.tv.Channel import androidx.tvprovider.media.tv.PreviewProgram import androidx.tvprovider.media.tv.TvContractCompat import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.SearchResponse import com.lagradost.cloudstream3.base64Encode import com.lagradost.cloudstream3.syncproviders.AccountManager.Companion.APP_STRING_SHARE import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.setKey import java.net.URLEncoder const val PROGRAM_ID_LIST_KEY = "persistent_program_ids" object TvChannelUtils { fun Context.saveProgramId(programId: Long) { val existing: List = getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() val updated = (existing + programId).distinct() setKey(PROGRAM_ID_LIST_KEY, updated) } fun Context.getStoredProgramIds(): List { return getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() } fun Context.removeProgramId(programId: Long) { val existing: List = getKey(PROGRAM_ID_LIST_KEY) ?: emptyList() val updated = existing.filter { it != programId } setKey(PROGRAM_ID_LIST_KEY, updated) } fun getChannelId(context: Context, channelName: String): Long? { return try { context.contentResolver.query( TvContractCompat.Channels.CONTENT_URI, arrayOf( TvContractCompat.Channels._ID, TvContractCompat.Channels.COLUMN_DISPLAY_NAME ), null, null, null )?.use { cursor -> while (cursor.moveToNext()) { val id = cursor.getLong( cursor.getColumnIndexOrThrow(TvContractCompat.Channels._ID) ) val name = cursor.getString( cursor.getColumnIndexOrThrow(TvContractCompat.Channels.COLUMN_DISPLAY_NAME) ) if (name == channelName) return id } null } } catch (e: Exception) { Log.e("TvChannelUtils", "Query failed: ${e.message}", e) null } } /** Insert programs into a channel */ @SuppressLint("RestrictedApi") fun addPrograms(context: Context, channelId: Long, items: List) { for (item in items) { try { val nameBase64 = base64Encode(item.apiName.toByteArray(Charsets.UTF_8)) val urlBase64 = base64Encode(item.url.toByteArray(Charsets.UTF_8)) val csshareUri = "$APP_STRING_SHARE:$nameBase64?$urlBase64" val poster=item.posterUrl val builder = PreviewProgram.Builder() .setChannelId(channelId) .setTitle(item.name) .apply { val scoreText = item.score?.toStringNull(0.1, 10, 1)?.let { " - " + txt(R.string.rating_format, it).asString(context) } ?: "" setDescription("${item.apiName}$scoreText") } .setContentId(item.url) .setType(TvContractCompat.PreviewPrograms.TYPE_MOVIE) .setIntentUri(csshareUri.toUri()) .setPosterArtAspectRatio(TvContractCompat.PreviewPrograms.ASPECT_RATIO_2_3) // Validate poster URL before setting if (!poster.isNullOrBlank() && poster.startsWith("http")) { builder.setPosterArtUri(poster.toUri()) } val program = builder.build() val uri = context.contentResolver.insert( TvContractCompat.PreviewPrograms.CONTENT_URI, program.toContentValues() ) if (uri != null) { val programId = ContentUris.parseId(uri) context.saveProgramId(programId) Log.d("TvChannelUtils", "Inserted program ${item.name}, ID=$programId") } else { Log.e("TvChannelUtils", "Insert failed for ${item.name}") } } catch (error: Exception) { Log.e("TvChannelUtils", "Error inserting ${item.name}: $error") } } } fun deleteStoredPrograms(context: Context) { val programIds = context.getStoredProgramIds() for (id in programIds) { val uri = ContentUris.withAppendedId(TvContractCompat.PreviewPrograms.CONTENT_URI, id) try { val rowsDeleted = context.contentResolver.delete(uri, null, null) if (rowsDeleted > 0) { context.removeProgramId(id) // Remove from persistent list } else { Log.w("ProgramDelete", "No program found for ID: $id") } } catch (e: Exception) { Log.e("ProgramDelete", "Failed to delete program ID: $id", e) } } Log.d("ProgramDelete", "Finished deleting stored programs") } fun createTvChannel(context: Context) { val componentName = ComponentName(context, MainActivity::class.java) val iconUri = "android.resource://${context.packageName}/mipmap/ic_launcher".toUri() val inputId = TvContractCompat.buildInputId(componentName) val channel = Channel.Builder() .setType(TvContractCompat.Channels.TYPE_PREVIEW) .setAppLinkIconUri(iconUri) .setDisplayName(context.getString(R.string.app_name)) .setAppLinkIntent(Intent(Intent.ACTION_VIEW).apply { data = "cloudstreamapp://open".toUri() }) .setInputId(inputId) .build() val channelUri = context.contentResolver.insert( TvContractCompat.Channels.CONTENT_URI, channel.toContentValues() ) channelUri?.let { val channelId = ContentUris.parseId(it) TvContractCompat.requestChannelBrowsable(context, channelId) Log.d("TvChannelUtils", "Channel Created: $channelId") } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/UIHelper.kt ================================================ package com.lagradost.cloudstream3.utils import android.Manifest import android.annotation.SuppressLint import android.app.Activity import android.app.AppOpsManager import android.app.Dialog import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.content.res.Configuration import android.content.res.Resources import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Color import android.graphics.ColorFilter import android.graphics.Paint import android.graphics.PixelFormat import android.graphics.drawable.Drawable import android.os.Build import android.os.Bundle import android.os.TransactionTooLargeException import android.util.Log import android.view.Gravity import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.view.ViewGroup.MarginLayoutParams import android.view.WindowManager import android.view.inputmethod.InputMethodManager import android.widget.ListAdapter import android.widget.ListView import android.widget.Toast.LENGTH_LONG import androidx.annotation.AttrRes import androidx.annotation.ColorInt import androidx.annotation.DimenRes import androidx.annotation.StyleRes import androidx.appcompat.view.ContextThemeWrapper import androidx.appcompat.view.menu.MenuBuilder import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.core.content.withStyledAttributes import androidx.core.graphics.alpha import androidx.core.graphics.blue import androidx.core.graphics.green import androidx.core.graphics.red import androidx.core.view.marginBottom import androidx.core.view.marginLeft import androidx.core.view.marginRight import androidx.core.view.marginTop import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.navigation.NavOptions import androidx.navigation.fragment.NavHostFragment import androidx.palette.graphics.Palette import androidx.preference.PreferenceManager import com.google.android.material.appbar.AppBarLayout import com.google.android.material.chip.Chip import com.google.android.material.chip.ChipDrawable import com.google.android.material.chip.ChipGroup import com.lagradost.cloudstream3.CloudStreamApp.Companion.context import com.lagradost.cloudstream3.CommonActivity.activity import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.ui.settings.Globals.EMULATOR import com.lagradost.cloudstream3.ui.settings.Globals.PHONE import com.lagradost.cloudstream3.ui.settings.Globals.TV import com.lagradost.cloudstream3.ui.settings.Globals.isLayout import com.lagradost.cloudstream3.utils.AppContextUtils.isRtl import com.lagradost.cloudstream3.utils.Coroutines.main import kotlinx.coroutines.delay import kotlin.math.roundToInt import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.disableBackPressedCallback import com.lagradost.cloudstream3.utils.BackPressedCallbackHelper.enableBackPressedCallback object UIHelper { val Int.toPx: Int get() = (this * Resources.getSystem().displayMetrics.density).toInt() val Float.toPx: Float get() = (this * Resources.getSystem().displayMetrics.density) val Int.toDp: Int get() = (this / Resources.getSystem().displayMetrics.density).toInt() val Float.toDp: Float get() = (this / Resources.getSystem().displayMetrics.density) fun Context.checkWrite(): Boolean { return (ContextCompat.checkSelfPermission( this, Manifest.permission.WRITE_EXTERNAL_STORAGE ) == PackageManager.PERMISSION_GRANTED // Since Android 13, we can't request external storage permission, // so don't check it. || Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) } fun populateChips( view: ChipGroup?, tags: List, @StyleRes style: Int = R.style.ChipFilled, @AttrRes textColor: Int? = R.attr.white, ) { if (view == null) return view.removeAllViews() val context = view.context ?: return val maxTags = tags.take(10) // Limited because they are too much maxTags.forEach { tag -> val chip = Chip(context) val chipDrawable = ChipDrawable.createFromAttributes( context, null, 0, style ) chip.setChipDrawable(chipDrawable) chip.text = tag chip.isChecked = false chip.isCheckable = false chip.isFocusable = false chip.isClickable = false textColor?.let { chip.setTextColor(context.colorFromAttribute(it)) } view.addView(chip) } } fun Activity.requestRW() { ActivityCompat.requestPermissions( this, arrayOf( Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.MANAGE_EXTERNAL_STORAGE ), 1337 ) } fun clipboardHelper(label: UiText, text: CharSequence) { val ctx = context ?: return try { ctx.let { val clip = ClipData.newPlainText(label.asString(ctx), text) val labelSuffix = txt(R.string.toast_copied).asString(ctx) ctx.getSystemService()?.setPrimaryClip(clip) if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { showToast("${label.asString(ctx)} $labelSuffix") } } } catch (t: Throwable) { Log.e("ClipboardService", "$t") when (t) { is SecurityException -> { showToast(R.string.clipboard_permission_error) } is TransactionTooLargeException -> { showToast(R.string.clipboard_too_large) } else -> { showToast(R.string.clipboard_unknown_error, LENGTH_LONG) } } } } /** * Sets ListView height dynamically based on the height of the items. * * @param listView to be resized * @return true if the listView is successfully resized, false otherwise */ fun setListViewHeightBasedOnItems(listView: ListView?) { val listAdapter: ListAdapter = listView?.adapter ?: return val numberOfItems: Int = listAdapter.count // Get total height of all items. var totalItemsHeight = 0 for (itemPos in 0 until numberOfItems) { val item: View = listAdapter.getView(itemPos, null, listView) item.measure(0, 0) totalItemsHeight += item.measuredHeight } // Get total height of all item dividers. val totalDividersHeight: Int = listView.dividerHeight * (numberOfItems - 1) // Set list height. val params: ViewGroup.LayoutParams = listView.layoutParams params.height = totalItemsHeight + totalDividersHeight listView.layoutParams = params listView.requestLayout() } fun Context.getSpanCount(isHorizontal:Boolean=false): Int { // val compactView = false val spanCountLandscape = if (isHorizontal) 3 else 6 val spanCountPortrait = if (isHorizontal) 2 else 3 val orientation = resources.configuration.orientation return if (orientation == Configuration.ORIENTATION_LANDSCAPE) { spanCountLandscape } else spanCountPortrait } fun Fragment.hideKeyboard() { activity?.window?.decorView?.clearFocus() view?.let { hideKeyboard(it) } } fun View?.setAppBarNoScrollFlagsOnTV() { if (isLayout(TV or EMULATOR)) { this?.updateLayoutParams { scrollFlags = AppBarLayout.LayoutParams.SCROLL_FLAG_NO_SCROLL } } } fun Activity.hideKeyboard() { window?.decorView?.clearFocus() this.findViewById(android.R.id.content)?.rootView?.let { hideKeyboard(it) } } fun Activity?.navigate( navigationId: Int, args: Bundle? = null, navOptions: NavOptions? = null // To control nav graph & manage back stack ) { val tag = "NavComponent" if (this is FragmentActivity) { try { runOnUiThread { // Navigate using navigation ID val navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment Log.i(tag, "Navigating to fragment: $navigationId") navHostFragment?.navController?.navigate(navigationId, args, navOptions) } } catch (t: Throwable) { logError(t) } } } // Open activities from an activity outside the nav graph fun Context.openActivity(activity: Class<*>, args: Bundle? = null) { val tag = "NavComponent" try { val intent = Intent(this, activity) if (args != null) { intent.putExtras(args) } Log.i(tag, "Navigating to Activity: ${activity.simpleName}") startActivity(intent) } catch (t: Throwable) { logError(t) } } /** If you want to call this from a BackPressedCallback, pass the name of the callback to temporarily disable it */ fun FragmentActivity.popCurrentPage(fromBackPressedCallback: String? = null) { // Use the main looper handler to post actions on the main thread main { // Post the back press action to the main thread handler to ensure it executes // after any currently pending UI updates or fragment transactions. if (fromBackPressedCallback != null) { disableBackPressedCallback(fromBackPressedCallback) } if (!supportFragmentManager.isStateSaved) { // Get the top fragment from the back stack Log.d("popFragment", "Destroying Fragment") // If the state is not saved, it's safe to perform the back press action. onBackPressedDispatcher.onBackPressed() } else { // If the state is saved, retry the back press action after a slight delay. // This gives the FragmentManager time to complete any ongoing state-saving // operations or transactions, ensuring that we do not encounter an IllegalStateException. delay(100) if (!supportFragmentManager.isStateSaved) { Log.d("popFragment", "Destroying after delay") onBackPressedDispatcher.onBackPressed() } } if (fromBackPressedCallback != null) { enableBackPressedCallback(fromBackPressedCallback) } } } @ColorInt fun Context.getResourceColor(@AttrRes resource: Int, alphaFactor: Float = 1f): Int { val color = colorFromAttribute(resource) return if (alphaFactor < 1f) adjustAlpha(color, alphaFactor) else color } @ColorInt fun Context.colorFromAttribute(@AttrRes attribute: Int): Int { var color = 0 withStyledAttributes(attrs = intArrayOf(attribute)) { color = getColor(0, 0) } return color } @ColorInt fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { val alpha = (color.alpha * factor).roundToInt() return Color.argb(alpha, color.red, color.green, color.blue) } var createPaletteAsyncCache: HashMap = hashMapOf() fun createPaletteAsync(url: String, bitmap: Bitmap, callback: (Palette) -> Unit) { createPaletteAsyncCache[url]?.let { palette -> callback.invoke(palette) return } Palette.from(bitmap).generate { paletteNull -> paletteNull?.let { palette -> createPaletteAsyncCache[url] = palette callback(palette) } } } fun Activity.hideSystemUI() { // Enables regular immersive mode. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val controller = WindowCompat.getInsetsController(window, window.decorView) controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE controller.hide(WindowInsetsCompat.Type.systemBars()) return } // For "lean back" mode, remove SYSTEM_UI_FLAG_IMMERSIVE. // Or for "sticky immersive," replace it with SYSTEM_UI_FLAG_IMMERSIVE_STICKY @Suppress("DEPRECATION") window.decorView.systemUiVisibility = ( View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY // Set the content to appear under the system bars so that the // content doesn't resize when the system bars hide and show. or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN // Hide the nav bar and status bar or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or View.SYSTEM_UI_FLAG_FULLSCREEN ) } fun Activity.enableEdgeToEdgeCompat() { // edge-to-edge is very buggy on earlier versions if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return WindowCompat.enableEdgeToEdge(window) } fun Activity.setNavigationBarColorCompat(@AttrRes resourceId: Int) { // edge-to-edge handles this if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) return @Suppress("DEPRECATION") window?.navigationBarColor = colorFromAttribute(resourceId) } fun Context.getStatusBarHeight(): Int { if (isLayout(TV or EMULATOR)) { return 0 } var result = 0 val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android") if (resourceId > 0) { result = resources.getDimensionPixelSize(resourceId) } return result } fun fixPaddingStatusbarMargin(v: View?) { if (v == null) return val ctx = v.context ?: return v.layoutParams = v.layoutParams.apply { if (this is MarginLayoutParams) { setMargins( v.marginLeft, v.marginTop + ctx.getStatusBarHeight(), v.marginRight, v.marginBottom ) } } } fun fixPaddingStatusbarView(v: View?) { if (v == null) return val ctx = v.context ?: return val params = v.layoutParams params.height = ctx.getStatusBarHeight() v.layoutParams = params } fun fixSystemBarsPadding( v: View, @DimenRes heightResId: Int? = null, @DimenRes widthResId: Int? = null, padTop: Boolean = true, padBottom: Boolean = true, padLeft: Boolean = true, padRight: Boolean = true, overlayCutout: Boolean = true, fixIme: Boolean = false ) { // edge-to-edge is very buggy on earlier versions so we just // handle the status bar here instead. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { if (padTop) { val ctx = v.context ?: return v.updatePadding(top = ctx.getStatusBarHeight()) } return } ViewCompat.setOnApplyWindowInsetsListener(v) { view, windowInsets -> val leftCheck = if (view.isRtl()) padRight else padLeft val rightCheck = if (view.isRtl()) padLeft else padRight val insetTypes = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() or if (fixIme) WindowInsetsCompat.Type.ime() else 0 val insets = windowInsets.getInsets(insetTypes) view.updatePadding( left = if (leftCheck) insets.left else view.paddingLeft, right = if (rightCheck) insets.right else view.paddingRight, bottom = if (padBottom) insets.bottom else view.paddingBottom, top = if (padTop) insets.top else view.paddingTop ) heightResId?.let { val heightPx = view.resources.getDimensionPixelSize(it) view.updateLayoutParams { height = heightPx + insets.bottom } } widthResId?.let { val widthPx = view.resources.getDimensionPixelSize(it) view.updateLayoutParams { val startInset = if (view.isRtl()) insets.right else insets.left width = if (startInset > 0) widthPx + startInset else widthPx } } if (overlayCutout && isLayout(PHONE)) { // Draw a black overlay over the cutout. We do this so that // it doesn't use the fragment background. We want it to // appear as if the screen actually ends at cutout. val cutout = windowInsets.displayCutout if (cutout != null) { val left = if (!leftCheck) 0 else cutout.safeInsetLeft val right = if (!rightCheck) 0 else cutout.safeInsetRight view.overlay.clear() if (left > 0 || right > 0) { view.overlay.add( CutoutOverlayDrawable( view, leftCutout = left, rightCutout = right ) ) } } } WindowInsetsCompat.CONSUMED } } fun Context.getNavigationBarHeight(): Int { var result = 0 val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android") if (resourceId > 0) { result = resources.getDimensionPixelSize(resourceId) } return result } fun Context?.isBottomLayout(): Boolean { if (this == null) return true val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) return settingsManager.getBoolean(getString(R.string.bottom_title_key), true) } fun Activity.changeStatusBarState(hide: Boolean) { try { if (hide) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val controller = WindowCompat.getInsetsController(window, window.decorView) controller.hide(WindowInsetsCompat.Type.statusBars()) } else { @Suppress("DEPRECATION") window.setFlags( WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN ) } } else { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val controller = WindowCompat.getInsetsController(window, window.decorView) controller.show(WindowInsetsCompat.Type.statusBars()) } else { @Suppress("DEPRECATION") window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) } } } catch (t: Throwable) { logError(t) } } // Shows the system bars by removing all the flags // except for the ones that make the content appear under the system bars. fun Activity.showSystemUI() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val controller = WindowCompat.getInsetsController(window, window.decorView) if (isLayout(EMULATOR)) { controller.show(WindowInsetsCompat.Type.navigationBars()) controller.hide(WindowInsetsCompat.Type.statusBars()) } else controller.show(WindowInsetsCompat.Type.systemBars()) return } @Suppress("DEPRECATION") window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN) changeStatusBarState(isLayout(EMULATOR)) } fun hideKeyboard(view: View?) { if (view == null) return val inputMethodManager = view.context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager? inputMethodManager?.hideSoftInputFromWindow(view.windowToken, 0) } fun showInputMethod(view: View?) { if (view == null) return val inputMethodManager = view.context.getSystemService(Activity.INPUT_METHOD_SERVICE) as InputMethodManager? inputMethodManager?.showSoftInput(view, 0) } fun Dialog?.dismissSafe(activity: Activity?) { if (this?.isShowing == true && activity?.isFinishing == false) { this.dismiss() } } fun Dialog?.dismissSafe() { if (this?.isShowing == true && activity?.isFinishing != true) { this.dismiss() } } /**id, stringRes */ @SuppressLint("RestrictedApi") fun View.popupMenuNoIcons( items: List>, onMenuItemClick: MenuItem.() -> Unit, ): PopupMenu { val ctw = ContextThemeWrapper(context, R.style.PopupMenu) val popup = PopupMenu( ctw, this, Gravity.NO_GRAVITY, androidx.appcompat.R.attr.actionOverflowMenuStyle, 0 ) items.forEach { (id, stringRes) -> popup.menu.add(0, id, 0, stringRes) } (popup.menu as? MenuBuilder)?.setOptionalIconsVisible(true) popup.setOnMenuItemClickListener { it.onMenuItemClick() true } popup.show() return popup } /**id, string */ @SuppressLint("RestrictedApi") fun View.popupMenuNoIconsAndNoStringRes( items: List>, onMenuItemClick: MenuItem.() -> Unit, ): PopupMenu { val ctw = ContextThemeWrapper(context, R.style.PopupMenu) val popup = PopupMenu( ctw, this, Gravity.NO_GRAVITY, androidx.appcompat.R.attr.actionOverflowMenuStyle, 0 ) items.forEach { (id, string) -> popup.menu.add(0, id, 0, string) } (popup.menu as? MenuBuilder)?.setOptionalIconsVisible(true) popup.setOnMenuItemClickListener { it.onMenuItemClick() true } popup.show() return popup } } private class CutoutOverlayDrawable( private val view: View, private val leftCutout: Int, private val rightCutout: Int, ) : Drawable() { private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLACK style = Paint.Style.FILL } override fun draw(canvas: Canvas) { if (leftCutout > 0) canvas.drawRect( 0f, 0f, leftCutout.toFloat(), view.height.toFloat(), paint ) if (rightCutout > 0) { canvas.drawRect( view.width - rightCutout.toFloat(), 0f, view.width.toFloat(), view.height.toFloat(), paint ) } } override fun setAlpha(alpha: Int) {} override fun setColorFilter(colorFilter: ColorFilter?) {} @Suppress("OVERRIDE_DEPRECATION") override fun getOpacity() = PixelFormat.OPAQUE } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/Vector2.kt ================================================ package com.lagradost.cloudstream3.utils import kotlin.math.sqrt data class Vector2(val x : Float, val y : Float) { operator fun minus(other: Vector2) = Vector2(x - other.x, y - other.y) operator fun plus(other: Vector2) = Vector2(x + other.x, y + other.y) operator fun times(other: Int) = Vector2(x * other, y * other) override fun toString(): String = "($x, $y)" fun distanceTo(other: Vector2) = (this - other).length private val lengthSquared by lazy { x*x + y*y } val length by lazy { sqrt(lengthSquared) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/VideoDownloadHelper.kt ================================================ ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadFileManagement.kt ================================================ package com.lagradost.cloudstream3.utils.downloader import android.content.Context import android.net.Uri import androidx.core.net.toUri import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.getFolderPrefix import com.lagradost.cloudstream3.isEpisodeBased import com.lagradost.safefile.MediaFileContentType import com.lagradost.safefile.SafeFile object DownloadFileManagement { private const val RESERVED_CHARS = "|\\?*<\":>+[]/\'" internal fun sanitizeFilename(name: String, removeSpaces: Boolean = false): String { var tempName = name for (c in RESERVED_CHARS) { tempName = tempName.replace(c, ' ') } if (removeSpaces) tempName = tempName.replace(" ", "") return tempName.replace(" ", " ").trim(' ') } /** * Used for getting video player subs. * @return List of pairs for the files in this format: * */ internal fun getFolder( context: Context, relativePath: String, basePath: String? ): List>? { val base = basePathToFile(context, basePath) val folder = base?.gotoDirectory(relativePath, createMissingDirectories = false) ?: return null //if (folder.isDirectory() != false) return null return folder.listFiles() ?.mapNotNull { (it.name() ?: "") to (it.uri() ?: return@mapNotNull null) } } /** * Turns a string to an UniFile. Used for stored string paths such as settings. * Should only be used to get a download path. * */ internal fun basePathToFile(context: Context, path: String?): SafeFile? { return when { path.isNullOrBlank() -> getDefaultDir(context) path.startsWith("content://") -> SafeFile.fromUri(context, path.toUri()) else -> SafeFile.fromFilePath(context, path) } } /** * Base path where downloaded things should be stored, changes depending on settings. * Returns the file and a string to be stored for future file retrieval. * UniFile.filePath is not sufficient for storage. * */ internal fun Context.getBasePath(): Pair { val settingsManager = PreferenceManager.getDefaultSharedPreferences(this) val basePathSetting = settingsManager.getString(getString(R.string.download_path_key), null) return basePathToFile(this, basePathSetting) to basePathSetting } internal fun getFileName( context: Context, metadata: DownloadObjects.DownloadEpisodeMetadata ): String { return getFileName(context, metadata.name, metadata.episode, metadata.season) } internal fun getFileName( context: Context, epName: String?, episode: Int?, season: Int? ): String { // kinda ugly ik return sanitizeFilename( if (epName == null) { if (season != null) { "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode" } else { "${context.getString(R.string.episode)} $episode" } } else { if (episode != null) { if (season != null) { "${context.getString(R.string.season)} $season ${context.getString(R.string.episode)} $episode - $epName" } else { "${context.getString(R.string.episode)} $episode - $epName" } } else { epName } } ) } internal fun DownloadObjects.DownloadedFileInfo.toFile(context: Context): SafeFile? { return basePathToFile(context, this.basePath)?.gotoDirectory( relativePath, createMissingDirectories = false ) ?.findFile(displayName) } internal fun getFolder(currentType: TvType, titleName: String): String { return if (currentType.isEpisodeBased()) { val sanitizedFileName = sanitizeFilename(titleName) "${currentType.getFolderPrefix()}/$sanitizedFileName" } else currentType.getFolderPrefix() } /** * Gets the default download path as an UniFile. * Vital for legacy downloads, be careful about changing anything here. * * As of writing UniFile is used for everything but download directory on scoped storage. * Special ContentResolver fuckery is needed for that as UniFile doesn't work. * */ fun getDefaultDir(context: Context): SafeFile? { // See https://www.py4u.net/discuss/614761 return SafeFile.fromMedia( context, MediaFileContentType.Downloads ) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadManager.kt ================================================ package com.lagradost.cloudstream3.utils.downloader import android.Manifest import android.annotation.SuppressLint import android.app.Notification import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Build.VERSION.SDK_INT import android.util.Log import android.widget.Toast import androidx.annotation.DrawableRes import androidx.core.app.ActivityCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat import androidx.core.net.toUri import androidx.preference.PreferenceManager import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull import com.lagradost.cloudstream3.BuildConfig import com.lagradost.cloudstream3.CommonActivity.showToast import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.IDownloadableMinimum import com.lagradost.cloudstream3.MainActivity import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.app import com.lagradost.cloudstream3.mvvm.launchSafe import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.services.VideoDownloadService import com.lagradost.cloudstream3.sortUrls import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO import com.lagradost.cloudstream3.ui.player.LOADTYPE_INAPP_DOWNLOAD import com.lagradost.cloudstream3.ui.player.RepoLinkGenerator import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper import com.lagradost.cloudstream3.ui.player.source_priority.QualityDataHelper.getLinkPriority import com.lagradost.cloudstream3.ui.result.ExtractorSubtitleLink import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import com.lagradost.cloudstream3.utils.AppContextUtils.createNotificationChannel import com.lagradost.cloudstream3.utils.AppContextUtils.sortSubs import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.Coroutines.main import com.lagradost.cloudstream3.utils.DOWNLOAD_EPISODE_CACHE import com.lagradost.cloudstream3.utils.DOWNLOAD_HEADER_CACHE import com.lagradost.cloudstream3.utils.DataStore.getFolderName import com.lagradost.cloudstream3.utils.DataStore.getKey import com.lagradost.cloudstream3.utils.DataStore.removeKey import com.lagradost.cloudstream3.utils.Event import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.cloudstream3.utils.ExtractorLinkType import com.lagradost.cloudstream3.utils.M3u8Helper import com.lagradost.cloudstream3.utils.M3u8Helper2 import com.lagradost.cloudstream3.utils.SubtitleHelper.fromTagToEnglishLanguageName import com.lagradost.cloudstream3.utils.SubtitleUtils.deleteMatchingSubtitles import com.lagradost.cloudstream3.utils.UIHelper.colorFromAttribute import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getBasePath import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getDefaultDir import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFileName import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.sanitizeFilename import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.toFile import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.CreateNotificationMetadata import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadEpisodeMetadata import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadItem import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadResumePackage import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadStatus import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadedFileInfo import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadedFileInfoResult import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.LazyStreamDownloadResponse import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.StreamData import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.appendAndDontOverride import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.cancel import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.downloadSubtitle import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getEstimatedTimeLeft import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.getImageBitmapFromUrl import com.lagradost.cloudstream3.utils.downloader.DownloadUtils.join import com.lagradost.cloudstream3.utils.txt import com.lagradost.safefile.SafeFile import com.lagradost.safefile.closeQuietly import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import java.io.Closeable import java.io.IOException import java.io.OutputStream const val DOWNLOAD_CHANNEL_ID = "cloudstream3.general" const val DOWNLOAD_CHANNEL_NAME = "Downloads" const val DOWNLOAD_CHANNEL_DESCRIPT = "The download notification channel" object VideoDownloadManager { fun maxConcurrentDownloads(context: Context): Int = PreferenceManager.getDefaultSharedPreferences(context) ?.getInt(context.getString(R.string.download_parallel_key), 3) ?: 3 private fun maxConcurrentConnections(context: Context): Int = PreferenceManager.getDefaultSharedPreferences(context) ?.getInt(context.getString(R.string.download_concurrent_key), 3) ?: 3 private val _currentDownloads: MutableStateFlow> = MutableStateFlow(emptySet()) val currentDownloads: StateFlow> = _currentDownloads const val TAG = "VDM" private const val DOWNLOAD_NOTIFICATION_TAG = "FROM_DOWNLOADER" private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36" @get:DrawableRes val imgDone get() = R.drawable.rddone @get:DrawableRes val imgDownloading get() = R.drawable.rdload @get:DrawableRes val imgPaused get() = R.drawable.rdpause @get:DrawableRes val imgStopped get() = R.drawable.rderror @get:DrawableRes val imgError get() = R.drawable.rderror @get:DrawableRes val pressToPauseIcon get() = R.drawable.ic_baseline_pause_24 @get:DrawableRes val pressToResumeIcon get() = R.drawable.ic_baseline_play_arrow_24 @get:DrawableRes val pressToStopIcon get() = R.drawable.baseline_stop_24 enum class DownloadType { IsPaused, IsDownloading, IsDone, IsFailed, IsStopped, IsPending } enum class DownloadActionType { Pause, Resume, Stop, } /** Invalid input, just skip to the next one as the same args will give the same error */ private val DOWNLOAD_INVALID_INPUT = DownloadStatus(retrySame = false, tryNext = true, success = false) /** no need to try any other mirror as we have downloaded the file */ private val DOWNLOAD_SUCCESS = DownloadStatus(retrySame = false, tryNext = false, success = true) /** the user pressed stop, so no need to download anything else */ private val DOWNLOAD_STOPPED = DownloadStatus(retrySame = false, tryNext = false, success = true) /** the process failed due to some reason, so we retry and also try the next mirror */ private val DOWNLOAD_FAILED = DownloadStatus(retrySame = true, tryNext = true, success = false) /** bad config, skip all mirrors as every call to download will have the same bad config */ private val DOWNLOAD_BAD_CONFIG = DownloadStatus(retrySame = false, tryNext = false, success = false) const val KEY_RESUME_PACKAGES = "download_resume_2" const val KEY_DOWNLOAD_INFO = "download_info" /** A key to save all the downloads which have not yet started and those currently running, using [DownloadQueueWrapper] * [KEY_RESUME_PACKAGES] can store keys which should not be automatically queued, unlike this key. */ const val KEY_RESUME_IN_QUEUE = "download_resume_queue_key" // private const val KEY_RESUME_QUEUE_PACKAGES = "download_q_resume" val downloadStatus = HashMap() val downloadStatusEvent = Event>() val downloadDeleteEvent = Event() val downloadEvent = Event>() val downloadProgressEvent = Event>() // val downloadQueue = LinkedList() private var hasCreatedNotChannel = false private fun Context.createNotificationChannel() { hasCreatedNotChannel = true this.createNotificationChannel( DOWNLOAD_CHANNEL_ID, DOWNLOAD_CHANNEL_NAME, DOWNLOAD_CHANNEL_DESCRIPT ) } fun cancelAllDownloadNotifications(context: Context) { val manager = NotificationManagerCompat.from(context) manager.activeNotifications.forEach { notification -> if (notification.tag == DOWNLOAD_NOTIFICATION_TAG) { manager.cancel(DOWNLOAD_NOTIFICATION_TAG, notification.id) } } } /** * @param hlsProgress will together with hlsTotal display another notification if used, to lessen the confusion about estimated size. * */ @SuppressLint("StringFormatInvalid") private suspend fun createDownloadNotification( context: Context, source: String?, linkName: String?, ep: DownloadEpisodeMetadata, state: DownloadType, progress: Long, total: Long, notificationCallback: (Int, Notification) -> Unit, hlsProgress: Long? = null, hlsTotal: Long? = null, bytesPerSecond: Long ): Notification? { try { if (total <= 0) return null// crash, invalid data val builder = NotificationCompat.Builder(context, DOWNLOAD_CHANNEL_ID) .setAutoCancel(true) .setColorized(true) .setOnlyAlertOnce(true) .setShowWhen(false) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setColor(context.colorFromAttribute(R.attr.colorPrimary)) .setContentTitle(ep.mainName) .setSmallIcon( when (state) { DownloadType.IsDone -> imgDone DownloadType.IsDownloading -> imgDownloading DownloadType.IsPaused -> imgPaused DownloadType.IsFailed -> imgError DownloadType.IsStopped -> imgStopped DownloadType.IsPending -> imgDownloading } ) if (ep.sourceApiName != null) { builder.setSubText(ep.sourceApiName) } if (source != null) { val intent = Intent(context, MainActivity::class.java).apply { data = source.toUri() flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK } val pendingIntent = PendingIntentCompat.getActivity(context, 0, intent, 0, false) builder.setContentIntent(pendingIntent) } if (state == DownloadType.IsDownloading || state == DownloadType.IsPaused) { builder.setProgress((total / 1000).toInt(), (progress / 1000).toInt(), false) } else if (state == DownloadType.IsPending) { builder.setProgress(0, 0, true) } val rowTwoExtra = if (ep.name != null) " - ${ep.name}\n" else "" val rowTwo = if (ep.season != null && ep.episode != null) { "${context.getString(R.string.season_short)}${ep.season}:${context.getString(R.string.episode_short)}${ep.episode}" + rowTwoExtra } else if (ep.episode != null) { "${context.getString(R.string.episode)} ${ep.episode}" + rowTwoExtra } else { (ep.name ?: "") + "" } val downloadFormat = context.getString(R.string.download_format) if (SDK_INT >= Build.VERSION_CODES.O) { if (ep.poster != null) { val poster = withContext(Dispatchers.IO) { context.getImageBitmapFromUrl(ep.poster) } if (poster != null) builder.setLargeIcon(poster) } val progressPercentage: Long val progressMbString: String val totalMbString: String val suffix: String val mbFormat = "%.1f MB" if (hlsProgress != null && hlsTotal != null) { progressPercentage = hlsProgress * 100 / hlsTotal progressMbString = hlsProgress.toString() totalMbString = hlsTotal.toString() suffix = " - $mbFormat".format(progress / 1000000f) } else { progressPercentage = progress * 100 / total progressMbString = mbFormat.format(progress / 1000000f) totalMbString = mbFormat.format(total / 1000000f) suffix = "" } val mbPerSecondString = if (state == DownloadType.IsDownloading) { " ($mbFormat/s)".format(bytesPerSecond.toFloat() / 1000000f) } else "" val remainingTime = if (state == DownloadType.IsDownloading) { getEstimatedTimeLeft(context, bytesPerSecond, progress, total) } else "" val bigText = when (state) { DownloadType.IsDownloading, DownloadType.IsPaused -> { (if (linkName == null) "" else "$linkName\n") + "$rowTwo\n$progressPercentage % ($progressMbString/$totalMbString)$suffix$mbPerSecondString $remainingTime" } DownloadType.IsPending -> { (if (linkName == null) "" else "$linkName\n") + rowTwo } DownloadType.IsFailed -> { downloadFormat.format( context.getString(R.string.download_failed), rowTwo ) } DownloadType.IsDone -> { downloadFormat.format(context.getString(R.string.download_done), rowTwo) } DownloadType.IsStopped -> { downloadFormat.format( context.getString(R.string.download_canceled), rowTwo ) } } val bodyStyle = NotificationCompat.BigTextStyle() bodyStyle.bigText(bigText) builder.setStyle(bodyStyle) } else { val txt = when (state) { DownloadType.IsDownloading, DownloadType.IsPaused, DownloadType.IsPending -> { rowTwo } DownloadType.IsFailed -> { downloadFormat.format( context.getString(R.string.download_failed), rowTwo ) } DownloadType.IsDone -> { downloadFormat.format(context.getString(R.string.download_done), rowTwo) } DownloadType.IsStopped -> { downloadFormat.format( context.getString(R.string.download_canceled), rowTwo ) } } builder.setContentText(txt) } if ((state == DownloadType.IsDownloading || state == DownloadType.IsPaused || state == DownloadType.IsPending) && SDK_INT >= Build.VERSION_CODES.O) { val actionTypes: MutableList = ArrayList() // INIT if (state == DownloadType.IsDownloading) { actionTypes.add(DownloadActionType.Pause) actionTypes.add(DownloadActionType.Stop) } if (state == DownloadType.IsPaused) { actionTypes.add(DownloadActionType.Resume) actionTypes.add(DownloadActionType.Stop) } if (state == DownloadType.IsPending) { actionTypes.add(DownloadActionType.Stop) } // ADD ACTIONS for ((index, i) in actionTypes.withIndex()) { val actionResultIntent = Intent(context, VideoDownloadService::class.java) actionResultIntent.putExtra( "type", when (i) { DownloadActionType.Resume -> "resume" DownloadActionType.Pause -> "pause" DownloadActionType.Stop -> "stop" } ) actionResultIntent.putExtra("id", ep.id) val pending: PendingIntent = PendingIntent.getService( // BECAUSE episodes lying near will have the same id +1, index will give the same requested as the previous episode, *100000 fixes this context, (4337 + index * 1000000 + ep.id), actionResultIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) builder.addAction( NotificationCompat.Action( when (i) { DownloadActionType.Resume -> pressToResumeIcon DownloadActionType.Pause -> pressToPauseIcon DownloadActionType.Stop -> pressToStopIcon }, when (i) { DownloadActionType.Resume -> context.getString(R.string.resume) DownloadActionType.Pause -> context.getString(R.string.pause) DownloadActionType.Stop -> context.getString(R.string.cancel) }, pending ) ) } } if (!hasCreatedNotChannel) { context.createNotificationChannel() } val notification = builder.build() notificationCallback(ep.id, notification) with(NotificationManagerCompat.from(context)) { // notificationId is a unique int for each notification that you must define if (ActivityCompat.checkSelfPermission( context, Manifest.permission.POST_NOTIFICATIONS ) != PackageManager.PERMISSION_GRANTED ) { return null } notify(DOWNLOAD_NOTIFICATION_TAG, ep.id, notification) } return notification } catch (e: Exception) { logError(e) return null } } @Throws(IOException::class) fun setupStream( context: Context, name: String, folder: String?, extension: String, tryResume: Boolean, ): StreamData { return setupStream( context.getBasePath().first ?: getDefaultDir(context) ?: throw IOException("Bad config"), name, folder, extension, tryResume ) } /** * Sets up the appropriate file and creates a data stream from the file. * Used for initializing downloads and backups. * */ @Throws(IOException::class) fun setupStream( baseFile: SafeFile, name: String, folder: String?, extension: String, tryResume: Boolean, ): StreamData { val displayName = getDisplayName(name, extension) val subDir = baseFile.gotoDirectory(folder, createMissingDirectories = true) ?: throw IOException("Cant create directory") val foundFile = subDir.findFile(displayName) val (file, fileLength) = if (foundFile == null || foundFile.exists() != true) { subDir.createFileOrThrow(displayName) to 0L } else { if (tryResume) { foundFile to foundFile.lengthOrThrow() } else { foundFile.deleteOrThrow() subDir.createFileOrThrow(displayName) to 0L } } return StreamData(fileLength, file) } /** This class handles the notifications, as well as the relevant key */ data class DownloadMetaData( private val id: Int?, var bytesDownloaded: Long = 0, var bytesWritten: Long = 0, var totalBytes: Long? = null, // notification metadata private var lastUpdatedMs: Long = 0, private var lastDownloadedBytes: Long = 0, private val createNotificationCallback: (CreateNotificationMetadata) -> Unit, private var internalType: DownloadType = DownloadType.IsPending, // how many segments that we have downloaded var hlsProgress: Int = 0, // how many segments that exist var hlsTotal: Int? = null, // this is how many segments that has been written to the file // will always be <= hlsProgress as we may keep some in a buffer var hlsWrittenProgress: Int = 0, // this is used for copy with metadata on how much we have downloaded for setKey private var downloadFileInfoTemplate: DownloadedFileInfo? = null ) : Closeable { fun setResumeLength(length: Long) { bytesDownloaded = length bytesWritten = length lastDownloadedBytes = length } val approxTotalBytes: Long get() = totalBytes ?: hlsTotal?.let { total -> (bytesDownloaded * (total / hlsProgress.toFloat())).toLong() } ?: bytesDownloaded private val isHLS get() = hlsTotal != null private var stopListener: (() -> Unit)? = null /** on cancel button pressed or failed invoke this once and only once */ fun setOnStop(callback: (() -> Unit)) { stopListener = callback } fun removeStopListener() { stopListener = null } private val downloadEventListener = { event: Pair -> if (event.first == id) { when (event.second) { DownloadActionType.Pause -> { type = DownloadType.IsPaused } DownloadActionType.Stop -> { type = DownloadType.IsStopped stopListener?.invoke() stopListener = null } DownloadActionType.Resume -> { type = DownloadType.IsDownloading } } } } private fun updateFileInfo() { if (id == null) return downloadFileInfoTemplate?.let { template -> setKey( KEY_DOWNLOAD_INFO, id.toString(), template.copy( totalBytes = approxTotalBytes, extraInfo = if (isHLS) hlsWrittenProgress.toString() else null ) ) } } fun setDownloadFileInfoTemplate(template: DownloadedFileInfo) { downloadFileInfoTemplate = template updateFileInfo() } init { if (id != null) { downloadEvent += downloadEventListener } } override fun close() { // as we may need to resume hls downloads, we save the current written index if (isHLS || totalBytes == null) { updateFileInfo() } if (id != null) { downloadEvent -= downloadEventListener downloadStatus -= id } stopListener = null } var type get() = internalType set(value) { internalType = value notify() } fun onDelete() { bytesDownloaded = 0 hlsWrittenProgress = 0 hlsProgress = 0 if (id != null) downloadDeleteEvent(id) //internalType = DownloadType.IsStopped notify() } companion object { const val UPDATE_RATE_MS: Long = 1000L } @JvmName("DownloadMetaDataNotify") private fun notify() { // max 10 sec between notifications, min 0.1s, this is to stop div by zero val dt = (System.currentTimeMillis() - lastUpdatedMs).coerceIn(100, 10000) val bytesPerSecond = ((bytesDownloaded - lastDownloadedBytes) * 1000L) / dt lastDownloadedBytes = bytesDownloaded lastUpdatedMs = System.currentTimeMillis() try { val bytes = approxTotalBytes // notification creation if (isHLS) { createNotificationCallback( CreateNotificationMetadata( internalType, bytesDownloaded, bytes, hlsTotal = hlsTotal?.toLong(), hlsProgress = hlsProgress.toLong(), bytesPerSecond = bytesPerSecond ) ) } else { createNotificationCallback( CreateNotificationMetadata( internalType, bytesDownloaded, bytes, bytesPerSecond = bytesPerSecond ) ) } // as hls has an approx file size we want to update this metadata if (isHLS) { updateFileInfo() } if (internalType == DownloadType.IsStopped || internalType == DownloadType.IsFailed) { stopListener?.invoke() stopListener = null } // push all events, this *should* not crash, TODO MUTEX? if (id != null) { downloadStatus[id] = type downloadProgressEvent(Triple(id, bytesDownloaded, bytes)) downloadStatusEvent(id to type) } } catch (t: Throwable) { logError(t) if (BuildConfig.DEBUG) { throw t } } } private fun checkNotification() { if (lastUpdatedMs + UPDATE_RATE_MS > System.currentTimeMillis()) return notify() } /** adds the length and pushes a notification if necessary */ fun addBytes(length: Long) { bytesDownloaded += length // we don't want to update the notification after it is paused, // download progress may not stop directly when we "pause" it if (type == DownloadType.IsDownloading) checkNotification() } fun addBytesWritten(length: Long) { bytesWritten += length } /** adds the length + hsl progress and pushes a notification if necessary */ fun addSegment(length: Long) { hlsProgress += 1 addBytes(length) } fun setWrittenSegment(segmentIndex: Int) { hlsWrittenProgress = segmentIndex + 1 // in case of abort we need to save every written progress updateFileInfo() } } data class LazyStreamDownloadData( private val url: String, private val headers: Map, private val referer: String, /** This specifies where chunk i starts and ends, * bytes=${chuckStartByte[ i ]}-${chuckStartByte[ i+1 ] -1} * where out of bounds => bytes=${chuckStartByte[ i ]}- */ private val chuckStartByte: LongArray, val totalLength: Long?, val downloadLength: Long?, val chuckSize: Long, val bufferSize: Int, val isResumed: Boolean, ) { val size get() = chuckStartByte.size /** returns what byte it has downloaded, * so start at 10 and download 4 bytes = return 14 * * the range is [startByte, endByte) to be able to do [a, b) [b, c) ect * * [a, null) will return inclusive to eof = [a, eof] * * throws an error if initial get request fails, can be specified as return startByte * */ @Throws private suspend fun resolve( startByte: Long, endByte: Long?, callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) ): Long = withContext(Dispatchers.IO) { var currentByte: Long = startByte val stopAt = endByte ?: Long.MAX_VALUE if (currentByte >= stopAt) return@withContext currentByte val request = app.get( url, headers = headers + mapOf( // range header is inclusive so [startByte, endByte-1] = [startByte, endByte) // if nothing at end the server will continue until eof "Range" to "bytes=$startByte-" // ${endByte?.minus(1)?.toString() ?: "" } ), referer = referer, verify = false ) val requestStream = request.body.byteStream() val buffer = ByteArray(bufferSize) var read: Int try { while (requestStream.read(buffer, 0, bufferSize).also { read = it } >= 0) { val start = currentByte currentByte += read.toLong() // this stops overflow if (currentByte >= stopAt) { callback(LazyStreamDownloadResponse(buffer, start, stopAt)) break } else { callback(LazyStreamDownloadResponse(buffer, start, currentByte)) } } } catch (e: CancellationException) { throw e } catch (t: Throwable) { logError(t) } finally { requestStream.closeQuietly() } return@withContext currentByte } /** retries the resolve n times and returns true if successful */ suspend fun resolveSafe( index: Int, retries: Int = 3, callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) ): Boolean { var start = chuckStartByte.getOrNull(index) ?: return false val end = chuckStartByte.getOrNull(index + 1) for (i in 0 until retries) { try { // in case start = resolve(start, end, callback) // no end defined, so we don't care exactly where it ended if (end == null) return true // we have download more or exactly what we needed if (start >= end) return true } catch (_: IllegalStateException) { return false } catch (_: CancellationException) { return false } catch (_: Throwable) { continue } } return false } } @Throws suspend fun streamLazy( url: String, headers: Map, referer: String, startByte: Long, /** how many bytes every connection should be, by default it is 10 MiB */ chuckSize: Long = (1 shl 20) * 10, /** maximum bytes in the buffer that responds */ bufferSize: Int = DEFAULT_BUFFER_SIZE, /** how many bytes bytes it should require to use the parallel downloader instead, * if we download a very small file we don't want it parallel */ maximumSmallSize: Long = chuckSize * 2 ): LazyStreamDownloadData { // we don't want to make a separate connection for every 1kb require(chuckSize > 1000) val headRequest = app.head(url = url, headers = headers, referer = referer, verify = false) var contentLength = headRequest.size if (contentLength != null && contentLength <= 0) contentLength = null val hasRangeSupport = when (headRequest.headers["Accept-Ranges"]?.lowercase()?.trim()) { // server has stated it has no support "none" -> false // server has stated it has support "bytes" -> true // if null or undefined (as bytes is the only range unit formally defined) // If the get request returns partial content we support range else -> { headRequest.headers["Accept-Ranges"]?.let { range -> Log.v(TAG, "Unknown Accept-Ranges tag: $range") } // as we don't poll the body this should be fine val getRequest = app.get( url, headers = headers + mapOf( "Range" to "bytes=0-${ // we don't want to request more than the actual file // but also more than 0 bytes contentLength?.let { max -> minOf(maxOf(max - 1L, 3L), 1023L) } ?: 1023L }" ), referer = referer, verify = false ) // if head request did not work then we can just look for the size here too // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Range if (contentLength == null) { contentLength = getRequest.headers["Content-Range"]?.trim()?.lowercase()?.let { range -> // we only support "bytes" unit if (range.startsWith("bytes")) { // may be '*' if unknown range.substringAfter("/").toLongOrNull() } else { Log.v(TAG, "Unknown Content-Range unit: $range") null } } } // supports range if status is partial content https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/206 getRequest.code == 206 } } Log.d( TAG, "Starting stream with url=$url, startByte=$startByte, contentLength=$contentLength, hasRangeSupport=$hasRangeSupport" ) var downloadLength: Long? = null val ranges = if (!hasRangeSupport) { // is the equivalent of [0..EOF] as we cant resume, nor can parallelize it downloadLength = contentLength LongArray(1) { 0 } } else if (contentLength == null || contentLength < maximumSmallSize) { if (contentLength != null) { downloadLength = contentLength - startByte } // is the equivalent of [startByte..EOF] as we don't know the size we can only do one // connection LongArray(1) { startByte } } else { downloadLength = contentLength - startByte // div with ceiling as // this makes the last part "unknown ending" and it will break at EOF // so eg startByte = 0, downloadLength = 13, chuckSize = 10 // = LongArray(2) { 0, 10 } = [0,10) + [10..EOF] LongArray(((downloadLength + chuckSize - 1) / chuckSize).toInt()) { idx -> startByte + idx * chuckSize } } return LazyStreamDownloadData( url = url, headers = headers, referer = referer, chuckStartByte = ranges, downloadLength = downloadLength, totalLength = contentLength, chuckSize = chuckSize, bufferSize = bufferSize, // we have only resumed if we had a downloaded file and we can resume isResumed = startByte > 0 && hasRangeSupport ) } /** download a file that consist of a single stream of data*/ suspend fun downloadThing( context: Context, link: IDownloadableMinimum, name: String, folder: String, extension: String, tryResume: Boolean, parentId: Int?, createNotificationCallback: (CreateNotificationMetadata) -> Unit, parallelConnections: Int = 3, /** how many bytes a valid file must be in bytes, * this should be different for subtitles and video */ minimumSize: Long = 100 ): DownloadStatus = withContext(Dispatchers.IO) { if (parallelConnections < 1) { return@withContext DOWNLOAD_INVALID_INPUT } var fileStream: OutputStream? = null //var requestStream: InputStream? = null val metadata = DownloadMetaData( totalBytes = 0, bytesDownloaded = 0, createNotificationCallback = createNotificationCallback, id = parentId, ) try { // get the file path val (baseFile, basePath) = context.getBasePath() val displayName = getDisplayName(name, extension) if (baseFile == null) return@withContext DOWNLOAD_BAD_CONFIG // set up the download file var stream = setupStream(baseFile, name, folder, extension, tryResume) fileStream = stream.open() metadata.setResumeLength(stream.startAt) metadata.type = DownloadType.IsPending val items = streamLazy( url = link.url.replace(" ", "%20"), referer = link.referer, startByte = stream.startAt, headers = link.headers.appendAndDontOverride( mapOf( "Accept-Encoding" to "identity", "accept" to "*/*", "user-agent" to USER_AGENT, "sec-ch-ua" to "\"Chromium\";v=\"91\", \" Not;A Brand\";v=\"99\"", "sec-fetch-mode" to "navigate", "sec-fetch-dest" to "video", "sec-fetch-user" to "?1", "sec-ch-ua-mobile" to "?0", ) ) ) // too short file, treat it as a invalid link if (items.totalLength != null && items.totalLength < minimumSize) { fileStream.closeQuietly() metadata.onDelete() stream.delete() return@withContext DOWNLOAD_INVALID_INPUT } // if we have an output stream that cant be resumed then we delete the entire file // and set up the stream again if (!items.isResumed && stream.startAt > 0) { fileStream.closeQuietly() stream.delete() metadata.setResumeLength(0) stream = setupStream(baseFile, name, folder, extension, false) fileStream = stream.open() } metadata.totalBytes = items.totalLength metadata.type = DownloadType.IsDownloading metadata.setDownloadFileInfoTemplate( DownloadedFileInfo( totalBytes = metadata.approxTotalBytes, relativePath = folder, displayName = displayName, basePath = basePath ) ) val currentMutex = Mutex() val current = (0 until items.size).iterator() val fileMutex = Mutex() // start to data val pendingData: HashMap = hashMapOf() val fileChecker = launch(Dispatchers.IO) { while (isActive) { if (stream.exists) { delay(5000) continue } fileMutex.withLock { metadata.type = DownloadType.IsStopped } break } } val jobs = (0 until parallelConnections).map { launch(Dispatchers.IO) { // @downloadexplanation // this may seem a bit complex but it more or less acts as a queue system // imagine we do the downloading [0,3] and it response in the order 0,2,3,1 // file: [_,_,_,_] queue: [_,_,_,_] Initial condition // file: [X,_,_,_] queue: [_,_,_,_] + added 0 directly to file // file: [X,_,_,_] queue: [_,_,X,_] + added 2 to queue // file: [X,_,_,_] queue: [_,_,X,X] + added 3 to queue // file: [X,X,_,_] queue: [_,_,X,X] + added 1 directly to file // file: [X,X,X,X] queue: [_,_,_,_] write the queue and remove from it // note that this is a bit more complex compared to hsl as ever segment // will return several bytearrays, and is therefore chained by the byte // so every request has a front and back byte instead of an index // this *requires* that no gap exist due because of resolve val callback: (suspend CoroutineScope.(LazyStreamDownloadResponse) -> Unit) = callback@{ response -> if (!isActive) return@callback fileMutex.withLock { // wait until not paused while (metadata.type == DownloadType.IsPaused) delay(100) // if stopped then throw if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed) { this.cancel() return@callback } val responseSize = response.size metadata.addBytes(response.size) if (response.startByte == metadata.bytesWritten) { // if we are first in the queue then write it directly fileStream.write( response.bytes, 0, responseSize.toInt() ) metadata.addBytesWritten(responseSize) } else { // otherwise append to queue, we need to clone the bytes as they will be overridden otherwise pendingData[response.startByte] = response.copy(bytes = response.bytes.clone()) } while (true) { // remove the current queue start, so no possibility of // while(true) { continue } in case size = 0, and removed extra // garbage val pending = pendingData.remove(metadata.bytesWritten) ?: break val size = pending.size fileStream.write( pending.bytes, 0, size.toInt() ) metadata.addBytesWritten(size) } } } // this will take up the first available job and resolve while (true) { if (!isActive) return@launch fileMutex.withLock { if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed ) return@launch } // mutex just in case, we never want this to fail due to multithreading val index = currentMutex.withLock { if (!current.hasNext()) return@launch current.nextInt() } // in case something has gone wrong set to failed if the fail is not caused by // user cancellation if (!items.resolveSafe(index, callback = callback)) { fileMutex.withLock { if (metadata.type != DownloadType.IsStopped) { metadata.type = DownloadType.IsFailed } } return@launch } } } } // fast stop as the jobs may be in a slow request metadata.setOnStop { jobs.cancel() } jobs.join() fileChecker.cancel() // jobs are finished so we don't want to stop them anymore metadata.removeStopListener() if (!stream.exists) metadata.type = DownloadType.IsStopped if (metadata.type == DownloadType.IsFailed) { return@withContext DOWNLOAD_FAILED } if (metadata.type == DownloadType.IsStopped) { // we need to close before delete fileStream.closeQuietly() metadata.onDelete() stream.delete() return@withContext DOWNLOAD_STOPPED } // in case the head request lies about content-size, // then we don't want shit output if (metadata.bytesDownloaded < minimumSize) { // we need to close before delete fileStream.closeQuietly() metadata.onDelete() stream.delete() return@withContext DOWNLOAD_INVALID_INPUT } metadata.type = DownloadType.IsDone return@withContext DOWNLOAD_SUCCESS } catch (e: IOException) { // some sort of IO error, this should not happened // we just rethrow it logError(e) throw e } catch (t: Throwable) { // some sort of network error, will error // note that when failing we don't want to delete the file, // only user interaction has that power metadata.type = DownloadType.IsFailed return@withContext DOWNLOAD_FAILED } finally { fileStream?.closeQuietly() //requestStream?.closeQuietly() metadata.close() } } private suspend fun downloadHLS( context: Context, link: ExtractorLink, name: String, folder: String, parentId: Int?, startIndex: Int?, createNotificationCallback: (CreateNotificationMetadata) -> Unit, parallelConnections: Int = 3 ): DownloadStatus = withContext(Dispatchers.IO) { if (parallelConnections < 1) return@withContext DOWNLOAD_INVALID_INPUT val metadata = DownloadMetaData( createNotificationCallback = createNotificationCallback, id = parentId ) var fileStream: OutputStream? = null try { val extension = "mp4" // the start .ts index var startAt = startIndex ?: 0 // set up the file data val (baseFile, basePath) = context.getBasePath() if (baseFile == null) return@withContext DOWNLOAD_BAD_CONFIG val displayName = getDisplayName(name, extension) val stream = setupStream(baseFile, name, folder, extension, startAt > 0) if (!stream.resume) startAt = 0 fileStream = stream.open() // push the metadata metadata.setResumeLength(stream.startAt) metadata.hlsProgress = startAt metadata.hlsWrittenProgress = startAt metadata.type = DownloadType.IsPending metadata.setDownloadFileInfoTemplate( DownloadedFileInfo( totalBytes = 0, relativePath = folder, displayName = displayName, basePath = basePath ) ) // do the initial get request to fetch the segments val m3u8 = M3u8Helper.M3u8Stream( link.url, link.quality, link.headers.appendAndDontOverride( mapOf( "Accept-Encoding" to "identity", "accept" to "*/*", "user-agent" to USER_AGENT, ) + if (link.referer.isNotBlank()) mapOf("referer" to link.referer) else emptyMap() ) ) val items = M3u8Helper2.hslLazy(m3u8, selectBest = true, requireAudio = true) metadata.hlsTotal = items.size metadata.type = DownloadType.IsDownloading val currentMutex = Mutex() val current = (startAt until items.size).iterator() val fileMutex = Mutex() val pendingData: HashMap = hashMapOf() val fileChecker = launch(Dispatchers.IO) { while (isActive) { if (stream.exists) { delay(5000) continue } fileMutex.withLock { metadata.type = DownloadType.IsStopped } break } } // see @downloadexplanation for explanation of this download strategy, // this keeps all jobs working at all times, // does several connections in parallel instead of a regular for loop to improve // download speed val jobs = (0 until parallelConnections).map { launch(Dispatchers.IO) { while (true) { if (!isActive) return@launch fileMutex.withLock { if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed ) return@launch } // mutex just in case, we never want this to fail due to multithreading val index = currentMutex.withLock { if (!current.hasNext()) return@launch current.nextInt() } // in case something has gone wrong set to failed if the fail is not caused by // user cancellation val bytes = items.resolveLinkSafe(index) ?: run { fileMutex.withLock { if (metadata.type != DownloadType.IsStopped) { metadata.type = DownloadType.IsFailed } } return@launch } fileMutex.withLock { try { // user pause while (metadata.type == DownloadType.IsPaused) delay(100) // if stopped then break to delete if (metadata.type == DownloadType.IsStopped || metadata.type == DownloadType.IsFailed || !isActive) return@launch val segmentLength = bytes.size.toLong() // send notification, no matter the actual write order metadata.addSegment(segmentLength) // directly write the bytes if you are first if (metadata.hlsWrittenProgress == index) { fileStream.write(bytes) metadata.addBytesWritten(segmentLength) metadata.setWrittenSegment(index) } else { // no need to clone as there will be no modification of this bytearray pendingData[index] = bytes } // write the cached bytes submitted by other threads while (true) { val cache = pendingData.remove(metadata.hlsWrittenProgress) ?: break val cacheLength = cache.size.toLong() fileStream.write(cache) metadata.addBytesWritten(cacheLength) metadata.setWrittenSegment(metadata.hlsWrittenProgress) } } catch (t: Throwable) { // this is in case of write fail logError(t) if (metadata.type != DownloadType.IsStopped) { metadata.type = DownloadType.IsFailed } } } } } } // fast stop as the jobs may be in a slow request metadata.setOnStop { jobs.cancel() } jobs.join() fileChecker.cancel() metadata.removeStopListener() if (!stream.exists) metadata.type = DownloadType.IsStopped if (metadata.type == DownloadType.IsFailed) { return@withContext DOWNLOAD_FAILED } if (metadata.type == DownloadType.IsStopped) { // we need to close before delete fileStream.closeQuietly() metadata.onDelete() stream.delete() return@withContext DOWNLOAD_STOPPED } metadata.type = DownloadType.IsDone return@withContext DOWNLOAD_SUCCESS } catch (t: Throwable) { logError(t) metadata.type = DownloadType.IsFailed return@withContext DOWNLOAD_FAILED } finally { fileStream?.closeQuietly() metadata.close() } } private fun getDisplayName(name: String, extension: String): String { return "$name.$extension" } private suspend fun downloadSingleEpisode( context: Context, source: String?, folder: String?, ep: DownloadEpisodeMetadata, link: ExtractorLink, notificationCallback: (Int, Notification) -> Unit, tryResume: Boolean = false, ): DownloadStatus { // no support for these file formats if (link.type == ExtractorLinkType.MAGNET || link.type == ExtractorLinkType.TORRENT || link.type == ExtractorLinkType.DASH) { return DOWNLOAD_INVALID_INPUT } val name = getFileName(context, ep) // Make sure this is cancelled when download is done or cancelled. val extractorJob = ioSafe { if (link.extractorData != null) { getApiFromNameNull(link.source)?.extractorVerifierJob(link.extractorData) } } val callback: (CreateNotificationMetadata) -> Unit = { meta -> main { createDownloadNotification( context, source, link.name, ep, meta.type, meta.bytesDownloaded, meta.bytesTotal, notificationCallback, meta.hlsProgress, meta.hlsTotal, meta.bytesPerSecond ) } } try { when (link.type) { ExtractorLinkType.M3U8 -> { val startIndex = if (tryResume) { context.getKey( KEY_DOWNLOAD_INFO, ep.id.toString(), null )?.extraInfo?.toIntOrNull() } else null return downloadHLS( context, link, name, folder ?: "", ep.id, startIndex, callback, parallelConnections = maxConcurrentConnections(context) ) } ExtractorLinkType.VIDEO -> { return downloadThing( context, link, name, folder ?: "", "mp4", tryResume, ep.id, callback, parallelConnections = maxConcurrentConnections(context), /** We require at least 10 MB video files */ minimumSize = (1 shl 20) * 10 ) } else -> throw IllegalArgumentException("Unsupported download type") } } catch (_: Throwable) { return DOWNLOAD_FAILED } finally { extractorJob.cancel() } } fun getDownloadFileInfo( context: Context, id: Int, ): DownloadedFileInfoResult? { try { val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return null val file = info.toFile(context) // only delete the key if the file is not found if (file == null || file.exists() == false) { return null } return DownloadedFileInfoResult( file.lengthOrThrow(), info.totalBytes, file.uriOrThrow() ) } catch (e: Exception) { logError(e) return null } } fun deleteFilesAndUpdateSettings( context: Context, ids: Set, scope: CoroutineScope, onComplete: (Set) -> Unit = {} ) { scope.launchSafe(Dispatchers.IO) { val deleteJobs = ids.map { id -> async { id to deleteFileAndUpdateSettings(context, id) } } val results = deleteJobs.awaitAll() val (successfulResults, failedResults) = results.partition { it.second } val successfulIds = successfulResults.map { it.first }.toSet() if (failedResults.isNotEmpty()) { failedResults.forEach { (id, _) -> // TODO show a toast if some failed? Log.e("FileDeletion", "Failed to delete file with ID: $id") } } else { Log.i("FileDeletion", "All files deleted successfully") } onComplete.invoke(successfulIds) } } private fun deleteFileAndUpdateSettings(context: Context, id: Int): Boolean { val success = deleteFile(context, id) if (success) context.removeKey(KEY_DOWNLOAD_INFO, id.toString()) return success } private fun deleteFile(context: Context, id: Int): Boolean { val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) ?: return false val file = info.toFile(context) val isFileDeleted = file?.delete() == true || file?.exists() == false if (isFileDeleted) { deleteMatchingSubtitles(context, info) downloadEvent.invoke(id to DownloadActionType.Stop) downloadProgressEvent.invoke(Triple(id, 0, 0)) downloadStatusEvent.invoke(id to DownloadType.IsStopped) downloadDeleteEvent.invoke(id) } return isFileDeleted } fun getDownloadResumePackage(context: Context, id: Int): DownloadResumePackage? { return context.getKey(KEY_RESUME_PACKAGES, id.toString()) } fun getDownloadQueuePackage(context: Context, id: Int): DownloadQueueWrapper? { return context.getKey(KEY_RESUME_IN_QUEUE, id.toString()) } fun getDownloadEpisodeMetadata( episode: ResultEpisode, titleName: String, apiName: String, currentPoster: String?, currentIsMovie: Boolean, tvType: TvType, ): DownloadEpisodeMetadata { return DownloadEpisodeMetadata( episode.id, episode.parentId, sanitizeFilename(titleName), apiName, episode.poster ?: currentPoster, episode.name, if (currentIsMovie) null else episode.season, if (currentIsMovie) null else episode.episode, tvType, ) } class EpisodeDownloadInstance( val context: Context, val downloadQueueWrapper: DownloadQueueWrapper ) { private val TAG = "EpisodeDownloadInstance" private var subtitleDownloadJob: Job? = null private var downloadJob: Job? = null private var linkLoadingJob: Job? = null /** isCompleted just means the download should not be retried. * It includes stopped by user AND completion of file download. * */ var isCompleted = false set(value) { field = value if (value) { removeKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString()) // Do not emit events when completed as it may also trigger on cancellation. // Force refresh the queue when completed. // May lead to some redundant calls, but ensures that the queue is always up to date. DownloadQueueManager.forceRefreshQueue() } } /** Cancels all active jobs and sets instance to failed. */ fun cancelDownload() { val cause = "Cancel call from cancelDownload" this.subtitleDownloadJob?.cancel(cause) this.linkLoadingJob?.cancel(cause) // Should not cancel the download job, it may need to clean up itself. // Better to send a status event using isStopped and let it cancel itself. isCancelled = true } // Run to cancel ongoing work, delete partial work and refresh queue private fun cleanup(status: DownloadType) { removeKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString()) val id = downloadQueueWrapper.id // Delete subtitles on cancel safe { val info = context.getKey(KEY_DOWNLOAD_INFO, id.toString()) if (info != null) { deleteMatchingSubtitles(context, info) } } downloadStatusEvent.invoke(Pair(id, status)) downloadStatus[id] = status downloadEvent.invoke(Pair(id, DownloadActionType.Stop)) // Force refresh the queue when failed. // May lead to some redundant calls, but ensures that the queue is always up to date. DownloadQueueManager.forceRefreshQueue() } var isCancelled = false set(value) { val oldField = field field = value // Clean up cancelled work, but only once if (value && !oldField) { cleanup(DownloadType.IsStopped) } } /** This failure can be both downloader and user initiated. * Do not automatically retry in case of failure. */ var isFailed = false set(value) { val oldField = field field = value // Clean up failed work, but only once if (value && !oldField) { cleanup(DownloadType.IsFailed) } } companion object { private fun displayNotification(context: Context, id: Int, notification: Notification) { safe { NotificationManagerCompat.from(context) .notify(DOWNLOAD_NOTIFICATION_TAG, id, notification) } } } private suspend fun downloadFromResume( downloadResumePackage: DownloadResumePackage, notificationCallback: (Int, Notification) -> Unit, ) { val item = downloadResumePackage.item val id = item.ep.id if (currentDownloads.value.contains(id)) { // IF IT IS ALREADY DOWNLOADING, RESUME IT downloadEvent.invoke(id to DownloadActionType.Resume) return } _currentDownloads.update { downloads -> downloads + id } try { for (index in (downloadResumePackage.linkIndex ?: 0) until item.links.size) { val link = item.links[index] val resume = downloadResumePackage.linkIndex == index setKey( KEY_RESUME_PACKAGES, id.toString(), DownloadResumePackage(item, index) ) var connectionResult = downloadSingleEpisode( context, item.source, item.folder, item.ep, link, notificationCallback, resume ) if (connectionResult.retrySame) { connectionResult = downloadSingleEpisode( context, item.source, item.folder, item.ep, link, notificationCallback, true ) } if (connectionResult.success) { // SUCCESS isCompleted = true break } else if (!connectionResult.tryNext || index >= item.links.lastIndex) { isFailed = true break } } } catch (e: Exception) { isFailed = true logError(e) } finally { isFailed = !isCompleted _currentDownloads.update { downloads -> downloads - id } } } private suspend fun startDownload( info: DownloadItem?, pkg: DownloadResumePackage? ) { try { if (info != null) { getDownloadResumePackage(context, info.ep.id)?.let { dpkg -> downloadFromResume(dpkg) { id, notification -> displayNotification(context, id, notification) } } ?: run { if (info.links.isEmpty()) return downloadFromResume( DownloadResumePackage(info, null) ) { id, notification -> displayNotification(context, id, notification) } } } else if (pkg != null) { downloadFromResume(pkg) { id, notification -> displayNotification(context, id, notification) } } return } catch (e: Exception) { isFailed = true logError(e) return } } private suspend fun downloadFromResume() { val resumePackage = downloadQueueWrapper.resumePackage ?: return downloadFromResume(resumePackage) { id, notification -> displayNotification(context, id, notification) } } fun startDownload() { Log.d(TAG, "Starting download ${downloadQueueWrapper.id}") setKey(KEY_RESUME_IN_QUEUE, downloadQueueWrapper.id.toString(), downloadQueueWrapper) ioSafe { if (downloadQueueWrapper.resumePackage != null) { downloadFromResume() // Load links if they are not already loaded } else if (downloadQueueWrapper.downloadItem != null && downloadQueueWrapper.downloadItem.links.isNullOrEmpty()) { downloadEpisodeWithoutLinks() } else if (downloadQueueWrapper.downloadItem?.links != null) { downloadEpisodeWithLinks( sortUrls(downloadQueueWrapper.downloadItem.links.toSet()), downloadQueueWrapper.downloadItem.subs ) } } } private fun downloadEpisodeWithLinks( links: List, subs: List? ) { val downloadItem = downloadQueueWrapper.downloadItem ?: return try { // Prepare visual keys setKey( DOWNLOAD_HEADER_CACHE, downloadItem.resultId.toString(), DownloadObjects.DownloadHeaderCached( apiName = downloadItem.apiName, url = downloadItem.resultUrl, type = downloadItem.resultType, name = downloadItem.resultName, poster = downloadItem.resultPoster, id = downloadItem.resultId, cacheTime = System.currentTimeMillis(), ) ) setKey( getFolderName( DOWNLOAD_EPISODE_CACHE, downloadItem.resultId.toString() ), // 3 deep folder for faster access downloadItem.episode.id.toString(), DownloadObjects.DownloadEpisodeCached( name = downloadItem.episode.name, poster = downloadItem.episode.poster, episode = downloadItem.episode.episode, season = downloadItem.episode.season, id = downloadItem.episode.id, parentId = downloadItem.resultId, score = downloadItem.episode.score, description = downloadItem.episode.description, cacheTime = System.currentTimeMillis(), ) ) val meta = getDownloadEpisodeMetadata( downloadItem.episode, downloadItem.resultName, downloadItem.apiName, downloadItem.resultPoster, downloadItem.isMovie, downloadItem.resultType ) val folder = getFolder(downloadItem.resultType, downloadItem.resultName) val src = "$DOWNLOAD_NAVIGATE_TO/${downloadItem.resultId}" // DOWNLOAD VIDEO val info = DownloadItem(src, folder, meta, links) this.downloadJob = ioSafe { startDownload(info, null) } // 1. Checks if the lang should be downloaded // 2. Makes it into the download format // 3. Downloads it as a .vtt file this.subtitleDownloadJob = ioSafe { try { val downloadList = SubtitlesFragment.getDownloadSubsLanguageTagIETF() subs?.filter { subtitle -> downloadList.any { langTagIETF -> subtitle.languageCode == langTagIETF || subtitle.originalName.contains( fromTagToEnglishLanguageName( langTagIETF ) ?: langTagIETF ) } } ?.map { ExtractorSubtitleLink(it.name, it.url, "", it.headers) } ?.take(3) // max subtitles download hardcoded (?_?) ?.forEach { link -> val fileName = getFileName(context, meta) downloadSubtitle(context, link, fileName, folder) } } catch (_: CancellationException) { val fileName = getFileName(context, meta) val info = DownloadedFileInfo( totalBytes = 0, relativePath = folder, displayName = fileName, basePath = context.getBasePath().second ) deleteMatchingSubtitles(context, info) } } } catch (e: Exception) { // The work is only failed if the job did not get started if (this.downloadJob == null) { isFailed = true } logError(e) } } private suspend fun downloadEpisodeWithoutLinks() { val downloadItem = downloadQueueWrapper.downloadItem ?: return val generator = RepoLinkGenerator(listOf(downloadItem.episode)) val currentLinks = mutableSetOf() val currentSubs = mutableSetOf() val meta = getDownloadEpisodeMetadata( downloadItem.episode, downloadItem.resultName, downloadItem.apiName, downloadItem.resultPoster, downloadItem.isMovie, downloadItem.resultType ) createDownloadNotification( context, downloadItem.apiName, txt(R.string.loading).asString(context), meta, DownloadType.IsPending, 0, 1, { _, _ -> }, null, null, 0 )?.let { linkLoadingNotification -> displayNotification(context, downloadItem.episode.id, linkLoadingNotification) } linkLoadingJob = ioSafe { generator.generateLinks( clearCache = false, sourceTypes = LOADTYPE_INAPP_DOWNLOAD, callback = { it.first?.let { link -> currentLinks.add(link) } }, subtitleCallback = { sub -> currentSubs.add(sub) }) } // Wait for link loading completion linkLoadingJob?.join() // Remove link loading notification NotificationManagerCompat.from(context).cancel(DOWNLOAD_NOTIFICATION_TAG, downloadItem.episode.id) if (linkLoadingJob?.isCancelled == true) { // Same as if no links, but no toast. // Cancelled link loading is presumed to be user initiated isCancelled = true return } else if (currentLinks.isEmpty()) { main { showToast( R.string.no_links_found_toast, Toast.LENGTH_SHORT ) } isFailed = true return } else { main { showToast( R.string.download_started, Toast.LENGTH_SHORT ) } } // Profiles should always contain a download type val profile = QualityDataHelper.getProfiles().first { it.types.contains( QualityDataHelper.QualityProfileType.Download) } val sortedLinks = currentLinks.sortedBy { link -> // Negative, because the highest priority should be first -getLinkPriority(profile.id, link) } downloadEpisodeWithLinks( sortedLinks, sortSubs(currentSubs), ) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadObjects.kt ================================================ package com.lagradost.cloudstream3.utils.downloader import android.net.Uri import com.fasterxml.jackson.annotation.JsonProperty import com.lagradost.cloudstream3.Score import com.lagradost.cloudstream3.TvType import com.lagradost.cloudstream3.services.DownloadQueueService import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.result.ResultEpisode import com.lagradost.cloudstream3.utils.ExtractorLink import com.lagradost.safefile.SafeFile import java.io.IOException import java.io.OutputStream import java.util.Objects object DownloadObjects { /** An item can either be something to resume or something new to start */ data class DownloadQueueWrapper( @JsonProperty("resumePackage") val resumePackage: DownloadResumePackage?, @JsonProperty("downloadItem") val downloadItem: DownloadQueueItem?, ) { init { assert(resumePackage != null || downloadItem != null) { "ResumeID and downloadItem cannot both be null at the same time!" } } /** Loop through the current download instances to see if it is currently downloading. Also includes link loading. */ fun isCurrentlyDownloading(): Boolean { return DownloadQueueService.downloadInstances.value.any { it.downloadQueueWrapper.id == this.id } } @JsonProperty("id") val id = resumePackage?.item?.ep?.id ?: downloadItem!!.episode.id @JsonProperty("parentId") val parentId = resumePackage?.item?.ep?.parentId ?: downloadItem!!.episode.parentId } /** General data about the episode and show to start a download from. */ data class DownloadQueueItem( @JsonProperty("episode") val episode: ResultEpisode, @JsonProperty("isMovie") val isMovie: Boolean, @JsonProperty("resultName") val resultName: String, @JsonProperty("resultType") val resultType: TvType, @JsonProperty("resultPoster") val resultPoster: String?, @JsonProperty("apiName") val apiName: String, @JsonProperty("resultId") val resultId: Int, @JsonProperty("resultUrl") val resultUrl: String, @JsonProperty("links") val links: List? = null, @JsonProperty("subs") val subs: List? = null, ) { fun toWrapper(): DownloadQueueWrapper { return DownloadQueueWrapper(null, this) } } abstract class DownloadCached( @JsonProperty("id") open val id: Int, ) data class DownloadEpisodeCached( @JsonProperty("name") val name: String?, @JsonProperty("poster") val poster: String?, @JsonProperty("episode") val episode: Int, @JsonProperty("season") val season: Int?, @JsonProperty("parentId") val parentId: Int, @JsonProperty("score") var score: Score? = null, @JsonProperty("description") val description: String?, @JsonProperty("cacheTime") val cacheTime: Long, override val id: Int, ) : DownloadCached(id) { @JsonProperty("rating", access = JsonProperty.Access.WRITE_ONLY) @Deprecated( "`rating` is the old scoring system, use score instead", replaceWith = ReplaceWith("score"), level = DeprecationLevel.ERROR ) var rating: Int? = null set(value) { if (value != null) { @Suppress("DEPRECATION_ERROR") score = Score.fromOld(value) } } } /** What to display to the user for a downloaded show/movie. Includes info such as name, poster and url */ data class DownloadHeaderCached( @JsonProperty("apiName") val apiName: String, @JsonProperty("url") val url: String, @JsonProperty("type") val type: TvType, @JsonProperty("name") val name: String, @JsonProperty("poster") val poster: String?, @JsonProperty("cacheTime") val cacheTime: Long, override val id: Int, ) : DownloadCached(id) data class DownloadResumePackage( @JsonProperty("item") val item: DownloadItem, /** Tills which link should get resumed */ @JsonProperty("linkIndex") val linkIndex: Int?, ) { fun toWrapper(): DownloadQueueWrapper { return DownloadQueueWrapper(this, null) } } data class DownloadItem( @JsonProperty("source") val source: String?, @JsonProperty("folder") val folder: String?, @JsonProperty("ep") val ep: DownloadEpisodeMetadata, @JsonProperty("links") val links: List, ) /** Metadata for a specific episode and how to display it. */ data class DownloadEpisodeMetadata( @JsonProperty("id") val id: Int, @JsonProperty("parentId") val parentId: Int, @JsonProperty("mainName") val mainName: String, @JsonProperty("sourceApiName") val sourceApiName: String?, @JsonProperty("poster") val poster: String?, @JsonProperty("name") val name: String?, @JsonProperty("season") val season: Int?, @JsonProperty("episode") val episode: Int?, @JsonProperty("type") val type: TvType?, ) data class DownloadedFileInfo( @JsonProperty("totalBytes") val totalBytes: Long, @JsonProperty("relativePath") val relativePath: String, @JsonProperty("displayName") val displayName: String, @JsonProperty("extraInfo") val extraInfo: String? = null, @JsonProperty("basePath") val basePath: String? = null // null is for legacy downloads. See getBasePath() ) data class DownloadedFileInfoResult( @JsonProperty("fileLength") val fileLength: Long, @JsonProperty("totalBytes") val totalBytes: Long, @JsonProperty("path") val path: Uri, ) data class ResumeWatching( @JsonProperty("parentId") val parentId: Int, @JsonProperty("episodeId") val episodeId: Int?, @JsonProperty("episode") val episode: Int?, @JsonProperty("season") val season: Int?, @JsonProperty("updateTime") val updateTime: Long, @JsonProperty("isFromDownload") val isFromDownload: Boolean, ) data class DownloadStatus( /** if you should retry with the same args and hope for a better result */ val retrySame: Boolean, /** if you should try the next mirror */ val tryNext: Boolean, /** if the result is what the user intended */ val success: Boolean, ) data class CreateNotificationMetadata( val type: VideoDownloadManager.DownloadType, val bytesDownloaded: Long, val bytesTotal: Long, val hlsProgress: Long? = null, val hlsTotal: Long? = null, val bytesPerSecond: Long ) data class StreamData( private val fileLength: Long, val file: SafeFile, //val fileStream: OutputStream, ) { @Throws(IOException::class) fun open(): OutputStream { return file.openOutputStreamOrThrow(resume) } @Throws(IOException::class) fun openNew(): OutputStream { return file.openOutputStreamOrThrow(false) } fun delete(): Boolean { return file.delete() == true } val resume: Boolean get() = fileLength > 0L val startAt: Long get() = if (resume) fileLength else 0L val exists: Boolean get() = file.exists() == true } /** bytes have the size end-start where the byte range is [start,end) * note that ByteArray is a pointer and therefore cant be stored without cloning it */ data class LazyStreamDownloadResponse( val bytes: ByteArray, val startByte: Long, val endByte: Long, ) { val size get() = endByte - startByte override fun toString(): String { return "$startByte->$endByte" } override fun equals(other: Any?): Boolean { if (other !is LazyStreamDownloadResponse) return false return other.startByte == startByte && other.endByte == endByte } override fun hashCode(): Int { return Objects.hash(startByte, endByte) } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadQueueManager.kt ================================================ package com.lagradost.cloudstream3.utils.downloader import android.content.Context import android.util.Log import androidx.core.content.ContextCompat import com.lagradost.cloudstream3.CloudStreamApp import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKey import com.lagradost.cloudstream3.CloudStreamApp.Companion.getKeys import com.lagradost.cloudstream3.CloudStreamApp.Companion.removeKeys import com.lagradost.cloudstream3.CloudStreamApp.Companion.setKey import com.lagradost.cloudstream3.MainActivity.Companion.lastError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.plugins.PluginManager import com.lagradost.cloudstream3.services.DownloadQueueService import com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.downloader.DownloadObjects.DownloadQueueWrapper import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.KEY_RESUME_IN_QUEUE import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadStatus import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.downloadStatusEvent import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadFileInfo import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadQueuePackage import com.lagradost.cloudstream3.utils.downloader.VideoDownloadManager.getDownloadResumePackage import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.updateAndGet // 1. Put a download on the queue // 2. The queue manager starts a foreground service to handle the queue // 3. The service starts work manager jobs to handle the downloads? object DownloadQueueManager { private const val TAG = "DownloadQueueManager" const val QUEUE_KEY = "download_queue_key" /** Flow of all active queued download, no active downloads. * This flow may see many changes, do not place expensive observers. * downloadInstances is the flow keeping track of active downloads. * @see com.lagradost.cloudstream3.services.DownloadQueueService.Companion.downloadInstances */ private val _queue: MutableStateFlow> by lazy { /** Persistent queue */ val currentValue = getKey>(QUEUE_KEY) ?: emptyArray() MutableStateFlow(currentValue) } val queue: StateFlow> by lazy { _queue } /** Start the queue, marks all queue objects as in progress. * Note that this may run twice without the service restarting * because MainActivity may be recreated. */ fun init(context: Context) { ioSafe { _queue.collect { queue -> setKey(QUEUE_KEY, queue) } } ioSafe startQueue@{ // Do not automatically start the queue if safe mode is activated. if (PluginManager.isSafeMode()) { // Prevent misleading UI VideoDownloadManager.cancelAllDownloadNotifications(context) return@startQueue } val resumeQueue = getPreResumeIds().filterNot { VideoDownloadManager.currentDownloads.value.contains(it) } .mapNotNull { id -> getDownloadResumePackage(context, id)?.toWrapper() ?: getDownloadQueuePackage(context, id) } val newQueue = _queue.updateAndGet { localQueue -> // Add resume packages to the first part of the queue, since they may have been removed from the queue when they started (resumeQueue + localQueue).distinctBy { it.id }.toTypedArray() } // Once added to the queue they can be safely removed removeKeys(KEY_RESUME_IN_QUEUE) // Make sure the download buttons display a pending status newQueue.forEach { obj -> setQueueStatus(obj.id, VideoDownloadManager.DownloadType.IsPending) } if (newQueue.any()) { startQueueService(context) } } } /** Downloads not yet started or in progress. */ private fun getPreResumeIds(): Set { return getKeys(KEY_RESUME_IN_QUEUE)?.mapNotNull { it.substringAfter("$KEY_RESUME_IN_QUEUE/").toIntOrNull() }?.toSet() ?: emptySet() } /** Adds an object to the internal persistent queue. It does not re-add an existing item. @return true if successfully added */ private fun add(downloadQueueWrapper: DownloadQueueWrapper): Boolean { Log.d(TAG, "Download added to queue: $downloadQueueWrapper") val newQueue = _queue.updateAndGet { localQueue -> // Do not add the same episode twice if (downloadQueueWrapper.isCurrentlyDownloading() || localQueue.any { it.id == downloadQueueWrapper.id }) { return@updateAndGet localQueue } localQueue + downloadQueueWrapper } return newQueue.any { it.id == downloadQueueWrapper.id } } /** Removes all objects with the same id from the internal persistent queue */ private fun remove(id: Int) { Log.d(TAG, "Download removed from the queue: $id") _queue.update { localQueue -> // The check is to prevent unnecessary updates if (!localQueue.any { it.id == id }) { return@update localQueue } localQueue.filter { it.id != id }.toTypedArray() } } /** Removes all items and returns the previous queue */ private fun removeAll(): Array { Log.d(TAG, "Removed everything from queue") return _queue.getAndUpdate { emptyArray() } } private fun reorder(downloadQueueWrapper: DownloadQueueWrapper, newPosition: Int) { _queue.update { localQueue -> val newIndex = newPosition.coerceIn(0, localQueue.size) val id = downloadQueueWrapper.id val newQueue = localQueue.filter { it.id != id }.toMutableList().apply { this.add(newIndex, downloadQueueWrapper) }.toTypedArray() newQueue } } /** Start a real download from the first item in the queue */ fun popQueue(context: Context): VideoDownloadManager.EpisodeDownloadInstance? { val first = queue.value.firstOrNull() ?: return null remove(first.id) val downloadInstance = VideoDownloadManager.EpisodeDownloadInstance(context, first) return downloadInstance } /** Marks the item as in queue for the download button */ private fun setQueueStatus(id: Int, status: VideoDownloadManager.DownloadType) { downloadStatusEvent.invoke( Pair( id, status ) ) downloadStatus[id] = status } private fun startQueueService(context: Context?) { if (context == null) { Log.d(TAG, "Cannot start download queue service, null context.") return } // Do not restart the download queue service if (DownloadQueueService.isRunning) { return } ioSafe { val intent = DownloadQueueService.getIntent(context) ContextCompat.startForegroundService(context, intent) } } /** Cancels an active download or removes it from queue depending on where it is. */ fun cancelDownload(id: Int) { Log.d(TAG, "Cancelling download: $id") val currentInstance = downloadInstances.value.find { it.downloadQueueWrapper.id == id } if (currentInstance != null) { currentInstance.cancelDownload() } else { removeFromQueue(id) } } /** Removes all queued items */ fun removeAllFromQueue() { removeAll().forEach { wrapper -> setQueueStatus(wrapper.id, VideoDownloadManager.DownloadType.IsStopped) } } /** Removes all objects with the same id from the internal persistent queue */ fun removeFromQueue(id: Int) { ioSafe { remove(id) setQueueStatus(id, VideoDownloadManager.DownloadType.IsStopped) } } /** Will move the download queue wrapper to a new position in the queue. * If the item does not exist it will also insert it. */ fun reorderItem(downloadQueueWrapper: DownloadQueueWrapper, newPosition: Int) { ioSafe { reorder(downloadQueueWrapper, newPosition) } } /** Add a new object to the queue. Will not queue completed downloads or current downloads. */ fun addToQueue(downloadQueueWrapper: DownloadQueueWrapper) = safe { val context = CloudStreamApp.context ?: return@safe val fileInfo = getDownloadFileInfo(context, downloadQueueWrapper.id) val isComplete = fileInfo != null && // Assure no division by 0 fileInfo.totalBytes > 0 && // If more than 98% downloaded then do not add to queue (fileInfo.fileLength.toFloat() / fileInfo.totalBytes.toFloat()) > 0.98f // Do not queue completed files! if (isComplete) return@safe if (add(downloadQueueWrapper)) { setQueueStatus(downloadQueueWrapper.id, VideoDownloadManager.DownloadType.IsPending) startQueueService(context) } } /** Refreshes the queue flow with the same value, but copied. * Good to run if the downloads are affected by some outside value change. */ fun forceRefreshQueue() { _queue.update { localQueue -> localQueue.copyOf() } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/utils/downloader/DownloadUtils.kt ================================================ package com.lagradost.cloudstream3.utils.downloader import android.content.Context import android.graphics.Bitmap import androidx.core.graphics.drawable.toBitmap import coil3.Extras import coil3.SingletonImageLoader import coil3.asDrawable import coil3.request.ImageRequest import coil3.request.SuccessResult import com.lagradost.cloudstream3.R import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.safe import com.lagradost.cloudstream3.ui.player.SubtitleData import com.lagradost.cloudstream3.ui.result.ExtractorSubtitleLink import com.lagradost.cloudstream3.utils.Coroutines.ioSafe import com.lagradost.cloudstream3.utils.UiText import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFileName import com.lagradost.cloudstream3.utils.downloader.DownloadFileManagement.getFolder import com.lagradost.cloudstream3.utils.txt import kotlinx.coroutines.Job import kotlinx.coroutines.runBlocking /** Separate object with helper functions for the downloader */ object DownloadUtils { private val cachedBitmaps = hashMapOf() internal fun Context.getImageBitmapFromUrl( url: String, headers: Map? = null ): Bitmap? = safe { if (cachedBitmaps.containsKey(url)) { return@safe cachedBitmaps[url] } val imageLoader = SingletonImageLoader.get(this) val request = ImageRequest.Builder(this) .data(url) .apply { headers?.forEach { (key, value) -> extras[Extras.Key(key)] = value } } .build() val bitmap = runBlocking { val result = imageLoader.execute(request) (result as? SuccessResult)?.image?.asDrawable(applicationContext.resources) ?.toBitmap() } bitmap?.let { cachedBitmaps[url] = it } return@safe bitmap } //calculate the time internal fun getEstimatedTimeLeft( context: Context, bytesPerSecond: Long, progress: Long, total: Long ): String { if (bytesPerSecond <= 0) return "" val timeInSec = (total - progress) / bytesPerSecond val hrs = timeInSec / 3600 val mins = (timeInSec % 3600) / 60 val secs = timeInSec % 60 val timeFormated: UiText? = when { hrs > 0 -> txt( R.string.download_time_left_hour_min_sec_format, hrs, mins, secs ) mins > 0 -> txt( R.string.download_time_left_min_sec_format, mins, secs ) secs > 0 -> txt( R.string.download_time_left_sec_format, secs ) else -> null } return timeFormated?.asString(context) ?: "" } internal fun downloadSubtitle( context: Context?, link: ExtractorSubtitleLink, fileName: String, folder: String ) { ioSafe { VideoDownloadManager.downloadThing( context ?: return@ioSafe, link, "$fileName ${link.name}", folder, if (link.url.contains(".srt")) "srt" else "vtt", false, null, createNotificationCallback = {} ) } } fun downloadSubtitle( context: Context?, link: SubtitleData, meta: DownloadObjects.DownloadEpisodeMetadata, ) { context?.let { ctx -> val fileName = getFileName(ctx, meta) val folder = getFolder(meta.type ?: return, meta.mainName) downloadSubtitle( ctx, ExtractorSubtitleLink(link.name, link.url, "", link.headers), fileName, folder ) } } /** Helper function to make sure duplicate attributes don't get overridden or inserted without lowercase cmp * example: map("a" to 1) appendAndDontOverride map("A" to 2, "a" to 3, "c" to 4) = map("a" to 1, "c" to 4) * */ internal fun Map.appendAndDontOverride(rhs: Map): Map { val out = this.toMutableMap() val current = this.keys.map { it.lowercase() } for ((key, value) in rhs) { if (current.contains(key.lowercase())) continue out[key] = value } return out } internal fun List.cancel() { forEach { job -> try { job.cancel() } catch (t: Throwable) { logError(t) } } } internal suspend fun List.join() { forEach { job -> try { job.join() } catch (t: Throwable) { logError(t) } } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/widget/CenterZoomLayoutManager.kt ================================================ package com.lagradost.cloudstream3.widget import android.content.Context import android.util.AttributeSet import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearSnapHelper import androidx.recyclerview.widget.RecyclerView import com.lagradost.cloudstream3.mvvm.logError import kotlin.math.abs import kotlin.math.min class CenterZoomLayoutManager : LinearLayoutManager { constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : super( context, attrs, defStyleAttr, defStyleRes ) constructor(context: Context?) : super(context) constructor(context: Context?, orientation: Int, reverseLayout: Boolean) : super( context, orientation, reverseLayout ) private var itemListener: ((Int) -> Unit)? = null // to not spam updates private var lastViewIndex: Int? = null private val mShrinkAmount = 0.15f private val mShrinkDistance = 0.9f fun updateSize(forceUpdate: Boolean = false) { val midpoint = width / 2f val d0 = 0f val d1 = mShrinkDistance * midpoint val s0 = 1f val s1 = 1f - mShrinkAmount var largestTag: Int? = null var largestSize = 0f for (i in 0 until childCount) { getChildAt(i)?.let { child -> try { val childMidpoint = (getDecoratedRight(child) + getDecoratedLeft(child)) / 2f val d = min(d1, abs(midpoint - childMidpoint)) val scale = s0 + (s1 - s0) * (d - d0) / (d1 - d0) child.scaleX = scale child.scaleY = scale if (scale > largestSize) { (child.tag as Int?)?.let { tag -> largestSize = scale largestTag = tag } } } catch (t : Throwable) { logError(t) } } } largestTag?.let { tag -> if (lastViewIndex != tag || forceUpdate) { lastViewIndex = tag itemListener?.invoke(tag) } } } fun setOnSizeListener(listener: (Int) -> Unit) { lastViewIndex = null itemListener = listener } fun removeOnSizeListener() { itemListener = null } override fun onLayoutCompleted(state: RecyclerView.State?) { super.onLayoutCompleted(state) if(waitForSnap != null) { this.getChildAt(snapChild ?: 1)?.let { view -> LinearSnapHelper().calculateDistanceToFinalSnap(this,view)?.get(0)?.let { dx -> waitForSnap?.invoke(dx) waitForSnap = null } } } updateSize() } private var waitForSnap : ((Int) -> Unit)? = null private var snapChild : Int? = null fun snap(snap : Int? = null, callback : (Int) -> Unit) { waitForSnap = callback snapChild = snap } override fun scrollHorizontallyBy(dx: Int, recycler: RecyclerView.Recycler, state: RecyclerView.State): Int { val orientation = orientation return if (orientation == HORIZONTAL) { val scrolled = super.scrollHorizontallyBy(dx, recycler, state) updateSize() scrolled } else { 0 } } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/widget/FlowLayout.kt ================================================ package com.lagradost.cloudstream3.widget import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.ViewGroup import androidx.core.content.withStyledAttributes import androidx.core.view.isVisible import androidx.core.view.marginEnd import com.lagradost.cloudstream3.R import kotlin.math.max class FlowLayout : ViewGroup { var itemSpacing: Int = 0 constructor(context: Context?) : super(context) //@JvmOverloads //constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int = 0) : super(context, attrs, defStyleAttr) @SuppressLint("CustomViewStyleable") internal constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) { c.withStyledAttributes(attrs, R.styleable.FlowLayout_Layout) { itemSpacing = getDimensionPixelSize(R.styleable.FlowLayout_Layout_itemSpacing, 0) } } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val realWidth = MeasureSpec.getSize(widthMeasureSpec) var currentHeight = 0 var currentWidth = 0 var currentChildHookPointx = 0 var currentChildHookPointy = 0 val childCount = this.childCount for (i in 0 until childCount) { val child = getChildAt(i) if (!child.isVisible) { continue } measureChild(child, widthMeasureSpec, heightMeasureSpec) val childWidth = child.measuredWidth val childHeight = child.measuredHeight //check if child can be placed in the current row, else go to next line if (currentChildHookPointx + childWidth - child.marginEnd - child.paddingEnd > realWidth) { //new line currentWidth = max(currentWidth, currentChildHookPointx) //reset for new line currentChildHookPointx = 0 currentChildHookPointy += childHeight + itemSpacing } currentHeight = max(currentHeight, currentChildHookPointy + childHeight) val nextChildHookPointx = currentChildHookPointx + childWidth + if (childWidth == 0) 0 else itemSpacing val nextChildHookPointy = currentChildHookPointy val lp = child.layoutParams as LayoutParams lp.x = currentChildHookPointx lp.y = currentChildHookPointy currentChildHookPointx = nextChildHookPointx currentChildHookPointy = nextChildHookPointy } currentWidth = max(currentChildHookPointx, currentWidth) setMeasuredDimension( resolveSize(currentWidth, widthMeasureSpec), resolveSize(currentHeight, heightMeasureSpec) ) } override fun onLayout(b: Boolean, left: Int, top: Int, right: Int, bottom: Int) { //call layout on children val childCount = this.childCount for (i in 0 until childCount) { val child = getChildAt(i) val lp = child.layoutParams as LayoutParams child.layout(lp.x, lp.y, lp.x + child.measuredWidth, lp.y + child.measuredHeight) } } override fun generateLayoutParams(attrs: AttributeSet): LayoutParams { return LayoutParams(context, attrs) } override fun generateDefaultLayoutParams(): LayoutParams { return LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT ) } override fun generateLayoutParams(p: ViewGroup.LayoutParams): LayoutParams { return LayoutParams(p) } override fun checkLayoutParams(p: ViewGroup.LayoutParams): Boolean { return p is LayoutParams } class LayoutParams : MarginLayoutParams { var spacing = -1 var x = 0 var y = 0 @SuppressLint("CustomViewStyleable") internal constructor(c: Context, attrs: AttributeSet?) : super(c, attrs) { c.withStyledAttributes(attrs, R.styleable.FlowLayout_Layout) { spacing = 0 } } internal constructor(width: Int, height: Int) : super(width, height) { spacing = 0 } constructor(source: MarginLayoutParams?) : super(source) internal constructor(source: ViewGroup.LayoutParams?) : super(source) } } ================================================ FILE: app/src/main/java/com/lagradost/cloudstream3/widget/LinearRecycleViewLayoutManager.kt ================================================ package com.lagradost.cloudstream3.widget import android.content.Context import android.view.View import androidx.recyclerview.widget.LinearLayoutManager class LinearRecycleViewLayoutManager( val context: Context, val nextFocusUp: Int, val nextFocusDown: Int ) : LinearLayoutManager(context) { override fun onInterceptFocusSearch(focused: View, direction: Int): View? { return try { val position = getPosition(focused) val count = itemCount //println("onInterceptFocusSearch position=$position count=$count focused=$focused direction=$direction") (if (position == count - 1 && direction == View.FOCUS_DOWN) { focused.rootView.findViewById(nextFocusDown) } else if (position == 0 && direction == View.FOCUS_UP) { focused.rootView.findViewById(nextFocusUp) } else { super.onInterceptFocusSearch(focused, direction) }) ?: super.onInterceptFocusSearch(focused, direction) } catch (t : Throwable) { super.onInterceptFocusSearch(focused, direction) } } } ================================================ FILE: app/src/main/res/anim/enter_anim.xml ================================================ ================================================ FILE: app/src/main/res/anim/exit_anim.xml ================================================ ================================================ FILE: app/src/main/res/anim/go_left.xml ================================================ ================================================ FILE: app/src/main/res/anim/go_right.xml ================================================ ================================================ FILE: app/src/main/res/anim/pop_enter.xml ================================================ ================================================ FILE: app/src/main/res/anim/pop_exit.xml ================================================ ================================================ FILE: app/src/main/res/anim/rotate_around_center_point.xml ================================================ ================================================ FILE: app/src/main/res/anim/rotate_left.xml ================================================ ================================================ FILE: app/src/main/res/anim/rotate_right.xml ================================================ ================================================ FILE: app/src/main/res/color/black_button_ripple.xml ================================================ ================================================ FILE: app/src/main/res/color/button_selector_color.xml ================================================ ================================================ FILE: app/src/main/res/color/check_selection_color.xml ================================================ ================================================ FILE: app/src/main/res/color/chip_color.xml ================================================ ================================================ FILE: app/src/main/res/color/chip_color_text.xml ================================================ ================================================ FILE: app/src/main/res/color/color_primary_transparent.xml ================================================ ================================================ FILE: app/src/main/res/color/item_select_color.xml ================================================ ================================================ FILE: app/src/main/res/color/item_select_color_tv.xml ================================================ ================================================ FILE: app/src/main/res/color/player_button_tv.xml ================================================ ================================================ FILE: app/src/main/res/color/player_on_button_tv.xml ================================================ ================================================ FILE: app/src/main/res/color/player_on_button_tv_attr.xml ================================================ ================================================ FILE: app/src/main/res/color/selectable_black.xml ================================================ ================================================ FILE: app/src/main/res/color/selectable_white.xml ================================================ ================================================ FILE: app/src/main/res/color/tag_stroke_color.xml ================================================ ================================================ FILE: app/src/main/res/color/text_selection_color.xml ================================================ ================================================ FILE: app/src/main/res/color/toggle_button.xml ================================================ ================================================ FILE: app/src/main/res/color/toggle_button_outline.xml ================================================ ================================================ FILE: app/src/main/res/color/toggle_button_text.xml ================================================ ================================================ FILE: app/src/main/res/color/toggle_selector.xml ================================================ ================================================ FILE: app/src/main/res/color/white_attr_20.xml ================================================ ================================================ FILE: app/src/main/res/color/white_transparent_toggle.xml ================================================ ================================================ FILE: app/src/main/res/drawable/arrow_and_edge_24px.xml ================================================ ================================================ FILE: app/src/main/res/drawable/arrow_or_edge_24px.xml ================================================ ================================================ FILE: app/src/main/res/drawable/arrows_input_24px.xml ================================================ ================================================ FILE: app/src/main/res/drawable/background_shadow.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_description_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_downloading_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_fullscreen_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_fullscreen_exit_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_grid_view_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_headphones_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_help_outline_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_list_alt_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_network_ping_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_notifications_none_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_remove_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_restore_page_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_save_as_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_skip_previous_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_stop_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_sync_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_text_snippet_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_theaters_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/benene.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_color_both.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_color_bottom.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_color_center.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_color_top.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bg_imdb_badge.xml ================================================ ================================================ FILE: app/src/main/res/drawable/bookmark_star_24px.xml ================================================ ================================================ FILE: app/src/main/res/drawable/circle_shape.xml ================================================ ================================================ FILE: app/src/main/res/drawable/circle_shape_dotted.xml ================================================ ================================================ FILE: app/src/main/res/drawable/circular_progress_bar.xml ================================================ ================================================ FILE: app/src/main/res/drawable/circular_progress_bar_clockwise.xml ================================================ ================================================ FILE: app/src/main/res/drawable/circular_progress_bar_counter_clockwise.xml ================================================ ================================================ FILE: app/src/main/res/drawable/circular_progress_bar_filled.xml ================================================ ================================================ FILE: app/src/main/res/drawable/circular_progress_bar_small_to_large.xml ================================================ ================================================ FILE: app/src/main/res/drawable/circular_progress_bar_top_to_bottom.xml ================================================ ================================================ FILE: app/src/main/res/drawable/clear_all_24px.xml ================================================ ================================================ FILE: app/src/main/res/drawable/cloud_2.xml ================================================ ================================================ FILE: app/src/main/res/drawable/cloud_2_gradient.xml ================================================ ================================================ FILE: app/src/main/res/drawable/cloud_2_gradient_beta.xml ================================================ ================================================ FILE: app/src/main/res/drawable/cloud_2_gradient_beta_old.xml ================================================ ================================================ FILE: app/src/main/res/drawable/cloud_2_gradient_debug.xml ================================================ ================================================ FILE: app/src/main/res/drawable/cloud_2_solid.xml ================================================ ================================================ FILE: app/src/main/res/drawable/custom_rating_bar.xml ================================================ ================================================ FILE: app/src/main/res/drawable/dashed_line_horizontal.xml ================================================ ================================================ FILE: app/src/main/res/drawable/default_cover.xml ================================================ ================================================ FILE: app/src/main/res/drawable/delete_all.xml ================================================ ================================================ FILE: app/src/main/res/drawable/dialog__window_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/download_icon_done.xml ================================================ ================================================ FILE: app/src/main/res/drawable/download_icon_error.xml ================================================ ================================================ FILE: app/src/main/res/drawable/download_icon_load.xml ================================================ ================================================ FILE: app/src/main/res/drawable/download_icon_pause.xml ================================================ ================================================ FILE: app/src/main/res/drawable/dub_bg_color.xml ================================================ ================================================ FILE: app/src/main/res/drawable/episodes_shadow.xml ================================================ ================================================ FILE: app/src/main/res/drawable/go_back_30.xml ================================================ ================================================ FILE: app/src/main/res/drawable/go_forward_30.xml ================================================ ================================================ FILE: app/src/main/res/drawable/home_alt.xml ================================================ ================================================ FILE: app/src/main/res/drawable/home_icon_filled_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/home_icon_outline_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/home_icon_selector.xml ================================================ ================================================ FILE: app/src/main/res/drawable/hourglass_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_anilist_icon.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_banner_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_add_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_arrow_back_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_arrow_back_ios_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_arrow_forward_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_aspect_ratio_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_autorenew_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_bookmark_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_bookmark_border_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_brightness_1_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_brightness_2_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_brightness_3_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_brightness_4_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_brightness_5_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_brightness_6_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_brightness_7_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_check_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_check_24_listview.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_clear_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_close_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_collections_bookmark_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_color_lens_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_construction_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_delete_outline_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_developer_mode_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_discord_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_dns_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_edit_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_equalizer_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_exit_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_extension_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_fast_forward_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_favorite_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_favorite_border_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_film_roll_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_filter_list_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_folder_open_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_hd_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_hearing_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_keyboard_arrow_down_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_keyboard_arrow_left_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_keyboard_arrow_right_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_language_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_more_vert_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_north_west_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_notifications_active_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_ondemand_video_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_open_in_new_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_pause_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_people_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_picture_in_picture_alt_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_play_arrow_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_playlist_play_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_public_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_remove_red_eye_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_replay_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_restart_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_resume_arrow.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_resume_arrow2.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_skip_next_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_skip_next_24_big.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_skip_next_rounded_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_sort_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_speed_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_star_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_star_border_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_storage_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_subtitles_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_system_update_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_text_format_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_thumb_down_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_thumb_up_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_touch_app_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_tune_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_tv_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_visibility_off_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_volume_down_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_volume_mute_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_volume_up_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_baseline_warning_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_battery.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cloudstream_monochrome.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cloudstream_monochrome_big.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cloudstreamlogotv.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cloudstreamlogotv_2.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cloudstreamlogotv_pre.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_cloudstreamlogotv_pre_2.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_dashboard_black_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_filled_notifications_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_fingerprint.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_github_logo.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_home_black_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_launcher_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_mic.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_network_stream.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_notifications_black_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_offline_pin_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_account_circle_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_home_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_info_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_notifications_24dp.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_remove_red_eye_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_settings_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_share_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_subtitles_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_outline_voice_over_off_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_refresh.xml ================================================ ================================================ FILE: app/src/main/res/drawable/indicator_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/kid_star_24px.xml ================================================ ================================================ FILE: app/src/main/res/drawable/kitsu_icon.xml ================================================ ================================================ FILE: app/src/main/res/drawable/library_icon.xml ================================================ ================================================ FILE: app/src/main/res/drawable/library_icon_filled.xml ================================================ ================================================ FILE: app/src/main/res/drawable/library_icon_selector.xml ================================================ ================================================ FILE: app/src/main/res/drawable/mal_logo.xml ================================================ ================================================ FILE: app/src/main/res/drawable/material_outline_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/monke_benene.xml ================================================ ================================================ FILE: app/src/main/res/drawable/monke_burrito.xml ================================================ ================================================ FILE: app/src/main/res/drawable/monke_coco.xml ================================================ ================================================ FILE: app/src/main/res/drawable/monke_cookie.xml ================================================ ================================================ FILE: app/src/main/res/drawable/monke_drink.xml ================================================ ================================================ FILE: app/src/main/res/drawable/monke_flusdered.xml ================================================ ================================================ FILE: app/src/main/res/drawable/monke_funny.xml ================================================ ================================================ FILE: app/src/main/res/drawable/monke_like.xml ================================================ ================================================ FILE: app/src/main/res/drawable/monke_party.xml ================================================ ================================================ FILE: app/src/main/res/drawable/monke_sob.xml ================================================ ================================================ FILE: app/src/main/res/drawable/netflix_download.xml ================================================ ================================================ FILE: app/src/main/res/drawable/netflix_download_batch.xml ================================================ ================================================ FILE: app/src/main/res/drawable/netflix_pause.xml ================================================ ================================================ FILE: app/src/main/res/drawable/netflix_play.xml ================================================ ================================================ FILE: app/src/main/res/drawable/netflix_skip_back.xml ================================================ ================================================ FILE: app/src/main/res/drawable/netflix_skip_forward.xml ================================================ ================================================ FILE: app/src/main/res/drawable/notifications_icon_selector.xml ================================================ ================================================ FILE: app/src/main/res/drawable/open_subtitles_icon.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_big_15_gray.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_big_20.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_big_20_gray.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_big_25_gray.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_big_35_gray.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_bookmark_add_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_card.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_drawable.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_drawable_forced.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_drawable_forced_round.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_drawable_less.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_drawable_less_inset.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_drawable_round_20.xml ================================================ ================================================ FILE: app/src/main/res/drawable/outline_less.xml ================================================ ================================================ FILE: app/src/main/res/drawable/pause_to_play.xml ================================================ ================================================ FILE: app/src/main/res/drawable/pin_ic.xml ================================================ ================================================ FILE: app/src/main/res/drawable/play_button.xml ================================================ ================================================ FILE: app/src/main/res/drawable/play_button_transparent.xml ================================================ ================================================ FILE: app/src/main/res/drawable/play_to_pause.xml ================================================ ================================================ FILE: app/src/main/res/drawable/player_button_tv.xml ================================================ ================================================ FILE: app/src/main/res/drawable/player_button_tv_attr.xml ================================================ ================================================ FILE: app/src/main/res/drawable/player_button_tv_attr_no_bg.xml ================================================ ================================================ FILE: app/src/main/res/drawable/player_gradient_tv.xml ================================================ ================================================ FILE: app/src/main/res/drawable/preview_seekbar_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/progress_drawable_vertical.xml ================================================ ================================================ FILE: app/src/main/res/drawable/question_mark_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/quick_novel_icon.xml ================================================ ================================================ FILE: app/src/main/res/drawable/rating_bg_color.xml ================================================ ================================================ FILE: app/src/main/res/drawable/rating_empty.xml ================================================ ================================================ FILE: app/src/main/res/drawable/rating_fill.xml ================================================ ================================================ FILE: app/src/main/res/drawable/rddone.xml ================================================ ================================================ FILE: app/src/main/res/drawable/rderror.xml ================================================ ================================================ FILE: app/src/main/res/drawable/rdload.xml ================================================ ================================================ FILE: app/src/main/res/drawable/rdpause.xml ================================================ ================================================ FILE: app/src/main/res/drawable/round_keyboard_arrow_up_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/rounded_dialog.xml ================================================ ================================================ FILE: app/src/main/res/drawable/rounded_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/rounded_progress.xml ================================================ ================================================ FILE: app/src/main/res/drawable/rounded_select_ripple.xml ================================================ ================================================ FILE: app/src/main/res/drawable/screen_rotation.xml ================================================ ================================================ FILE: app/src/main/res/drawable/search_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/search_icon.xml ================================================ ================================================ FILE: app/src/main/res/drawable/settings_alt.xml ================================================ ================================================ FILE: app/src/main/res/drawable/settings_icon_filled.xml ================================================ ================================================ FILE: app/src/main/res/drawable/settings_icon_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/settings_icon_selector.xml ================================================ ================================================ FILE: app/src/main/res/drawable/simkl_logo.xml ================================================ ================================================ FILE: app/src/main/res/drawable/solid_primary.xml ================================================ ================================================ FILE: app/src/main/res/drawable/speedup.xml ================================================ ================================================ FILE: app/src/main/res/drawable/splash_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable/storage_bar_left.xml ================================================ ================================================ FILE: app/src/main/res/drawable/storage_bar_left_box.xml ================================================ ================================================ FILE: app/src/main/res/drawable/storage_bar_mid.xml ================================================ ================================================ FILE: app/src/main/res/drawable/storage_bar_mid_box.xml ================================================ ================================================ FILE: app/src/main/res/drawable/storage_bar_right.xml ================================================ ================================================ FILE: app/src/main/res/drawable/storage_bar_right_box.xml ================================================ ================================================ FILE: app/src/main/res/drawable/sub_bg_color.xml ================================================ ================================================ FILE: app/src/main/res/drawable/subdl_logo_big.xml ================================================ ================================================ FILE: app/src/main/res/drawable/subtitles_background_gradient.xml ================================================ ================================================ FILE: app/src/main/res/drawable/sun_1.xml ================================================ ================================================ FILE: app/src/main/res/drawable/sun_2.xml ================================================ ================================================ FILE: app/src/main/res/drawable/sun_3.xml ================================================ ================================================ FILE: app/src/main/res/drawable/sun_4.xml ================================================ ================================================ FILE: app/src/main/res/drawable/sun_5.xml ================================================ ================================================ FILE: app/src/main/res/drawable/sun_6.xml ================================================ ================================================ FILE: app/src/main/res/drawable/sun_7.xml ================================================ ================================================ FILE: app/src/main/res/drawable/sun_7_24.xml ================================================ ================================================ FILE: app/src/main/res/drawable/tab_selector.xml ================================================ ================================================ FILE: app/src/main/res/drawable/title_24px.xml ================================================ ================================================ FILE: app/src/main/res/drawable/title_shadow.xml ================================================ ================================================ FILE: app/src/main/res/drawable/type_bg_color.xml ================================================ ================================================ FILE: app/src/main/res/drawable/video_bottom_button.xml ================================================ ================================================ FILE: app/src/main/res/drawable/video_frame.xml ================================================ ================================================ FILE: app/src/main/res/drawable/video_locked.xml ================================================ ================================================ FILE: app/src/main/res/drawable/video_outline.xml ================================================ ================================================ FILE: app/src/main/res/drawable/video_pause.xml ================================================ ================================================ FILE: app/src/main/res/drawable/video_play.xml ================================================ ================================================ FILE: app/src/main/res/drawable/video_tap_button.xml ================================================ ================================================ FILE: app/src/main/res/drawable/video_tap_button_always_white.xml ================================================ ================================================ FILE: app/src/main/res/drawable/video_tap_button_skip.xml ================================================ ================================================ FILE: app/src/main/res/drawable/video_unlocked.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v24/ic_banner_background.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v24/ic_banner_foreground.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v24/ic_launcher_foreground.xml ================================================ ================================================ FILE: app/src/main/res/font/google_sans.xml ================================================ ================================================ FILE: app/src/main/res/layout/account_edit_dialog.xml ================================================ ================================================ FILE: app/src/main/res/layout/account_list_item.xml ================================================ ================================================ FILE: app/src/main/res/layout/account_list_item_add.xml ================================================ ================================================ FILE: app/src/main/res/layout/account_list_item_edit.xml ================================================ ================================================ FILE: app/src/main/res/layout/account_managment.xml ================================================ ================================================ FILE: app/src/main/res/layout/account_select_linear.xml ================================================ ================================================ FILE: app/src/main/res/layout/account_single.xml ================================================ ================================================ FILE: app/src/main/res/layout/account_switch.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_account_select.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_main.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_main_tv.xml ================================================ ================================================ FILE: app/src/main/res/layout/add_account_input.xml ================================================ ================================================ FILE: app/src/main/res/layout/add_remove_sites.xml ================================================ ================================================ FILE: app/src/main/res/layout/add_repo_input.xml ================================================ ================================================ FILE: app/src/main/res/layout/add_site_input.xml ================================================ ================================================ FILE: app/src/main/res/layout/bottom_input_dialog.xml ================================================ ================================================ FILE: app/src/main/res/layout/bottom_loading.xml ================================================ ================================================ FILE: app/src/main/res/layout/bottom_resultview_preview.xml ================================================ ================================================ FILE: app/src/main/res/layout/bottom_resultview_preview_tv.xml ================================================ ================================================ FILE: app/src/main/res/layout/bottom_selection_dialog.xml ================================================ ================================================ FILE: app/src/main/res/layout/bottom_selection_dialog_direct.xml ================================================ ================================================ FILE: app/src/main/res/layout/bottom_text_dialog.xml ================================================ ================================================ FILE: app/src/main/res/layout/cast_item.xml ================================================ ================================================ FILE: app/src/main/res/layout/chromecast_subtitle_settings.xml ================================================ ================================================ FILE: app/src/main/res/layout/confirm_exit_dialog.xml ================================================ ================================================ FILE: app/src/main/res/layout/custom_preference_category_material.xml ================================================ ================================================ FILE: app/src/main/res/layout/custom_preference_material.xml ================================================ ================================================ FILE: app/src/main/res/layout/custom_preference_widget_seekbar.xml ================================================ ================================================ FILE: app/src/main/res/layout/device_auth.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_loading.xml ================================================ ================================================ FILE: app/src/main/res/layout/dialog_online_subtitles.xml ================================================ ================================================ FILE: app/src/main/res/layout/download_button.xml ================================================ ================================================ FILE: app/src/main/res/layout/download_button_layout.xml ================================================ ================================================ FILE: app/src/main/res/layout/download_button_view.xml ================================================ ================================================ FILE: app/src/main/res/layout/download_child_episode.xml ================================================ ================================================ FILE: app/src/main/res/layout/download_header_episode.xml ================================================ ================================================ FILE: app/src/main/res/layout/download_queue_item.xml ================================================ ================================================ FILE: app/src/main/res/layout/empty_layout.xml ================================================ ================================================ FILE: app/src/main/res/layout/extra_brightness_overlay.xml ================================================ ================================================ FILE: app/src/main/res/layout/fragment_child_downloads.xml ================================================