- * DownloadActivity.java is part of NewPipe.
- *
- * NewPipe is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * NewPipe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with NewPipe. If not, see .
- */
-
-package org.schabi.newpipe;
-
-import android.app.AlertDialog;
-import android.content.BroadcastReceiver;
-import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.content.SharedPreferences;
-import android.content.pm.PackageManager;
-import android.os.Build;
-import android.os.Bundle;
-import android.os.Handler;
-import android.os.Looper;
-import android.util.Log;
-import android.view.KeyEvent;
-import android.view.LayoutInflater;
-import android.view.Menu;
-import android.view.MenuItem;
-import android.view.View;
-import android.view.ViewGroup;
-import android.webkit.WebView;
-import android.widget.AdapterView;
-import android.widget.ArrayAdapter;
-import android.widget.FrameLayout;
-import android.widget.Spinner;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.appcompat.app.ActionBar;
-import androidx.appcompat.app.ActionBarDrawerToggle;
-import androidx.appcompat.app.AppCompatActivity;
-import androidx.core.app.ActivityCompat;
-import androidx.core.content.ContextCompat;
-import androidx.core.view.GravityCompat;
-import androidx.drawerlayout.widget.DrawerLayout;
-import androidx.fragment.app.Fragment;
-import androidx.fragment.app.FragmentContainerView;
-import androidx.fragment.app.FragmentManager;
-import androidx.preference.PreferenceManager;
-
-import com.google.android.material.bottomsheet.BottomSheetBehavior;
-
-import org.schabi.newpipe.databinding.ActivityMainBinding;
-import org.schabi.newpipe.databinding.DrawerHeaderBinding;
-import org.schabi.newpipe.databinding.DrawerLayoutBinding;
-import org.schabi.newpipe.databinding.InstanceSpinnerLayoutBinding;
-import org.schabi.newpipe.databinding.ToolbarLayoutBinding;
-import org.schabi.newpipe.error.ErrorUtil;
-import org.schabi.newpipe.extractor.NewPipe;
-import org.schabi.newpipe.extractor.StreamingService;
-import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
-import org.schabi.newpipe.extractor.exceptions.ExtractionException;
-import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance;
-import org.schabi.newpipe.fragments.BackPressable;
-import org.schabi.newpipe.fragments.MainFragment;
-import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
-import org.schabi.newpipe.fragments.list.comments.CommentRepliesFragment;
-import org.schabi.newpipe.fragments.list.search.SearchFragment;
-import org.schabi.newpipe.local.feed.notifications.NotificationWorker;
-import org.schabi.newpipe.player.Player;
-import org.schabi.newpipe.player.event.OnKeyDownListener;
-import org.schabi.newpipe.player.helper.PlayerHolder;
-import org.schabi.newpipe.player.playqueue.PlayQueue;
-import org.schabi.newpipe.settings.UpdateSettingsFragment;
-import org.schabi.newpipe.settings.migration.MigrationManager;
-import org.schabi.newpipe.util.Constants;
-import org.schabi.newpipe.util.DeviceUtils;
-import org.schabi.newpipe.util.KioskTranslator;
-import org.schabi.newpipe.util.Localization;
-import org.schabi.newpipe.util.NavigationHelper;
-import org.schabi.newpipe.util.PeertubeHelper;
-import org.schabi.newpipe.util.PermissionHelper;
-import org.schabi.newpipe.util.ReleaseVersionUtil;
-import org.schabi.newpipe.util.SerializedCache;
-import org.schabi.newpipe.util.ServiceHelper;
-import org.schabi.newpipe.util.StateSaver;
-import org.schabi.newpipe.util.ThemeHelper;
-import org.schabi.newpipe.util.external_communication.ShareUtils;
-import org.schabi.newpipe.views.FocusOverlayView;
-
-import java.time.Instant;
-import java.time.temporal.ChronoUnit;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Objects;
-
-public class MainActivity extends AppCompatActivity {
- private static final String TAG = "MainActivity";
- @SuppressWarnings("ConstantConditions")
- public static final boolean DEBUG = !BuildConfig.BUILD_TYPE.equals("release");
-
- private ActivityMainBinding mainBinding;
- private DrawerHeaderBinding drawerHeaderBinding;
- private DrawerLayoutBinding drawerLayoutBinding;
- private ToolbarLayoutBinding toolbarLayoutBinding;
-
- private ActionBarDrawerToggle toggle;
-
- private boolean servicesShown = false;
-
- private BroadcastReceiver broadcastReceiver;
-
- private static final int ITEM_ID_SUBSCRIPTIONS = -1;
- private static final int ITEM_ID_FEED = -2;
- private static final int ITEM_ID_BOOKMARKS = -3;
- private static final int ITEM_ID_DOWNLOADS = -4;
- private static final int ITEM_ID_HISTORY = -5;
- private static final int ITEM_ID_SETTINGS = 0;
- private static final int ITEM_ID_DONATION = 1;
- private static final int ITEM_ID_ABOUT = 2;
-
- private static final int ORDER = 0;
- public static final String KEY_IS_IN_BACKGROUND = "is_in_background";
-
- private SharedPreferences sharedPreferences;
- private SharedPreferences.Editor sharedPrefEditor;
- /*//////////////////////////////////////////////////////////////////////////
- // Activity's LifeCycle
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- protected void onCreate(final Bundle savedInstanceState) {
- if (DEBUG) {
- Log.d(TAG, "onCreate() called with: "
- + "savedInstanceState = [" + savedInstanceState + "]");
- }
-
- Localization.migrateAppLanguageSettingIfNecessary(getApplicationContext());
- ThemeHelper.setDayNightMode(this);
- ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this));
-
- // Fixes text color turning black in dark/black mode:
- // https://github.com/TeamNewPipe/NewPipe/issues/12016
- // For further reference see: https://issuetracker.google.com/issues/37124582
- if (DeviceUtils.supportsWebView()) {
- try {
- new WebView(this);
- } catch (final Throwable e) {
- if (DEBUG) {
- Log.e(TAG, "Failed to create WebView", e);
- }
- }
- }
-
- super.onCreate(savedInstanceState);
- sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
- sharedPrefEditor = sharedPreferences.edit();
-
- mainBinding = ActivityMainBinding.inflate(getLayoutInflater());
- drawerLayoutBinding = mainBinding.drawerLayout;
- drawerHeaderBinding = DrawerHeaderBinding.bind(drawerLayoutBinding.navigation
- .getHeaderView(0));
- toolbarLayoutBinding = mainBinding.toolbarLayout;
- setContentView(mainBinding.getRoot());
-
- if (getSupportFragmentManager().getBackStackEntryCount() == 0) {
- initFragments();
- }
-
- setSupportActionBar(toolbarLayoutBinding.toolbar);
- try {
- setupDrawer();
- } catch (final Exception e) {
- ErrorUtil.showUiErrorSnackbar(this, "Setting up drawer", e);
- }
- if (DeviceUtils.isTv(this)) {
- FocusOverlayView.setupFocusObserver(this);
- }
- openMiniPlayerUponPlayerStarted();
-
- if (PermissionHelper.checkPostNotificationsPermission(this,
- PermissionHelper.POST_NOTIFICATIONS_REQUEST_CODE)) {
- // Schedule worker for checking for new streams and creating corresponding notifications
- // if this is enabled by the user.
- NotificationWorker.initialize(this);
- }
- if (!UpdateSettingsFragment.wasUserAskedForConsent(this)
- && !App.getInstance().isFirstRun()
- && ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
- UpdateSettingsFragment.askForConsentToUpdateChecks(this);
- }
-
- // ReleaseVersionUtil.INSTANCE.isReleaseApk() will be true only for main official build
- // We want every release build (nightly, nightly-refactor) to show the popup
- if (!DEBUG) {
- showKeepAndroidDialog();
- showApi23RequirementDialog();
- }
-
- MigrationManager.showUserInfoIfPresent(this);
- }
-
- @Override
- protected void onPostCreate(final Bundle savedInstanceState) {
- super.onPostCreate(savedInstanceState);
-
- final App app = App.getInstance();
-
- if (sharedPreferences.getBoolean(app.getString(R.string.update_app_key), false)
- && sharedPreferences
- .getBoolean(app.getString(R.string.update_check_consent_key), false)) {
- // Start the worker which is checking all conditions
- // and eventually searching for a new version.
- NewVersionWorker.enqueueNewVersionCheckingWork(app, false);
- }
- }
-
- @Override
- protected void onStart() {
- super.onStart();
- sharedPrefEditor.putBoolean(KEY_IS_IN_BACKGROUND, false).apply();
- Log.d(TAG, "App moved to foreground");
- }
-
- @Override
- protected void onStop() {
- super.onStop();
- sharedPrefEditor.putBoolean(KEY_IS_IN_BACKGROUND, true).apply();
- Log.d(TAG, "App moved to background");
- }
- private void setupDrawer() throws ExtractionException {
- addDrawerMenuForCurrentService();
-
- toggle = new ActionBarDrawerToggle(this, mainBinding.getRoot(),
- toolbarLayoutBinding.toolbar, R.string.drawer_open, R.string.drawer_close);
- toggle.syncState();
- mainBinding.getRoot().addDrawerListener(toggle);
- mainBinding.getRoot().addDrawerListener(new DrawerLayout.SimpleDrawerListener() {
- private int lastService;
-
- @Override
- public void onDrawerOpened(final View drawerView) {
- lastService = ServiceHelper.getSelectedServiceId(MainActivity.this);
- }
-
- @Override
- public void onDrawerClosed(final View drawerView) {
- if (servicesShown) {
- toggleServices();
- }
- if (lastService != ServiceHelper.getSelectedServiceId(MainActivity.this)) {
- ActivityCompat.recreate(MainActivity.this);
- }
- }
- });
-
- drawerLayoutBinding.navigation.setNavigationItemSelectedListener(this::drawerItemSelected);
- setupDrawerHeader();
- }
-
- /**
- * Builds the drawer menu for the current service.
- *
- * @throws ExtractionException if the service didn't provide available kiosks
- */
- private void addDrawerMenuForCurrentService() throws ExtractionException {
- //Tabs
- drawerLayoutBinding.navigation.getMenu()
- .add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER,
- R.string.tab_subscriptions)
- .setIcon(R.drawable.ic_tv);
- drawerLayoutBinding.navigation.getMenu()
- .add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title)
- .setIcon(R.drawable.ic_subscriptions);
- drawerLayoutBinding.navigation.getMenu()
- .add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks)
- .setIcon(R.drawable.ic_bookmark);
- drawerLayoutBinding.navigation.getMenu()
- .add(R.id.menu_tabs_group, ITEM_ID_DOWNLOADS, ORDER, R.string.downloads)
- .setIcon(R.drawable.ic_file_download);
- drawerLayoutBinding.navigation.getMenu()
- .add(R.id.menu_tabs_group, ITEM_ID_HISTORY, ORDER, R.string.action_history)
- .setIcon(R.drawable.ic_history);
-
- //Kiosks
- final int currentServiceId = ServiceHelper.getSelectedServiceId(this);
- final StreamingService service = NewPipe.getService(currentServiceId);
-
- int kioskMenuItemId = 0;
-
- for (final String ks : service.getKioskList().getAvailableKiosks()) {
- drawerLayoutBinding.navigation.getMenu()
- .add(R.id.menu_kiosks_group, kioskMenuItemId, 0, KioskTranslator
- .getTranslatedKioskName(ks, this))
- .setIcon(KioskTranslator.getKioskIcon(ks));
- kioskMenuItemId++;
- }
-
- //Settings and About
- drawerLayoutBinding.navigation.getMenu()
- .add(R.id.menu_options_about_group, ITEM_ID_SETTINGS, ORDER, R.string.settings)
- .setIcon(R.drawable.ic_settings);
- drawerLayoutBinding.navigation.getMenu()
- .add(R.id.menu_options_about_group, ITEM_ID_DONATION, ORDER,
- R.string.donation_title)
- .setIcon(R.drawable.volunteer_activism_ic);
- drawerLayoutBinding.navigation.getMenu()
- .add(R.id.menu_options_about_group, ITEM_ID_ABOUT, ORDER, R.string.tab_about)
- .setIcon(R.drawable.ic_info_outline);
- }
-
- private boolean drawerItemSelected(final MenuItem item) {
- final int groupId = item.getGroupId();
- if (groupId == R.id.menu_services_group) {
- changeService(item);
- } else if (groupId == R.id.menu_tabs_group) {
- tabSelected(item);
- } else if (groupId == R.id.menu_kiosks_group) {
- try {
- kioskSelected(item);
- } catch (final Exception e) {
- ErrorUtil.showUiErrorSnackbar(this, "Selecting drawer kiosk", e);
- }
- } else if (groupId == R.id.menu_options_about_group) {
- optionsAboutSelected(item);
- } else {
- return false;
- }
-
- mainBinding.getRoot().closeDrawers();
- return true;
- }
-
- private void changeService(final MenuItem item) {
- drawerLayoutBinding.navigation.getMenu()
- .getItem(ServiceHelper.getSelectedServiceId(this))
- .setChecked(false);
- ServiceHelper.setSelectedServiceId(this, item.getItemId());
- drawerLayoutBinding.navigation.getMenu()
- .getItem(ServiceHelper.getSelectedServiceId(this))
- .setChecked(true);
- }
-
- private void tabSelected(final MenuItem item) {
- switch (item.getItemId()) {
- case ITEM_ID_SUBSCRIPTIONS:
- NavigationHelper.openSubscriptionFragment(getSupportFragmentManager());
- break;
- case ITEM_ID_FEED:
- NavigationHelper.openFeedFragment(getSupportFragmentManager());
- break;
- case ITEM_ID_BOOKMARKS:
- NavigationHelper.openBookmarksFragment(getSupportFragmentManager());
- break;
- case ITEM_ID_DOWNLOADS:
- NavigationHelper.openDownloads(this);
- break;
- case ITEM_ID_HISTORY:
- NavigationHelper.openStatisticFragment(getSupportFragmentManager());
- break;
- }
- }
-
- private void kioskSelected(final MenuItem item) throws ExtractionException {
- final StreamingService currentService = ServiceHelper.getSelectedService(this);
- int kioskMenuItemId = 0;
- for (final String kioskId : currentService.getKioskList().getAvailableKiosks()) {
- if (kioskMenuItemId == item.getItemId()) {
- NavigationHelper.openKioskFragment(getSupportFragmentManager(),
- currentService.getServiceId(), kioskId);
- break;
- }
- kioskMenuItemId++;
- }
- }
-
- private void optionsAboutSelected(final MenuItem item) {
- switch (item.getItemId()) {
- case ITEM_ID_SETTINGS:
- NavigationHelper.openSettings(this);
- break;
- case ITEM_ID_DONATION:
- ShareUtils.openUrlInBrowser(this, getString(R.string.donation_url));
- break;
- case ITEM_ID_ABOUT:
- NavigationHelper.openAbout(this);
- break;
- }
- }
-
- private void setupDrawerHeader() {
- drawerHeaderBinding.drawerHeaderActionButton.setOnClickListener(view -> toggleServices());
-
- // If the current app name is bigger than the default "NewPipe" (7 chars),
- // let the text view grow a little more as well.
- if (getString(R.string.app_name).length() > "NewPipe".length()) {
- final ViewGroup.LayoutParams layoutParams =
- drawerHeaderBinding.drawerHeaderNewpipeTitle.getLayoutParams();
- layoutParams.width = ViewGroup.LayoutParams.WRAP_CONTENT;
- drawerHeaderBinding.drawerHeaderNewpipeTitle.setLayoutParams(layoutParams);
- drawerHeaderBinding.drawerHeaderNewpipeTitle.setMaxLines(2);
- drawerHeaderBinding.drawerHeaderNewpipeTitle.setMinWidth(getResources()
- .getDimensionPixelSize(R.dimen.drawer_header_newpipe_title_default_width));
- drawerHeaderBinding.drawerHeaderNewpipeTitle.setMaxWidth(getResources()
- .getDimensionPixelSize(R.dimen.drawer_header_newpipe_title_max_width));
- }
- }
-
- private void toggleServices() {
- servicesShown = !servicesShown;
-
- drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_services_group);
- drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_tabs_group);
- drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_kiosks_group);
- drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_options_about_group);
-
- // Show up or down arrow
- drawerHeaderBinding.drawerArrow.setImageResource(
- servicesShown ? R.drawable.ic_arrow_drop_up : R.drawable.ic_arrow_drop_down);
-
- if (servicesShown) {
- showServices();
- } else {
- try {
- addDrawerMenuForCurrentService();
- } catch (final Exception e) {
- ErrorUtil.showUiErrorSnackbar(this, "Showing main page tabs", e);
- }
- }
- }
-
- private void showServices() {
- for (final StreamingService s : NewPipe.getServices()) {
- final String title = s.getServiceInfo().getName();
-
- final MenuItem menuItem = drawerLayoutBinding.navigation.getMenu()
- .add(R.id.menu_services_group, s.getServiceId(), ORDER, title)
- .setIcon(ServiceHelper.getIcon(s.getServiceId()));
-
- // peertube specifics
- if (s.getServiceId() == 3) {
- enhancePeertubeMenu(menuItem);
- }
- }
- drawerLayoutBinding.navigation.getMenu()
- .getItem(ServiceHelper.getSelectedServiceId(this))
- .setChecked(true);
- }
-
- private void enhancePeertubeMenu(final MenuItem menuItem) {
- final PeertubeInstance currentInstance = PeertubeHelper.getCurrentInstance();
- menuItem.setTitle(currentInstance.getName());
- final Spinner spinner = InstanceSpinnerLayoutBinding.inflate(LayoutInflater.from(this))
- .getRoot();
- final List instances = PeertubeHelper.getInstanceList(this);
- final List items = new ArrayList<>();
- int defaultSelect = 0;
- for (final PeertubeInstance instance : instances) {
- items.add(instance.getName());
- if (instance.getUrl().equals(currentInstance.getUrl())) {
- defaultSelect = items.size() - 1;
- }
- }
- final ArrayAdapter adapter = new ArrayAdapter<>(this,
- R.layout.instance_spinner_item, items);
- adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
- spinner.setAdapter(adapter);
- spinner.setSelection(defaultSelect, false);
- spinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
- @Override
- public void onItemSelected(final AdapterView> parent, final View view,
- final int position, final long id) {
- final PeertubeInstance newInstance = instances.get(position);
- if (newInstance.getUrl().equals(PeertubeHelper.getCurrentInstance().getUrl())) {
- return;
- }
- PeertubeHelper.selectInstance(newInstance, getApplicationContext());
- changeService(menuItem);
- mainBinding.getRoot().closeDrawers();
- new Handler(Looper.getMainLooper()).postDelayed(() -> {
- getSupportFragmentManager().popBackStack(null,
- FragmentManager.POP_BACK_STACK_INCLUSIVE);
- ActivityCompat.recreate(MainActivity.this);
- }, 300);
- }
-
- @Override
- public void onNothingSelected(final AdapterView> parent) {
-
- }
- });
- menuItem.setActionView(spinner);
- }
-
- @Override
- protected void onDestroy() {
- super.onDestroy();
- if (!isChangingConfigurations()) {
- StateSaver.clearStateFiles();
- }
- if (broadcastReceiver != null) {
- unregisterReceiver(broadcastReceiver);
- }
- }
-
- @Override
- protected void onResume() {
- // Change the date format to match the selected language on resume
- Localization.initPrettyTime(Localization.resolvePrettyTime());
- super.onResume();
-
- // Close drawer on return, and don't show animation,
- // so it looks like the drawer isn't open when the user returns to MainActivity
- mainBinding.getRoot().closeDrawer(GravityCompat.START, false);
- try {
- final int selectedServiceId = ServiceHelper.getSelectedServiceId(this);
- final String selectedServiceName = NewPipe.getService(selectedServiceId)
- .getServiceInfo().getName();
- drawerHeaderBinding.drawerHeaderServiceView.setText(selectedServiceName);
- drawerHeaderBinding.drawerHeaderServiceIcon.setImageResource(ServiceHelper
- .getIcon(selectedServiceId));
-
- drawerHeaderBinding.drawerHeaderServiceView.post(() -> drawerHeaderBinding
- .drawerHeaderServiceView.setSelected(true));
- drawerHeaderBinding.drawerHeaderActionButton.setContentDescription(
- getString(R.string.drawer_header_description) + selectedServiceName);
- } catch (final Exception e) {
- ErrorUtil.showUiErrorSnackbar(this, "Setting up service toggle", e);
- }
-
- if (sharedPreferences.getBoolean(Constants.KEY_THEME_CHANGE, false)) {
- if (DEBUG) {
- Log.d(TAG, "Theme has changed, recreating activity...");
- }
- sharedPrefEditor.putBoolean(Constants.KEY_THEME_CHANGE, false).apply();
- ActivityCompat.recreate(this);
- }
-
- if (sharedPreferences.getBoolean(Constants.KEY_MAIN_PAGE_CHANGE, false)) {
- if (DEBUG) {
- Log.d(TAG, "main page has changed, recreating main fragment...");
- }
- sharedPrefEditor.putBoolean(Constants.KEY_MAIN_PAGE_CHANGE, false).apply();
- NavigationHelper.openMainActivity(this);
- }
-
- final boolean isHistoryEnabled = sharedPreferences.getBoolean(
- getString(R.string.enable_watch_history_key), true);
- drawerLayoutBinding.navigation.getMenu().findItem(ITEM_ID_HISTORY)
- .setVisible(isHistoryEnabled);
- }
-
- @Override
- protected void onNewIntent(final Intent intent) {
- if (DEBUG) {
- Log.d(TAG, "onNewIntent() called with: intent = [" + intent + "]");
- }
- if (intent != null) {
- // Return if launched from a launcher (e.g. Nova Launcher, Pixel Launcher ...)
- // to not destroy the already created backstack
- final String action = intent.getAction();
- if ((action != null && action.equals(Intent.ACTION_MAIN))
- && intent.hasCategory(Intent.CATEGORY_LAUNCHER)) {
- return;
- }
- }
-
- super.onNewIntent(intent);
- setIntent(intent);
- handleIntent(intent);
- }
-
- @Override
- public boolean onKeyDown(final int keyCode, final KeyEvent event) {
- final Fragment fragment = getSupportFragmentManager()
- .findFragmentById(R.id.fragment_player_holder);
- if (fragment instanceof OnKeyDownListener
- && !bottomSheetHiddenOrCollapsed()) {
- // Provide keyDown event to fragment which then sends this event
- // to the main player service
- return ((OnKeyDownListener) fragment).onKeyDown(keyCode)
- || super.onKeyDown(keyCode, event);
- }
- return super.onKeyDown(keyCode, event);
- }
-
- @Override
- public void onBackPressed() {
- if (DEBUG) {
- Log.d(TAG, "onBackPressed() called");
- }
-
- if (DeviceUtils.isTv(this)) {
- if (mainBinding.getRoot().isDrawerOpen(drawerLayoutBinding.navigation)) {
- mainBinding.getRoot().closeDrawers();
- return;
- }
- }
-
- // In case bottomSheet is not visible on the screen or collapsed we can assume that the user
- // interacts with a fragment inside fragment_holder so all back presses should be
- // handled by it
- if (bottomSheetHiddenOrCollapsed()) {
- final FragmentManager fm = getSupportFragmentManager();
- final Fragment fragment = fm.findFragmentById(R.id.fragment_holder);
- // If current fragment implements BackPressable (i.e. can/wanna handle back press)
- // delegate the back press to it
- if (fragment instanceof BackPressable) {
- if (((BackPressable) fragment).onBackPressed()) {
- return;
- }
- } else if (fragment instanceof CommentRepliesFragment) {
- // expand DetailsFragment if CommentRepliesFragment was opened
- // to show the top level comments again
- // Expand DetailsFragment if CommentRepliesFragment was opened
- // and no other CommentRepliesFragments are on top of the back stack
- // to show the top level comments again.
- openDetailFragmentFromCommentReplies(fm, false);
- }
-
- } else {
- final Fragment fragmentPlayer = getSupportFragmentManager()
- .findFragmentById(R.id.fragment_player_holder);
- // If current fragment implements BackPressable (i.e. can/wanna handle back press)
- // delegate the back press to it
- if (fragmentPlayer instanceof BackPressable) {
- if (!((BackPressable) fragmentPlayer).onBackPressed()) {
- BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder)
- .setState(BottomSheetBehavior.STATE_COLLAPSED);
- }
- return;
- }
- }
-
- if (getSupportFragmentManager().getBackStackEntryCount() == 1) {
- finish();
- } else {
- super.onBackPressed();
- }
- }
-
- @Override
- public void onRequestPermissionsResult(final int requestCode,
- @NonNull final String[] permissions,
- @NonNull final int[] grantResults) {
- super.onRequestPermissionsResult(requestCode, permissions, grantResults);
- for (final int i : grantResults) {
- if (i == PackageManager.PERMISSION_DENIED) {
- return;
- }
- }
- switch (requestCode) {
- case PermissionHelper.DOWNLOADS_REQUEST_CODE:
- NavigationHelper.openDownloads(this);
- break;
- case PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE:
- final Fragment fragment = getSupportFragmentManager()
- .findFragmentById(R.id.fragment_player_holder);
- if (fragment instanceof VideoDetailFragment) {
- ((VideoDetailFragment) fragment).openDownloadDialog();
- }
- break;
- case PermissionHelper.POST_NOTIFICATIONS_REQUEST_CODE:
- NotificationWorker.initialize(this);
- break;
- }
- }
-
- /**
- * Implement the following diagram behavior for the up button:
- *
- * +---------------+
- * | Main Screen +----+
- * +-------+-------+ |
- * | |
- * ▲ Up | Search Button
- * | |
- * +----+-----+ |
- * +------------+ Search |◄-----+
- * | +----+-----+
- * | Open |
- * | something ▲ Up
- * | |
- * | +------------+-------------+
- * | | |
- * | | Video <-> Channel |
- * +---►| Channel <-> Playlist |
- * | Video <-> .... |
- * | |
- * +--------------------------+
- *
- */
- private void onHomeButtonPressed() {
- final FragmentManager fm = getSupportFragmentManager();
- final Fragment fragment = fm.findFragmentById(R.id.fragment_holder);
-
- if (fragment instanceof CommentRepliesFragment) {
- // Expand DetailsFragment if CommentRepliesFragment was opened
- // and no other CommentRepliesFragments are on top of the back stack
- // to show the top level comments again.
- openDetailFragmentFromCommentReplies(fm, true);
- } else if (!NavigationHelper.tryGotoSearchFragment(fm)) {
- // If search fragment wasn't found in the backstack go to the main fragment
- NavigationHelper.gotoMainFragment(fm);
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Menu
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- public boolean onCreateOptionsMenu(final Menu menu) {
- if (DEBUG) {
- Log.d(TAG, "onCreateOptionsMenu() called with: menu = [" + menu + "]");
- }
- super.onCreateOptionsMenu(menu);
-
- final Fragment fragment =
- getSupportFragmentManager().findFragmentById(R.id.fragment_holder);
- if (!(fragment instanceof SearchFragment)) {
- toolbarLayoutBinding.toolbarSearchContainer.getRoot().setVisibility(View.GONE);
- }
-
- final ActionBar actionBar = getSupportActionBar();
- if (actionBar != null) {
- actionBar.setDisplayHomeAsUpEnabled(false);
- }
-
- updateDrawerNavigation();
-
- return true;
- }
-
- @Override
- public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
- if (DEBUG) {
- Log.d(TAG, "onOptionsItemSelected() called with: item = [" + item + "]");
- }
-
- if (item.getItemId() == android.R.id.home) {
- onHomeButtonPressed();
- return true;
- }
- return super.onOptionsItemSelected(item);
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Init
- //////////////////////////////////////////////////////////////////////////*/
-
- private void initFragments() {
- if (DEBUG) {
- Log.d(TAG, "initFragments() called");
- }
- StateSaver.clearStateFiles();
- if (getIntent() != null && getIntent().hasExtra(Constants.KEY_LINK_TYPE)) {
- // When user watch a video inside popup and then tries to open the video in main player
- // while the app is closed he will see a blank fragment on place of kiosk.
- // Let's open it first
- if (getSupportFragmentManager().getBackStackEntryCount() == 0) {
- NavigationHelper.openMainFragment(getSupportFragmentManager());
- }
-
- handleIntent(getIntent());
- } else {
- NavigationHelper.gotoMainFragment(getSupportFragmentManager());
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Utils
- //////////////////////////////////////////////////////////////////////////*/
-
- private void updateDrawerNavigation() {
- if (getSupportActionBar() == null) {
- return;
- }
-
- final Fragment fragment = getSupportFragmentManager()
- .findFragmentById(R.id.fragment_holder);
- if (fragment instanceof MainFragment) {
- getSupportActionBar().setDisplayHomeAsUpEnabled(false);
- if (toggle != null) {
- toggle.syncState();
- toolbarLayoutBinding.toolbar.setNavigationOnClickListener(v -> mainBinding.getRoot()
- .open());
- mainBinding.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED);
- }
- } else {
- mainBinding.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_LOCKED_CLOSED);
- getSupportActionBar().setDisplayHomeAsUpEnabled(true);
- toolbarLayoutBinding.toolbar.setNavigationOnClickListener(v -> onHomeButtonPressed());
- }
- }
-
- private void handleIntent(final Intent intent) {
- try {
- if (DEBUG) {
- Log.d(TAG, "handleIntent() called with: intent = [" + intent + "]");
- }
-
- if (intent.hasExtra(Constants.KEY_LINK_TYPE)) {
- final String url = intent.getStringExtra(Constants.KEY_URL);
- final int serviceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, 0);
- String title = intent.getStringExtra(Constants.KEY_TITLE);
- if (title == null) {
- title = "";
- }
-
- final StreamingService.LinkType linkType = ((StreamingService.LinkType) intent
- .getSerializableExtra(Constants.KEY_LINK_TYPE));
- assert linkType != null;
- switch (linkType) {
- case STREAM:
- final String intentCacheKey = intent.getStringExtra(
- Player.PLAY_QUEUE_KEY);
- final PlayQueue playQueue = intentCacheKey != null
- ? SerializedCache.getInstance()
- .take(intentCacheKey, PlayQueue.class)
- : null;
-
- final boolean switchingPlayers = intent.getBooleanExtra(
- VideoDetailFragment.KEY_SWITCHING_PLAYERS, false);
- NavigationHelper.openVideoDetailFragment(
- getApplicationContext(), getSupportFragmentManager(),
- serviceId, url, title, playQueue, switchingPlayers);
- break;
- case CHANNEL:
- NavigationHelper.openChannelFragment(getSupportFragmentManager(),
- serviceId, url, title);
- break;
- case PLAYLIST:
- NavigationHelper.openPlaylistFragment(getSupportFragmentManager(),
- serviceId, url, title);
- break;
- }
- } else if (intent.hasExtra(Constants.KEY_OPEN_SEARCH)) {
- String searchString = intent.getStringExtra(Constants.KEY_SEARCH_STRING);
- if (searchString == null) {
- searchString = "";
- }
- final int serviceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, 0);
- NavigationHelper.openSearchFragment(
- getSupportFragmentManager(),
- serviceId,
- searchString);
-
- } else {
- NavigationHelper.gotoMainFragment(getSupportFragmentManager());
- }
- } catch (final Exception e) {
- ErrorUtil.showUiErrorSnackbar(this, "Handling intent", e);
- }
- }
-
- private void openMiniPlayerIfMissing() {
- final Fragment fragmentPlayer = getSupportFragmentManager()
- .findFragmentById(R.id.fragment_player_holder);
- if (fragmentPlayer == null) {
- // We still don't have a fragment attached to the activity. It can happen when a user
- // started popup or background players without opening a stream inside the fragment.
- // Adding it in a collapsed state (only mini player will be visible).
- NavigationHelper.showMiniPlayer(getSupportFragmentManager());
- }
- }
-
- private void openMiniPlayerUponPlayerStarted() {
- if (getIntent().getSerializableExtra(Constants.KEY_LINK_TYPE)
- == StreamingService.LinkType.STREAM) {
- // handleIntent() already takes care of opening video detail fragment
- // due to an intent containing a STREAM link
- return;
- }
-
- if (PlayerHolder.getInstance().isPlayerOpen()) {
- // if the player is already open, no need for a broadcast receiver
- openMiniPlayerIfMissing();
- } else {
- // listen for player start intent being sent around
- broadcastReceiver = new BroadcastReceiver() {
- @Override
- public void onReceive(final Context context, final Intent intent) {
- if (Objects.equals(intent.getAction(),
- VideoDetailFragment.ACTION_PLAYER_STARTED)
- && PlayerHolder.getInstance().isPlayerOpen()) {
- openMiniPlayerIfMissing();
- // At this point the player is added 100%, we can unregister. Other actions
- // are useless since the fragment will not be removed after that.
- unregisterReceiver(broadcastReceiver);
- broadcastReceiver = null;
- }
- }
- };
- final IntentFilter intentFilter = new IntentFilter();
- intentFilter.addAction(VideoDetailFragment.ACTION_PLAYER_STARTED);
- ContextCompat.registerReceiver(this, broadcastReceiver, intentFilter,
- ContextCompat.RECEIVER_EXPORTED);
-
- // If the PlayerHolder is not bound yet, but the service is running, try to bind to it.
- // Once the connection is established, the ACTION_PLAYER_STARTED will be sent.
- PlayerHolder.getInstance().tryBindIfNeeded(this);
- }
- }
-
- private void openDetailFragmentFromCommentReplies(
- @NonNull final FragmentManager fm,
- final boolean popBackStack
- ) {
- // obtain the name of the fragment under the replies fragment that's going to be popped
- @Nullable final String fragmentUnderEntryName;
- if (fm.getBackStackEntryCount() < 2) {
- fragmentUnderEntryName = null;
- } else {
- fragmentUnderEntryName = fm.getBackStackEntryAt(fm.getBackStackEntryCount() - 2)
- .getName();
- }
-
- // the root comment is the comment for which the user opened the replies page
- @Nullable final CommentRepliesFragment repliesFragment =
- (CommentRepliesFragment) fm.findFragmentByTag(CommentRepliesFragment.TAG);
- @Nullable final CommentsInfoItem rootComment =
- repliesFragment == null ? null : repliesFragment.getCommentsInfoItem();
-
- // sometimes this function pops the backstack, other times it's handled by the system
- if (popBackStack) {
- fm.popBackStackImmediate();
- }
-
- // only expand the bottom sheet back if there are no more nested comment replies fragments
- // stacked under the one that is currently being popped
- if (CommentRepliesFragment.TAG.equals(fragmentUnderEntryName)) {
- return;
- }
-
- final BottomSheetBehavior behavior = BottomSheetBehavior
- .from(mainBinding.fragmentPlayerHolder);
- // do not return to the comment if the details fragment was closed
- if (behavior.getState() == BottomSheetBehavior.STATE_HIDDEN) {
- return;
- }
-
- // scroll to the root comment once the bottom sheet expansion animation is finished
- behavior.addBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() {
- @Override
- public void onStateChanged(@NonNull final View bottomSheet,
- final int newState) {
- if (newState == BottomSheetBehavior.STATE_EXPANDED) {
- final Fragment detailFragment = fm.findFragmentById(
- R.id.fragment_player_holder);
- if (detailFragment instanceof VideoDetailFragment && rootComment != null) {
- // should always be the case
- ((VideoDetailFragment) detailFragment).scrollToComment(rootComment);
- }
- behavior.removeBottomSheetCallback(this);
- }
- }
-
- @Override
- public void onSlide(@NonNull final View bottomSheet, final float slideOffset) {
- // not needed, listener is removed once the sheet is expanded
- }
- });
-
- behavior.setState(BottomSheetBehavior.STATE_EXPANDED);
- }
-
- private boolean bottomSheetHiddenOrCollapsed() {
- final BottomSheetBehavior bottomSheetBehavior =
- BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder);
-
- final int sheetState = bottomSheetBehavior.getState();
- return sheetState == BottomSheetBehavior.STATE_HIDDEN
- || sheetState == BottomSheetBehavior.STATE_COLLAPSED;
- }
-
- private void showKeepAndroidDialog() {
- final var prefs = PreferenceManager.getDefaultSharedPreferences(this);
- final var lastCheckKey = getString(R.string.kao_last_checked_key);
- final var lastCheck = Instant.ofEpochMilli(prefs.getLong(lastCheckKey, 0));
- final var now = Instant.now();
-
- if (lastCheck.plus(30, ChronoUnit.DAYS).isBefore(now)) {
- final String detailsUrl = getKeepAndroidOpenDetailsUrl();
- final var solutionUrl = "https://github.com/woheller69/FreeDroidWarn#solutions";
-
- final var dialog = new AlertDialog.Builder(this)
- .setTitle("Keep Android Open")
- .setCancelable(false)
- .setMessage(R.string.kao_dialog_warning)
- .setPositiveButton(android.R.string.ok, (d, w) -> prefs.edit()
- .putLong(lastCheckKey, now.toEpochMilli())
- .apply())
- .setNeutralButton(R.string.kao_solution, null)
- .setNegativeButton(R.string.kao_dialog_more_info, null)
- .show();
-
- // If we use setNeutralButton/setNegativeButton, dialog will close after pressing the
- // buttons, but we want it to close only when positive button is pressed
- dialog.getButton(AlertDialog.BUTTON_NEGATIVE)
- .setOnClickListener(v -> ShareUtils.openUrlInBrowser(this, detailsUrl));
- dialog.getButton(AlertDialog.BUTTON_NEUTRAL)
- .setOnClickListener(v -> ShareUtils.openUrlInBrowser(this, solutionUrl));
- }
- }
-
- @NonNull
- private static String getKeepAndroidOpenDetailsUrl() {
- final var supportedLanguages = List.of("fr", "de", "ca", "es", "id", "it", "pl",
- "pt", "cs", "sk", "fa", "ar", "tr", "el", "th", "ru", "uk", "ko", "zh", "ja");
- final String kaoBaseUrl = "https://keepandroidopen.org/";
- final var locale = Localization.getAppLocale();
- if (supportedLanguages.contains(locale.getLanguage())) {
- if ("zh".equals(locale.getLanguage())) {
- return kaoBaseUrl + ("TW".equals(locale.getCountry()) ? "zh-TW" : "zh-CN");
- } else {
- return kaoBaseUrl + locale.getLanguage();
- }
- } else {
- return kaoBaseUrl;
- }
- }
-
- private void showApi23RequirementDialog() {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- return; // only show dialog on the devices that will stop being supported
- }
-
- final var prefs = PreferenceManager.getDefaultSharedPreferences(this);
- final var shownKey = getString(R.string.api23_requirement_dialog_shown_key);
- if (prefs.getBoolean(shownKey, false)) {
- return; // dialog was already shown in the past, no need to show it again
- }
-
- final var dialog = new AlertDialog.Builder(this)
- .setTitle(R.string.api23_requirement_dialog_title)
- .setCancelable(false)
- .setMessage(R.string.api23_requirement_dialog_message)
- .setPositiveButton(android.R.string.ok, (d, w) -> prefs.edit()
- .putBoolean(shownKey, true)
- .apply())
- .setNegativeButton(R.string.api23_requirement_dialog_blogpost, null)
- .show();
-
- // If we use setNegativeButton, dialog will close after pressing the button,
- // but we want it to close only when positive button is pressed
- final var blogpostUrl = "https://newpipe.net/blog/pinned/announcement/drop-android-5/";
- dialog.getButton(AlertDialog.BUTTON_NEGATIVE)
- .setOnClickListener(v -> ShareUtils.openUrlInBrowser(this, blogpostUrl));
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.kt b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.kt
deleted file mode 100644
index 6527bd2ae..000000000
--- a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.kt
+++ /dev/null
@@ -1,80 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2017-2024 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe
-
-import android.content.Context
-import androidx.room.Room.databaseBuilder
-import kotlin.concurrent.Volatile
-import org.schabi.newpipe.database.AppDatabase
-import org.schabi.newpipe.database.Migrations.MIGRATION_1_2
-import org.schabi.newpipe.database.Migrations.MIGRATION_2_3
-import org.schabi.newpipe.database.Migrations.MIGRATION_3_4
-import org.schabi.newpipe.database.Migrations.MIGRATION_4_5
-import org.schabi.newpipe.database.Migrations.MIGRATION_5_6
-import org.schabi.newpipe.database.Migrations.MIGRATION_6_7
-import org.schabi.newpipe.database.Migrations.MIGRATION_7_8
-import org.schabi.newpipe.database.Migrations.MIGRATION_8_9
-
-object NewPipeDatabase {
-
- @Volatile
- private var databaseInstance: AppDatabase? = null
-
- private fun getDatabase(context: Context): AppDatabase {
- return databaseBuilder(
- context.applicationContext,
- AppDatabase::class.java,
- AppDatabase.Companion.DATABASE_NAME
- ).addMigrations(
- MIGRATION_1_2,
- MIGRATION_2_3,
- MIGRATION_3_4,
- MIGRATION_4_5,
- MIGRATION_5_6,
- MIGRATION_6_7,
- MIGRATION_7_8,
- MIGRATION_8_9
- ).build()
- }
-
- @JvmStatic
- fun getInstance(context: Context): AppDatabase {
- var result = databaseInstance
- if (result == null) {
- synchronized(NewPipeDatabase::class.java) {
- result = databaseInstance
- if (result == null) {
- databaseInstance = getDatabase(context)
- result = databaseInstance
- }
- }
- }
-
- return result!!
- }
-
- @JvmStatic
- fun checkpoint() {
- checkNotNull(databaseInstance) { "database is not initialized" }
- val c = databaseInstance!!.query("pragma wal_checkpoint(full)", null)
- if (c.moveToFirst() && c.getInt(0) == 1) {
- throw RuntimeException("Checkpoint was blocked from completing")
- }
- }
-
- @JvmStatic
- fun close() {
- if (databaseInstance != null) {
- synchronized(NewPipeDatabase::class.java) {
- if (databaseInstance != null) {
- databaseInstance!!.close()
- databaseInstance = null
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt b/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt
deleted file mode 100644
index 4cdcc6c69..000000000
--- a/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt
+++ /dev/null
@@ -1,186 +0,0 @@
-package org.schabi.newpipe
-
-import android.content.Context
-import android.content.Intent
-import android.util.Log
-import android.widget.Toast
-import androidx.core.app.NotificationCompat
-import androidx.core.app.NotificationManagerCompat
-import androidx.core.app.PendingIntentCompat
-import androidx.core.content.ContextCompat
-import androidx.core.content.edit
-import androidx.core.net.toUri
-import androidx.preference.PreferenceManager
-import androidx.work.OneTimeWorkRequestBuilder
-import androidx.work.WorkManager
-import androidx.work.Worker
-import androidx.work.WorkerParameters
-import androidx.work.workDataOf
-import com.grack.nanojson.JsonParser
-import com.grack.nanojson.JsonParserException
-import java.io.IOException
-import org.schabi.newpipe.extractor.downloader.Response
-import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
-import org.schabi.newpipe.util.ReleaseVersionUtil
-
-class NewVersionWorker(
- context: Context,
- workerParams: WorkerParameters
-) : Worker(context, workerParams) {
-
- /**
- * Method to compare the current and latest available app version.
- * If a newer version is available, we show the update notification.
- *
- * @param versionName Name of new version
- * @param apkLocationUrl Url with the new apk
- * @param versionCode Code of new version
- */
- private fun compareAppVersionAndShowNotification(
- versionName: String,
- apkLocationUrl: String?,
- versionCode: Int
- ) {
- if (BuildConfig.VERSION_CODE >= versionCode) {
- if (inputData.getBoolean(IS_MANUAL, false)) {
- // Show toast stating that the app is up-to-date if the update check was manual.
- ContextCompat.getMainExecutor(applicationContext).execute {
- Toast.makeText(
- applicationContext,
- R.string.app_update_unavailable_toast,
- Toast.LENGTH_SHORT
- ).show()
- }
- }
- return
- }
-
- // A pending intent to open the apk location url in the browser.
- val intent = Intent(Intent.ACTION_VIEW, apkLocationUrl?.toUri())
- intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- val pendingIntent = PendingIntentCompat.getActivity(
- applicationContext,
- 0,
- intent,
- 0,
- false
- )
- val channelId = applicationContext.getString(R.string.app_update_notification_channel_id)
- val notificationBuilder = NotificationCompat.Builder(applicationContext, channelId)
- .setSmallIcon(R.drawable.ic_newpipe_update)
- .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
- .setAutoCancel(true)
- .setContentIntent(pendingIntent)
- .setContentTitle(
- applicationContext.getString(R.string.app_update_available_notification_title)
- )
- .setContentText(
- applicationContext.getString(
- R.string.app_update_available_notification_text,
- versionName
- )
- )
-
- val notificationManager = NotificationManagerCompat.from(applicationContext)
- if (notificationManager.areNotificationsEnabled()) {
- notificationManager.notify(2000, notificationBuilder.build())
- }
- }
-
- @Throws(IOException::class, ReCaptchaException::class)
- private fun checkNewVersion() {
- // Check if the current apk is a github one or not.
- if (!ReleaseVersionUtil.isReleaseApk) {
- return
- }
-
- if (!inputData.getBoolean(IS_MANUAL, false)) {
- val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
- // Check if the last request has happened a certain time ago
- // to reduce the number of API requests.
- val expiry = prefs.getLong(applicationContext.getString(R.string.update_expiry_key), 0)
- if (!ReleaseVersionUtil.isLastUpdateCheckExpired(expiry)) {
- return
- }
- }
-
- // Make a network request to get latest NewPipe data.
- val response = DownloaderImpl.getInstance().get(NEWPIPE_API_URL)
- handleResponse(response)
- }
-
- private fun handleResponse(response: Response) {
- val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
- try {
- // Store a timestamp which needs to be exceeded,
- // before a new request to the API is made.
- val newExpiry = ReleaseVersionUtil.coerceUpdateCheckExpiry(response.getHeader("expires"))
- prefs.edit {
- putLong(applicationContext.getString(R.string.update_expiry_key), newExpiry)
- }
- } catch (e: Exception) {
- if (DEBUG) {
- Log.w(TAG, "Could not extract and save new expiry date", e)
- }
- }
-
- // Parse the json from the response.
- try {
- val newpipeVersionInfo = JsonParser.`object`()
- .from(response.responseBody()).getObject("flavors")
- .getObject("newpipe")
-
- val versionName = newpipeVersionInfo.getString("version")
- val versionCode = newpipeVersionInfo.getInt("version_code")
- val apkLocationUrl = newpipeVersionInfo.getString("apk")
- compareAppVersionAndShowNotification(versionName, apkLocationUrl, versionCode)
- } catch (e: JsonParserException) {
- // Most likely something is wrong in data received from NEWPIPE_API_URL.
- // Do not alarm user and fail silently.
- if (DEBUG) {
- Log.w(TAG, "Could not get NewPipe API: invalid json", e)
- }
- }
- }
-
- override fun doWork(): Result {
- return try {
- checkNewVersion()
- Result.success()
- } catch (e: IOException) {
- Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e)
- Result.failure()
- } catch (e: ReCaptchaException) {
- Log.e(TAG, "ReCaptchaException should never happen here.", e)
- Result.failure()
- }
- }
-
- companion object {
- private val DEBUG = MainActivity.DEBUG
- private val TAG = NewVersionWorker::class.java.simpleName
- private const val NEWPIPE_API_URL = "https://newpipe.net/api/data.json"
- private const val IS_MANUAL = "isManual"
-
- /**
- * Start a new worker which checks if all conditions for performing a version check are met,
- * fetches the API endpoint [.NEWPIPE_API_URL] containing info about the latest NewPipe
- * version and displays a notification about an available update if one is available.
- *
- * Following conditions need to be met, before data is requested from the server:
- *
- * * The app is signed with the correct signing key (by TeamNewPipe / schabi).
- * If the signing key differs from the one used upstream, the update cannot be installed.
- * * The user enabled searching for and notifying about updates in the settings.
- * * The app did not recently check for updates.
- * We do not want to make unnecessary connections and DOS our servers.
- */
- @JvmStatic
- fun enqueueNewVersionCheckingWork(context: Context, isManual: Boolean) {
- val workRequest = OneTimeWorkRequestBuilder()
- .setInputData(workDataOf(IS_MANUAL to isManual))
- .build()
- WorkManager.getInstance(context).enqueue(workRequest)
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/PanicResponderActivity.java b/app/src/main/java/org/schabi/newpipe/PanicResponderActivity.java
deleted file mode 100644
index f0d1af81a..000000000
--- a/app/src/main/java/org/schabi/newpipe/PanicResponderActivity.java
+++ /dev/null
@@ -1,44 +0,0 @@
-package org.schabi.newpipe;
-
-import android.annotation.SuppressLint;
-import android.app.Activity;
-import android.content.Intent;
-import android.os.Bundle;
-
-/*
- * Copyright (C) Hans-Christoph Steiner 2016
- * PanicResponderActivity.java is part of NewPipe.
- *
- * NewPipe is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * NewPipe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with NewPipe. If not, see .
- */
-
-public class PanicResponderActivity extends Activity {
- public static final String PANIC_TRIGGER_ACTION = "info.guardianproject.panic.action.TRIGGER";
-
- @SuppressLint("NewApi")
- @Override
- protected void onCreate(final Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- final Intent intent = getIntent();
- if (intent != null && PANIC_TRIGGER_ACTION.equals(intent.getAction())) {
- // TODO: Explicitly clear the search results
- // once they are restored when the app restarts
- // or if the app reloads the current video after being killed,
- // that should be cleared also
- ExitActivity.exitAndRemoveFromRecentApps(this);
- }
-
- finishAndRemoveTask();
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java b/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java
deleted file mode 100644
index 3eeb912c8..000000000
--- a/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java
+++ /dev/null
@@ -1,94 +0,0 @@
-package org.schabi.newpipe;
-
-import static org.schabi.newpipe.util.SparseItemUtil.fetchStreamInfoAndSaveToDatabase;
-import static org.schabi.newpipe.util.external_communication.ShareUtils.shareText;
-
-import android.content.Context;
-import android.view.ContextThemeWrapper;
-import android.view.View;
-import android.widget.PopupMenu;
-
-import androidx.fragment.app.FragmentManager;
-
-import org.schabi.newpipe.database.stream.model.StreamEntity;
-import org.schabi.newpipe.download.DownloadDialog;
-import org.schabi.newpipe.local.dialog.PlaylistDialog;
-import org.schabi.newpipe.player.playqueue.PlayQueue;
-import org.schabi.newpipe.player.playqueue.PlayQueueItem;
-import org.schabi.newpipe.util.NavigationHelper;
-import org.schabi.newpipe.util.SparseItemUtil;
-
-import java.util.List;
-
-public final class QueueItemMenuUtil {
- private QueueItemMenuUtil() {
- }
-
- public static void openPopupMenu(final PlayQueue playQueue,
- final PlayQueueItem item,
- final View view,
- final boolean hideDetails,
- final FragmentManager fragmentManager,
- final Context context) {
- final ContextThemeWrapper themeWrapper =
- new ContextThemeWrapper(context, R.style.DarkPopupMenu);
-
- final PopupMenu popupMenu = new PopupMenu(themeWrapper, view);
- popupMenu.inflate(R.menu.menu_play_queue_item);
-
- if (hideDetails) {
- popupMenu.getMenu().findItem(R.id.menu_item_details).setVisible(false);
- }
-
- popupMenu.setOnMenuItemClickListener(menuItem -> {
- final int itemId = menuItem.getItemId();
- if (itemId == R.id.menu_item_remove) {
- final int index = playQueue.indexOf(item);
- playQueue.remove(index);
- return true;
- } else if (itemId == R.id.menu_item_details) {
- // playQueue is null since we don't want any queue change
- NavigationHelper.openVideoDetail(context, item.getServiceId(),
- item.getUrl(), item.getTitle(), null,
- false);
- return true;
- } else if (itemId == R.id.menu_item_append_playlist) {
- PlaylistDialog.createCorrespondingDialog(
- context,
- List.of(new StreamEntity(item)),
- dialog -> dialog.show(
- fragmentManager,
- "QueueItemMenuUtil@append_playlist"
- )
- );
-
- return true;
- } else if (itemId == R.id.menu_item_channel_details) {
- SparseItemUtil.fetchUploaderUrlIfSparse(context, item.getServiceId(),
- item.getUrl(), item.getUploaderUrl(),
- // An intent must be used here.
- // Opening with FragmentManager transactions is not working,
- // as PlayQueueActivity doesn't use fragments.
- uploaderUrl -> NavigationHelper.openChannelFragmentUsingIntent(
- context, item.getServiceId(), uploaderUrl, item.getUploader()
- ));
- return true;
- } else if (itemId == R.id.menu_item_share) {
- shareText(context, item.getTitle(), item.getUrl(),
- item.getThumbnails());
- return true;
- } else if (itemId == R.id.menu_item_download) {
- fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(),
- info -> {
- final DownloadDialog downloadDialog = new DownloadDialog(context,
- info);
- downloadDialog.show(fragmentManager, "downloadDialog");
- });
- return true;
- }
- return false;
- });
-
- popupMenu.show();
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java
deleted file mode 100644
index 2997f937f..000000000
--- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java
+++ /dev/null
@@ -1,1078 +0,0 @@
-package org.schabi.newpipe;
-
-import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.AUDIO;
-import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.VIDEO;
-
-import android.annotation.SuppressLint;
-import android.app.IntentService;
-import android.content.Context;
-import android.content.DialogInterface;
-import android.content.Intent;
-import android.content.SharedPreferences;
-import android.content.pm.PackageManager;
-import android.os.Build;
-import android.os.Bundle;
-import android.text.TextUtils;
-import android.view.ContextThemeWrapper;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.view.WindowManager;
-import android.widget.Button;
-import android.widget.RadioButton;
-import android.widget.RadioGroup;
-import android.widget.Toast;
-
-import androidx.annotation.DrawableRes;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.StringRes;
-import androidx.appcompat.app.AlertDialog;
-import androidx.appcompat.app.AppCompatActivity;
-import androidx.appcompat.content.res.AppCompatResources;
-import androidx.core.app.NotificationCompat;
-import androidx.core.app.ServiceCompat;
-import androidx.core.math.MathUtils;
-import androidx.fragment.app.DialogFragment;
-import androidx.fragment.app.Fragment;
-import androidx.fragment.app.FragmentManager;
-import androidx.lifecycle.DefaultLifecycleObserver;
-import androidx.lifecycle.Lifecycle;
-import androidx.lifecycle.LifecycleOwner;
-import androidx.preference.PreferenceManager;
-
-import com.evernote.android.state.State;
-import com.livefront.bridge.Bridge;
-
-import org.schabi.newpipe.database.stream.model.StreamEntity;
-import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
-import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding;
-import org.schabi.newpipe.download.DownloadDialog;
-import org.schabi.newpipe.download.LoadingDialog;
-import org.schabi.newpipe.error.ErrorInfo;
-import org.schabi.newpipe.error.ErrorUtil;
-import org.schabi.newpipe.error.ReCaptchaActivity;
-import org.schabi.newpipe.error.UserAction;
-import org.schabi.newpipe.extractor.Info;
-import org.schabi.newpipe.extractor.NewPipe;
-import org.schabi.newpipe.extractor.StreamingService;
-import org.schabi.newpipe.extractor.StreamingService.LinkType;
-import org.schabi.newpipe.extractor.channel.ChannelInfo;
-import org.schabi.newpipe.extractor.exceptions.ExtractionException;
-import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
-import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
-import org.schabi.newpipe.extractor.stream.StreamInfo;
-import org.schabi.newpipe.local.dialog.PlaylistDialog;
-import org.schabi.newpipe.player.PlayerType;
-import org.schabi.newpipe.player.helper.PlayerHelper;
-import org.schabi.newpipe.player.helper.PlayerHolder;
-import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue;
-import org.schabi.newpipe.player.playqueue.PlayQueue;
-import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue;
-import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
-import org.schabi.newpipe.util.ChannelTabHelper;
-import org.schabi.newpipe.util.Constants;
-import org.schabi.newpipe.util.DeviceUtils;
-import org.schabi.newpipe.util.ExtractorHelper;
-import org.schabi.newpipe.util.NavigationHelper;
-import org.schabi.newpipe.util.PermissionHelper;
-import org.schabi.newpipe.util.ThemeHelper;
-import org.schabi.newpipe.util.external_communication.ShareUtils;
-import org.schabi.newpipe.util.urlfinder.UrlFinder;
-import org.schabi.newpipe.views.FocusOverlayView;
-
-import java.io.Serializable;
-import java.lang.ref.Reference;
-import java.lang.ref.WeakReference;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import java.util.Optional;
-import java.util.function.Consumer;
-
-import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
-import io.reactivex.rxjava3.core.Observable;
-import io.reactivex.rxjava3.core.Single;
-import io.reactivex.rxjava3.disposables.CompositeDisposable;
-import io.reactivex.rxjava3.disposables.Disposable;
-import io.reactivex.rxjava3.schedulers.Schedulers;
-
-/**
- * Get the url from the intent and open it in the chosen preferred player.
- */
-public class RouterActivity extends AppCompatActivity {
- protected final CompositeDisposable disposables = new CompositeDisposable();
- @State
- protected int currentServiceId = -1;
- @State
- protected LinkType currentLinkType;
- @State
- protected int selectedRadioPosition = -1;
- protected int selectedPreviously = -1;
- protected String currentUrl;
- private StreamingService currentService;
- private boolean selectionIsDownload = false;
- private boolean selectionIsAddToPlaylist = false;
- private AlertDialog alertDialogChoice = null;
- private FragmentManager.FragmentLifecycleCallbacks dismissListener = null;
-
- @Override
- protected void onCreate(final Bundle savedInstanceState) {
- ThemeHelper.setDayNightMode(this);
- setTheme(ThemeHelper.isLightThemeSelected(this)
- ? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark);
-
- // Pass-through touch events to background activities
- // so that our transparent window won't lock UI in the mean time
- // network request is underway before showing PlaylistDialog or DownloadDialog
- // (ref: https://stackoverflow.com/a/10606141)
- getWindow().addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
- | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
- | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE);
-
- // Android never fails to impress us with a list of new restrictions per API.
- // Starting with S (Android 12) one of the prerequisite conditions has to be met
- // before the FLAG_NOT_TOUCHABLE flag is allowed to kick in:
- // @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE
- // For our present purpose it seems we can just set LayoutParams.alpha to 0
- // on the strength of "4. Fully transparent windows" without affecting the scrim of dialogs
- final WindowManager.LayoutParams params = getWindow().getAttributes();
- params.alpha = 0f;
- getWindow().setAttributes(params);
-
- super.onCreate(savedInstanceState);
- Bridge.restoreInstanceState(this, savedInstanceState);
-
- // FragmentManager will take care to recreate (Playlist|Download)Dialog when screen rotates
- // We used to .setOnDismissListener(dialog -> finish()); when creating these DialogFragments
- // but those callbacks won't survive a config change
- // Try an alternate approach to hook into FragmentManager instead, to that effect
- // (ref: https://stackoverflow.com/a/44028453)
- final FragmentManager fm = getSupportFragmentManager();
- if (dismissListener == null) {
- dismissListener = new FragmentManager.FragmentLifecycleCallbacks() {
- @Override
- public void onFragmentDestroyed(@NonNull final FragmentManager fm,
- @NonNull final Fragment f) {
- super.onFragmentDestroyed(fm, f);
- if (f instanceof DialogFragment && fm.getFragments().isEmpty()) {
- // No more DialogFragments, we're done
- finish();
- }
- }
- };
- }
- fm.registerFragmentLifecycleCallbacks(dismissListener, false);
-
- if (TextUtils.isEmpty(currentUrl)) {
- currentUrl = getUrl(getIntent());
-
- if (TextUtils.isEmpty(currentUrl)) {
- handleText();
- finish();
- }
- }
- }
-
- @Override
- protected void onStop() {
- super.onStop();
- // we need to dismiss the dialog before leaving the activity or we get leaks
- if (alertDialogChoice != null) {
- alertDialogChoice.dismiss();
- }
- }
-
- @Override
- protected void onSaveInstanceState(@NonNull final Bundle outState) {
- super.onSaveInstanceState(outState);
- Bridge.saveInstanceState(this, outState);
- }
-
- @Override
- protected void onStart() {
- super.onStart();
-
- // Don't overlap the DialogFragment after rotating the screen
- // If there's no DialogFragment, we're either starting afresh
- // or we didn't make it to PlaylistDialog or DownloadDialog before the orientation change
- if (getSupportFragmentManager().getFragments().isEmpty()) {
- // Start over from scratch
- handleUrl(currentUrl);
- }
- }
-
- @Override
- protected void onDestroy() {
- super.onDestroy();
-
- if (dismissListener != null) {
- getSupportFragmentManager().unregisterFragmentLifecycleCallbacks(dismissListener);
- }
-
- disposables.clear();
- }
-
- @Override
- public void finish() {
- // allow the activity to recreate in case orientation changes
- if (!isChangingConfigurations()) {
- super.finish();
- }
- }
-
- private void handleUrl(final String url) {
- disposables.add(Observable
- .fromCallable(() -> {
- try {
- if (currentServiceId == -1) {
- currentService = NewPipe.getServiceByUrl(url);
- currentServiceId = currentService.getServiceId();
- currentLinkType = currentService.getLinkTypeByUrl(url);
- currentUrl = url;
- } else {
- currentService = NewPipe.getService(currentServiceId);
- }
-
- // return whether the url was found to be supported or not
- return currentLinkType != LinkType.NONE;
- } catch (final ExtractionException e) {
- // this can be reached only when the url is completely unsupported
- return false;
- }
- })
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(isUrlSupported -> {
- if (isUrlSupported) {
- onSuccess();
- } else {
- showUnsupportedUrlDialog(url);
- }
- }, throwable -> handleError(this, new ErrorInfo(throwable,
- UserAction.SHARE_TO_NEWPIPE, "Getting service from url: " + url,
- null, url))));
- }
-
- /**
- * @param context the context. It will be {@code finish()}ed at the end of the handling if it is
- * an instance of {@link RouterActivity}.
- * @param errorInfo the error information
- */
- private static void handleError(final Context context, final ErrorInfo errorInfo) {
- if (errorInfo.getRecaptchaUrl() != null) {
- Toast.makeText(context, R.string.recaptcha_request_toast, Toast.LENGTH_LONG).show();
- // Starting ReCaptcha Challenge Activity
- final Intent intent = new Intent(context, ReCaptchaActivity.class);
- intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
- intent.putExtra(ReCaptchaActivity.RECAPTCHA_URL_EXTRA, errorInfo.getRecaptchaUrl());
- context.startActivity(intent);
- } else if (errorInfo.isReportable()) {
- ErrorUtil.createNotification(context, errorInfo);
- } else {
- // this exception does not usually indicate a problem that should be reported,
- // so just show a toast instead of the notification
- Toast.makeText(context, errorInfo.getMessage(context), Toast.LENGTH_LONG).show();
- }
-
- if (context instanceof RouterActivity) {
- ((RouterActivity) context).finish();
- }
- }
-
- protected void showUnsupportedUrlDialog(final String url) {
- final Context context = getThemeWrapperContext();
- new AlertDialog.Builder(context)
- .setTitle(R.string.unsupported_url)
- .setMessage(R.string.unsupported_url_dialog_message)
- .setIcon(R.drawable.ic_share)
- .setPositiveButton(R.string.open_in_browser,
- (dialog, which) -> ShareUtils.openUrlInBrowser(this, url))
- .setNegativeButton(R.string.share,
- (dialog, which) -> ShareUtils.shareText(this, "", url)) // no subject
- .setNeutralButton(R.string.cancel, null)
- .setOnDismissListener(dialog -> finish())
- .show();
- }
-
- protected void onSuccess() {
- final SharedPreferences preferences = PreferenceManager
- .getDefaultSharedPreferences(this);
-
- final ChoiceAvailabilityChecker choiceChecker = new ChoiceAvailabilityChecker(
- getChoicesForService(currentService, currentLinkType),
- preferences.getString(getString(R.string.preferred_open_action_key),
- getString(R.string.preferred_open_action_default)));
-
- // Check for non-player related choices
- if (choiceChecker.isAvailableAndSelected(
- R.string.show_info_key,
- R.string.download_key,
- R.string.add_to_playlist_key)) {
- handleChoice(choiceChecker.getSelectedChoiceKey());
- return;
- }
- // Check if the choice is player related
- if (choiceChecker.isAvailableAndSelected(
- R.string.video_player_key,
- R.string.background_player_key,
- R.string.popup_player_key,
- R.string.enqueue_key)) {
-
- final String selectedChoice = choiceChecker.getSelectedChoiceKey();
-
- final boolean isExtVideoEnabled = preferences.getBoolean(
- getString(R.string.use_external_video_player_key), false);
- final boolean isExtAudioEnabled = preferences.getBoolean(
- getString(R.string.use_external_audio_player_key), false);
- final boolean isVideoPlayerSelected =
- selectedChoice.equals(getString(R.string.video_player_key))
- || selectedChoice.equals(getString(R.string.popup_player_key));
- final boolean isAudioPlayerSelected =
- selectedChoice.equals(getString(R.string.background_player_key));
- final boolean isEnqueueSelected =
- selectedChoice.equals(getString(R.string.enqueue_key));
-
- if (currentLinkType != LinkType.STREAM
- && ((isExtAudioEnabled && isAudioPlayerSelected)
- || (isExtVideoEnabled && isVideoPlayerSelected))
- ) {
- Toast.makeText(this, R.string.external_player_unsupported_link_type,
- Toast.LENGTH_LONG).show();
- handleChoice(getString(R.string.show_info_key));
- return;
- }
-
- final var capabilities = currentService.getServiceInfo().getMediaCapabilities();
-
- // Check if the service supports the choice
- if ((isVideoPlayerSelected && capabilities.contains(VIDEO))
- || (isAudioPlayerSelected && capabilities.contains(AUDIO))
- || (isEnqueueSelected && (capabilities.contains(VIDEO)
- || capabilities.contains(AUDIO)))) {
- handleChoice(selectedChoice);
- } else {
- handleChoice(getString(R.string.show_info_key));
- }
- return;
- }
-
- // Default / Ask always
- final List availableChoices = choiceChecker.getAvailableChoices();
- switch (availableChoices.size()) {
- case 1:
- handleChoice(availableChoices.get(0).key);
- break;
- case 0:
- handleChoice(getString(R.string.show_info_key));
- break;
- default:
- showDialog(availableChoices);
- break;
- }
- }
-
- /**
- * This is a helper class for checking if the choices are available and/or selected.
- */
- class ChoiceAvailabilityChecker {
- private final List availableChoices;
- private final String selectedChoiceKey;
-
- ChoiceAvailabilityChecker(
- @NonNull final List availableChoices,
- @NonNull final String selectedChoiceKey) {
- this.availableChoices = availableChoices;
- this.selectedChoiceKey = selectedChoiceKey;
- }
-
- public List getAvailableChoices() {
- return availableChoices;
- }
-
- public String getSelectedChoiceKey() {
- return selectedChoiceKey;
- }
-
- public boolean isAvailableAndSelected(@StringRes final int... wantedKeys) {
- return Arrays.stream(wantedKeys).anyMatch(this::isAvailableAndSelected);
- }
-
- public boolean isAvailableAndSelected(@StringRes final int wantedKey) {
- final String wanted = getString(wantedKey);
- // Check if the wanted option is selected
- if (!selectedChoiceKey.equals(wanted)) {
- return false;
- }
- // Check if it's available
- return availableChoices.stream().anyMatch(item -> wanted.equals(item.key));
- }
- }
-
- private void showDialog(final List choices) {
- final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this);
-
- final Context themeWrapperContext = getThemeWrapperContext();
- final LayoutInflater layoutInflater = LayoutInflater.from(themeWrapperContext);
-
- final SingleChoiceDialogViewBinding binding =
- SingleChoiceDialogViewBinding.inflate(layoutInflater);
- final RadioGroup radioGroup = binding.list;
-
- final DialogInterface.OnClickListener dialogButtonsClickListener = (dialog, which) -> {
- final int indexOfChild = radioGroup.indexOfChild(
- radioGroup.findViewById(radioGroup.getCheckedRadioButtonId()));
- final AdapterChoiceItem choice = choices.get(indexOfChild);
-
- handleChoice(choice.key);
-
- // open future streams always like this one, because "always" button was used by user
- if (which == DialogInterface.BUTTON_POSITIVE) {
- preferences.edit()
- .putString(getString(R.string.preferred_open_action_key), choice.key)
- .apply();
- }
- };
-
- alertDialogChoice = new AlertDialog.Builder(themeWrapperContext)
- .setTitle(R.string.preferred_open_action_share_menu_title)
- .setView(binding.getRoot())
- .setCancelable(true)
- .setNegativeButton(R.string.just_once, dialogButtonsClickListener)
- .setPositiveButton(R.string.always, dialogButtonsClickListener)
- .setOnDismissListener(dialog -> {
- if (!selectionIsDownload && !selectionIsAddToPlaylist) {
- finish();
- }
- })
- .create();
-
- alertDialogChoice.setOnShowListener(dialog -> setDialogButtonsState(
- alertDialogChoice, radioGroup.getCheckedRadioButtonId() != -1));
-
- radioGroup.setOnCheckedChangeListener((group, checkedId) ->
- setDialogButtonsState(alertDialogChoice, true));
- final View.OnClickListener radioButtonsClickListener = v -> {
- final int indexOfChild = radioGroup.indexOfChild(v);
- if (indexOfChild == -1) {
- return;
- }
-
- selectedPreviously = selectedRadioPosition;
- selectedRadioPosition = indexOfChild;
-
- if (selectedPreviously == selectedRadioPosition) {
- handleChoice(choices.get(selectedRadioPosition).key);
- }
- };
-
- int id = 12345;
- for (final AdapterChoiceItem item : choices) {
- final RadioButton radioButton = ListRadioIconItemBinding.inflate(layoutInflater)
- .getRoot();
- radioButton.setText(item.description);
- radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds(
- AppCompatResources.getDrawable(themeWrapperContext, item.icon),
- null, null, null);
- radioButton.setChecked(false);
- radioButton.setId(id++);
- radioButton.setLayoutParams(new RadioGroup.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
- radioButton.setOnClickListener(radioButtonsClickListener);
- radioGroup.addView(radioButton);
- }
-
- if (selectedRadioPosition == -1) {
- final String lastSelectedPlayer = preferences.getString(
- getString(R.string.preferred_open_action_last_selected_key), null);
- if (!TextUtils.isEmpty(lastSelectedPlayer)) {
- for (int i = 0; i < choices.size(); i++) {
- final AdapterChoiceItem c = choices.get(i);
- if (lastSelectedPlayer.equals(c.key)) {
- selectedRadioPosition = i;
- break;
- }
- }
- }
- }
-
- selectedRadioPosition = MathUtils.clamp(selectedRadioPosition, -1, choices.size() - 1);
- if (selectedRadioPosition != -1) {
- ((RadioButton) radioGroup.getChildAt(selectedRadioPosition)).setChecked(true);
- }
- selectedPreviously = selectedRadioPosition;
-
- alertDialogChoice.show();
-
- if (DeviceUtils.isTv(this)) {
- FocusOverlayView.setupFocusObserver(alertDialogChoice);
- }
- }
-
- private List getChoicesForService(final StreamingService service,
- final LinkType linkType) {
- final AdapterChoiceItem showInfo = new AdapterChoiceItem(
- getString(R.string.show_info_key), getString(R.string.show_info),
- R.drawable.ic_info_outline);
- final AdapterChoiceItem videoPlayer = new AdapterChoiceItem(
- getString(R.string.video_player_key), getString(R.string.video_player),
- R.drawable.ic_play_arrow);
- final AdapterChoiceItem backgroundPlayer = new AdapterChoiceItem(
- getString(R.string.background_player_key), getString(R.string.background_player),
- R.drawable.ic_headset);
- final AdapterChoiceItem popupPlayer = new AdapterChoiceItem(
- getString(R.string.popup_player_key), getString(R.string.popup_player),
- R.drawable.ic_picture_in_picture);
-
- final List returnedItems = new ArrayList<>();
- returnedItems.add(showInfo); // Always present
-
- final var capabilities = service.getServiceInfo().getMediaCapabilities();
-
- if (linkType == LinkType.STREAM || linkType == LinkType.PLAYLIST) {
- if (capabilities.contains(VIDEO)) {
- returnedItems.add(videoPlayer);
- returnedItems.add(popupPlayer);
- }
- if (capabilities.contains(AUDIO)) {
- returnedItems.add(backgroundPlayer);
- }
-
- // Enqueue is only shown if the current queue is not empty.
- // However, if the playqueue or the player is cleared after this item was chosen and
- // while the item is extracted, it will automatically fall back to background player.
- if (PlayerHolder.getInstance().getQueueSize() > 0) {
- returnedItems.add(new AdapterChoiceItem(getString(R.string.enqueue_key),
- getString(R.string.enqueue_stream), R.drawable.ic_add));
- }
-
- if (linkType == LinkType.STREAM) {
- // download is redundant for linkType CHANNEL AND PLAYLIST
- // (till playlist downloading is not supported )
- returnedItems.add(new AdapterChoiceItem(getString(R.string.download_key),
- getString(R.string.download),
- R.drawable.ic_file_download));
-
- // Add to playlist is not necessary for CHANNEL and PLAYLIST linkType
- // since those can not be added to a playlist
- returnedItems.add(new AdapterChoiceItem(getString(R.string.add_to_playlist_key),
- getString(R.string.add_to_playlist),
- R.drawable.ic_playlist_add));
- }
- } else {
- // LinkType.NONE is never present because it's filtered out before
- // channels and playlist can be played as they contain a list of videos
- final SharedPreferences preferences = PreferenceManager
- .getDefaultSharedPreferences(this);
- final boolean isExtVideoEnabled = preferences.getBoolean(
- getString(R.string.use_external_video_player_key), false);
- final boolean isExtAudioEnabled = preferences.getBoolean(
- getString(R.string.use_external_audio_player_key), false);
-
- if (capabilities.contains(VIDEO) && !isExtVideoEnabled) {
- returnedItems.add(videoPlayer);
- returnedItems.add(popupPlayer);
- }
- if (capabilities.contains(AUDIO) && !isExtAudioEnabled) {
- returnedItems.add(backgroundPlayer);
- }
- }
-
- return returnedItems;
- }
-
- protected Context getThemeWrapperContext() {
- return new ContextThemeWrapper(this, ThemeHelper.isLightThemeSelected(this)
- ? R.style.LightTheme : R.style.DarkTheme);
- }
-
- private void setDialogButtonsState(final AlertDialog dialog, final boolean state) {
- final Button negativeButton = dialog.getButton(DialogInterface.BUTTON_NEGATIVE);
- final Button positiveButton = dialog.getButton(DialogInterface.BUTTON_POSITIVE);
- if (negativeButton == null || positiveButton == null) {
- return;
- }
-
- negativeButton.setEnabled(state);
- positiveButton.setEnabled(state);
- }
-
- private void handleText() {
- final String searchString = getIntent().getStringExtra(Intent.EXTRA_TEXT);
- final int serviceId = getIntent().getIntExtra(Constants.KEY_SERVICE_ID, 0);
- final Intent intent = new Intent(getThemeWrapperContext(), MainActivity.class);
- intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
- startActivity(intent);
- NavigationHelper.openSearch(getThemeWrapperContext(), serviceId, searchString);
- }
-
- private void handleChoice(final String selectedChoiceKey) {
- final List validChoicesList = Arrays.asList(getResources()
- .getStringArray(R.array.preferred_open_action_values_list));
- if (validChoicesList.contains(selectedChoiceKey)) {
- PreferenceManager.getDefaultSharedPreferences(this).edit()
- .putString(getString(
- R.string.preferred_open_action_last_selected_key), selectedChoiceKey)
- .apply();
- }
-
- if (selectedChoiceKey.equals(getString(R.string.popup_player_key))
- && !PermissionHelper.isPopupEnabledElseAsk(this)) {
- finish();
- return;
- }
-
- if (selectedChoiceKey.equals(getString(R.string.download_key))) {
- if (PermissionHelper.checkStoragePermissions(this,
- PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) {
- selectionIsDownload = true;
- openDownloadDialog();
- }
- return;
- }
-
- if (selectedChoiceKey.equals(getString(R.string.add_to_playlist_key))) {
- selectionIsAddToPlaylist = true;
- openAddToPlaylistDialog();
- return;
- }
-
- // stop and bypass FetcherService if InfoScreen was selected since
- // StreamDetailFragment can fetch data itself
- if (selectedChoiceKey.equals(getString(R.string.show_info_key))
- || canHandleChoiceLikeShowInfo(selectedChoiceKey)) {
- disposables.add(Observable
- .fromCallable(() -> NavigationHelper.getIntentByLink(this, currentUrl))
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(intent -> {
- startActivity(intent);
- finish();
- }, throwable -> handleError(this, new ErrorInfo(throwable,
- UserAction.SHARE_TO_NEWPIPE, "Starting info activity: " + currentUrl,
- null, currentUrl)))
- );
- return;
- }
-
- final Intent intent = new Intent(this, FetcherService.class);
- final Choice choice = new Choice(currentService.getServiceId(), currentLinkType,
- currentUrl, selectedChoiceKey);
- intent.putExtra(FetcherService.KEY_CHOICE, choice);
- startService(intent);
-
- finish();
- }
-
- private boolean canHandleChoiceLikeShowInfo(final String selectedChoiceKey) {
- if (!selectedChoiceKey.equals(getString(R.string.video_player_key))) {
- return false;
- }
- // "video player" can be handled like "show info" (because VideoDetailFragment can load
- // the stream instead of FetcherService) when...
-
- // ...Autoplay is enabled
- if (!PlayerHelper.isAutoplayAllowedByUser(getThemeWrapperContext())) {
- return false;
- }
-
- final boolean isExtVideoEnabled = PreferenceManager.getDefaultSharedPreferences(this)
- .getBoolean(getString(R.string.use_external_video_player_key), false);
- // ...it's not done via an external player
- if (isExtVideoEnabled) {
- return false;
- }
-
- // ...the player is not running or in normal Video-mode/type
- final PlayerType playerType = PlayerHolder.getInstance().getType();
- return playerType == null || playerType == PlayerType.MAIN;
- }
-
- public static class PersistentFragment extends Fragment {
- private WeakReference weakContext;
- private final CompositeDisposable disposables = new CompositeDisposable();
- private int running = 0;
-
- private synchronized void inFlight(final boolean started) {
- if (started) {
- running++;
- } else {
- running--;
- if (running <= 0) {
- getActivityContext().ifPresent(context -> context.getSupportFragmentManager()
- .beginTransaction().remove(this).commit());
- }
- }
- }
-
- @Override
- public void onAttach(@NonNull final Context activityContext) {
- super.onAttach(activityContext);
- weakContext = new WeakReference<>((AppCompatActivity) activityContext);
- }
-
- @Override
- public void onDetach() {
- super.onDetach();
- weakContext = null;
- }
-
- @SuppressWarnings("deprecation")
- @Override
- public void onCreate(final Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setRetainInstance(true);
- }
-
- @Override
- public void onDestroy() {
- super.onDestroy();
- disposables.clear();
- }
-
- /**
- * @return the activity context, if there is one and the activity is not finishing
- */
- private Optional getActivityContext() {
- return Optional.ofNullable(weakContext)
- .map(Reference::get)
- .filter(context -> !context.isFinishing());
- }
-
- // guard against IllegalStateException in calling DialogFragment.show() whilst in background
- // (which could happen, say, when the user pressed the home button while waiting for
- // the network request to return) when it internally calls FragmentTransaction.commit()
- // after the FragmentManager has saved its states (isStateSaved() == true)
- // (ref: https://stackoverflow.com/a/39813506)
- private void runOnVisible(final Consumer runnable) {
- getActivityContext().ifPresentOrElse(context -> {
- if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) {
- context.runOnUiThread(() -> {
- runnable.accept(context);
- inFlight(false);
- });
- } else {
- getLifecycle().addObserver(new DefaultLifecycleObserver() {
- @Override
- public void onResume(@NonNull final LifecycleOwner owner) {
- getLifecycle().removeObserver(this);
- getActivityContext().ifPresentOrElse(context ->
- context.runOnUiThread(() -> {
- runnable.accept(context);
- inFlight(false);
- }),
- () -> inFlight(false)
- );
- }
- });
- // this trick doesn't seem to work on Android 10+ (API 29)
- // which places restrictions on starting activities from the background
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
- && !context.isChangingConfigurations()) {
- // try to bring the activity back to front if minimised
- final Intent i = new Intent(context, RouterActivity.class);
- i.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
- startActivity(i);
- }
- }
-
- }, () ->
- // this branch is executed if there is no activity context
- inFlight(false)
- );
- }
-
- Single pleaseWait(final Single single) {
- // 'abuse' ambWith() here to cancel the toast for us when the wait is over
- return single.ambWith(Single.create(emitter -> getActivityContext().ifPresent(context ->
- context.runOnUiThread(() -> {
- // Getting the stream info usually takes a moment
- // Notifying the user here to ensure that no confusion arises
- final Toast toast = Toast.makeText(context,
- getString(R.string.processing_may_take_a_moment),
- Toast.LENGTH_LONG);
- toast.show();
- emitter.setCancellable(toast::cancel);
- }))));
- }
-
- @SuppressLint("CheckResult")
- private void openDownloadDialog(final int currentServiceId, final String currentUrl) {
- inFlight(true);
- final LoadingDialog loadingDialog = new LoadingDialog(R.string.loading_metadata_title);
- loadingDialog.show(getParentFragmentManager(), "loadingDialog");
- disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true)
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .compose(this::pleaseWait)
- .subscribe(result ->
- runOnVisible(ctx -> {
- loadingDialog.dismiss();
- final FragmentManager fm = ctx.getSupportFragmentManager();
- final DownloadDialog downloadDialog = new DownloadDialog(ctx, result);
- // dismiss listener to be handled by FragmentManager
- downloadDialog.show(fm, "downloadDialog");
- }
- ), throwable -> runOnVisible(ctx -> {
- loadingDialog.dismiss();
- ((RouterActivity) ctx).showUnsupportedUrlDialog(currentUrl);
- })));
- }
-
- private void openAddToPlaylistDialog(final int currentServiceId, final String currentUrl) {
- inFlight(true);
- disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, false)
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .compose(this::pleaseWait)
- .subscribe(
- info -> getActivityContext().ifPresent(context ->
- PlaylistDialog.createCorrespondingDialog(context,
- List.of(new StreamEntity(info)),
- playlistDialog -> runOnVisible(ctx -> {
- // dismiss listener to be handled by FragmentManager
- final FragmentManager fm =
- ctx.getSupportFragmentManager();
- playlistDialog.show(fm, "addToPlaylistDialog");
- })
- )),
- throwable -> runOnVisible(ctx -> handleError(ctx, new ErrorInfo(
- throwable, UserAction.REQUESTED_STREAM,
- "Tried to add " + currentUrl + " to a playlist",
- ((RouterActivity) ctx).currentService.getServiceId(),
- currentUrl)
- ))
- )
- );
- }
- }
-
- private void openAddToPlaylistDialog() {
- getPersistFragment().openAddToPlaylistDialog(currentServiceId, currentUrl);
- }
-
- private void openDownloadDialog() {
- getPersistFragment().openDownloadDialog(currentServiceId, currentUrl);
- }
-
- private PersistentFragment getPersistFragment() {
- final FragmentManager fm = getSupportFragmentManager();
- PersistentFragment persistFragment =
- (PersistentFragment) fm.findFragmentByTag("PERSIST_FRAGMENT");
- if (persistFragment == null) {
- persistFragment = new PersistentFragment();
- fm.beginTransaction()
- .add(persistFragment, "PERSIST_FRAGMENT")
- .commitNow();
- }
- return persistFragment;
- }
-
- @Override
- public void onRequestPermissionsResult(final int requestCode,
- @NonNull final String[] permissions,
- @NonNull final int[] grantResults) {
- super.onRequestPermissionsResult(requestCode, permissions, grantResults);
- for (final int i : grantResults) {
- if (i == PackageManager.PERMISSION_DENIED) {
- finish();
- return;
- }
- }
- if (requestCode == PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE) {
- openDownloadDialog();
- }
- }
-
- private static class AdapterChoiceItem {
- final String description;
- final String key;
- @DrawableRes
- final int icon;
-
- AdapterChoiceItem(final String key, final String description, final int icon) {
- this.key = key;
- this.description = description;
- this.icon = icon;
- }
- }
-
- private static class Choice implements Serializable {
- final int serviceId;
- final String url;
- final String playerChoice;
- final LinkType linkType;
-
- Choice(final int serviceId, final LinkType linkType,
- final String url, final String playerChoice) {
- this.serviceId = serviceId;
- this.linkType = linkType;
- this.url = url;
- this.playerChoice = playerChoice;
- }
-
- @NonNull
- @Override
- public String toString() {
- return serviceId + ":" + url + " > " + linkType + " ::: " + playerChoice;
- }
- }
-
- public static class FetcherService extends IntentService {
-
- public static final String KEY_CHOICE = "key_choice";
- private static final int ID = 456;
- private Disposable fetcher;
-
- public FetcherService() {
- super(FetcherService.class.getSimpleName());
- }
-
- @Override
- public void onCreate() {
- super.onCreate();
- startForeground(ID, createNotification().build());
- }
-
- @Override
- protected void onHandleIntent(@Nullable final Intent intent) {
- if (intent == null) {
- return;
- }
-
- final Serializable serializable = intent.getSerializableExtra(KEY_CHOICE);
- if (!(serializable instanceof Choice)) {
- return;
- }
- final Choice playerChoice = (Choice) serializable;
- handleChoice(playerChoice);
- }
-
- public void handleChoice(final Choice choice) {
- Single extends Info> single = null;
- UserAction userAction = UserAction.SOMETHING_ELSE;
-
- switch (choice.linkType) {
- case STREAM:
- single = ExtractorHelper.getStreamInfo(choice.serviceId, choice.url, false);
- userAction = UserAction.REQUESTED_STREAM;
- break;
- case CHANNEL:
- single = ExtractorHelper.getChannelInfo(choice.serviceId, choice.url, false);
- userAction = UserAction.REQUESTED_CHANNEL;
- break;
- case PLAYLIST:
- single = ExtractorHelper.getPlaylistInfo(choice.serviceId, choice.url, false);
- userAction = UserAction.REQUESTED_PLAYLIST;
- break;
- }
-
-
- if (single != null) {
- final UserAction finalUserAction = userAction;
- final Consumer resultHandler = getResultHandler(choice);
- fetcher = single
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(info -> {
- resultHandler.accept(info);
- if (fetcher != null) {
- fetcher.dispose();
- }
- }, throwable -> handleError(this, new ErrorInfo(throwable, finalUserAction,
- choice.url + " opened with " + choice.playerChoice,
- choice.serviceId, choice.url)));
- }
- }
-
- public Consumer getResultHandler(final Choice choice) {
- return info -> {
- final String videoPlayerKey = getString(R.string.video_player_key);
- final String backgroundPlayerKey = getString(R.string.background_player_key);
- final String popupPlayerKey = getString(R.string.popup_player_key);
-
- final SharedPreferences preferences = PreferenceManager
- .getDefaultSharedPreferences(this);
- final boolean isExtVideoEnabled = preferences.getBoolean(
- getString(R.string.use_external_video_player_key), false);
- final boolean isExtAudioEnabled = preferences.getBoolean(
- getString(R.string.use_external_audio_player_key), false);
-
- final PlayQueue playQueue;
- if (info instanceof StreamInfo) {
- if (choice.playerChoice.equals(backgroundPlayerKey) && isExtAudioEnabled) {
- NavigationHelper.playOnExternalAudioPlayer(this, (StreamInfo) info);
- return;
- } else if (choice.playerChoice.equals(videoPlayerKey) && isExtVideoEnabled) {
- NavigationHelper.playOnExternalVideoPlayer(this, (StreamInfo) info);
- return;
- }
- playQueue = new SinglePlayQueue((StreamInfo) info);
- } else if (info instanceof ChannelInfo) {
- final Optional playableTab = ((ChannelInfo) info).getTabs()
- .stream()
- .filter(ChannelTabHelper::isStreamsTab)
- .findFirst();
-
- if (playableTab.isPresent()) {
- playQueue = new ChannelTabPlayQueue(info.getServiceId(), playableTab.get());
- } else {
- return; // there is no playable tab
- }
- } else if (info instanceof PlaylistInfo) {
- playQueue = new PlaylistPlayQueue((PlaylistInfo) info);
- } else {
- return;
- }
-
- if (choice.playerChoice.equals(videoPlayerKey)) {
- NavigationHelper.playOnMainPlayer(this, playQueue, false);
- } else if (choice.playerChoice.equals(backgroundPlayerKey)) {
- NavigationHelper.playOnBackgroundPlayer(this, playQueue, true);
- } else if (choice.playerChoice.equals(popupPlayerKey)) {
- NavigationHelper.playOnPopupPlayer(this, playQueue, true);
- } else if (choice.playerChoice.equals(getString(R.string.enqueue_key))) {
- NavigationHelper.enqueueOnPlayer(this, playQueue);
- }
- };
- }
-
- @Override
- public void onDestroy() {
- super.onDestroy();
- ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE);
- if (fetcher != null) {
- fetcher.dispose();
- }
- }
-
- private NotificationCompat.Builder createNotification() {
- return new NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
- .setOngoing(true)
- .setSmallIcon(R.drawable.ic_newpipe_triangle_white)
- .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
- .setContentTitle(
- getString(R.string.preferred_player_fetcher_notification_title))
- .setContentText(
- getString(R.string.preferred_player_fetcher_notification_message));
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Utils
- //////////////////////////////////////////////////////////////////////////*/
-
- @Nullable
- private String getUrl(final Intent intent) {
- String foundUrl = null;
- if (intent.getData() != null) {
- // Called from another app
- foundUrl = intent.getData().toString();
- } else if (intent.getStringExtra(Intent.EXTRA_TEXT) != null) {
- // Called from the share menu
- final String extraText = intent.getStringExtra(Intent.EXTRA_TEXT);
- foundUrl = UrlFinder.firstUrlFromInput(extraText);
- }
-
- return foundUrl;
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt
deleted file mode 100644
index ed5951f04..000000000
--- a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt
+++ /dev/null
@@ -1,260 +0,0 @@
-package org.schabi.newpipe.about
-
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.MenuItem
-import android.view.View
-import android.view.ViewGroup
-import android.widget.Button
-import androidx.annotation.StringRes
-import androidx.appcompat.app.AppCompatActivity
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.FragmentActivity
-import androidx.viewpager2.adapter.FragmentStateAdapter
-import com.google.android.material.tabs.TabLayoutMediator
-import org.schabi.newpipe.BuildConfig
-import org.schabi.newpipe.R
-import org.schabi.newpipe.databinding.ActivityAboutBinding
-import org.schabi.newpipe.databinding.FragmentAboutBinding
-import org.schabi.newpipe.util.ThemeHelper
-import org.schabi.newpipe.util.external_communication.ShareUtils
-
-class AboutActivity : AppCompatActivity() {
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- ThemeHelper.setTheme(this)
- title = getString(R.string.title_activity_about)
-
- val aboutBinding = ActivityAboutBinding.inflate(layoutInflater)
- setContentView(aboutBinding.root)
- setSupportActionBar(aboutBinding.aboutToolbar)
- supportActionBar?.setDisplayHomeAsUpEnabled(true)
-
- // Create the adapter that will return a fragment for each of the three
- // primary sections of the activity.
- val mAboutStateAdapter = AboutStateAdapter(this)
- // Set up the ViewPager with the sections adapter.
- aboutBinding.aboutViewPager2.adapter = mAboutStateAdapter
- TabLayoutMediator(
- aboutBinding.aboutTabLayout,
- aboutBinding.aboutViewPager2
- ) { tab, position ->
- tab.setText(mAboutStateAdapter.getPageTitle(position))
- }.attach()
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- if (item.itemId == android.R.id.home) {
- finish()
- return true
- }
- return super.onOptionsItemSelected(item)
- }
-
- /**
- * A placeholder fragment containing a simple view.
- */
- class AboutFragment : Fragment() {
- private fun Button.openLink(@StringRes url: Int) {
- setOnClickListener {
- ShareUtils.openUrlInApp(context, requireContext().getString(url))
- }
- }
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View {
- FragmentAboutBinding.inflate(inflater, container, false).apply {
- aboutAppVersion.text = BuildConfig.VERSION_NAME
- aboutGithubLink.openLink(R.string.github_url)
- aboutDonationLink.openLink(R.string.donation_url)
- aboutWebsiteLink.openLink(R.string.website_url)
- aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url)
- faqLink.openLink(R.string.faq_url)
- return root
- }
- }
- }
-
- /**
- * A [FragmentStateAdapter] that returns a fragment corresponding to
- * one of the sections/tabs/pages.
- */
- private class AboutStateAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
- private val posAbout = 0
- private val posLicense = 1
- private val totalCount = 2
-
- override fun createFragment(position: Int): Fragment {
- return when (position) {
- posAbout -> AboutFragment()
- posLicense -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS)
- else -> error("Unknown position for ViewPager2")
- }
- }
-
- override fun getItemCount(): Int {
- // Show 2 total pages.
- return totalCount
- }
-
- fun getPageTitle(position: Int): Int {
- return when (position) {
- posAbout -> R.string.tab_about
- posLicense -> R.string.tab_licenses
- else -> error("Unknown position for ViewPager2")
- }
- }
- }
-
- companion object {
- /**
- * List of all software components.
- */
- private val SOFTWARE_COMPONENTS = arrayListOf(
- SoftwareComponent(
- "ACRA",
- "2013",
- "Kevin Gaudin",
- "https://github.com/ACRA/acra",
- StandardLicenses.APACHE2
- ),
- SoftwareComponent(
- "AndroidX",
- "2005 - 2011",
- "The Android Open Source Project",
- "https://developer.android.com/jetpack",
- StandardLicenses.APACHE2
- ),
- SoftwareComponent(
- "ExoPlayer",
- "2014 - 2020",
- "Google, Inc.",
- "https://github.com/google/ExoPlayer",
- StandardLicenses.APACHE2
- ),
- SoftwareComponent(
- "GigaGet",
- "2014 - 2015",
- "Peter Cai",
- "https://github.com/PaperAirplane-Dev-Team/GigaGet",
- StandardLicenses.GPL3
- ),
- SoftwareComponent(
- "Groupie",
- "2016",
- "Lisa Wray",
- "https://github.com/lisawray/groupie",
- StandardLicenses.MIT
- ),
- SoftwareComponent(
- "Android-State",
- "2018",
- "Evernote",
- "https://github.com/Evernote/android-state",
- StandardLicenses.EPL1
- ),
- SoftwareComponent(
- "Bridge",
- "2021",
- "Livefront",
- "https://github.com/livefront/bridge",
- StandardLicenses.APACHE2
- ),
- SoftwareComponent(
- "Jsoup",
- "2009 - 2020",
- "Jonathan Hedley",
- "https://github.com/jhy/jsoup",
- StandardLicenses.MIT
- ),
- SoftwareComponent(
- "Markwon",
- "2019",
- "Dimitry Ivanov",
- "https://github.com/noties/Markwon",
- StandardLicenses.APACHE2
- ),
- SoftwareComponent(
- "Material Components for Android",
- "2016 - 2020",
- "Google, Inc.",
- "https://github.com/material-components/material-components-android",
- StandardLicenses.APACHE2
- ),
- SoftwareComponent(
- "NewPipe Extractor",
- "2017 - 2020",
- "Christian Schabesberger",
- "https://github.com/TeamNewPipe/NewPipeExtractor",
- StandardLicenses.GPL3
- ),
- SoftwareComponent(
- "NoNonsense-FilePicker",
- "2016",
- "Jonas Kalderstam",
- "https://github.com/spacecowboy/NoNonsense-FilePicker",
- StandardLicenses.MPL2
- ),
- SoftwareComponent(
- "OkHttp",
- "2019",
- "Square, Inc.",
- "https://square.github.io/okhttp/",
- StandardLicenses.APACHE2
- ),
- SoftwareComponent(
- "Coil",
- "2023",
- "Coil Contributors",
- "https://coil-kt.github.io/coil/",
- StandardLicenses.APACHE2
- ),
- SoftwareComponent(
- "PrettyTime",
- "2012 - 2020",
- "Lincoln Baxter, III",
- "https://github.com/ocpsoft/prettytime",
- StandardLicenses.APACHE2
- ),
- SoftwareComponent(
- "ProcessPhoenix",
- "2015",
- "Jake Wharton",
- "https://github.com/JakeWharton/ProcessPhoenix",
- StandardLicenses.APACHE2
- ),
- SoftwareComponent(
- "RxAndroid",
- "2015",
- "The RxAndroid authors",
- "https://github.com/ReactiveX/RxAndroid",
- StandardLicenses.APACHE2
- ),
- SoftwareComponent(
- "RxBinding",
- "2015",
- "Jake Wharton",
- "https://github.com/JakeWharton/RxBinding",
- StandardLicenses.APACHE2
- ),
- SoftwareComponent(
- "RxJava",
- "2016 - 2020",
- "RxJava Contributors",
- "https://github.com/ReactiveX/RxJava",
- StandardLicenses.APACHE2
- ),
- SoftwareComponent(
- "SearchPreference",
- "2018",
- "ByteHamster",
- "https://github.com/ByteHamster/SearchPreference",
- StandardLicenses.MIT
- )
- )
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/about/License.kt b/app/src/main/java/org/schabi/newpipe/about/License.kt
deleted file mode 100644
index fc50c646d..000000000
--- a/app/src/main/java/org/schabi/newpipe/about/License.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package org.schabi.newpipe.about
-
-import android.os.Parcelable
-import java.io.Serializable
-import kotlinx.parcelize.Parcelize
-
-/**
- * Class for storing information about a software license.
- */
-@Parcelize
-class License(val name: String, val abbreviation: String, val filename: String) : Parcelable, Serializable
diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt b/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt
deleted file mode 100644
index bd0632c13..000000000
--- a/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt
+++ /dev/null
@@ -1,142 +0,0 @@
-package org.schabi.newpipe.about
-
-import android.os.Bundle
-import android.util.Base64
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.webkit.WebView
-import androidx.appcompat.app.AlertDialog
-import androidx.core.os.BundleCompat
-import androidx.core.os.bundleOf
-import androidx.fragment.app.Fragment
-import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
-import io.reactivex.rxjava3.core.Observable
-import io.reactivex.rxjava3.disposables.CompositeDisposable
-import io.reactivex.rxjava3.disposables.Disposable
-import io.reactivex.rxjava3.schedulers.Schedulers
-import org.schabi.newpipe.BuildConfig
-import org.schabi.newpipe.R
-import org.schabi.newpipe.databinding.FragmentLicensesBinding
-import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding
-import org.schabi.newpipe.ktx.parcelableArrayList
-import org.schabi.newpipe.util.external_communication.ShareUtils
-
-/**
- * Fragment containing the software licenses.
- */
-class LicenseFragment : Fragment() {
- private lateinit var softwareComponents: List
- private var activeSoftwareComponent: SoftwareComponent? = null
- private val compositeDisposable = CompositeDisposable()
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- softwareComponents = arguments?.parcelableArrayList(ARG_COMPONENTS)!!
- .sortedBy { it.name } // Sort components by name
- activeSoftwareComponent = savedInstanceState?.let {
- BundleCompat.getSerializable(it, SOFTWARE_COMPONENT_KEY, SoftwareComponent::class.java)
- }
- }
-
- override fun onDestroy() {
- compositeDisposable.dispose()
- super.onDestroy()
- }
-
- override fun onCreateView(
- inflater: LayoutInflater,
- container: ViewGroup?,
- savedInstanceState: Bundle?
- ): View {
- val binding = FragmentLicensesBinding.inflate(inflater, container, false)
- binding.licensesAppReadLicense.setOnClickListener {
- compositeDisposable.add(
- showLicense(NEWPIPE_SOFTWARE_COMPONENT)
- )
- }
- for (component in softwareComponents) {
- val componentBinding = ItemSoftwareComponentBinding
- .inflate(inflater, container, false)
- componentBinding.name.text = component.name
- componentBinding.copyright.text = getString(
- R.string.copyright,
- component.years,
- component.copyrightOwner,
- component.license.abbreviation
- )
- val root: View = componentBinding.root
- root.tag = component
- root.setOnClickListener {
- compositeDisposable.add(
- showLicense(component)
- )
- }
- binding.licensesSoftwareComponents.addView(root)
- registerForContextMenu(root)
- }
- activeSoftwareComponent?.let { compositeDisposable.add(showLicense(it)) }
- return binding.root
- }
-
- override fun onSaveInstanceState(savedInstanceState: Bundle) {
- super.onSaveInstanceState(savedInstanceState)
- activeSoftwareComponent?.let { savedInstanceState.putSerializable(SOFTWARE_COMPONENT_KEY, it) }
- }
-
- private fun showLicense(
- softwareComponent: SoftwareComponent
- ): Disposable {
- return if (context == null) {
- Disposable.empty()
- } else {
- val context = requireContext()
- activeSoftwareComponent = softwareComponent
- Observable.fromCallable { getFormattedLicense(context, softwareComponent.license) }
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe { formattedLicense ->
- val webViewData = Base64.encodeToString(
- formattedLicense.toByteArray(),
- Base64.NO_PADDING
- )
- val webView = WebView(context)
- webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
-
- val builder = AlertDialog.Builder(requireContext())
- .setTitle(softwareComponent.name)
- .setView(webView)
- .setOnCancelListener { activeSoftwareComponent = null }
- .setOnDismissListener { activeSoftwareComponent = null }
- .setPositiveButton(R.string.done) { dialog, _ -> dialog.dismiss() }
-
- if (softwareComponent != NEWPIPE_SOFTWARE_COMPONENT) {
- builder.setNeutralButton(R.string.open_website_license) { _, _ ->
- ShareUtils.openUrlInApp(requireContext(), softwareComponent.link)
- }
- }
-
- builder.show()
- }
- }
- }
-
- companion object {
- private const val ARG_COMPONENTS = "components"
- private const val SOFTWARE_COMPONENT_KEY = "ACTIVE_SOFTWARE_COMPONENT"
- private val NEWPIPE_SOFTWARE_COMPONENT = SoftwareComponent(
- "NewPipe",
- "2014-2023",
- "Team NewPipe",
- "https://newpipe.net/",
- StandardLicenses.GPL3,
- BuildConfig.VERSION_NAME
- )
-
- fun newInstance(softwareComponents: ArrayList): LicenseFragment {
- val fragment = LicenseFragment()
- fragment.arguments = bundleOf(ARG_COMPONENTS to softwareComponents)
- return fragment
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt b/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt
deleted file mode 100644
index 32e4f812f..000000000
--- a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-package org.schabi.newpipe.about
-
-import android.content.Context
-import java.io.IOException
-import org.schabi.newpipe.R
-import org.schabi.newpipe.util.ThemeHelper
-
-/**
- * @param context the context to use
- * @param license the license
- * @return String which contains a HTML formatted license page
- * styled according to the context's theme
- */
-fun getFormattedLicense(context: Context, license: License): String {
- try {
- return context.assets.open(license.filename).bufferedReader().use { it.readText() }
- // split the HTML file and insert the stylesheet into the HEAD of the file
- .replace("", "")
- } catch (e: IOException) {
- throw IllegalArgumentException("Could not get license file: ${license.filename}", e)
- }
-}
-
-/**
- * @param context the Android context
- * @return String which is a CSS stylesheet according to the context's theme
- */
-fun getLicenseStylesheet(context: Context): String {
- val isLightTheme = ThemeHelper.isLightThemeSelected(context)
- val licenseBackgroundColor = getHexRGBColor(
- context,
- if (isLightTheme) R.color.light_license_background_color else R.color.dark_license_background_color
- )
- val licenseTextColor = getHexRGBColor(
- context,
- if (isLightTheme) R.color.light_license_text_color else R.color.dark_license_text_color
- )
- val youtubePrimaryColor = getHexRGBColor(
- context,
- if (isLightTheme) R.color.light_youtube_primary_color else R.color.dark_youtube_primary_color
- )
- return "body{padding:12px 15px;margin:0;background:#$licenseBackgroundColor;color:#$licenseTextColor}" +
- "a[href]{color:#$youtubePrimaryColor}pre{white-space:pre-wrap}"
-}
-
-/**
- * Cast R.color to a hexadecimal color value.
- *
- * @param context the context to use
- * @param color the color number from R.color
- * @return a six characters long String with hexadecimal RGB values
- */
-fun getHexRGBColor(context: Context, color: Int): String {
- return context.getString(color).substring(3)
-}
diff --git a/app/src/main/java/org/schabi/newpipe/about/SoftwareComponent.kt b/app/src/main/java/org/schabi/newpipe/about/SoftwareComponent.kt
deleted file mode 100644
index a43ddfd5e..000000000
--- a/app/src/main/java/org/schabi/newpipe/about/SoftwareComponent.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package org.schabi.newpipe.about
-
-import android.os.Parcelable
-import java.io.Serializable
-import kotlinx.parcelize.Parcelize
-
-@Parcelize
-class SoftwareComponent
-@JvmOverloads
-constructor(
- val name: String,
- val years: String,
- val copyrightOwner: String,
- val link: String,
- val license: License,
- val version: String? = null
-) : Parcelable, Serializable
diff --git a/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.kt b/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.kt
deleted file mode 100644
index c5b9618fe..000000000
--- a/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package org.schabi.newpipe.about
-
-/**
- * Class containing information about standard software licenses.
- */
-object StandardLicenses {
- @JvmField
- val GPL3 = License("GNU General Public License, Version 3.0", "GPLv3", "gpl_3.html")
-
- @JvmField
- val APACHE2 = License("Apache License, Version 2.0", "ALv2", "apache2.html")
-
- @JvmField
- val MPL2 = License("Mozilla Public License, Version 2.0", "MPL 2.0", "mpl2.html")
-
- @JvmField
- val MIT = License("MIT License", "MIT", "mit.html")
-
- @JvmField
- val EPL1 = License("Eclipse Public License, Version 1.0", "EPL 1.0", "epl1.html")
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.kt b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.kt
deleted file mode 100644
index 286eddf7b..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.kt
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2017-2024 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database
-
-import androidx.room.Database
-import androidx.room.RoomDatabase
-import androidx.room.TypeConverters
-import org.schabi.newpipe.database.feed.dao.FeedDAO
-import org.schabi.newpipe.database.feed.dao.FeedGroupDAO
-import org.schabi.newpipe.database.feed.model.FeedEntity
-import org.schabi.newpipe.database.feed.model.FeedGroupEntity
-import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity
-import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
-import org.schabi.newpipe.database.history.dao.SearchHistoryDAO
-import org.schabi.newpipe.database.history.dao.StreamHistoryDAO
-import org.schabi.newpipe.database.history.model.SearchHistoryEntry
-import org.schabi.newpipe.database.history.model.StreamHistoryEntity
-import org.schabi.newpipe.database.playlist.dao.PlaylistDAO
-import org.schabi.newpipe.database.playlist.dao.PlaylistRemoteDAO
-import org.schabi.newpipe.database.playlist.dao.PlaylistStreamDAO
-import org.schabi.newpipe.database.playlist.model.PlaylistEntity
-import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
-import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
-import org.schabi.newpipe.database.stream.dao.StreamDAO
-import org.schabi.newpipe.database.stream.dao.StreamStateDAO
-import org.schabi.newpipe.database.stream.model.StreamEntity
-import org.schabi.newpipe.database.stream.model.StreamStateEntity
-import org.schabi.newpipe.database.subscription.SubscriptionDAO
-import org.schabi.newpipe.database.subscription.SubscriptionEntity
-
-@TypeConverters(Converters::class)
-@Database(
- version = Migrations.DB_VER_9,
- entities = [
- SubscriptionEntity::class,
- SearchHistoryEntry::class,
- StreamEntity::class,
- StreamHistoryEntity::class,
- StreamStateEntity::class,
- PlaylistEntity::class,
- PlaylistStreamEntity::class,
- PlaylistRemoteEntity::class,
- FeedEntity::class,
- FeedGroupEntity::class,
- FeedGroupSubscriptionEntity::class,
- FeedLastUpdatedEntity::class
- ]
-)
-abstract class AppDatabase : RoomDatabase() {
- abstract fun feedDAO(): FeedDAO
- abstract fun feedGroupDAO(): FeedGroupDAO
- abstract fun playlistDAO(): PlaylistDAO
- abstract fun playlistRemoteDAO(): PlaylistRemoteDAO
- abstract fun playlistStreamDAO(): PlaylistStreamDAO
- abstract fun searchHistoryDAO(): SearchHistoryDAO
- abstract fun streamDAO(): StreamDAO
- abstract fun streamHistoryDAO(): StreamHistoryDAO
- abstract fun streamStateDAO(): StreamStateDAO
- abstract fun subscriptionDAO(): SubscriptionDAO
-
- companion object {
- const val DATABASE_NAME: String = "newpipe.db"
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/BasicDAO.kt b/app/src/main/java/org/schabi/newpipe/database/BasicDAO.kt
deleted file mode 100644
index 74c7cc87c..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/BasicDAO.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2017-2022 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database
-
-import androidx.room.Dao
-import androidx.room.Delete
-import androidx.room.Insert
-import androidx.room.Update
-import io.reactivex.rxjava3.core.Flowable
-
-@Dao
-interface BasicDAO {
-
- /* Inserts */
- @Insert
- fun insert(entity: Entity): Long
-
- @Insert
- fun insertAll(entities: Collection): List
-
- /* Searches */
- fun getAll(): Flowable>
-
- fun listByService(serviceId: Int): Flowable>
-
- /* Deletes */
- @Delete
- fun delete(entity: Entity)
-
- fun deleteAll(): Int
-
- /* Updates */
- @Update
- fun update(entity: Entity): Int
-
- @Update
- fun update(entities: Collection)
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/Converters.kt b/app/src/main/java/org/schabi/newpipe/database/Converters.kt
deleted file mode 100644
index f9cbb1de2..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/Converters.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package org.schabi.newpipe.database
-
-import androidx.room.TypeConverter
-import java.time.Instant
-import java.time.OffsetDateTime
-import java.time.ZoneOffset
-import org.schabi.newpipe.extractor.stream.StreamType
-import org.schabi.newpipe.local.subscription.FeedGroupIcon
-
-class Converters {
- /**
- * Convert a long value to a [OffsetDateTime].
- *
- * @param value the long value
- * @return the `OffsetDateTime`
- */
- @TypeConverter
- fun offsetDateTimeFromTimestamp(value: Long?): OffsetDateTime? {
- return value?.let { OffsetDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) }
- }
-
- /**
- * Convert a [OffsetDateTime] to a long value.
- *
- * @param offsetDateTime the `OffsetDateTime`
- * @return the long value
- */
- @TypeConverter
- fun offsetDateTimeToTimestamp(offsetDateTime: OffsetDateTime?): Long? {
- return offsetDateTime?.withOffsetSameInstant(ZoneOffset.UTC)?.toInstant()?.toEpochMilli()
- }
-
- @TypeConverter
- fun streamTypeOf(value: String): StreamType {
- return StreamType.valueOf(value)
- }
-
- @TypeConverter
- fun stringOf(streamType: StreamType): String {
- return streamType.name
- }
-
- @TypeConverter
- fun integerOf(feedGroupIcon: FeedGroupIcon): Int {
- return feedGroupIcon.id
- }
-
- @TypeConverter
- fun feedGroupIconOf(id: Int): FeedGroupIcon {
- return FeedGroupIcon.entries.first { it.id == id }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt b/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt
deleted file mode 100644
index 944b247bf..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2018-2020 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database
-
-interface LocalItem {
- val localItemType: LocalItemType
-
- enum class LocalItemType {
- PLAYLIST_LOCAL_ITEM,
- PLAYLIST_REMOTE_ITEM,
-
- PLAYLIST_STREAM_ITEM,
- STATISTIC_STREAM_ITEM
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/Migrations.kt b/app/src/main/java/org/schabi/newpipe/database/Migrations.kt
deleted file mode 100644
index 414f74893..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/Migrations.kt
+++ /dev/null
@@ -1,351 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2018-2024 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database
-
-import android.util.Log
-import androidx.room.migration.Migration
-import org.schabi.newpipe.MainActivity
-
-object Migrations {
-
- // /////////////////////////////////////////////////////////////////////// //
- // Test new migrations manually by importing a database from daily usage //
- // and checking if the migration works (Use the Database Inspector //
- // https://developer.android.com/studio/inspect/database). //
- // If you add a migration point it out in the pull request, so that //
- // others remember to test it themselves. //
- // /////////////////////////////////////////////////////////////////////// //
-
- const val DB_VER_1 = 1
- const val DB_VER_2 = 2
- const val DB_VER_3 = 3
- const val DB_VER_4 = 4
- const val DB_VER_5 = 5
- const val DB_VER_6 = 6
- const val DB_VER_7 = 7
- const val DB_VER_8 = 8
- const val DB_VER_9 = 9
-
- private val TAG = Migrations::class.java.getName()
- private val isDebug = MainActivity.DEBUG
-
- val MIGRATION_1_2 = Migration(DB_VER_1, DB_VER_2) { db ->
- if (isDebug) {
- Log.d(TAG, "Start migrating database")
- }
-
- /*
- * Unfortunately these queries must be hardcoded due to the possibility of
- * schema and names changing at a later date, thus invalidating the older migration
- * scripts if they are not hardcoded.
- * */
-
- // Not much we can do about this, since room doesn't create tables before migration.
- // It's either this or blasting the entire database anew.
- db.execSQL(
- "CREATE INDEX `index_search_history_search` " +
- "ON `search_history` (`search`)"
- )
- db.execSQL(
- "CREATE TABLE IF NOT EXISTS `streams` " +
- "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
- "`service_id` INTEGER NOT NULL, `url` TEXT, `title` TEXT, " +
- "`stream_type` TEXT, `duration` INTEGER, `uploader` TEXT, " +
- "`thumbnail_url` TEXT)"
- )
- db.execSQL(
- "CREATE UNIQUE INDEX `index_streams_service_id_url` " +
- "ON `streams` (`service_id`, `url`)"
- )
- db.execSQL(
- "CREATE TABLE IF NOT EXISTS `stream_history` " +
- "(`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, " +
- "`repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), " +
- "FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) " +
- "ON UPDATE CASCADE ON DELETE CASCADE )"
- )
- db.execSQL(
- "CREATE INDEX `index_stream_history_stream_id` " +
- "ON `stream_history` (`stream_id`)"
- )
- db.execSQL(
- "CREATE TABLE IF NOT EXISTS `stream_state` " +
- "(`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, " +
- "PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) " +
- "REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )"
- )
- db.execSQL(
- "CREATE TABLE IF NOT EXISTS `playlists` " +
- "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
- "`name` TEXT, `thumbnail_url` TEXT)"
- )
- db.execSQL("CREATE INDEX `index_playlists_name` ON `playlists` (`name`)")
- db.execSQL(
- "CREATE TABLE IF NOT EXISTS `playlist_stream_join` " +
- "(`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, " +
- "`join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), " +
- "FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) " +
- "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " +
- "FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) " +
- "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
- )
- db.execSQL(
- "CREATE UNIQUE INDEX " +
- "`index_playlist_stream_join_playlist_id_join_index` " +
- "ON `playlist_stream_join` (`playlist_id`, `join_index`)"
- )
- db.execSQL(
- "CREATE INDEX `index_playlist_stream_join_stream_id` " +
- "ON `playlist_stream_join` (`stream_id`)"
- )
- db.execSQL(
- "CREATE TABLE IF NOT EXISTS `remote_playlists` " +
- "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
- "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, " +
- "`thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)"
- )
- db.execSQL(
- "CREATE INDEX `index_remote_playlists_name` " +
- "ON `remote_playlists` (`name`)"
- )
- db.execSQL(
- "CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " +
- "ON `remote_playlists` (`service_id`, `url`)"
- )
-
- // Populate streams table with existing entries in watch history
- // Latest data first, thus ignoring older entries with the same indices
- db.execSQL(
- "INSERT OR IGNORE INTO streams (service_id, url, title, " +
- "stream_type, duration, uploader, thumbnail_url) " +
-
- "SELECT service_id, url, title, 'VIDEO_STREAM', duration, " +
- "uploader, thumbnail_url " +
-
- "FROM watch_history " +
- "ORDER BY creation_date DESC"
- )
-
- // Once the streams have PKs, join them with the normalized history table
- // and populate it with the remaining data from watch history
- db.execSQL(
- "INSERT INTO stream_history (stream_id, access_date, repeat_count)" +
- "SELECT uid, creation_date, 1 " +
- "FROM watch_history INNER JOIN streams " +
- "ON watch_history.service_id == streams.service_id " +
- "AND watch_history.url == streams.url " +
- "ORDER BY creation_date DESC"
- )
-
- db.execSQL("DROP TABLE IF EXISTS watch_history")
-
- if (isDebug) {
- Log.d(TAG, "Stop migrating database")
- }
- }
-
- val MIGRATION_2_3 = Migration(DB_VER_2, DB_VER_3) { db ->
- // Add NOT NULLs and new fields
- db.execSQL(
- "CREATE TABLE IF NOT EXISTS streams_new " +
- "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
- "service_id INTEGER NOT NULL, url TEXT NOT NULL, title TEXT NOT NULL, " +
- "stream_type TEXT NOT NULL, duration INTEGER NOT NULL, " +
- "uploader TEXT NOT NULL, thumbnail_url TEXT, view_count INTEGER, " +
- "textual_upload_date TEXT, upload_date INTEGER, " +
- "is_upload_date_approximation INTEGER)"
- )
-
- db.execSQL(
- "INSERT INTO streams_new (uid, service_id, url, title, stream_type, " +
- "duration, uploader, thumbnail_url, view_count, textual_upload_date, " +
- "upload_date, is_upload_date_approximation) " +
-
- "SELECT uid, service_id, url, ifnull(title, ''), " +
- "ifnull(stream_type, 'VIDEO_STREAM'), ifnull(duration, 0), " +
- "ifnull(uploader, ''), ifnull(thumbnail_url, ''), NULL, NULL, NULL, NULL " +
-
- "FROM streams WHERE url IS NOT NULL"
- )
-
- db.execSQL("DROP TABLE streams")
- db.execSQL("ALTER TABLE streams_new RENAME TO streams")
- db.execSQL(
- "CREATE UNIQUE INDEX index_streams_service_id_url " +
- "ON streams (service_id, url)"
- )
-
- // Tables for feed feature
- db.execSQL(
- "CREATE TABLE IF NOT EXISTS feed " +
- "(stream_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, " +
- "PRIMARY KEY(stream_id, subscription_id), " +
- "FOREIGN KEY(stream_id) REFERENCES streams(uid) " +
- "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " +
- "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " +
- "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
- )
- db.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)")
- db.execSQL(
- "CREATE TABLE IF NOT EXISTS feed_group " +
- "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, " +
- "icon_id INTEGER NOT NULL, sort_order INTEGER NOT NULL)"
- )
- db.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)")
- db.execSQL(
- "CREATE TABLE IF NOT EXISTS feed_group_subscription_join " +
- "(group_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, " +
- "PRIMARY KEY(group_id, subscription_id), " +
- "FOREIGN KEY(group_id) REFERENCES feed_group(uid) " +
- "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " +
- "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " +
- "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
- )
- db.execSQL(
- "CREATE INDEX index_feed_group_subscription_join_subscription_id " +
- "ON feed_group_subscription_join (subscription_id)"
- )
- db.execSQL(
- "CREATE TABLE IF NOT EXISTS feed_last_updated " +
- "(subscription_id INTEGER NOT NULL, last_updated INTEGER, " +
- "PRIMARY KEY(subscription_id), " +
- "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " +
- "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
- )
- }
-
- val MIGRATION_3_4 = Migration(DB_VER_3, DB_VER_4) { db ->
- db.execSQL("ALTER TABLE streams ADD COLUMN uploader_url TEXT")
- }
-
- val MIGRATION_4_5 = Migration(DB_VER_4, DB_VER_5) { db ->
- db.execSQL(
- "ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` " +
- "INTEGER NOT NULL DEFAULT 0"
- )
- }
-
- val MIGRATION_5_6 = Migration(DB_VER_5, DB_VER_6) { db ->
- db.execSQL(
- "ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` " +
- "INTEGER NOT NULL DEFAULT 0"
- )
- }
-
- val MIGRATION_6_7 = Migration(DB_VER_6, DB_VER_7) { db ->
- // Create a new column thumbnail_stream_id
- db.execSQL(
- "ALTER TABLE `playlists` ADD COLUMN `thumbnail_stream_id` " +
- "INTEGER NOT NULL DEFAULT -1"
- )
-
- // Migrate the thumbnail_url to the thumbnail_stream_id
- db.execSQL(
- "UPDATE playlists SET thumbnail_stream_id = (" +
- " SELECT CASE WHEN COUNT(*) != 0 then stream_uid ELSE -1 END" +
- " FROM (" +
- " SELECT p.uid AS playlist_uid, s.uid AS stream_uid" +
- " FROM playlists p" +
- " LEFT JOIN playlist_stream_join ps ON p.uid = ps.playlist_id" +
- " LEFT JOIN streams s ON s.uid = ps.stream_id" +
- " WHERE s.thumbnail_url = p.thumbnail_url) AS temporary_table" +
- " WHERE playlist_uid = playlists.uid)"
- )
-
- // Remove the thumbnail_url field in the playlist table
- db.execSQL(
- "CREATE TABLE IF NOT EXISTS `playlists_new`" +
- "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
- "name TEXT, " +
- "is_thumbnail_permanent INTEGER NOT NULL, " +
- "thumbnail_stream_id INTEGER NOT NULL)"
- )
-
- db.execSQL(
- "INSERT INTO playlists_new" +
- " SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id " +
- " FROM playlists"
- )
-
- db.execSQL("DROP TABLE playlists")
- db.execSQL("ALTER TABLE playlists_new RENAME TO playlists")
- db.execSQL(
- "CREATE INDEX IF NOT EXISTS " +
- "`index_playlists_name` ON `playlists` (`name`)"
- )
- }
-
- val MIGRATION_7_8 = Migration(DB_VER_7, DB_VER_8) { db ->
- db.execSQL(
- "DELETE FROM search_history WHERE id NOT IN (SELECT id FROM (SELECT " +
- "MIN(id) as id FROM search_history GROUP BY trim(search), service_id ) tmp)"
- )
- db.execSQL("UPDATE search_history SET search = trim(search)")
- }
-
- val MIGRATION_8_9 = Migration(DB_VER_8, DB_VER_9) { db ->
- try {
- db.beginTransaction()
-
- // Update playlists.
- // Create a temp table to initialize display_index.
- db.execSQL(
- "CREATE TABLE `playlists_tmp` " +
- "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
- "`name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, " +
- "`thumbnail_stream_id` INTEGER NOT NULL, " +
- "`display_index` INTEGER NOT NULL)"
- )
- db.execSQL(
- "INSERT INTO `playlists_tmp` " +
- "(`uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, " +
- "`display_index`) " +
- "SELECT `uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, " +
- "-1 " +
- "FROM `playlists`"
- )
-
- // Replace the old table, note that this also removes the index on the name which
- // we don't need anymore.
- db.execSQL("DROP TABLE `playlists`")
- db.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`")
-
- // Update remote_playlists.
- // Create a temp table to initialize display_index.
- db.execSQL(
- "CREATE TABLE `remote_playlists_tmp` " +
- "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
- "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, " +
- "`thumbnail_url` TEXT, `uploader` TEXT, " +
- "`display_index` INTEGER NOT NULL," +
- "`stream_count` INTEGER)"
- )
- db.execSQL(
- "INSERT INTO `remote_playlists_tmp` (`uid`, `service_id`, " +
- "`name`, `url`, `thumbnail_url`, `uploader`, `display_index`, " +
- "`stream_count`)" +
- "SELECT `uid`, `service_id`, `name`, `url`, `thumbnail_url`, `uploader`, " +
- "-1, `stream_count` FROM `remote_playlists`"
- )
-
- // Replace the old table, note that this also removes the index on the name which
- // we don't need anymore.
- db.execSQL("DROP TABLE `remote_playlists`")
- db.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`")
-
- // Create index on the new table.
- db.execSQL(
- "CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " +
- "ON `remote_playlists` (`service_id`, `url`)"
- )
-
- db.setTransactionSuccessful()
- } finally {
- db.endTransaction()
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt
deleted file mode 100644
index 5861fa767..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt
+++ /dev/null
@@ -1,237 +0,0 @@
-package org.schabi.newpipe.database.feed.dao
-
-import androidx.room.Dao
-import androidx.room.Insert
-import androidx.room.OnConflictStrategy
-import androidx.room.Query
-import androidx.room.Transaction
-import androidx.room.Update
-import io.reactivex.rxjava3.core.Flowable
-import io.reactivex.rxjava3.core.Maybe
-import java.time.OffsetDateTime
-import org.schabi.newpipe.database.feed.model.FeedEntity
-import org.schabi.newpipe.database.feed.model.FeedGroupEntity
-import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
-import org.schabi.newpipe.database.stream.StreamWithState
-import org.schabi.newpipe.database.stream.model.StreamStateEntity
-import org.schabi.newpipe.database.subscription.NotificationMode
-import org.schabi.newpipe.database.subscription.SubscriptionEntity
-
-@Dao
-abstract class FeedDAO {
- @Query("DELETE FROM feed")
- abstract fun deleteAll(): Int
-
- /**
- * @param groupId the group id to get feed streams of; use
- * [FeedGroupEntity.GROUP_ALL_ID] to not filter by group
- * @param includePlayed if false, only return all of the live, never-played or non-finished
- * feed streams (see `@see` items); if true no filter is applied
- * @param uploadDateBefore get only streams uploaded before this date (useful to filter out
- * future streams); use null to not filter by upload date
- * @return the feed streams filtered according to the conditions provided in the parameters
- * @see StreamStateEntity.isFinished()
- * @see StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS
- * @see StreamStateEntity.PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS
- */
- @Query(
- """
- SELECT s.*, sst.progress_time
- FROM streams s
-
- LEFT JOIN stream_state sst
- ON s.uid = sst.stream_id
-
- LEFT JOIN stream_history sh
- ON s.uid = sh.stream_id
-
- INNER JOIN feed f
- ON s.uid = f.stream_id
-
- LEFT JOIN feed_group_subscription_join fgs
- ON (
- :groupId <> ${FeedGroupEntity.GROUP_ALL_ID}
- AND fgs.subscription_id = f.subscription_id
- )
-
- WHERE (
- :groupId = ${FeedGroupEntity.GROUP_ALL_ID}
- OR fgs.group_id = :groupId
- )
- AND (
- :includePlayed
- OR sh.stream_id IS NULL
- OR sst.stream_id IS NULL
- OR sst.progress_time < s.duration * 1000 - ${StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS}
- OR sst.progress_time < s.duration * 1000 * 3 / 4
- OR s.stream_type = 'LIVE_STREAM'
- OR s.stream_type = 'AUDIO_LIVE_STREAM'
- )
- AND (
- :includePartiallyPlayed
- OR sh.stream_id IS NULL
- OR sst.stream_id IS NULL
- OR (sst.progress_time <= ${StreamStateEntity.PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS}
- AND sst.progress_time <= s.duration * 1000 / 4)
- OR (sst.progress_time >= s.duration * 1000 - ${StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS}
- AND sst.progress_time >= s.duration * 1000 * 3 / 4)
- )
- AND (
- :uploadDateBefore IS NULL
- OR s.upload_date IS NULL
- OR s.upload_date < :uploadDateBefore
- )
-
- ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
- LIMIT 500
- """
- )
- abstract fun getStreams(
- groupId: Long,
- includePlayed: Boolean,
- includePartiallyPlayed: Boolean,
- uploadDateBefore: OffsetDateTime?
- ): Maybe>
-
- /**
- * Remove links to streams that are older than the given date
- * **but keep at least one stream per uploader**.
- *
- * One stream per uploader is kept because it is needed as reference
- * when fetching new streams to check if they are new or not.
- * @param offsetDateTime the newest date to keep, older streams are removed
- */
- @Query(
- """
- DELETE FROM feed
- WHERE feed.stream_id IN (SELECT uid from (
- SELECT s.uid,
- (SELECT MAX(upload_date)
- FROM streams s1
- INNER JOIN feed f1
- ON s1.uid = f1.stream_id
- WHERE f1.subscription_id = f.subscription_id) max_upload_date
- FROM streams s
- INNER JOIN feed f
- ON s.uid = f.stream_id
-
- WHERE s.upload_date < :offsetDateTime
- AND s.upload_date <> max_upload_date))
- """
- )
- abstract fun unlinkStreamsOlderThan(offsetDateTime: OffsetDateTime)
-
- @Query(
- """
- DELETE FROM feed
-
- WHERE feed.subscription_id = :subscriptionId
-
- AND feed.stream_id IN (
- SELECT s.uid FROM streams s
-
- INNER JOIN feed f
- ON s.uid = f.stream_id
-
- WHERE s.stream_type = "LIVE_STREAM" OR s.stream_type = "AUDIO_LIVE_STREAM"
- )
- """
- )
- abstract fun unlinkOldLivestreams(subscriptionId: Long)
-
- @Insert(onConflict = OnConflictStrategy.IGNORE)
- abstract fun insert(feedEntity: FeedEntity)
-
- @Insert(onConflict = OnConflictStrategy.IGNORE)
- abstract fun insertAll(entities: List): List
-
- @Insert(onConflict = OnConflictStrategy.IGNORE)
- internal abstract fun insertLastUpdated(lastUpdatedEntity: FeedLastUpdatedEntity): Long
-
- @Update(onConflict = OnConflictStrategy.IGNORE)
- internal abstract fun updateLastUpdated(lastUpdatedEntity: FeedLastUpdatedEntity)
-
- @Transaction
- open fun setLastUpdatedForSubscription(lastUpdatedEntity: FeedLastUpdatedEntity) {
- val id = insertLastUpdated(lastUpdatedEntity)
-
- if (id == -1L) {
- updateLastUpdated(lastUpdatedEntity)
- }
- }
-
- @Query(
- """
- SELECT MIN(lu.last_updated) FROM feed_last_updated lu
-
- INNER JOIN feed_group_subscription_join fgs
- ON fgs.subscription_id = lu.subscription_id AND fgs.group_id = :groupId
- """
- )
- abstract fun oldestSubscriptionUpdate(groupId: Long): Flowable>
-
- @Query("SELECT MIN(last_updated) FROM feed_last_updated")
- abstract fun oldestSubscriptionUpdateFromAll(): Flowable>
-
- @Query("SELECT COUNT(*) FROM feed_last_updated WHERE last_updated IS NULL")
- abstract fun notLoadedCount(): Flowable
-
- @Query(
- """
- SELECT COUNT(*) FROM subscriptions s
-
- INNER JOIN feed_group_subscription_join fgs
- ON s.uid = fgs.subscription_id AND fgs.group_id = :groupId
-
- LEFT JOIN feed_last_updated lu
- ON s.uid = lu.subscription_id
-
- WHERE lu.last_updated IS NULL
- """
- )
- abstract fun notLoadedCountForGroup(groupId: Long): Flowable
-
- @Query(
- """
- SELECT s.* FROM subscriptions s
-
- LEFT JOIN feed_last_updated lu
- ON s.uid = lu.subscription_id
-
- WHERE lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold
- """
- )
- abstract fun getAllOutdated(outdatedThreshold: OffsetDateTime): Flowable>
-
- @Query(
- """
- SELECT s.* FROM subscriptions s
-
- INNER JOIN feed_group_subscription_join fgs
- ON s.uid = fgs.subscription_id AND fgs.group_id = :groupId
-
- LEFT JOIN feed_last_updated lu
- ON s.uid = lu.subscription_id
-
- WHERE lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold
- """
- )
- abstract fun getAllOutdatedForGroup(groupId: Long, outdatedThreshold: OffsetDateTime): Flowable>
-
- @Query(
- """
- SELECT s.* FROM subscriptions s
-
- LEFT JOIN feed_last_updated lu
- ON s.uid = lu.subscription_id
-
- WHERE
- (lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold)
- AND s.notification_mode = :notificationMode
- """
- )
- abstract fun getOutdatedWithNotificationMode(
- outdatedThreshold: OffsetDateTime,
- @NotificationMode notificationMode: Int
- ): Flowable>
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedGroupDAO.kt b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedGroupDAO.kt
deleted file mode 100644
index 217eef03f..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedGroupDAO.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-package org.schabi.newpipe.database.feed.dao
-
-import androidx.room.Dao
-import androidx.room.Insert
-import androidx.room.OnConflictStrategy
-import androidx.room.Query
-import androidx.room.Transaction
-import androidx.room.Update
-import io.reactivex.rxjava3.core.Flowable
-import io.reactivex.rxjava3.core.Maybe
-import org.schabi.newpipe.database.feed.model.FeedGroupEntity
-import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity
-
-@Dao
-abstract class FeedGroupDAO {
-
- @Query("SELECT * FROM feed_group ORDER BY sort_order ASC")
- abstract fun getAll(): Flowable>
-
- @Query("SELECT * FROM feed_group WHERE uid = :groupId")
- abstract fun getGroup(groupId: Long): Maybe
-
- @Transaction
- open fun insert(feedGroupEntity: FeedGroupEntity): Long {
- val nextSortOrder = nextSortOrder()
- feedGroupEntity.sortOrder = nextSortOrder
- return insertInternal(feedGroupEntity)
- }
-
- @Update(onConflict = OnConflictStrategy.IGNORE)
- abstract fun update(feedGroupEntity: FeedGroupEntity): Int
-
- @Query("DELETE FROM feed_group")
- abstract fun deleteAll(): Int
-
- @Query("DELETE FROM feed_group WHERE uid = :groupId")
- abstract fun delete(groupId: Long): Int
-
- @Query("SELECT subscription_id FROM feed_group_subscription_join WHERE group_id = :groupId")
- abstract fun getSubscriptionIdsFor(groupId: Long): Flowable>
-
- @Query("DELETE FROM feed_group_subscription_join WHERE group_id = :groupId")
- abstract fun deleteSubscriptionsFromGroup(groupId: Long): Int
-
- @Insert(onConflict = OnConflictStrategy.IGNORE)
- abstract fun insertSubscriptionsToGroup(entities: List): List
-
- @Transaction
- open fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List) {
- deleteSubscriptionsFromGroup(groupId)
- insertSubscriptionsToGroup(subscriptionIds.map { FeedGroupSubscriptionEntity(groupId, it) })
- }
-
- @Transaction
- open fun updateOrder(orderMap: Map) {
- orderMap.forEach { (groupId, sortOrder) -> updateOrder(groupId, sortOrder) }
- }
-
- @Query("UPDATE feed_group SET sort_order = :sortOrder WHERE uid = :groupId")
- abstract fun updateOrder(groupId: Long, sortOrder: Long): Int
-
- @Query("SELECT IFNULL(MAX(sort_order) + 1, 0) FROM feed_group")
- protected abstract fun nextSortOrder(): Long
-
- @Insert(onConflict = OnConflictStrategy.ABORT)
- protected abstract fun insertInternal(feedGroupEntity: FeedGroupEntity): Long
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedEntity.kt b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedEntity.kt
deleted file mode 100644
index 86568bc90..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedEntity.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-package org.schabi.newpipe.database.feed.model
-
-import androidx.room.ColumnInfo
-import androidx.room.Entity
-import androidx.room.ForeignKey
-import androidx.room.Index
-import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.FEED_TABLE
-import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.STREAM_ID
-import org.schabi.newpipe.database.feed.model.FeedEntity.Companion.SUBSCRIPTION_ID
-import org.schabi.newpipe.database.stream.model.StreamEntity
-import org.schabi.newpipe.database.subscription.SubscriptionEntity
-
-@Entity(
- tableName = FEED_TABLE,
- primaryKeys = [STREAM_ID, SUBSCRIPTION_ID],
- indices = [Index(SUBSCRIPTION_ID)],
- foreignKeys = [
- ForeignKey(
- entity = StreamEntity::class,
- parentColumns = [StreamEntity.STREAM_ID],
- childColumns = [STREAM_ID],
- onDelete = ForeignKey.CASCADE,
- onUpdate = ForeignKey.CASCADE,
- deferred = true
- ),
- ForeignKey(
- entity = SubscriptionEntity::class,
- parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID],
- childColumns = [SUBSCRIPTION_ID],
- onDelete = ForeignKey.CASCADE,
- onUpdate = ForeignKey.CASCADE,
- deferred = true
- )
- ]
-)
-data class FeedEntity(
- @ColumnInfo(name = STREAM_ID)
- var streamId: Long,
-
- @ColumnInfo(name = SUBSCRIPTION_ID)
- var subscriptionId: Long
-) {
-
- companion object {
- const val FEED_TABLE = "feed"
-
- const val STREAM_ID = "stream_id"
- const val SUBSCRIPTION_ID = "subscription_id"
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupEntity.kt b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupEntity.kt
deleted file mode 100644
index 1dd26946a..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupEntity.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package org.schabi.newpipe.database.feed.model
-
-import androidx.room.ColumnInfo
-import androidx.room.Entity
-import androidx.room.Index
-import androidx.room.PrimaryKey
-import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.FEED_GROUP_TABLE
-import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.SORT_ORDER
-import org.schabi.newpipe.local.subscription.FeedGroupIcon
-
-@Entity(
- tableName = FEED_GROUP_TABLE,
- indices = [Index(SORT_ORDER)]
-)
-data class FeedGroupEntity(
- @PrimaryKey(autoGenerate = true)
- @ColumnInfo(name = ID)
- val uid: Long,
-
- @ColumnInfo(name = NAME)
- var name: String,
-
- @ColumnInfo(name = ICON)
- var icon: FeedGroupIcon,
-
- @ColumnInfo(name = SORT_ORDER)
- var sortOrder: Long = -1
-) {
- companion object {
- const val FEED_GROUP_TABLE = "feed_group"
-
- const val ID = "uid"
- const val NAME = "name"
- const val ICON = "icon_id"
- const val SORT_ORDER = "sort_order"
-
- const val GROUP_ALL_ID = -1L
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupSubscriptionEntity.kt b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupSubscriptionEntity.kt
deleted file mode 100644
index 6dac3c89c..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupSubscriptionEntity.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-package org.schabi.newpipe.database.feed.model
-
-import androidx.room.ColumnInfo
-import androidx.room.Entity
-import androidx.room.ForeignKey
-import androidx.room.Index
-import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.FEED_GROUP_SUBSCRIPTION_TABLE
-import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.GROUP_ID
-import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.SUBSCRIPTION_ID
-import org.schabi.newpipe.database.subscription.SubscriptionEntity
-
-@Entity(
- tableName = FEED_GROUP_SUBSCRIPTION_TABLE,
- primaryKeys = [GROUP_ID, SUBSCRIPTION_ID],
- indices = [Index(SUBSCRIPTION_ID)],
- foreignKeys = [
- ForeignKey(
- entity = FeedGroupEntity::class,
- parentColumns = [FeedGroupEntity.ID],
- childColumns = [GROUP_ID],
- onDelete = ForeignKey.CASCADE,
- onUpdate = ForeignKey.CASCADE,
- deferred = true
- ),
-
- ForeignKey(
- entity = SubscriptionEntity::class,
- parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID],
- childColumns = [SUBSCRIPTION_ID],
- onDelete = ForeignKey.CASCADE,
- onUpdate = ForeignKey.CASCADE,
- deferred = true
- )
- ]
-)
-data class FeedGroupSubscriptionEntity(
- @ColumnInfo(name = GROUP_ID)
- var feedGroupId: Long,
-
- @ColumnInfo(name = SUBSCRIPTION_ID)
- var subscriptionId: Long
-) {
-
- companion object {
- const val FEED_GROUP_SUBSCRIPTION_TABLE = "feed_group_subscription_join"
-
- const val GROUP_ID = "group_id"
- const val SUBSCRIPTION_ID = "subscription_id"
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedLastUpdatedEntity.kt b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedLastUpdatedEntity.kt
deleted file mode 100644
index fc0ee6742..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedLastUpdatedEntity.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package org.schabi.newpipe.database.feed.model
-
-import androidx.room.ColumnInfo
-import androidx.room.Entity
-import androidx.room.ForeignKey
-import androidx.room.PrimaryKey
-import java.time.OffsetDateTime
-import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity.Companion.FEED_LAST_UPDATED_TABLE
-import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity.Companion.SUBSCRIPTION_ID
-import org.schabi.newpipe.database.subscription.SubscriptionEntity
-
-@Entity(
- tableName = FEED_LAST_UPDATED_TABLE,
- foreignKeys = [
- ForeignKey(
- entity = SubscriptionEntity::class,
- parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID],
- childColumns = [SUBSCRIPTION_ID],
- onDelete = ForeignKey.CASCADE,
- onUpdate = ForeignKey.CASCADE,
- deferred = true
- )
- ]
-)
-data class FeedLastUpdatedEntity(
- @PrimaryKey
- @ColumnInfo(name = SUBSCRIPTION_ID)
- var subscriptionId: Long,
-
- @ColumnInfo(name = LAST_UPDATED)
- var lastUpdated: OffsetDateTime? = null
-) {
- companion object {
- const val FEED_LAST_UPDATED_TABLE = "feed_last_updated"
-
- const val SUBSCRIPTION_ID = "subscription_id"
- const val LAST_UPDATED = "last_updated"
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.kt b/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.kt
deleted file mode 100644
index ddcb00489..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2017-2021 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database.history.dao
-
-import androidx.room.Dao
-import androidx.room.Query
-import io.reactivex.rxjava3.core.Flowable
-import org.schabi.newpipe.database.BasicDAO
-import org.schabi.newpipe.database.history.model.SearchHistoryEntry
-
-@Dao
-interface SearchHistoryDAO : BasicDAO {
-
- @get:Query("SELECT * FROM search_history WHERE id = (SELECT MAX(id) FROM search_history)")
- val latestEntry: SearchHistoryEntry?
-
- @Query("DELETE FROM search_history")
- override fun deleteAll(): Int
-
- @Query("DELETE FROM search_history WHERE search = :query")
- fun deleteAllWhereQuery(query: String): Int
-
- @Query("SELECT * FROM search_history ORDER BY creation_date DESC")
- override fun getAll(): Flowable>
-
- @Query("SELECT search FROM search_history GROUP BY search ORDER BY MAX(creation_date) DESC LIMIT :limit")
- fun getUniqueEntries(limit: Int): Flowable>
-
- @Query("SELECT * FROM search_history WHERE service_id = :serviceId ORDER BY creation_date DESC")
- override fun listByService(serviceId: Int): Flowable>
-
- @Query(
- """
- SELECT search FROM search_history WHERE search LIKE :query ||
- '%' GROUP BY search ORDER BY MAX(creation_date) DESC LIMIT :limit
- """
- )
- fun getSimilarEntries(query: String, limit: Int): Flowable>
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt
deleted file mode 100644
index 916d4e5ed..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2018-2022 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database.history.dao
-
-import androidx.room.Dao
-import androidx.room.Query
-import androidx.room.RewriteQueriesToDropUnusedColumns
-import io.reactivex.rxjava3.core.Flowable
-import org.schabi.newpipe.database.BasicDAO
-import org.schabi.newpipe.database.history.model.StreamHistoryEntity
-import org.schabi.newpipe.database.history.model.StreamHistoryEntry
-import org.schabi.newpipe.database.stream.StreamStatisticsEntry
-
-@Dao
-abstract class StreamHistoryDAO : BasicDAO {
-
- @Query("SELECT * FROM stream_history")
- abstract override fun getAll(): Flowable>
-
- @Query("DELETE FROM stream_history")
- abstract override fun deleteAll(): Int
-
- override fun listByService(serviceId: Int): Flowable> {
- throw UnsupportedOperationException()
- }
-
- @get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY access_date DESC")
- abstract val history: Flowable>
-
- @get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY uid ASC")
- abstract val historySortedById: Flowable>
-
- @Query("SELECT * FROM stream_history WHERE stream_id = :streamId ORDER BY access_date DESC LIMIT 1")
- abstract fun getLatestEntry(streamId: Long): StreamHistoryEntity?
-
- @Query("DELETE FROM stream_history WHERE stream_id = :streamId")
- abstract fun deleteStreamHistory(streamId: Long): Int
-
- // Select the latest entry and watch count for each stream id on history table
- @RewriteQueriesToDropUnusedColumns
- @Query(
- """
- SELECT * FROM streams
-
- INNER JOIN (
- SELECT stream_id, MAX(access_date) AS latestAccess, SUM(repeat_count) AS watchCount
- FROM stream_history
- GROUP BY stream_id
- )
- ON uid = stream_id
-
- LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state )
- ON uid = stream_id_alias
- """
- )
- abstract fun getStatistics(): Flowable>
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/SearchHistoryEntry.kt b/app/src/main/java/org/schabi/newpipe/database/history/model/SearchHistoryEntry.kt
deleted file mode 100644
index eee213453..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/history/model/SearchHistoryEntry.kt
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2022 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database.history.model
-
-import androidx.room.ColumnInfo
-import androidx.room.Entity
-import androidx.room.Ignore
-import androidx.room.Index
-import androidx.room.PrimaryKey
-import java.time.OffsetDateTime
-
-@Entity(
- tableName = SearchHistoryEntry.TABLE_NAME,
- indices = [Index(value = [SearchHistoryEntry.SEARCH])]
-)
-data class SearchHistoryEntry @JvmOverloads constructor(
- @ColumnInfo(name = CREATION_DATE)
- var creationDate: OffsetDateTime?,
-
- @ColumnInfo(name = SERVICE_ID)
- val serviceId: Int,
-
- @ColumnInfo(name = SEARCH)
- val search: String?,
-
- @ColumnInfo(name = ID)
- @PrimaryKey(autoGenerate = true)
- val id: Long = 0
-) {
-
- @Ignore
- fun hasEqualValues(otherEntry: SearchHistoryEntry): Boolean {
- return serviceId == otherEntry.serviceId && search == otherEntry.search
- }
-
- companion object {
- const val ID = "id"
- const val TABLE_NAME = "search_history"
- const val SERVICE_ID = "service_id"
- const val CREATION_DATE = "creation_date"
- const val SEARCH = "search"
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.kt b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.kt
deleted file mode 100644
index deba7dd3a..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.kt
+++ /dev/null
@@ -1,56 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2018-2022 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database.history.model
-
-import androidx.room.ColumnInfo
-import androidx.room.Entity
-import androidx.room.ForeignKey
-import androidx.room.ForeignKey.Companion.CASCADE
-import androidx.room.Index
-import java.time.OffsetDateTime
-import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.JOIN_STREAM_ID
-import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.STREAM_ACCESS_DATE
-import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE
-import org.schabi.newpipe.database.stream.model.StreamEntity
-import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
-
-/**
- * @param streamUid the stream id this history item will refer to
- * @param accessDate the last time the stream was accessed
- * @param repeatCount the total number of views this stream received
- */
-@Entity(
- tableName = STREAM_HISTORY_TABLE,
- primaryKeys = [JOIN_STREAM_ID, STREAM_ACCESS_DATE],
- indices = [Index(value = [JOIN_STREAM_ID])],
- foreignKeys = [
- ForeignKey(
- entity = StreamEntity::class,
- parentColumns = arrayOf(STREAM_ID),
- childColumns = arrayOf(JOIN_STREAM_ID),
- onDelete = CASCADE,
- onUpdate = CASCADE
- )
- ]
-)
-data class StreamHistoryEntity(
- @ColumnInfo(name = JOIN_STREAM_ID)
- val streamUid: Long,
-
- @ColumnInfo(name = STREAM_ACCESS_DATE)
- var accessDate: OffsetDateTime,
-
- @ColumnInfo(name = STREAM_REPEAT_COUNT)
- var repeatCount: Long
-) {
- companion object {
- const val STREAM_HISTORY_TABLE: String = "stream_history"
- const val STREAM_ACCESS_DATE: String = "access_date"
- const val JOIN_STREAM_ID: String = "stream_id"
- const val STREAM_REPEAT_COUNT: String = "repeat_count"
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt
deleted file mode 100644
index 816b25c2a..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt
+++ /dev/null
@@ -1,44 +0,0 @@
-package org.schabi.newpipe.database.history.model
-
-import androidx.room.ColumnInfo
-import androidx.room.Embedded
-import java.time.OffsetDateTime
-import org.schabi.newpipe.database.stream.model.StreamEntity
-import org.schabi.newpipe.extractor.stream.StreamInfoItem
-import org.schabi.newpipe.util.image.ImageStrategy
-
-data class StreamHistoryEntry(
- @Embedded
- val streamEntity: StreamEntity,
-
- @ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID)
- val streamId: Long,
-
- @ColumnInfo(name = StreamHistoryEntity.STREAM_ACCESS_DATE)
- val accessDate: OffsetDateTime,
-
- @ColumnInfo(name = StreamHistoryEntity.STREAM_REPEAT_COUNT)
- val repeatCount: Long
-) {
-
- fun toStreamHistoryEntity(): StreamHistoryEntity {
- return StreamHistoryEntity(streamId, accessDate, repeatCount)
- }
-
- fun hasEqualValues(other: StreamHistoryEntry): Boolean {
- return this.streamEntity.uid == other.streamEntity.uid && streamId == other.streamId &&
- accessDate.isEqual(other.accessDate)
- }
-
- fun toStreamInfoItem(): StreamInfoItem = StreamInfoItem(
- streamEntity.serviceId,
- streamEntity.url,
- streamEntity.title,
- streamEntity.streamType
- ).apply {
- duration = streamEntity.duration
- uploaderName = streamEntity.uploader
- uploaderUrl = streamEntity.uploaderUrl
- thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.kt
deleted file mode 100644
index 84972a89e..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2023-2024 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database.playlist
-
-import androidx.room.ColumnInfo
-import org.schabi.newpipe.database.playlist.model.PlaylistEntity
-
-/**
- * This class adds a field to [PlaylistMetadataEntry] that contains an integer representing
- * how many times a specific stream is already contained inside a local playlist. Used to be able
- * to grey out playlists which already contain the current stream in the playlist append dialog.
- * @see org.schabi.newpipe.local.playlist.LocalPlaylistManager.getPlaylistDuplicates
- */
-data class PlaylistDuplicatesEntry(
- @ColumnInfo(name = PlaylistEntity.PLAYLIST_ID)
- override val uid: Long,
-
- @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_URL)
- override val thumbnailUrl: String?,
-
- @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT)
- override val isThumbnailPermanent: Boolean?,
-
- @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID)
- override val thumbnailStreamId: Long?,
-
- @ColumnInfo(name = PlaylistEntity.PLAYLIST_DISPLAY_INDEX)
- override var displayIndex: Long?,
-
- @ColumnInfo(name = PLAYLIST_STREAM_COUNT)
- override val streamCount: Long,
-
- @ColumnInfo(name = PlaylistEntity.PLAYLIST_NAME)
- override val orderingName: String?,
-
- @ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED)
- val timesStreamIsContained: Long
-) : PlaylistMetadataEntry(
- uid = uid,
- orderingName = orderingName,
- thumbnailUrl = thumbnailUrl,
- isThumbnailPermanent = isThumbnailPermanent,
- thumbnailStreamId = thumbnailStreamId,
- displayIndex = displayIndex,
- streamCount = streamCount
-) {
- companion object {
- const val PLAYLIST_TIMES_STREAM_IS_CONTAINED: String = "timesStreamIsContained"
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt
deleted file mode 100644
index 4f2f79aa0..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database.playlist
-
-import org.schabi.newpipe.database.LocalItem
-
-interface PlaylistLocalItem : LocalItem {
- val orderingName: String?
- val displayIndex: Long?
- val uid: Long
- val thumbnailUrl: String?
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.kt
deleted file mode 100644
index 9b62c1380..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database.playlist
-
-import androidx.room.ColumnInfo
-import org.schabi.newpipe.database.LocalItem.LocalItemType
-import org.schabi.newpipe.database.playlist.model.PlaylistEntity
-
-open class PlaylistMetadataEntry(
- @ColumnInfo(name = PlaylistEntity.PLAYLIST_ID)
- override val uid: Long,
-
- @ColumnInfo(name = PlaylistEntity.PLAYLIST_NAME)
- override val orderingName: String?,
-
- @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_URL)
- override val thumbnailUrl: String?,
-
- @ColumnInfo(name = PlaylistEntity.PLAYLIST_DISPLAY_INDEX)
- override var displayIndex: Long?,
-
- @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT)
- open val isThumbnailPermanent: Boolean?,
-
- @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID)
- open val thumbnailStreamId: Long?,
-
- @ColumnInfo(name = PLAYLIST_STREAM_COUNT)
- open val streamCount: Long
-) : PlaylistLocalItem {
-
- override val localItemType: LocalItemType
- get() = LocalItemType.PLAYLIST_LOCAL_ITEM
-
- companion object {
- const val PLAYLIST_STREAM_COUNT: String = "streamCount"
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt
deleted file mode 100644
index 90fdee2d3..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt
+++ /dev/null
@@ -1,49 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2020-2023 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database.playlist
-
-import androidx.room.ColumnInfo
-import androidx.room.Embedded
-import org.schabi.newpipe.database.LocalItem
-import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
-import org.schabi.newpipe.database.stream.model.StreamEntity
-import org.schabi.newpipe.database.stream.model.StreamStateEntity
-import org.schabi.newpipe.extractor.stream.StreamInfoItem
-import org.schabi.newpipe.util.image.ImageStrategy
-
-data class PlaylistStreamEntry(
- @Embedded
- val streamEntity: StreamEntity,
-
- @ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_MILLIS, defaultValue = "0")
- val progressMillis: Long,
-
- @ColumnInfo(name = PlaylistStreamEntity.JOIN_STREAM_ID)
- val streamId: Long,
-
- @ColumnInfo(name = PlaylistStreamEntity.JOIN_INDEX)
- val joinIndex: Int
-) : LocalItem {
-
- override val localItemType: LocalItem.LocalItemType
- get() = LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM
-
- @Throws(IllegalArgumentException::class)
- fun toStreamInfoItem(): StreamInfoItem {
- return StreamInfoItem(
- streamEntity.serviceId,
- streamEntity.url,
- streamEntity.title,
- streamEntity.streamType
- ).apply {
- duration = streamEntity.duration
- uploaderName = streamEntity.uploader
- uploaderUrl = streamEntity.uploaderUrl
- thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt
deleted file mode 100644
index 9c2dd89a8..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2018-2022 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database.playlist.dao
-
-import androidx.room.Dao
-import androidx.room.Query
-import androidx.room.Transaction
-import io.reactivex.rxjava3.core.Flowable
-import org.schabi.newpipe.database.BasicDAO
-import org.schabi.newpipe.database.playlist.model.PlaylistEntity
-
-@Dao
-interface PlaylistDAO : BasicDAO {
-
- @Query("SELECT * FROM playlists")
- override fun getAll(): Flowable>
-
- @Query("DELETE FROM playlists")
- override fun deleteAll(): Int
-
- override fun listByService(serviceId: Int): Flowable> {
- throw UnsupportedOperationException()
- }
-
- @Query("SELECT * FROM playlists WHERE uid = :playlistId")
- fun getPlaylist(playlistId: Long): Flowable>
-
- @Query("DELETE FROM playlists WHERE uid = :playlistId")
- fun deletePlaylist(playlistId: Long): Int
-
- @get:Query("SELECT COUNT(*) FROM playlists")
- val count: Flowable
-
- @Transaction
- fun upsertPlaylist(playlist: PlaylistEntity): Long {
- if (playlist.uid == -1L) {
- // This situation is probably impossible.
- return insert(playlist)
- } else {
- update(playlist)
- return playlist.uid
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt
deleted file mode 100644
index 36a80bc91..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database.playlist.dao
-
-import androidx.room.Dao
-import androidx.room.Query
-import androidx.room.Transaction
-import io.reactivex.rxjava3.core.Flowable
-import org.schabi.newpipe.database.BasicDAO
-import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
-
-@Dao
-interface PlaylistRemoteDAO : BasicDAO {
-
- @Query("SELECT * FROM remote_playlists")
- override fun getAll(): Flowable>
-
- @Query("DELETE FROM remote_playlists")
- override fun deleteAll(): Int
-
- @Query("SELECT * FROM remote_playlists WHERE service_id = :serviceId")
- override fun listByService(serviceId: Int): Flowable>
-
- @Query("SELECT * FROM remote_playlists WHERE uid = :playlistId")
- fun getPlaylist(playlistId: Long): Flowable
-
- @Query("SELECT * FROM remote_playlists WHERE url = :url AND service_id = :serviceId")
- fun getPlaylist(serviceId: Long, url: String?): Flowable>
-
- @get:Query("SELECT * FROM remote_playlists ORDER BY display_index")
- val playlists: Flowable>
-
- @Query("SELECT uid FROM remote_playlists WHERE url = :url AND service_id = :serviceId")
- fun getPlaylistIdInternal(serviceId: Long, url: String?): Long?
-
- @Transaction
- fun upsert(playlist: PlaylistRemoteEntity): Long {
- val playlistId = getPlaylistIdInternal(playlist.serviceId.toLong(), playlist.url)
-
- if (playlistId == null) {
- return insert(playlist)
- } else {
- playlist.uid = playlistId
- update(playlist)
- return playlistId
- }
- }
-
- @Query("DELETE FROM remote_playlists WHERE uid = :playlistId")
- fun deletePlaylist(playlistId: Long): Int
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt
deleted file mode 100644
index c6b6e37a4..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt
+++ /dev/null
@@ -1,136 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2018-2024 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database.playlist.dao
-
-import androidx.room.Dao
-import androidx.room.Query
-import androidx.room.RewriteQueriesToDropUnusedColumns
-import androidx.room.Transaction
-import io.reactivex.rxjava3.core.Flowable
-import org.schabi.newpipe.database.BasicDAO
-import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry
-import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry
-import org.schabi.newpipe.database.playlist.PlaylistStreamEntry
-import org.schabi.newpipe.database.playlist.model.PlaylistEntity.Companion.DEFAULT_THUMBNAIL_ID
-import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
-
-@Dao
-interface PlaylistStreamDAO : BasicDAO {
-
- @Query("SELECT * FROM playlist_stream_join")
- override fun getAll(): Flowable>
-
- @Query("DELETE FROM playlist_stream_join")
- override fun deleteAll(): Int
-
- override fun listByService(serviceId: Int): Flowable> {
- throw UnsupportedOperationException()
- }
-
- @Query("DELETE FROM playlist_stream_join WHERE playlist_id = :playlistId")
- fun deleteBatch(playlistId: Long)
-
- @Query("SELECT COALESCE(MAX(join_index), -1) FROM playlist_stream_join WHERE playlist_id = :playlistId")
- fun getMaximumIndexOf(playlistId: Long): Flowable
-
- @Query(
- """
- SELECT CASE WHEN COUNT(*) != 0 then stream_id ELSE $DEFAULT_THUMBNAIL_ID END
- FROM streams
-
- LEFT JOIN playlist_stream_join
- ON uid = stream_id
-
- WHERE playlist_id = :playlistId LIMIT 1
- """
- )
- fun getAutomaticThumbnailStreamId(playlistId: Long): Flowable
-
- // get ids of streams of the given playlist then merge with the stream metadata
- @RewriteQueriesToDropUnusedColumns
- @Transaction
- @Query(
- """
- SELECT * FROM streams
-
- INNER JOIN (SELECT stream_id, join_index FROM playlist_stream_join WHERE playlist_id = :playlistId)
- ON uid = stream_id
-
- LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state )
- ON uid = stream_id_alias
-
- ORDER BY join_index ASC
- """
- )
- fun getOrderedStreamsOf(playlistId: Long): Flowable>
-
- // If a playlist has no streams, there won’t be any rows in the **playlist_stream_join** table
- // that have a foreign key to that playlist. Thus, the **playlist_id** will not have a
- // corresponding value in any rows of the join table. So, if you group by the **playlist_id**,
- // only playlists that contain videos are grouped and displayed. Look at #9642 #13055
-
- @Transaction
- @Query(
- """
- SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id, display_index,
- (SELECT thumbnail_url FROM streams WHERE streams.uid = thumbnail_stream_id) AS thumbnail_url,
-
- COALESCE(COUNT(playlist_id), 0) AS streamCount FROM playlists
-
- LEFT JOIN playlist_stream_join
- ON playlists.uid = playlist_id
-
- GROUP BY uid
- ORDER BY display_index
- """
- )
- fun getPlaylistMetadata(): Flowable>
-
- @RewriteQueriesToDropUnusedColumns
- @Transaction
- @Query(
- """
- SELECT *, MIN(join_index) FROM streams
-
- INNER JOIN (SELECT stream_id, join_index FROM playlist_stream_join WHERE playlist_id = :playlistId)
- ON uid = stream_id
-
- LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state )
- ON uid = stream_id_alias
-
- GROUP BY uid
- ORDER BY MIN(join_index) ASC
- """
- )
- fun getStreamsWithoutDuplicates(playlistId: Long): Flowable>
-
- // If a playlist has no streams, there won’t be any rows in the **playlist_stream_join** table
- // that have a foreign key to that playlist. Thus, the **playlist_id** will not have a
- // corresponding value in any rows of the join table. So, if you group by the **playlist_id**,
- // only playlists that contain videos are grouped and displayed. Look at #9642 #13055
-
- @Transaction
- @Query(
- """
- SELECT playlists.uid, name, is_thumbnail_permanent, thumbnail_stream_id, display_index,
- (SELECT thumbnail_url FROM streams WHERE streams.uid = thumbnail_stream_id) AS thumbnail_url,
-
- COALESCE(COUNT(playlist_id), 0) AS streamCount,
- COALESCE(SUM(url = :streamUrl), 0) AS timesStreamIsContained FROM playlists
-
- LEFT JOIN playlist_stream_join
- ON playlists.uid = playlist_id
-
- LEFT JOIN streams
- ON streams.uid = stream_id AND :streamUrl = :streamUrl
-
- GROUP BY playlists.uid
- ORDER BY display_index, name
- """
- )
- fun getPlaylistDuplicatesMetadata(streamUrl: String): Flowable>
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.kt
deleted file mode 100644
index 1f1862f4f..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.kt
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2018-2024 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database.playlist.model
-
-import androidx.room.ColumnInfo
-import androidx.room.Entity
-import androidx.room.Ignore
-import androidx.room.PrimaryKey
-import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry
-
-@Entity(tableName = PlaylistEntity.Companion.PLAYLIST_TABLE)
-data class PlaylistEntity @JvmOverloads constructor(
- @PrimaryKey(autoGenerate = true)
- @ColumnInfo(name = PLAYLIST_ID)
- var uid: Long = 0,
-
- @ColumnInfo(name = PLAYLIST_NAME)
- var name: String?,
-
- @ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT)
- var isThumbnailPermanent: Boolean,
-
- @ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID)
- var thumbnailStreamId: Long,
-
- @ColumnInfo(name = PLAYLIST_DISPLAY_INDEX)
- var displayIndex: Long
-) {
-
- @Ignore
- constructor(item: PlaylistMetadataEntry) : this(
- uid = item.uid,
- name = item.orderingName,
- isThumbnailPermanent = item.isThumbnailPermanent!!,
- thumbnailStreamId = item.thumbnailStreamId!!,
- displayIndex = item.displayIndex!!
- )
-
- companion object {
- const val DEFAULT_THUMBNAIL_ID = -1L
-
- const val PLAYLIST_TABLE = "playlists"
- const val PLAYLIST_ID = "uid"
- const val PLAYLIST_NAME = "name"
- const val PLAYLIST_THUMBNAIL_URL = "thumbnail_url"
- const val PLAYLIST_DISPLAY_INDEX = "display_index"
- const val PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent"
- const val PLAYLIST_THUMBNAIL_STREAM_ID = "thumbnail_stream_id"
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.kt
deleted file mode 100644
index 254fa425a..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.kt
+++ /dev/null
@@ -1,100 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database.playlist.model
-
-import android.text.TextUtils
-import androidx.room.ColumnInfo
-import androidx.room.Entity
-import androidx.room.Ignore
-import androidx.room.Index
-import androidx.room.PrimaryKey
-import org.schabi.newpipe.database.LocalItem.LocalItemType
-import org.schabi.newpipe.database.playlist.PlaylistLocalItem
-import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_SERVICE_ID
-import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE
-import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_URL
-import org.schabi.newpipe.extractor.playlist.PlaylistInfo
-import org.schabi.newpipe.util.NO_SERVICE_ID
-import org.schabi.newpipe.util.image.ImageStrategy
-
-@Entity(
- tableName = REMOTE_PLAYLIST_TABLE,
- indices = [
- Index(
- value = [REMOTE_PLAYLIST_SERVICE_ID, REMOTE_PLAYLIST_URL],
- unique = true
- )
- ]
-)
-data class PlaylistRemoteEntity(
- @PrimaryKey(autoGenerate = true)
- @ColumnInfo(name = REMOTE_PLAYLIST_ID)
- override var uid: Long = 0,
-
- @ColumnInfo(name = REMOTE_PLAYLIST_SERVICE_ID)
- val serviceId: Int = NO_SERVICE_ID,
-
- @ColumnInfo(name = REMOTE_PLAYLIST_NAME)
- override val orderingName: String?,
-
- @ColumnInfo(name = REMOTE_PLAYLIST_URL)
- val url: String?,
-
- @ColumnInfo(name = REMOTE_PLAYLIST_THUMBNAIL_URL)
- override val thumbnailUrl: String?,
-
- @ColumnInfo(name = REMOTE_PLAYLIST_UPLOADER_NAME)
- val uploader: String?,
-
- @ColumnInfo(name = REMOTE_PLAYLIST_DISPLAY_INDEX)
- override var displayIndex: Long = -1, // Make sure the new item is on the top
-
- @ColumnInfo(name = REMOTE_PLAYLIST_STREAM_COUNT)
- val streamCount: Long?
-) : PlaylistLocalItem {
-
- constructor(playlistInfo: PlaylistInfo) : this(
- serviceId = playlistInfo.serviceId,
- orderingName = playlistInfo.name,
- url = playlistInfo.url,
- thumbnailUrl = ImageStrategy.imageListToDbUrl(
- playlistInfo.thumbnails.ifEmpty { playlistInfo.uploaderAvatars }
- ),
- uploader = playlistInfo.uploaderName,
- streamCount = playlistInfo.streamCount
- )
-
- override val localItemType: LocalItemType
- get() = LocalItemType.PLAYLIST_REMOTE_ITEM
-
- /**
- * Returns boolean comparing the online playlist and the local copy.
- * (False if info changed such as playlist name or track count)
- */
- @Ignore
- fun isIdenticalTo(info: PlaylistInfo): Boolean {
- return this.serviceId == info.serviceId && this.streamCount == info.streamCount &&
- TextUtils.equals(this.orderingName, info.name) &&
- TextUtils.equals(this.url, info.url) &&
- // we want to update the local playlist data even when either the remote thumbnail
- // URL changes, or the preferred image quality setting is changed by the user
- TextUtils.equals(thumbnailUrl, ImageStrategy.imageListToDbUrl(info.thumbnails)) &&
- TextUtils.equals(this.uploader, info.uploaderName)
- }
-
- companion object {
- const val REMOTE_PLAYLIST_TABLE = "remote_playlists"
- const val REMOTE_PLAYLIST_ID = "uid"
- const val REMOTE_PLAYLIST_SERVICE_ID = "service_id"
- const val REMOTE_PLAYLIST_NAME = "name"
- const val REMOTE_PLAYLIST_URL = "url"
- const val REMOTE_PLAYLIST_THUMBNAIL_URL = "thumbnail_url"
- const val REMOTE_PLAYLIST_UPLOADER_NAME = "uploader"
- const val REMOTE_PLAYLIST_DISPLAY_INDEX = "display_index"
- const val REMOTE_PLAYLIST_STREAM_COUNT = "stream_count"
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.kt
deleted file mode 100644
index 6ab1b6ac4..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.kt
+++ /dev/null
@@ -1,68 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2018-2020 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database.playlist.model
-
-import androidx.room.ColumnInfo
-import androidx.room.Entity
-import androidx.room.ForeignKey
-import androidx.room.ForeignKey.Companion.CASCADE
-import androidx.room.Index
-import org.schabi.newpipe.database.LocalItem
-import org.schabi.newpipe.database.playlist.model.PlaylistEntity.Companion.PLAYLIST_ID
-import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.JOIN_INDEX
-import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID
-import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.JOIN_STREAM_ID
-import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE
-import org.schabi.newpipe.database.stream.model.StreamEntity
-
-@Entity(
- tableName = PLAYLIST_STREAM_JOIN_TABLE,
- primaryKeys = [JOIN_PLAYLIST_ID, JOIN_INDEX],
- indices = [
- Index(value = [JOIN_PLAYLIST_ID, JOIN_INDEX], unique = true),
- Index(value = [JOIN_STREAM_ID])
- ],
- foreignKeys = [
- ForeignKey(
- entity = PlaylistEntity::class,
- parentColumns = arrayOf(PLAYLIST_ID),
- childColumns = arrayOf(JOIN_PLAYLIST_ID),
- onDelete = CASCADE,
- onUpdate = CASCADE,
- deferred = true
- ),
- ForeignKey(
- entity = StreamEntity::class,
- parentColumns = arrayOf(StreamEntity.STREAM_ID),
- childColumns = arrayOf(JOIN_STREAM_ID),
- onDelete = CASCADE,
- onUpdate = CASCADE,
- deferred = true
- )
- ]
-)
-data class PlaylistStreamEntity(
- @ColumnInfo(name = JOIN_PLAYLIST_ID)
- val playlistUid: Long,
-
- @ColumnInfo(name = JOIN_STREAM_ID)
- val streamUid: Long,
-
- @ColumnInfo(name = JOIN_INDEX)
- val index: Int
-) : LocalItem {
-
- override val localItemType: LocalItem.LocalItemType
- get() = LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM
-
- companion object {
- const val PLAYLIST_STREAM_JOIN_TABLE = "playlist_stream_join"
- const val JOIN_PLAYLIST_ID = "playlist_id"
- const val JOIN_STREAM_ID = "stream_id"
- const val JOIN_INDEX = "join_index"
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt
deleted file mode 100644
index ce74678ca..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2020-2023 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database.stream
-
-import androidx.room.ColumnInfo
-import androidx.room.Embedded
-import androidx.room.Ignore
-import java.time.OffsetDateTime
-import org.schabi.newpipe.database.LocalItem
-import org.schabi.newpipe.database.history.model.StreamHistoryEntity
-import org.schabi.newpipe.database.stream.model.StreamEntity
-import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.STREAM_PROGRESS_MILLIS
-import org.schabi.newpipe.extractor.stream.StreamInfoItem
-import org.schabi.newpipe.util.image.ImageStrategy
-
-data class StreamStatisticsEntry(
- @Embedded
- val streamEntity: StreamEntity,
-
- @ColumnInfo(name = STREAM_PROGRESS_MILLIS, defaultValue = "0")
- val progressMillis: Long,
-
- @ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID)
- val streamId: Long,
-
- @ColumnInfo(name = STREAM_LATEST_DATE)
- val latestAccessDate: OffsetDateTime,
-
- @ColumnInfo(name = STREAM_WATCH_COUNT)
- val watchCount: Long
-) : LocalItem {
-
- override val localItemType: LocalItem.LocalItemType
- get() = LocalItem.LocalItemType.STATISTIC_STREAM_ITEM
-
- @Ignore
- fun toStreamInfoItem(): StreamInfoItem {
- return StreamInfoItem(
- streamEntity.serviceId,
- streamEntity.url,
- streamEntity.title,
- streamEntity.streamType
- ).apply {
- duration = streamEntity.duration
- uploaderName = streamEntity.uploader
- uploaderUrl = streamEntity.uploaderUrl
- thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
- }
- }
-
- companion object {
- const val STREAM_LATEST_DATE = "latestAccess"
- const val STREAM_WATCH_COUNT = "watchCount"
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/StreamWithState.kt b/app/src/main/java/org/schabi/newpipe/database/stream/StreamWithState.kt
deleted file mode 100644
index abeabf888..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/stream/StreamWithState.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package org.schabi.newpipe.database.stream
-
-import androidx.room.ColumnInfo
-import androidx.room.Embedded
-import org.schabi.newpipe.database.stream.model.StreamEntity
-import org.schabi.newpipe.database.stream.model.StreamStateEntity
-
-data class StreamWithState(
- @Embedded
- val stream: StreamEntity,
-
- @ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_MILLIS)
- val stateProgressMillis: Long?
-)
diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt
deleted file mode 100644
index 86ba262f5..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt
+++ /dev/null
@@ -1,148 +0,0 @@
-package org.schabi.newpipe.database.stream.dao
-
-import androidx.room.ColumnInfo
-import androidx.room.Dao
-import androidx.room.Insert
-import androidx.room.OnConflictStrategy
-import androidx.room.Query
-import androidx.room.Transaction
-import io.reactivex.rxjava3.core.Completable
-import io.reactivex.rxjava3.core.Flowable
-import java.time.OffsetDateTime
-import org.schabi.newpipe.database.BasicDAO
-import org.schabi.newpipe.database.stream.model.StreamEntity
-import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
-import org.schabi.newpipe.extractor.stream.StreamType
-import org.schabi.newpipe.util.StreamTypeUtil
-
-@Dao
-abstract class StreamDAO : BasicDAO {
- @Query("SELECT * FROM streams")
- abstract override fun getAll(): Flowable>
-
- @Query("DELETE FROM streams")
- abstract override fun deleteAll(): Int
-
- @Query("SELECT * FROM streams WHERE service_id = :serviceId")
- abstract override fun listByService(serviceId: Int): Flowable>
-
- @Query("SELECT * FROM streams WHERE url = :url AND service_id = :serviceId")
- abstract fun getStream(serviceId: Long, url: String): Flowable>
-
- @Query("UPDATE streams SET uploader_url = :uploaderUrl WHERE url = :url AND service_id = :serviceId")
- abstract fun setUploaderUrl(serviceId: Long, url: String, uploaderUrl: String): Completable
-
- @Insert(onConflict = OnConflictStrategy.IGNORE)
- internal abstract fun silentInsertInternal(stream: StreamEntity): Long
-
- @Insert(onConflict = OnConflictStrategy.IGNORE)
- internal abstract fun silentInsertAllInternal(streams: List): List
-
- @Query("SELECT COUNT(*) != 0 FROM streams WHERE url = :url AND service_id = :serviceId")
- internal abstract fun exists(serviceId: Int, url: String): Boolean
-
- @Query(
- """
- SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration
- FROM streams WHERE url = :url AND service_id = :serviceId
- """
- )
- internal abstract fun getMinimalStreamForCompare(serviceId: Int, url: String): StreamCompareFeed?
-
- @Transaction
- open fun upsert(newerStream: StreamEntity): Long {
- val uid = silentInsertInternal(newerStream)
-
- if (uid != -1L) {
- newerStream.uid = uid
- return uid
- }
-
- compareAndUpdateStream(newerStream)
-
- update(newerStream)
- return newerStream.uid
- }
-
- @Transaction
- open fun upsertAll(streams: List): List {
- val insertUidList = silentInsertAllInternal(streams)
-
- val streamIds = ArrayList(streams.size)
- for ((index, uid) in insertUidList.withIndex()) {
- val newerStream = streams[index]
- if (uid != -1L) {
- streamIds.add(uid)
- newerStream.uid = uid
- continue
- }
-
- compareAndUpdateStream(newerStream)
- streamIds.add(newerStream.uid)
- }
-
- update(streams)
- return streamIds
- }
-
- private fun compareAndUpdateStream(newerStream: StreamEntity) {
- val existentMinimalStream = getMinimalStreamForCompare(newerStream.serviceId, newerStream.url)
- ?: error("Stream cannot be null just after insertion.")
- newerStream.uid = existentMinimalStream.uid
-
- if (!StreamTypeUtil.isLiveStream(newerStream.streamType)) {
- // Use the existent upload date if the newer stream does not have a better precision
- // (i.e. is an approximation). This is done to prevent unnecessary changes.
- val hasBetterPrecision =
- newerStream.uploadDate != null && newerStream.isUploadDateApproximation != true
- if (existentMinimalStream.uploadDate != null && !hasBetterPrecision) {
- newerStream.uploadDate = existentMinimalStream.uploadDate
- newerStream.textualUploadDate = existentMinimalStream.textualUploadDate
- newerStream.isUploadDateApproximation = existentMinimalStream.isUploadDateApproximation
- }
-
- if (existentMinimalStream.duration > 0 && newerStream.duration < 0) {
- newerStream.duration = existentMinimalStream.duration
- }
- }
- }
-
- @Query(
- """
- DELETE FROM streams WHERE
-
- NOT EXISTS (SELECT 1 FROM stream_history sh
- WHERE sh.stream_id = streams.uid)
-
- AND NOT EXISTS (SELECT 1 FROM playlist_stream_join ps
- WHERE ps.stream_id = streams.uid)
-
- AND NOT EXISTS (SELECT 1 FROM feed f
- WHERE f.stream_id = streams.uid)
- """
- )
- abstract fun deleteOrphans(): Int
-
- /**
- * Minimal entry class used when comparing/updating an existent stream.
- */
- internal data class StreamCompareFeed(
- @ColumnInfo(name = STREAM_ID)
- var uid: Long = 0,
-
- @ColumnInfo(name = StreamEntity.STREAM_TYPE)
- var streamType: StreamType,
-
- @ColumnInfo(name = StreamEntity.STREAM_TEXTUAL_UPLOAD_DATE)
- var textualUploadDate: String? = null,
-
- @ColumnInfo(name = StreamEntity.STREAM_UPLOAD_DATE)
- var uploadDate: OffsetDateTime? = null,
-
- @ColumnInfo(name = StreamEntity.STREAM_IS_UPLOAD_DATE_APPROXIMATION)
- var isUploadDateApproximation: Boolean? = null,
-
- @ColumnInfo(name = StreamEntity.STREAM_DURATION)
- var duration: Long
- )
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt
deleted file mode 100644
index f3c44f1f2..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2018-2021 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database.stream.dao
-
-import androidx.room.Dao
-import androidx.room.Insert
-import androidx.room.OnConflictStrategy
-import androidx.room.Query
-import androidx.room.Transaction
-import io.reactivex.rxjava3.core.Flowable
-import org.schabi.newpipe.database.BasicDAO
-import org.schabi.newpipe.database.stream.model.StreamStateEntity
-
-@Dao
-interface StreamStateDAO : BasicDAO {
-
- @Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE)
- override fun getAll(): Flowable>
-
- @Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE)
- override fun deleteAll(): Int
-
- override fun listByService(serviceId: Int): Flowable> {
- throw UnsupportedOperationException()
- }
-
- @Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
- fun getState(streamId: Long): Flowable>
-
- @Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
- fun deleteState(streamId: Long): Int
-
- @Insert(onConflict = OnConflictStrategy.Companion.IGNORE)
- fun silentInsertInternal(streamState: StreamStateEntity)
-
- @Transaction
- fun upsert(stream: StreamStateEntity): Long {
- silentInsertInternal(stream)
- return update(stream).toLong()
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.kt b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.kt
deleted file mode 100644
index 067f666b6..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.kt
+++ /dev/null
@@ -1,132 +0,0 @@
-package org.schabi.newpipe.database.stream.model
-
-import androidx.room.ColumnInfo
-import androidx.room.Entity
-import androidx.room.Ignore
-import androidx.room.Index
-import androidx.room.PrimaryKey
-import java.io.Serializable
-import java.time.OffsetDateTime
-import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_SERVICE_ID
-import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_TABLE
-import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_URL
-import org.schabi.newpipe.extractor.localization.DateWrapper
-import org.schabi.newpipe.extractor.stream.StreamInfo
-import org.schabi.newpipe.extractor.stream.StreamInfoItem
-import org.schabi.newpipe.extractor.stream.StreamType
-import org.schabi.newpipe.player.playqueue.PlayQueueItem
-import org.schabi.newpipe.util.image.ImageStrategy
-
-@Entity(
- tableName = STREAM_TABLE,
- indices = [
- Index(value = [STREAM_SERVICE_ID, STREAM_URL], unique = true)
- ]
-)
-data class StreamEntity(
- @PrimaryKey(autoGenerate = true)
- @ColumnInfo(name = STREAM_ID)
- var uid: Long = 0,
-
- @ColumnInfo(name = STREAM_SERVICE_ID)
- var serviceId: Int,
-
- @ColumnInfo(name = STREAM_URL)
- var url: String,
-
- @ColumnInfo(name = STREAM_TITLE)
- var title: String,
-
- @ColumnInfo(name = STREAM_TYPE)
- var streamType: StreamType,
-
- @ColumnInfo(name = STREAM_DURATION)
- var duration: Long,
-
- @ColumnInfo(name = STREAM_UPLOADER)
- var uploader: String,
-
- @ColumnInfo(name = STREAM_UPLOADER_URL)
- var uploaderUrl: String? = null,
-
- @ColumnInfo(name = STREAM_THUMBNAIL_URL)
- var thumbnailUrl: String? = null,
-
- @ColumnInfo(name = STREAM_VIEWS)
- var viewCount: Long? = null,
-
- @ColumnInfo(name = STREAM_TEXTUAL_UPLOAD_DATE)
- var textualUploadDate: String? = null,
-
- @ColumnInfo(name = STREAM_UPLOAD_DATE)
- var uploadDate: OffsetDateTime? = null,
-
- @ColumnInfo(name = STREAM_IS_UPLOAD_DATE_APPROXIMATION)
- var isUploadDateApproximation: Boolean? = null
-) : Serializable {
- @Ignore
- constructor(item: StreamInfoItem) : this(
- serviceId = item.serviceId, url = item.url, title = item.name,
- streamType = item.streamType, duration = item.duration, uploader = item.uploaderName,
- uploaderUrl = item.uploaderUrl,
- thumbnailUrl = ImageStrategy.imageListToDbUrl(item.thumbnails), viewCount = item.viewCount,
- textualUploadDate = item.textualUploadDate, uploadDate = item.uploadDate?.offsetDateTime(),
- isUploadDateApproximation = item.uploadDate?.isApproximation
- )
-
- @Ignore
- constructor(info: StreamInfo) : this(
- serviceId = info.serviceId, url = info.url, title = info.name,
- streamType = info.streamType, duration = info.duration, uploader = info.uploaderName,
- uploaderUrl = info.uploaderUrl,
- thumbnailUrl = ImageStrategy.imageListToDbUrl(info.thumbnails), viewCount = info.viewCount,
- textualUploadDate = info.textualUploadDate, uploadDate = info.uploadDate?.offsetDateTime(),
- isUploadDateApproximation = info.uploadDate?.isApproximation
- )
-
- @Ignore
- constructor(item: PlayQueueItem) : this(
- serviceId = item.serviceId,
- url = item.url,
- title = item.title,
- streamType = item.streamType,
- duration = item.duration,
- uploader = item.uploader,
- uploaderUrl = item.uploaderUrl,
- thumbnailUrl = ImageStrategy.imageListToDbUrl(item.thumbnails)
- )
-
- fun toStreamInfoItem(): StreamInfoItem {
- val item = StreamInfoItem(serviceId, url, title, streamType)
- item.duration = duration
- item.uploaderName = uploader
- item.uploaderUrl = uploaderUrl
- item.thumbnails = ImageStrategy.dbUrlToImageList(thumbnailUrl)
-
- if (viewCount != null) item.viewCount = viewCount as Long
- item.textualUploadDate = textualUploadDate
- item.uploadDate = uploadDate?.let {
- DateWrapper(it, isUploadDateApproximation ?: false)
- }
-
- return item
- }
-
- companion object {
- const val STREAM_TABLE = "streams"
- const val STREAM_ID = "uid"
- const val STREAM_SERVICE_ID = "service_id"
- const val STREAM_URL = "url"
- const val STREAM_TITLE = "title"
- const val STREAM_TYPE = "stream_type"
- const val STREAM_DURATION = "duration"
- const val STREAM_UPLOADER = "uploader"
- const val STREAM_UPLOADER_URL = "uploader_url"
- const val STREAM_THUMBNAIL_URL = "thumbnail_url"
-
- const val STREAM_VIEWS = "view_count"
- const val STREAM_TEXTUAL_UPLOAD_DATE = "textual_upload_date"
- const val STREAM_UPLOAD_DATE = "upload_date"
- const val STREAM_IS_UPLOAD_DATE_APPROXIMATION = "is_upload_date_approximation"
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.kt b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.kt
deleted file mode 100644
index 759a2dcec..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.kt
+++ /dev/null
@@ -1,85 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2018-2023 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database.stream.model
-
-import androidx.room.ColumnInfo
-import androidx.room.Entity
-import androidx.room.ForeignKey
-import androidx.room.ForeignKey.Companion.CASCADE
-import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
-import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.JOIN_STREAM_ID
-import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.PLAYBACK_FINISHED_END_MILLISECONDS
-import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.STREAM_STATE_TABLE
-
-@Entity(
- tableName = STREAM_STATE_TABLE,
- primaryKeys = [JOIN_STREAM_ID],
- foreignKeys = [
- ForeignKey(
- entity = StreamEntity::class,
- parentColumns = arrayOf(STREAM_ID),
- childColumns = arrayOf(JOIN_STREAM_ID),
- onDelete = CASCADE,
- onUpdate = CASCADE
- )
- ]
-)
-data class StreamStateEntity(
- @ColumnInfo(name = JOIN_STREAM_ID)
- val streamUid: Long,
-
- @ColumnInfo(name = STREAM_PROGRESS_MILLIS)
- val progressMillis: Long
-) {
- /**
- * The state will be considered valid, and thus be saved, if the progress is more than
- * [PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS] or at least 1/4 of the video length.
- * @param durationInSeconds the duration of the stream connected with this state, in seconds
- * @return whether this stream state entity should be saved or not
- */
- fun isValid(durationInSeconds: Long): Boolean {
- return progressMillis > PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS ||
- progressMillis > durationInSeconds * 1000 / 4
- }
-
- /**
- * The video will be considered as finished, if the time left is less than
- * [PLAYBACK_FINISHED_END_MILLISECONDS] and the progress is at least 3/4 of the video length.
- * The state will be saved anyway, so that it can be shown under stream info items, but the
- * player will not resume if a state is considered as finished. Finished streams are also the
- * ones that can be filtered out in the feed fragment.
- * @param durationInSeconds the duration of the stream connected with this state, in seconds
- * @return whether the stream is finished or not
- */
- fun isFinished(durationInSeconds: Long): Boolean {
- return progressMillis >= durationInSeconds * 1000 - PLAYBACK_FINISHED_END_MILLISECONDS &&
- progressMillis >= durationInSeconds * 1000 * 3 / 4
- }
-
- companion object {
- const val STREAM_STATE_TABLE = "stream_state"
- const val JOIN_STREAM_ID = "stream_id"
-
- // This additional field is required for the SQL query because 'stream_id' is used
- // for some other joins already
- const val JOIN_STREAM_ID_ALIAS = "stream_id_alias"
- const val STREAM_PROGRESS_MILLIS = "progress_time"
-
- /**
- * Playback state will not be saved, if playback time is less than this threshold
- * (5000ms = 5s).
- */
- const val PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS = 5000L
-
- /**
- * Stream will be considered finished if the playback time left exceeds this threshold
- * (60000ms = 60s).
- * @see org.schabi.newpipe.database.stream.model.StreamStateEntity.isFinished
- */
- const val PLAYBACK_FINISHED_END_MILLISECONDS = 60000L
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.kt
deleted file mode 100644
index f9bb18c0c..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2021 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database.subscription
-
-import androidx.annotation.IntDef
-
-@IntDef(NotificationMode.Companion.DISABLED, NotificationMode.Companion.ENABLED)
-@Retention(AnnotationRetention.SOURCE)
-annotation class NotificationMode {
- companion object {
- const val DISABLED = 0
- const val ENABLED = 1 // other values reserved for the future
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt
deleted file mode 100644
index 72bdbcf5c..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt
+++ /dev/null
@@ -1,112 +0,0 @@
-package org.schabi.newpipe.database.subscription
-
-import androidx.room.Dao
-import androidx.room.Insert
-import androidx.room.OnConflictStrategy
-import androidx.room.Query
-import androidx.room.RewriteQueriesToDropUnusedColumns
-import androidx.room.Transaction
-import io.reactivex.rxjava3.core.Flowable
-import io.reactivex.rxjava3.core.Maybe
-import org.schabi.newpipe.database.BasicDAO
-
-@Dao
-abstract class SubscriptionDAO : BasicDAO {
- @Query("SELECT COUNT(*) FROM subscriptions")
- abstract fun rowCount(): Flowable
-
- @Query("SELECT * FROM subscriptions WHERE service_id = :serviceId")
- abstract override fun listByService(serviceId: Int): Flowable>
-
- @Query("SELECT * FROM subscriptions ORDER BY name COLLATE NOCASE ASC")
- abstract override fun getAll(): Flowable>
-
- @Query(
- """
- SELECT * FROM subscriptions
-
- WHERE name LIKE '%' || :filter || '%'
-
- ORDER BY name COLLATE NOCASE ASC
- """
- )
- abstract fun getSubscriptionsFiltered(filter: String): Flowable>
-
- @RewriteQueriesToDropUnusedColumns
- @Query(
- """
- SELECT * FROM subscriptions s
-
- LEFT JOIN feed_group_subscription_join fgs
- ON s.uid = fgs.subscription_id
-
- WHERE (fgs.subscription_id IS NULL OR fgs.group_id = :currentGroupId)
-
- ORDER BY name COLLATE NOCASE ASC
- """
- )
- abstract fun getSubscriptionsOnlyUngrouped(
- currentGroupId: Long
- ): Flowable>
-
- @RewriteQueriesToDropUnusedColumns
- @Query(
- """
- SELECT * FROM subscriptions s
-
- LEFT JOIN feed_group_subscription_join fgs
- ON s.uid = fgs.subscription_id
-
- WHERE (fgs.subscription_id IS NULL OR fgs.group_id = :currentGroupId)
- AND s.name LIKE '%' || :filter || '%'
-
- ORDER BY name COLLATE NOCASE ASC
- """
- )
- abstract fun getSubscriptionsOnlyUngroupedFiltered(
- currentGroupId: Long,
- filter: String
- ): Flowable>
-
- @Query("SELECT * FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId")
- abstract fun getSubscriptionFlowable(serviceId: Int, url: String): Flowable>
-
- @Query("SELECT * FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId")
- abstract fun getSubscription(serviceId: Int, url: String): Maybe
-
- @Query("SELECT * FROM subscriptions WHERE uid = :subscriptionId")
- abstract fun getSubscription(subscriptionId: Long): SubscriptionEntity
-
- @Query("DELETE FROM subscriptions")
- abstract override fun deleteAll(): Int
-
- @Query("DELETE FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId")
- abstract fun deleteSubscription(serviceId: Int, url: String): Int
-
- @Query("SELECT uid FROM subscriptions WHERE url LIKE :url AND service_id = :serviceId")
- internal abstract fun getSubscriptionIdInternal(serviceId: Int, url: String): Long?
-
- @Insert(onConflict = OnConflictStrategy.IGNORE)
- internal abstract fun silentInsertAllInternal(entities: List): List
-
- @Transaction
- open fun upsertAll(entities: List): List {
- val insertUidList = silentInsertAllInternal(entities)
-
- insertUidList.forEachIndexed { index: Int, uidFromInsert: Long ->
- val entity = entities[index]
-
- if (uidFromInsert != -1L) {
- entity.uid = uidFromInsert
- } else {
- val subscriptionIdFromDb = getSubscriptionIdInternal(entity.serviceId, entity.url!!)
- ?: error("Subscription cannot be null just after insertion.")
- entity.uid = subscriptionIdFromDb
-
- update(entity)
- }
- }
-
- return entities
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt
deleted file mode 100644
index 7df9830e4..000000000
--- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2017-2024 NewPipe contributors
- * SPDX-FileCopyrightText: 2025 NewPipe e.V.
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.database.subscription
-
-import androidx.room.ColumnInfo
-import androidx.room.Entity
-import androidx.room.Ignore
-import androidx.room.Index
-import androidx.room.PrimaryKey
-import org.schabi.newpipe.extractor.channel.ChannelInfo
-import org.schabi.newpipe.extractor.channel.ChannelInfoItem
-import org.schabi.newpipe.util.NO_SERVICE_ID
-import org.schabi.newpipe.util.image.ImageStrategy
-
-@Entity(
- tableName = SubscriptionEntity.Companion.SUBSCRIPTION_TABLE,
- indices = [
- Index(
- value = [SubscriptionEntity.Companion.SUBSCRIPTION_SERVICE_ID, SubscriptionEntity.Companion.SUBSCRIPTION_URL],
- unique = true
- )
- ]
-)
-data class SubscriptionEntity(
- @PrimaryKey(autoGenerate = true)
- var uid: Long = 0,
-
- @ColumnInfo(name = SUBSCRIPTION_SERVICE_ID)
- var serviceId: Int = NO_SERVICE_ID,
-
- @ColumnInfo(name = SUBSCRIPTION_URL)
- var url: String? = null,
-
- @ColumnInfo(name = SUBSCRIPTION_NAME)
- var name: String? = null,
-
- @ColumnInfo(name = SUBSCRIPTION_AVATAR_URL)
- var avatarUrl: String? = null,
-
- @ColumnInfo(name = SUBSCRIPTION_SUBSCRIBER_COUNT)
- var subscriberCount: Long? = null,
-
- @ColumnInfo(name = SUBSCRIPTION_DESCRIPTION)
- var description: String? = null,
-
- @get:NotificationMode
- @ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE)
- var notificationMode: Int = 0
-) {
- @Ignore
- fun toChannelInfoItem(): ChannelInfoItem {
- return ChannelInfoItem(this.serviceId, this.url, this.name).apply {
- thumbnails = ImageStrategy.dbUrlToImageList(this@SubscriptionEntity.avatarUrl)
- subscriberCount = this@SubscriptionEntity.subscriberCount ?: -1
- description = this@SubscriptionEntity.description
- }
- }
-
- companion object {
- const val SUBSCRIPTION_UID: String = "uid"
- const val SUBSCRIPTION_TABLE: String = "subscriptions"
- const val SUBSCRIPTION_SERVICE_ID: String = "service_id"
- const val SUBSCRIPTION_URL: String = "url"
- const val SUBSCRIPTION_NAME: String = "name"
- const val SUBSCRIPTION_AVATAR_URL: String = "avatar_url"
- const val SUBSCRIPTION_SUBSCRIBER_COUNT: String = "subscriber_count"
- const val SUBSCRIPTION_DESCRIPTION: String = "description"
- const val SUBSCRIPTION_NOTIFICATION_MODE: String = "notification_mode"
-
- @JvmStatic
- @Ignore
- fun from(info: ChannelInfo): SubscriptionEntity {
- return SubscriptionEntity(
- serviceId = info.serviceId,
- url = info.url,
- name = info.name,
- avatarUrl = ImageStrategy.imageListToDbUrl(info.avatars),
- description = info.description,
- subscriberCount = info.subscriberCount
- )
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java b/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java
deleted file mode 100644
index 33702a6a3..000000000
--- a/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java
+++ /dev/null
@@ -1,94 +0,0 @@
-package org.schabi.newpipe.download;
-
-import android.content.Intent;
-import android.os.Bundle;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.view.ViewTreeObserver;
-
-import androidx.appcompat.app.ActionBar;
-import androidx.appcompat.app.AppCompatActivity;
-import androidx.fragment.app.FragmentTransaction;
-
-import org.schabi.newpipe.R;
-import org.schabi.newpipe.databinding.ActivityDownloaderBinding;
-import org.schabi.newpipe.util.DeviceUtils;
-import org.schabi.newpipe.util.ThemeHelper;
-import org.schabi.newpipe.views.FocusOverlayView;
-
-import us.shandian.giga.service.DownloadManagerService;
-import us.shandian.giga.ui.fragment.MissionsFragment;
-
-public class DownloadActivity extends AppCompatActivity {
-
- private static final String MISSIONS_FRAGMENT_TAG = "fragment_tag";
-
- @Override
- protected void onCreate(final Bundle savedInstanceState) {
- // Service
- final Intent i = new Intent();
- i.setClass(this, DownloadManagerService.class);
- startService(i);
-
- ThemeHelper.setTheme(this);
-
- super.onCreate(savedInstanceState);
-
- final ActivityDownloaderBinding downloaderBinding =
- ActivityDownloaderBinding.inflate(getLayoutInflater());
- setContentView(downloaderBinding.getRoot());
-
- setSupportActionBar(downloaderBinding.toolbarLayout.toolbar);
-
- final ActionBar actionBar = getSupportActionBar();
- if (actionBar != null) {
- actionBar.setDisplayHomeAsUpEnabled(true);
- actionBar.setTitle(R.string.downloads_title);
- actionBar.setDisplayShowTitleEnabled(true);
- }
-
- getWindow().getDecorView().getViewTreeObserver()
- .addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
- @Override
- public void onGlobalLayout() {
- updateFragments();
- getWindow().getDecorView().getViewTreeObserver().removeOnGlobalLayoutListener(this);
- }
- });
-
- if (DeviceUtils.isTv(this)) {
- FocusOverlayView.setupFocusObserver(this);
- }
- }
-
- private void updateFragments() {
- final MissionsFragment fragment = new MissionsFragment();
-
- getSupportFragmentManager().beginTransaction()
- .replace(R.id.frame, fragment, MISSIONS_FRAGMENT_TAG)
- .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
- .commit();
- }
-
- @Override
- public boolean onCreateOptionsMenu(final Menu menu) {
- super.onCreateOptionsMenu(menu);
- final MenuInflater inflater = getMenuInflater();
-
- inflater.inflate(R.menu.download_menu, menu);
-
- return true;
- }
-
- @Override
- public boolean onOptionsItemSelected(final MenuItem item) {
- switch (item.getItemId()) {
- case android.R.id.home:
- onBackPressed();
- return true;
- default:
- return super.onOptionsItemSelected(item);
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
deleted file mode 100644
index 178fcefe1..000000000
--- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
+++ /dev/null
@@ -1,1127 +0,0 @@
-package org.schabi.newpipe.download;
-
-import static org.schabi.newpipe.extractor.stream.DeliveryMethod.PROGRESSIVE_HTTP;
-import static org.schabi.newpipe.util.ListHelper.getStreamsOfSpecifiedDelivery;
-
-import android.app.Activity;
-import android.content.ComponentName;
-import android.content.Context;
-import android.content.Intent;
-import android.content.ServiceConnection;
-import android.content.SharedPreferences;
-import android.net.Uri;
-import android.os.Bundle;
-import android.os.Environment;
-import android.os.IBinder;
-import android.provider.Settings;
-import android.util.Log;
-import android.view.LayoutInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.AdapterView;
-import android.widget.RadioGroup;
-import android.widget.SeekBar;
-import android.widget.Toast;
-
-import androidx.activity.result.ActivityResult;
-import androidx.activity.result.ActivityResultLauncher;
-import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
-import androidx.annotation.IdRes;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.StringRes;
-import androidx.appcompat.app.AlertDialog;
-import androidx.appcompat.widget.Toolbar;
-import androidx.collection.SparseArrayCompat;
-import androidx.documentfile.provider.DocumentFile;
-import androidx.fragment.app.DialogFragment;
-import androidx.preference.PreferenceManager;
-
-import com.evernote.android.state.State;
-import com.livefront.bridge.Bridge;
-import com.nononsenseapps.filepicker.Utils;
-
-import org.schabi.newpipe.MainActivity;
-import org.schabi.newpipe.R;
-import org.schabi.newpipe.databinding.DownloadDialogBinding;
-import org.schabi.newpipe.error.ErrorInfo;
-import org.schabi.newpipe.error.ErrorUtil;
-import org.schabi.newpipe.error.UserAction;
-import org.schabi.newpipe.extractor.MediaFormat;
-import org.schabi.newpipe.extractor.NewPipe;
-import org.schabi.newpipe.extractor.localization.Localization;
-import org.schabi.newpipe.extractor.stream.AudioStream;
-import org.schabi.newpipe.extractor.stream.Stream;
-import org.schabi.newpipe.extractor.stream.StreamInfo;
-import org.schabi.newpipe.extractor.stream.SubtitlesStream;
-import org.schabi.newpipe.extractor.stream.VideoStream;
-import org.schabi.newpipe.settings.NewPipeSettings;
-import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard;
-import org.schabi.newpipe.streams.io.StoredDirectoryHelper;
-import org.schabi.newpipe.streams.io.StoredFileHelper;
-import org.schabi.newpipe.util.AudioTrackAdapter;
-import org.schabi.newpipe.util.AudioTrackAdapter.AudioTracksWrapper;
-import org.schabi.newpipe.util.FilePickerActivityHelper;
-import org.schabi.newpipe.util.FilenameUtils;
-import org.schabi.newpipe.util.ListHelper;
-import org.schabi.newpipe.util.PermissionHelper;
-import org.schabi.newpipe.util.SecondaryStreamHelper;
-import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener;
-import org.schabi.newpipe.util.StreamItemAdapter;
-import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper;
-import org.schabi.newpipe.util.ThemeHelper;
-
-import java.io.File;
-import java.io.IOException;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Locale;
-import java.util.Objects;
-import java.util.Optional;
-
-import io.reactivex.rxjava3.disposables.CompositeDisposable;
-import us.shandian.giga.get.MissionRecoveryInfo;
-import us.shandian.giga.postprocessing.Postprocessing;
-import us.shandian.giga.service.DownloadManager;
-import us.shandian.giga.service.DownloadManagerService;
-import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder;
-import us.shandian.giga.service.MissionState;
-
-public class DownloadDialog extends DialogFragment
- implements RadioGroup.OnCheckedChangeListener, AdapterView.OnItemSelectedListener {
- private static final String TAG = "DialogFragment";
- private static final boolean DEBUG = MainActivity.DEBUG;
-
- @State
- StreamInfo currentInfo;
- @State
- StreamInfoWrapper wrappedVideoStreams;
- @State
- StreamInfoWrapper wrappedSubtitleStreams;
- @State
- AudioTracksWrapper wrappedAudioTracks;
- @State
- int selectedAudioTrackIndex;
- @State
- int selectedVideoIndex; // set in the constructor
- @State
- int selectedAudioIndex = 0; // default to the first item
- @State
- int selectedSubtitleIndex = 0; // default to the first item
-
- private StoredDirectoryHelper mainStorageAudio = null;
- private StoredDirectoryHelper mainStorageVideo = null;
- private DownloadManager downloadManager = null;
- private MenuItem okButton = null;
- private Context context = null;
- private boolean askForSavePath;
-
- private AudioTrackAdapter audioTrackAdapter;
- private StreamItemAdapter audioStreamsAdapter;
- private StreamItemAdapter videoStreamsAdapter;
- private StreamItemAdapter subtitleStreamsAdapter;
-
- private final CompositeDisposable disposables = new CompositeDisposable();
-
- private DownloadDialogBinding dialogBinding;
-
- private SharedPreferences prefs;
-
- // Variables for file name and MIME type when picking new folder because it's not set yet
- private String filenameTmp;
- private String mimeTmp;
-
- private final ActivityResultLauncher requestDownloadSaveAsLauncher =
- registerForActivityResult(
- new StartActivityForResult(), this::requestDownloadSaveAsResult);
- private final ActivityResultLauncher requestDownloadPickAudioFolderLauncher =
- registerForActivityResult(
- new StartActivityForResult(), this::requestDownloadPickAudioFolderResult);
- private final ActivityResultLauncher requestDownloadPickVideoFolderLauncher =
- registerForActivityResult(
- new StartActivityForResult(), this::requestDownloadPickVideoFolderResult);
-
- /*//////////////////////////////////////////////////////////////////////////
- // Instance creation
- //////////////////////////////////////////////////////////////////////////*/
-
- public DownloadDialog() {
- // Just an empty default no-arg ctor to keep Fragment.instantiate() happy
- // otherwise InstantiationException will be thrown when fragment is recreated
- // TODO: Maybe use a custom FragmentFactory instead?
- }
-
- /**
- * Create a new download dialog with the video, audio and subtitle streams from the provided
- * stream info. Video streams and video-only streams will be put into a single list menu,
- * sorted according to their resolution and the default video resolution will be selected.
- *
- * @param context the context to use just to obtain preferences and strings (will not be stored)
- * @param info the info from which to obtain downloadable streams and other info (e.g. title)
- */
- public DownloadDialog(@NonNull final Context context, @NonNull final StreamInfo info) {
- this.currentInfo = info;
-
- final List audioStreams =
- getStreamsOfSpecifiedDelivery(info.getAudioStreams(), PROGRESSIVE_HTTP);
- final List> groupedAudioStreams =
- ListHelper.getGroupedAudioStreams(context, audioStreams);
- this.wrappedAudioTracks = new AudioTracksWrapper(groupedAudioStreams, context);
- this.selectedAudioTrackIndex =
- ListHelper.getDefaultAudioTrackGroup(context, groupedAudioStreams);
-
- // TODO: Adapt this code when the downloader support other types of stream deliveries
- final List videoStreams = ListHelper.getSortedStreamVideosList(
- context,
- getStreamsOfSpecifiedDelivery(info.getVideoStreams(), PROGRESSIVE_HTTP),
- getStreamsOfSpecifiedDelivery(info.getVideoOnlyStreams(), PROGRESSIVE_HTTP),
- false,
- // If there are multiple languages available, prefer streams without audio
- // to allow language selection
- wrappedAudioTracks.size() > 1
- );
-
- this.wrappedVideoStreams = new StreamInfoWrapper<>(videoStreams, context);
- this.wrappedSubtitleStreams = new StreamInfoWrapper<>(
- getStreamsOfSpecifiedDelivery(info.getSubtitles(), PROGRESSIVE_HTTP), context);
-
- this.selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams);
- }
-
-
- /*//////////////////////////////////////////////////////////////////////////
- // Android lifecycle
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- public void onCreate(@Nullable final Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- if (DEBUG) {
- Log.d(TAG, "onCreate() called with: "
- + "savedInstanceState = [" + savedInstanceState + "]");
- }
-
- if (!PermissionHelper.checkStoragePermissions(getActivity(),
- PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) {
- dismiss();
- return;
- }
-
- // context will remain null if dismiss() was called above, allowing to check whether the
- // dialog is being dismissed in onViewCreated()
- context = getContext();
-
- setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(context));
- Bridge.restoreInstanceState(this, savedInstanceState);
-
- this.audioTrackAdapter = new AudioTrackAdapter(wrappedAudioTracks);
- this.subtitleStreamsAdapter = new StreamItemAdapter<>(wrappedSubtitleStreams);
- updateSecondaryStreams();
-
- final Intent intent = new Intent(context, DownloadManagerService.class);
- context.startService(intent);
-
- context.bindService(intent, new ServiceConnection() {
- @Override
- public void onServiceConnected(final ComponentName cname, final IBinder service) {
- final DownloadManagerBinder mgr = (DownloadManagerBinder) service;
-
- mainStorageAudio = mgr.getMainStorageAudio();
- mainStorageVideo = mgr.getMainStorageVideo();
- downloadManager = mgr.getDownloadManager();
- askForSavePath = mgr.askForSavePath();
-
- okButton.setEnabled(true);
-
- context.unbindService(this);
- }
-
- @Override
- public void onServiceDisconnected(final ComponentName name) {
- // nothing to do
- }
- }, Context.BIND_AUTO_CREATE);
- }
-
- /**
- * Update the displayed video streams based on the selected audio track.
- */
- private void updateSecondaryStreams() {
- final StreamInfoWrapper audioStreams = getWrappedAudioStreams();
- final var secondaryStreams = new SparseArrayCompat>(4);
- final List videoStreams = wrappedVideoStreams.getStreamsList();
- wrappedVideoStreams.resetInfo();
-
- for (int i = 0; i < videoStreams.size(); i++) {
- if (!videoStreams.get(i).isVideoOnly()) {
- continue;
- }
- final AudioStream audioStream = SecondaryStreamHelper.getAudioStreamFor(
- context, audioStreams.getStreamsList(), videoStreams.get(i));
-
- if (audioStream != null) {
- secondaryStreams.append(i, new SecondaryStreamHelper<>(audioStreams, audioStream));
- } else if (DEBUG) {
- final MediaFormat mediaFormat = videoStreams.get(i).getFormat();
- if (mediaFormat != null) {
- Log.w(TAG, "No audio stream candidates for video format "
- + mediaFormat.name());
- } else {
- Log.w(TAG, "No audio stream candidates for unknown video format");
- }
- }
- }
-
- this.videoStreamsAdapter = new StreamItemAdapter<>(wrappedVideoStreams, secondaryStreams);
- this.audioStreamsAdapter = new StreamItemAdapter<>(audioStreams);
- }
-
- @Override
- public View onCreateView(@NonNull final LayoutInflater inflater,
- final ViewGroup container,
- final Bundle savedInstanceState) {
- if (DEBUG) {
- Log.d(TAG, "onCreateView() called with: "
- + "inflater = [" + inflater + "], container = [" + container + "], "
- + "savedInstanceState = [" + savedInstanceState + "]");
- }
- return inflater.inflate(R.layout.download_dialog, container);
- }
-
- @Override
- public void onViewCreated(@NonNull final View view,
- @Nullable final Bundle savedInstanceState) {
- super.onViewCreated(view, savedInstanceState);
- dialogBinding = DownloadDialogBinding.bind(view);
- if (context == null) {
- return; // the dialog is being dismissed, see the call to dismiss() in onCreate()
- }
-
- dialogBinding.fileName.setText(FilenameUtils.createFilename(getContext(),
- currentInfo.getName()));
- selectedAudioIndex = ListHelper.getDefaultAudioFormat(getContext(),
- getWrappedAudioStreams().getStreamsList());
-
- selectedSubtitleIndex = getSubtitleIndexBy(subtitleStreamsAdapter.getAll());
-
- dialogBinding.qualitySpinner.setOnItemSelectedListener(this);
- dialogBinding.audioStreamSpinner.setOnItemSelectedListener(this);
- dialogBinding.audioTrackSpinner.setOnItemSelectedListener(this);
- dialogBinding.videoAudioGroup.setOnCheckedChangeListener(this);
-
- initToolbar(dialogBinding.toolbarLayout.toolbar);
- setupDownloadOptions();
-
- prefs = PreferenceManager.getDefaultSharedPreferences(requireContext());
-
- final int threads = prefs.getInt(getString(R.string.default_download_threads), 3);
- dialogBinding.threadsCount.setText(String.valueOf(threads));
- dialogBinding.threads.setProgress(threads - 1);
- dialogBinding.threads.setOnSeekBarChangeListener(new SimpleOnSeekBarChangeListener() {
- @Override
- public void onProgressChanged(@NonNull final SeekBar seekbar,
- final int progress,
- final boolean fromUser) {
- final int newProgress = progress + 1;
- prefs.edit().putInt(getString(R.string.default_download_threads), newProgress)
- .apply();
- dialogBinding.threadsCount.setText(String.valueOf(newProgress));
- }
- });
-
- fetchStreamsSize();
- }
-
- private void initToolbar(final Toolbar toolbar) {
- if (DEBUG) {
- Log.d(TAG, "initToolbar() called with: toolbar = [" + toolbar + "]");
- }
-
- toolbar.setTitle(R.string.download_dialog_title);
- toolbar.setNavigationIcon(R.drawable.ic_arrow_back);
- toolbar.inflateMenu(R.menu.dialog_url);
- toolbar.setNavigationOnClickListener(v -> dismiss());
- toolbar.setNavigationContentDescription(R.string.cancel);
-
- okButton = toolbar.getMenu().findItem(R.id.okay);
- okButton.setEnabled(false); // disable until the download service connection is done
-
- toolbar.setOnMenuItemClickListener(item -> {
- if (item.getItemId() == R.id.okay) {
- prepareSelectedDownload();
- return true;
- }
- return false;
- });
- }
-
- @Override
- public void onDestroy() {
- super.onDestroy();
- disposables.clear();
- }
-
- @Override
- public void onDestroyView() {
- dialogBinding = null;
- super.onDestroyView();
- }
-
- @Override
- public void onSaveInstanceState(@NonNull final Bundle outState) {
- super.onSaveInstanceState(outState);
- Bridge.saveInstanceState(this, outState);
- }
-
-
- /*//////////////////////////////////////////////////////////////////////////
- // Video, audio and subtitle spinners
- //////////////////////////////////////////////////////////////////////////*/
-
- private void fetchStreamsSize() {
- disposables.clear();
- disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedVideoStreams)
- .subscribe(result -> {
- if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
- == R.id.video_button) {
- setupVideoSpinner();
- }
- }, throwable -> ErrorUtil.showSnackbar(context,
- new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
- "Downloading video stream size", currentInfo))));
- disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(getWrappedAudioStreams())
- .subscribe(result -> {
- if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
- == R.id.audio_button) {
- setupAudioSpinner();
- }
- }, throwable -> ErrorUtil.showSnackbar(context,
- new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
- "Downloading audio stream size", currentInfo))));
- disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedSubtitleStreams)
- .subscribe(result -> {
- if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
- == R.id.subtitle_button) {
- setupSubtitleSpinner();
- }
- }, throwable -> ErrorUtil.showSnackbar(context,
- new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
- "Downloading subtitle stream size", currentInfo))));
- }
-
- private void setupAudioTrackSpinner() {
- if (getContext() == null) {
- return;
- }
-
- dialogBinding.audioTrackSpinner.setAdapter(audioTrackAdapter);
- dialogBinding.audioTrackSpinner.setSelection(selectedAudioTrackIndex);
- }
-
- private void setupAudioSpinner() {
- if (getContext() == null) {
- return;
- }
-
- dialogBinding.qualitySpinner.setVisibility(View.GONE);
- setRadioButtonsState(true);
- dialogBinding.audioStreamSpinner.setAdapter(audioStreamsAdapter);
- dialogBinding.audioStreamSpinner.setSelection(selectedAudioIndex);
- dialogBinding.audioStreamSpinner.setVisibility(View.VISIBLE);
- dialogBinding.audioTrackSpinner.setVisibility(
- wrappedAudioTracks.size() > 1 ? View.VISIBLE : View.GONE);
- dialogBinding.audioTrackPresentInVideoText.setVisibility(View.GONE);
- }
-
- private void setupVideoSpinner() {
- if (getContext() == null) {
- return;
- }
-
- dialogBinding.qualitySpinner.setAdapter(videoStreamsAdapter);
- dialogBinding.qualitySpinner.setSelection(selectedVideoIndex);
- dialogBinding.qualitySpinner.setVisibility(View.VISIBLE);
- setRadioButtonsState(true);
- dialogBinding.audioStreamSpinner.setVisibility(View.GONE);
- onVideoStreamSelected();
- }
-
- private void onVideoStreamSelected() {
- final boolean isVideoOnly = videoStreamsAdapter.getItem(selectedVideoIndex).isVideoOnly();
-
- dialogBinding.audioTrackSpinner.setVisibility(
- isVideoOnly && wrappedAudioTracks.size() > 1 ? View.VISIBLE : View.GONE);
- dialogBinding.audioTrackPresentInVideoText.setVisibility(
- !isVideoOnly && wrappedAudioTracks.size() > 1 ? View.VISIBLE : View.GONE);
- }
-
- private void setupSubtitleSpinner() {
- if (getContext() == null) {
- return;
- }
-
- dialogBinding.qualitySpinner.setAdapter(subtitleStreamsAdapter);
- dialogBinding.qualitySpinner.setSelection(selectedSubtitleIndex);
- dialogBinding.qualitySpinner.setVisibility(View.VISIBLE);
- setRadioButtonsState(true);
- dialogBinding.audioStreamSpinner.setVisibility(View.GONE);
- dialogBinding.audioTrackSpinner.setVisibility(View.GONE);
- dialogBinding.audioTrackPresentInVideoText.setVisibility(View.GONE);
- }
-
-
- /*//////////////////////////////////////////////////////////////////////////
- // Activity results
- //////////////////////////////////////////////////////////////////////////*/
-
- private void requestDownloadPickAudioFolderResult(final ActivityResult result) {
- requestDownloadPickFolderResult(
- result, getString(R.string.download_path_audio_key), DownloadManager.TAG_AUDIO);
- }
-
- private void requestDownloadPickVideoFolderResult(final ActivityResult result) {
- requestDownloadPickFolderResult(
- result, getString(R.string.download_path_video_key), DownloadManager.TAG_VIDEO);
- }
-
- private void requestDownloadSaveAsResult(@NonNull final ActivityResult result) {
- if (result.getResultCode() != Activity.RESULT_OK) {
- return;
- }
-
- if (result.getData() == null || result.getData().getData() == null) {
- showFailedDialog(R.string.general_error);
- return;
- }
-
- if (FilePickerActivityHelper.isOwnFileUri(context, result.getData().getData())) {
- final File file = Utils.getFileForUri(result.getData().getData());
- checkSelectedDownload(null, Uri.fromFile(file), file.getName(),
- StoredFileHelper.DEFAULT_MIME);
- return;
- }
-
- final DocumentFile docFile = DocumentFile.fromSingleUri(context,
- result.getData().getData());
- if (docFile == null) {
- showFailedDialog(R.string.general_error);
- return;
- }
-
- // check if the selected file was previously used
- checkSelectedDownload(null, result.getData().getData(), docFile.getName(),
- docFile.getType());
- }
-
- private void requestDownloadPickFolderResult(@NonNull final ActivityResult result,
- final String key,
- final String tag) {
- if (result.getResultCode() != Activity.RESULT_OK) {
- return;
- }
-
- if (result.getData() == null || result.getData().getData() == null) {
- showFailedDialog(R.string.general_error);
- return;
- }
-
- Uri uri = result.getData().getData();
- if (FilePickerActivityHelper.isOwnFileUri(context, uri)) {
- uri = Uri.fromFile(Utils.getFileForUri(uri));
- } else {
- context.grantUriPermission(context.getPackageName(), uri,
- StoredDirectoryHelper.PERMISSION_FLAGS);
- }
-
- PreferenceManager.getDefaultSharedPreferences(context).edit().putString(key,
- uri.toString()).apply();
-
- try {
- final StoredDirectoryHelper mainStorage = new StoredDirectoryHelper(context, uri, tag);
- checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp),
- filenameTmp, mimeTmp);
- } catch (final IOException e) {
- showFailedDialog(R.string.general_error);
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Listeners
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- public void onCheckedChanged(final RadioGroup group, @IdRes final int checkedId) {
- if (DEBUG) {
- Log.d(TAG, "onCheckedChanged() called with: "
- + "group = [" + group + "], checkedId = [" + checkedId + "]");
- }
- boolean flag = true;
-
- if (checkedId == R.id.audio_button) {
- setupAudioSpinner();
- } else if (checkedId == R.id.video_button) {
- setupVideoSpinner();
- } else if (checkedId == R.id.subtitle_button) {
- setupSubtitleSpinner();
- flag = false;
- }
-
- dialogBinding.threads.setEnabled(flag);
- }
-
- @Override
- public void onItemSelected(final AdapterView> parent,
- final View view,
- final int position,
- final long id) {
- if (DEBUG) {
- Log.d(TAG, "onItemSelected() called with: "
- + "parent = [" + parent + "], view = [" + view + "], "
- + "position = [" + position + "], id = [" + id + "]");
- }
-
- final int parentId = parent.getId();
- if (parentId == R.id.quality_spinner) {
- final int checkedRadioButtonId = dialogBinding.videoAudioGroup
- .getCheckedRadioButtonId();
- if (checkedRadioButtonId == R.id.video_button) {
- selectedVideoIndex = position;
- onVideoStreamSelected();
- } else if (checkedRadioButtonId == R.id.subtitle_button) {
- selectedSubtitleIndex = position;
- }
- onItemSelectedSetFileName();
- } else if (parentId == R.id.audio_track_spinner) {
- final boolean trackChanged = selectedAudioTrackIndex != position;
- selectedAudioTrackIndex = position;
- if (trackChanged) {
- updateSecondaryStreams();
- fetchStreamsSize();
- }
- } else if (parentId == R.id.audio_stream_spinner) {
- selectedAudioIndex = position;
- }
- }
-
- private void onItemSelectedSetFileName() {
- final String fileName = FilenameUtils.createFilename(getContext(), currentInfo.getName());
- final String prevFileName = Optional.ofNullable(dialogBinding.fileName.getText())
- .map(Object::toString)
- .orElse("");
-
- if (prevFileName.isEmpty()
- || prevFileName.equals(fileName)
- || prevFileName.startsWith(getString(R.string.caption_file_name, fileName, ""))) {
- // only update the file name field if it was not edited by the user
-
- final int radioButtonId = dialogBinding.videoAudioGroup
- .getCheckedRadioButtonId();
- if (radioButtonId == R.id.audio_button || radioButtonId == R.id.video_button) {
- if (!prevFileName.equals(fileName)) {
- // since the user might have switched between audio and video, the correct
- // text might already be in place, so avoid resetting the cursor position
- dialogBinding.fileName.setText(fileName);
- }
- } else if (radioButtonId == R.id.subtitle_button) {
- final String setSubtitleLanguageCode = subtitleStreamsAdapter
- .getItem(selectedSubtitleIndex).getLanguageTag();
- // this will reset the cursor position, which is bad UX, but it can't be avoided
- dialogBinding.fileName.setText(getString(
- R.string.caption_file_name, fileName, setSubtitleLanguageCode));
- }
- }
- }
-
- @Override
- public void onNothingSelected(final AdapterView> parent) {
- }
-
-
- /*//////////////////////////////////////////////////////////////////////////
- // Download
- //////////////////////////////////////////////////////////////////////////*/
-
- protected void setupDownloadOptions() {
- setRadioButtonsState(false);
- setupAudioTrackSpinner();
-
- final boolean isVideoStreamsAvailable = videoStreamsAdapter.getCount() > 0;
- final boolean isAudioStreamsAvailable = audioStreamsAdapter.getCount() > 0;
- final boolean isSubtitleStreamsAvailable = subtitleStreamsAdapter.getCount() > 0;
-
- dialogBinding.audioButton.setVisibility(isAudioStreamsAvailable ? View.VISIBLE
- : View.GONE);
- dialogBinding.videoButton.setVisibility(isVideoStreamsAvailable ? View.VISIBLE
- : View.GONE);
- dialogBinding.subtitleButton.setVisibility(isSubtitleStreamsAvailable
- ? View.VISIBLE : View.GONE);
-
- prefs = PreferenceManager.getDefaultSharedPreferences(requireContext());
- final String defaultMedia = prefs.getString(getString(R.string.last_used_download_type),
- getString(R.string.last_download_type_video_key));
-
- if (isVideoStreamsAvailable
- && (defaultMedia.equals(getString(R.string.last_download_type_video_key)))) {
- dialogBinding.videoButton.setChecked(true);
- setupVideoSpinner();
- } else if (isAudioStreamsAvailable
- && (defaultMedia.equals(getString(R.string.last_download_type_audio_key)))) {
- dialogBinding.audioButton.setChecked(true);
- setupAudioSpinner();
- } else if (isSubtitleStreamsAvailable
- && (defaultMedia.equals(getString(R.string.last_download_type_subtitle_key)))) {
- dialogBinding.subtitleButton.setChecked(true);
- setupSubtitleSpinner();
- } else if (isVideoStreamsAvailable) {
- dialogBinding.videoButton.setChecked(true);
- setupVideoSpinner();
- } else if (isAudioStreamsAvailable) {
- dialogBinding.audioButton.setChecked(true);
- setupAudioSpinner();
- } else if (isSubtitleStreamsAvailable) {
- dialogBinding.subtitleButton.setChecked(true);
- setupSubtitleSpinner();
- } else {
- Toast.makeText(getContext(), R.string.no_streams_available_download,
- Toast.LENGTH_SHORT).show();
- dismiss();
- }
- }
-
- private void setRadioButtonsState(final boolean enabled) {
- dialogBinding.audioButton.setEnabled(enabled);
- dialogBinding.videoButton.setEnabled(enabled);
- dialogBinding.subtitleButton.setEnabled(enabled);
- }
-
- private StreamInfoWrapper getWrappedAudioStreams() {
- if (selectedAudioTrackIndex < 0 || selectedAudioTrackIndex > wrappedAudioTracks.size()) {
- return StreamInfoWrapper.empty();
- }
- return wrappedAudioTracks.getTracksList().get(selectedAudioTrackIndex);
- }
-
- private int getSubtitleIndexBy(@NonNull final List streams) {
- final Localization preferredLocalization = NewPipe.getPreferredLocalization();
-
- int candidate = 0;
- for (int i = 0; i < streams.size(); i++) {
- final Locale streamLocale = streams.get(i).getLocale();
-
- final boolean languageEquals = streamLocale.getLanguage() != null
- && preferredLocalization.getLanguageCode() != null
- && streamLocale.getLanguage()
- .equals(new Locale(preferredLocalization.getLanguageCode()).getLanguage());
- final boolean countryEquals = streamLocale.getCountry() != null
- && streamLocale.getCountry().equals(preferredLocalization.getCountryCode());
-
- if (languageEquals) {
- if (countryEquals) {
- return i;
- }
-
- candidate = i;
- }
- }
-
- return candidate;
- }
-
- @NonNull
- private String getNameEditText() {
- final String str = Objects.requireNonNull(dialogBinding.fileName.getText()).toString()
- .trim();
-
- return FilenameUtils.createFilename(context, str.isEmpty() ? currentInfo.getName() : str);
- }
-
- private void showFailedDialog(@StringRes final int msg) {
- new AlertDialog.Builder(context)
- .setTitle(R.string.general_error)
- .setMessage(msg)
- .setNegativeButton(getString(R.string.ok), null)
- .show();
- }
-
- private void launchDirectoryPicker(final ActivityResultLauncher launcher) {
- NoFileManagerSafeGuard.launchSafe(launcher, StoredDirectoryHelper.getPicker(context), TAG,
- context);
- }
-
- private void prepareSelectedDownload() {
- final StoredDirectoryHelper mainStorage;
- final MediaFormat format;
- final String selectedMediaType;
- final long size;
-
- // first, build the filename and get the output folder (if possible)
- // later, run a very very very large file checking logic
-
- filenameTmp = getNameEditText().concat(".");
-
- final int checkedRadioButtonId = dialogBinding.videoAudioGroup.getCheckedRadioButtonId();
- if (checkedRadioButtonId == R.id.audio_button) {
- selectedMediaType = getString(R.string.last_download_type_audio_key);
- mainStorage = mainStorageAudio;
- format = audioStreamsAdapter.getItem(selectedAudioIndex).getFormat();
- size = getWrappedAudioStreams().getSizeInBytes(selectedAudioIndex);
- if (format == MediaFormat.WEBMA_OPUS) {
- mimeTmp = "audio/ogg";
- filenameTmp += "opus";
- } else if (format != null) {
- mimeTmp = format.mimeType;
- filenameTmp += format.getSuffix();
- }
- } else if (checkedRadioButtonId == R.id.video_button) {
- selectedMediaType = getString(R.string.last_download_type_video_key);
- mainStorage = mainStorageVideo;
- format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat();
- size = wrappedVideoStreams.getSizeInBytes(selectedVideoIndex);
- if (format != null) {
- mimeTmp = format.mimeType;
- filenameTmp += format.getSuffix();
- }
- } else if (checkedRadioButtonId == R.id.subtitle_button) {
- selectedMediaType = getString(R.string.last_download_type_subtitle_key);
- mainStorage = mainStorageVideo; // subtitle & video files go together
- format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat();
- size = wrappedSubtitleStreams.getSizeInBytes(selectedSubtitleIndex);
- if (format != null) {
- mimeTmp = format.mimeType;
- }
-
- if (format == MediaFormat.TTML) {
- filenameTmp += MediaFormat.SRT.getSuffix();
- } else if (format != null) {
- filenameTmp += format.getSuffix();
- }
- } else {
- throw new RuntimeException("No stream selected");
- }
-
- if (!askForSavePath && (mainStorage == null
- || mainStorage.isDirect() == NewPipeSettings.useStorageAccessFramework(context)
- || mainStorage.isInvalidSafStorage())) {
- // Pick new download folder if one of:
- // - Download folder is not set
- // - Download folder uses SAF while SAF is disabled
- // - Download folder doesn't use SAF while SAF is enabled
- // - Download folder uses SAF but the user manually revoked access to it
- Toast.makeText(context, getString(R.string.no_dir_yet),
- Toast.LENGTH_LONG).show();
-
- if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) {
- launchDirectoryPicker(requestDownloadPickAudioFolderLauncher);
- } else {
- launchDirectoryPicker(requestDownloadPickVideoFolderLauncher);
- }
-
- return;
- }
-
- if (askForSavePath) {
- final Uri initialPath;
- if (NewPipeSettings.useStorageAccessFramework(context)) {
- initialPath = null;
- } else {
- final File initialSavePath;
- if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) {
- initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MUSIC);
- } else {
- initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MOVIES);
- }
- initialPath = Uri.parse(initialSavePath.getAbsolutePath());
- }
-
- NoFileManagerSafeGuard.launchSafe(requestDownloadSaveAsLauncher,
- StoredFileHelper.getNewPicker(context, filenameTmp, mimeTmp, initialPath), TAG,
- context);
-
- return;
- }
-
- // Check for free storage space
- final long freeSpace = mainStorage.getFreeStorageSpace();
- if (freeSpace <= size) {
- Toast.makeText(context, getString(R.
- string.error_insufficient_storage), Toast.LENGTH_LONG).show();
- // move the user to storage setting tab
- final Intent storageSettingsIntent = new Intent(Settings.
- ACTION_INTERNAL_STORAGE_SETTINGS);
- if (storageSettingsIntent.resolveActivity(context.getPackageManager())
- != null) {
- startActivity(storageSettingsIntent);
- }
- return;
- }
-
- // check for existing file with the same name
- checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp,
- mimeTmp);
-
- // remember the last media type downloaded by the user
- prefs.edit().putString(getString(R.string.last_used_download_type), selectedMediaType)
- .apply();
- }
-
- private void checkSelectedDownload(final StoredDirectoryHelper mainStorage,
- final Uri targetFile,
- final String filename,
- final String mime) {
- StoredFileHelper storage;
-
- try {
- if (mainStorage == null) {
- // using SAF on older android version
- storage = new StoredFileHelper(context, null, targetFile, "");
- } else if (targetFile == null) {
- // the file does not exist, but it is probably used in a pending download
- storage = new StoredFileHelper(mainStorage.getUri(), filename, mime,
- mainStorage.getTag());
- } else {
- // the target filename is already use, attempt to use it
- storage = new StoredFileHelper(context, mainStorage.getUri(), targetFile,
- mainStorage.getTag());
- }
- } catch (final Exception e) {
- ErrorUtil.createNotification(requireContext(),
- new ErrorInfo(e, UserAction.DOWNLOAD_FAILED, "Getting storage"));
- return;
- }
-
- // get state of potential mission referring to the same file
- final MissionState state = downloadManager.checkForExistingMission(storage);
- @StringRes final int msgBtn;
- @StringRes final int msgBody;
-
- // this switch checks if there is already a mission referring to the same file
- switch (state) {
- case Finished: // there is already a finished mission
- msgBtn = R.string.overwrite;
- msgBody = R.string.overwrite_finished_warning;
- break;
- case Pending:
- msgBtn = R.string.overwrite;
- msgBody = R.string.download_already_pending;
- break;
- case PendingRunning:
- msgBtn = R.string.generate_unique_name;
- msgBody = R.string.download_already_running;
- break;
- case None: // there is no mission referring to the same file
- if (mainStorage == null) {
- // This part is called if:
- // * using SAF on older android version
- // * save path not defined
- // * if the file exists overwrite it, is not necessary ask
- if (!storage.existsAsFile() && !storage.create()) {
- showFailedDialog(R.string.error_file_creation);
- return;
- }
- continueSelectedDownload(storage);
- return;
- } else if (targetFile == null) {
- // This part is called if:
- // * the filename is not used in a pending/finished download
- // * the file does not exists, create
-
- if (!mainStorage.mkdirs()) {
- showFailedDialog(R.string.error_path_creation);
- return;
- }
-
- storage = mainStorage.createFile(filename, mime);
- if (storage == null || !storage.canWrite()) {
- showFailedDialog(R.string.error_file_creation);
- return;
- }
-
- continueSelectedDownload(storage);
- return;
- }
- msgBtn = R.string.overwrite;
- msgBody = R.string.overwrite_unrelated_warning;
- break;
- default:
- return; // unreachable
- }
-
- final AlertDialog.Builder askDialog = new AlertDialog.Builder(context)
- .setTitle(R.string.download_dialog_title)
- .setMessage(msgBody)
- .setNegativeButton(R.string.cancel, null);
- final StoredFileHelper finalStorage = storage;
-
-
- if (mainStorage == null) {
- // This part is called if:
- // * using SAF on older android version
- // * save path not defined
- switch (state) {
- case Pending:
- case Finished:
- askDialog.setPositiveButton(msgBtn, (dialog, which) -> {
- dialog.dismiss();
- downloadManager.forgetMission(finalStorage);
- continueSelectedDownload(finalStorage);
- });
- break;
- }
-
- askDialog.show();
- return;
- }
-
- askDialog.setPositiveButton(msgBtn, (dialog, which) -> {
- dialog.dismiss();
-
- StoredFileHelper storageNew;
- switch (state) {
- case Finished:
- case Pending:
- downloadManager.forgetMission(finalStorage);
- case None:
- if (targetFile == null) {
- storageNew = mainStorage.createFile(filename, mime);
- } else {
- try {
- // try take (or steal) the file
- storageNew = new StoredFileHelper(context, mainStorage.getUri(),
- targetFile, mainStorage.getTag());
- } catch (final IOException e) {
- Log.e(TAG, "Failed to take (or steal) the file in "
- + targetFile.toString());
- storageNew = null;
- }
- }
-
- if (storageNew != null && storageNew.canWrite()) {
- continueSelectedDownload(storageNew);
- } else {
- showFailedDialog(R.string.error_file_creation);
- }
- break;
- case PendingRunning:
- storageNew = mainStorage.createUniqueFile(filename, mime);
- if (storageNew == null) {
- showFailedDialog(R.string.error_file_creation);
- } else {
- continueSelectedDownload(storageNew);
- }
- break;
- }
- });
-
- askDialog.show();
- }
-
- private void continueSelectedDownload(@NonNull final StoredFileHelper storage) {
- if (!storage.canWrite()) {
- showFailedDialog(R.string.permission_denied);
- return;
- }
-
- // check if the selected file has to be overwritten, by simply checking its length
- try {
- if (storage.length() > 0) {
- storage.truncate();
- }
- } catch (final IOException e) {
- Log.e(TAG, "Failed to truncate the file: " + storage.getUri().toString(), e);
- showFailedDialog(R.string.overwrite_failed);
- return;
- }
-
- final Stream selectedStream;
- Stream secondaryStream = null;
- final char kind;
- int threads = dialogBinding.threads.getProgress() + 1;
- final String[] urls;
- final List recoveryInfo;
- String psName = null;
- String[] psArgs = null;
- long nearLength = 0;
-
- // more download logic: select muxer, subtitle converter, etc.
- final int checkedRadioButtonId = dialogBinding.videoAudioGroup.getCheckedRadioButtonId();
- if (checkedRadioButtonId == R.id.audio_button) {
- kind = 'a';
- selectedStream = audioStreamsAdapter.getItem(selectedAudioIndex);
-
- if (selectedStream.getFormat() == MediaFormat.M4A) {
- psName = Postprocessing.ALGORITHM_M4A_NO_DASH;
- } else if (selectedStream.getFormat() == MediaFormat.WEBMA_OPUS) {
- psName = Postprocessing.ALGORITHM_OGG_FROM_WEBM_DEMUXER;
- }
- } else if (checkedRadioButtonId == R.id.video_button) {
- kind = 'v';
- selectedStream = videoStreamsAdapter.getItem(selectedVideoIndex);
-
- final SecondaryStreamHelper secondary = videoStreamsAdapter
- .getAllSecondary()
- .get(wrappedVideoStreams.getStreamsList().indexOf(selectedStream));
-
- if (secondary != null) {
- secondaryStream = secondary.getStream();
-
- if (selectedStream.getFormat() == MediaFormat.MPEG_4) {
- psName = Postprocessing.ALGORITHM_MP4_FROM_DASH_MUXER;
- } else {
- psName = Postprocessing.ALGORITHM_WEBM_MUXER;
- }
-
- final long videoSize = wrappedVideoStreams.getSizeInBytes(
- (VideoStream) selectedStream);
-
- // set nearLength, only, if both sizes are fetched or known. This probably
- // does not work on slow networks but is later updated in the downloader
- if (secondary.getSizeInBytes() > 0 && videoSize > 0) {
- nearLength = secondary.getSizeInBytes() + videoSize;
- }
- }
- } else if (checkedRadioButtonId == R.id.subtitle_button) {
- threads = 1; // use unique thread for subtitles due small file size
- kind = 's';
- selectedStream = subtitleStreamsAdapter.getItem(selectedSubtitleIndex);
-
- if (selectedStream.getFormat() == MediaFormat.TTML) {
- psName = Postprocessing.ALGORITHM_TTML_CONVERTER;
- psArgs = new String[]{
- selectedStream.getFormat().getSuffix(),
- "false" // ignore empty frames
- };
- }
- } else {
- return;
- }
-
- if (secondaryStream == null) {
- urls = new String[] {
- selectedStream.getContent()
- };
- recoveryInfo = List.of(new MissionRecoveryInfo(selectedStream));
- } else {
- if (secondaryStream.getDeliveryMethod() != PROGRESSIVE_HTTP) {
- throw new IllegalArgumentException("Unsupported stream delivery format"
- + secondaryStream.getDeliveryMethod());
- }
-
- urls = new String[] {
- selectedStream.getContent(), secondaryStream.getContent()
- };
- recoveryInfo = List.of(
- new MissionRecoveryInfo(selectedStream),
- new MissionRecoveryInfo(secondaryStream)
- );
- }
-
- DownloadManagerService.startMission(context, urls, storage, kind, threads,
- currentInfo, psName, psArgs, nearLength, new ArrayList<>(recoveryInfo));
-
- Toast.makeText(context, getString(R.string.download_has_started),
- Toast.LENGTH_SHORT).show();
-
- dismiss();
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/download/LoadingDialog.java b/app/src/main/java/org/schabi/newpipe/download/LoadingDialog.java
deleted file mode 100644
index 9e6861908..000000000
--- a/app/src/main/java/org/schabi/newpipe/download/LoadingDialog.java
+++ /dev/null
@@ -1,87 +0,0 @@
-package org.schabi.newpipe.download;
-
-import android.os.Bundle;
-import android.util.Log;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.StringRes;
-import androidx.appcompat.widget.Toolbar;
-import androidx.fragment.app.DialogFragment;
-
-import org.schabi.newpipe.MainActivity;
-import org.schabi.newpipe.R;
-import org.schabi.newpipe.databinding.DownloadLoadingDialogBinding;
-
-/**
- * This class contains a dialog which shows a loading indicator and has a customizable title.
- */
-public class LoadingDialog extends DialogFragment {
- private static final String TAG = "LoadingDialog";
- private static final boolean DEBUG = MainActivity.DEBUG;
- private DownloadLoadingDialogBinding dialogLoadingBinding;
- private final @StringRes int title;
-
- /**
- * Create a new LoadingDialog.
- *
- *
- * The dialog contains a loading indicator and has a customizable title.
- *
- * Use {@code show()} to display the dialog to the user.
- *
- *
- * @param title an informative title shown in the dialog's toolbar
- */
- public LoadingDialog(final @StringRes int title) {
- this.title = title;
- }
-
- @Override
- public void onCreate(@Nullable final Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- if (DEBUG) {
- Log.d(TAG, "onCreate() called with: "
- + "savedInstanceState = [" + savedInstanceState + "]");
- }
- this.setCancelable(false);
- }
-
- @Override
- public View onCreateView(
- @NonNull final LayoutInflater inflater,
- final ViewGroup container,
- final Bundle savedInstanceState) {
- if (DEBUG) {
- Log.d(TAG, "onCreateView() called with: "
- + "inflater = [" + inflater + "], container = [" + container + "], "
- + "savedInstanceState = [" + savedInstanceState + "]");
- }
- return inflater.inflate(R.layout.download_loading_dialog, container);
- }
-
- @Override
- public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) {
- super.onViewCreated(view, savedInstanceState);
- dialogLoadingBinding = DownloadLoadingDialogBinding.bind(view);
- initToolbar(dialogLoadingBinding.toolbarLayout.toolbar);
- }
-
- private void initToolbar(final Toolbar toolbar) {
- if (DEBUG) {
- Log.d(TAG, "initToolbar() called with: toolbar = [" + toolbar + "]");
- }
- toolbar.setTitle(requireContext().getString(title));
- toolbar.setNavigationOnClickListener(v -> dismiss());
-
- }
-
- @Override
- public void onDestroyView() {
- dialogLoadingBinding = null;
- super.onDestroyView();
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java b/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java
deleted file mode 100644
index 90d8f4797..000000000
--- a/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java
+++ /dev/null
@@ -1,43 +0,0 @@
-package org.schabi.newpipe.error;
-
-import android.content.Context;
-
-import androidx.annotation.NonNull;
-
-import org.acra.ReportField;
-import org.acra.data.CrashReportData;
-import org.acra.sender.ReportSender;
-import org.schabi.newpipe.R;
-
-/*
- * Created by Christian Schabesberger on 13.09.16.
- *
- * Copyright (C) Christian Schabesberger 2015
- * AcraReportSender.java is part of NewPipe.
- *
- * NewPipe is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * NewPipe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with NewPipe. If not, see .
- */
-
-public class AcraReportSender implements ReportSender {
-
- @Override
- public void send(@NonNull final Context context, @NonNull final CrashReportData report) {
- ErrorUtil.openActivity(context, new ErrorInfo(
- new String[]{report.getString(ReportField.STACK_TRACE)},
- UserAction.UI_ERROR,
- "ACRA report",
- null,
- R.string.app_ui_crash));
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/error/AcraReportSenderFactory.java b/app/src/main/java/org/schabi/newpipe/error/AcraReportSenderFactory.java
deleted file mode 100644
index e63d55063..000000000
--- a/app/src/main/java/org/schabi/newpipe/error/AcraReportSenderFactory.java
+++ /dev/null
@@ -1,44 +0,0 @@
-package org.schabi.newpipe.error;
-
-import android.content.Context;
-
-import androidx.annotation.NonNull;
-
-import com.google.auto.service.AutoService;
-
-import org.acra.config.CoreConfiguration;
-import org.acra.sender.ReportSender;
-import org.acra.sender.ReportSenderFactory;
-import org.schabi.newpipe.App;
-
-/*
- * Created by Christian Schabesberger on 13.09.16.
- *
- * Copyright (C) Christian Schabesberger 2015
- * AcraReportSenderFactory.java is part of NewPipe.
- *
- * NewPipe is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * NewPipe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with NewPipe. If not, see .
- */
-
-/**
- * Used by ACRA in {@link App}.initAcra() as the factory for report senders.
- */
-@AutoService(ReportSenderFactory.class)
-public class AcraReportSenderFactory implements ReportSenderFactory {
- @NonNull
- public ReportSender create(@NonNull final Context context,
- @NonNull final CoreConfiguration config) {
- return new AcraReportSender();
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.kt
deleted file mode 100644
index c68a2cfd1..000000000
--- a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.kt
+++ /dev/null
@@ -1,282 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2015-2026 NewPipe contributors
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.error
-
-import android.content.Context
-import android.content.Intent
-import android.os.Build
-import android.os.Bundle
-import android.util.Log
-import android.view.Menu
-import android.view.MenuItem
-import androidx.appcompat.app.AlertDialog
-import androidx.appcompat.app.AppCompatActivity
-import androidx.core.content.IntentCompat
-import androidx.core.net.toUri
-import com.grack.nanojson.JsonWriter
-import java.time.ZonedDateTime
-import java.time.format.DateTimeFormatter
-import org.schabi.newpipe.BuildConfig
-import org.schabi.newpipe.R
-import org.schabi.newpipe.databinding.ActivityErrorBinding
-import org.schabi.newpipe.util.Localization
-import org.schabi.newpipe.util.ThemeHelper
-import org.schabi.newpipe.util.external_communication.ShareUtils
-import org.schabi.newpipe.util.text.setTextWithLinks
-
-/**
- * This activity is used to show error details and allow reporting them in various ways.
- * Use [ErrorUtil.openActivity] to correctly open this activity.
- */
-class ErrorActivity : AppCompatActivity() {
- private lateinit var errorInfo: ErrorInfo
- private lateinit var currentTimeStamp: String
-
- private lateinit var binding: ActivityErrorBinding
-
- private val contentCountryString: String
- get() = Localization.getPreferredContentCountry(this).countryCode
-
- private val contentLanguageString: String
- get() = Localization.getPreferredLocalization(this).localizationCode
-
- private val appLanguage: String
- get() = Localization.getAppLocale().toString()
-
- private val osString: String
- get() {
- val name = System.getProperty("os.name")!!
- val osBase = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- Build.VERSION.BASE_OS.ifEmpty { "Android" }
- } else {
- "Android"
- }
- return "$name $osBase ${Build.VERSION.RELEASE} - ${Build.VERSION.SDK_INT}"
- }
-
- private val errorEmailSubject: String
- get() = "$ERROR_EMAIL_SUBJECT ${getString(R.string.app_name)} ${BuildConfig.VERSION_NAME}"
-
- // /////////////////////////////////////////////////////////////////////
- // Activity lifecycle
- // /////////////////////////////////////////////////////////////////////
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
-
- ThemeHelper.setDayNightMode(this)
- ThemeHelper.setTheme(this)
-
- binding = ActivityErrorBinding.inflate(layoutInflater)
- setContentView(binding.getRoot())
-
- setSupportActionBar(binding.toolbarLayout.toolbar)
- supportActionBar?.apply {
- setDisplayHomeAsUpEnabled(true)
- setTitle(R.string.error_report_title)
- setDisplayShowTitleEnabled(true)
- }
-
- errorInfo = IntentCompat.getParcelableExtra(intent, ERROR_INFO, ErrorInfo::class.java)!!
-
- // important add guru meditation
- addGuruMeditation()
- // print current time, as zoned ISO8601 timestamp
- currentTimeStamp = ZonedDateTime.now().format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)
-
- binding.errorReportEmailButton.setOnClickListener { _ ->
- openPrivacyPolicyDialog(this, "EMAIL")
- }
-
- binding.errorReportCopyButton.setOnClickListener { _ ->
- ShareUtils.copyToClipboard(this, buildMarkdown())
- }
-
- binding.errorReportGitHubButton.setOnClickListener { _ ->
- openPrivacyPolicyDialog(this, "GITHUB")
- }
-
- // normal bugreport
- buildInfo(errorInfo)
- binding.errorMessageView.setTextWithLinks(errorInfo.getMessage(this))
- binding.errorView.text = formErrorText(errorInfo.stackTraces)
-
- // print stack trace once again for debugging:
- errorInfo.stackTraces.forEach { Log.e(TAG, it) }
- }
-
- override fun onCreateOptionsMenu(menu: Menu): Boolean {
- menuInflater.inflate(R.menu.error_menu, menu)
- return true
- }
-
- override fun onOptionsItemSelected(item: MenuItem): Boolean {
- return when (item.itemId) {
- android.R.id.home -> {
- onBackPressed()
- true
- }
-
- R.id.menu_item_share_error -> {
- ShareUtils.shareText(
- applicationContext,
- getString(R.string.error_report_title),
- buildJson()
- )
- true
- }
-
- else -> false
- }
- }
-
- private fun openPrivacyPolicyDialog(context: Context, action: String) {
- AlertDialog.Builder(context)
- .setIcon(android.R.drawable.ic_dialog_alert)
- .setTitle(R.string.privacy_policy_title)
- .setMessage(R.string.start_accept_privacy_policy)
- .setCancelable(false)
- .setNeutralButton(R.string.read_privacy_policy) { _, _ ->
- ShareUtils.openUrlInApp(context, context.getString(R.string.privacy_policy_url))
- }
- .setPositiveButton(R.string.accept) { _, _ ->
- if (action == "EMAIL") { // send on email
- val intent = Intent(Intent.ACTION_SENDTO)
- .setData("mailto:".toUri()) // only email apps should handle this
- .putExtra(Intent.EXTRA_EMAIL, arrayOf(ERROR_EMAIL_ADDRESS))
- .putExtra(Intent.EXTRA_SUBJECT, errorEmailSubject)
- .putExtra(Intent.EXTRA_TEXT, buildJson())
- ShareUtils.openIntentInApp(context, intent)
- } else if (action == "GITHUB") { // open the NewPipe issue page on GitHub
- ShareUtils.openUrlInApp(this, ERROR_GITHUB_ISSUE_URL)
- }
- }
- .setNegativeButton(R.string.decline, null)
- .show()
- }
-
- private fun formErrorText(stacktrace: Array): String {
- val separator = "-------------------------------------"
- return stacktrace.joinToString(separator + "\n", separator + "\n", separator)
- }
-
- private fun buildInfo(info: ErrorInfo) {
- binding.errorInfoLabelsView.text = getString(R.string.info_labels)
-
- val text = info.userAction.message + "\n" +
- info.request + "\n" +
- contentLanguageString + "\n" +
- contentCountryString + "\n" +
- appLanguage + "\n" +
- info.getServiceName() + "\n" +
- currentTimeStamp + "\n" +
- packageName + "\n" +
- BuildConfig.VERSION_NAME + "\n" +
- osString
-
- binding.errorInfosView.text = text
- }
-
- private fun buildJson(): String {
- try {
- return JsonWriter.string()
- .`object`()
- .value("user_action", errorInfo.userAction.message)
- .value("request", errorInfo.request)
- .value("content_language", contentLanguageString)
- .value("content_country", contentCountryString)
- .value("app_language", appLanguage)
- .value("service", errorInfo.getServiceName())
- .value("package", packageName)
- .value("version", BuildConfig.VERSION_NAME)
- .value("os", osString)
- .value("time", currentTimeStamp)
- .array("exceptions", errorInfo.stackTraces.toList())
- .value("user_comment", binding.errorCommentBox.getText().toString())
- .end()
- .done()
- } catch (exception: Exception) {
- Log.e(TAG, "Error while erroring: Could not build json", exception)
- }
-
- return ""
- }
-
- private fun buildMarkdown(): String {
- try {
- return buildString(1024) {
- val userComment = binding.errorCommentBox.text.toString()
- if (userComment.isNotEmpty()) {
- appendLine(userComment)
- }
-
- // basic error info
- appendLine("## Exception")
- appendLine("* __User Action:__ ${errorInfo.userAction.message}")
- appendLine("* __Request:__ ${errorInfo.request}")
- appendLine("* __Content Country:__ $contentCountryString")
- appendLine("* __Content Language:__ $contentLanguageString")
- appendLine("* __App Language:__ $appLanguage")
- appendLine("* __Service:__ ${errorInfo.getServiceName()}")
- appendLine("* __Timestamp:__ $currentTimeStamp")
- appendLine("* __Package:__ $packageName")
- appendLine("* __Service:__ ${errorInfo.getServiceName()}")
- appendLine("* __Version:__ ${BuildConfig.VERSION_NAME}")
- appendLine("* __OS:__ $osString")
-
- // Collapse all logs to a single paragraph when there are more than one
- // to keep the GitHub issue clean.
- if (errorInfo.stackTraces.size > 1) {
- append("Exceptions (")
- append(errorInfo.stackTraces.size)
- append(")
\n")
- }
-
- // add the logs
- errorInfo.stackTraces.forEachIndexed { index, stacktrace ->
- append("Crash log ")
- if (errorInfo.stackTraces.size > 1) {
- append(index + 1)
- }
- append("")
- append("
\n")
- append("\n```\n${stacktrace}\n```\n")
- append("
\n")
- }
-
- // make sure to close everything
- if (errorInfo.stackTraces.size > 1) {
- append("
\n")
- }
-
- append("
\n")
- }
- } catch (exception: Exception) {
- Log.e(TAG, "Error while erroring: Could not build markdown", exception)
- return ""
- }
- }
-
- private fun addGuruMeditation() {
- // just an easter egg
- var text = binding.errorSorryView.text.toString()
- text += "\n" + getString(R.string.guru_meditation)
- binding.errorSorryView.text = text
- }
-
- companion object {
- // LOG TAGS
- private val TAG = ErrorActivity::class.java.toString()
-
- // BUNDLE TAGS
- const val ERROR_INFO = "error_info"
-
- private const val ERROR_EMAIL_ADDRESS = "crashreport@newpipe.schabi.org"
- private const val ERROR_EMAIL_SUBJECT = "Exception in "
-
- private const val ERROR_GITHUB_ISSUE_URL = "https://github.com/TeamNewPipe/NewPipe/issues"
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt
deleted file mode 100644
index 82f7d84bf..000000000
--- a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt
+++ /dev/null
@@ -1,364 +0,0 @@
-package org.schabi.newpipe.error
-
-import android.content.Context
-import android.os.Parcelable
-import androidx.annotation.StringRes
-import androidx.core.content.ContextCompat
-import com.google.android.exoplayer2.ExoPlaybackException
-import com.google.android.exoplayer2.upstream.HttpDataSource
-import com.google.android.exoplayer2.upstream.Loader
-import java.net.UnknownHostException
-import kotlinx.parcelize.Parcelize
-import org.schabi.newpipe.R
-import org.schabi.newpipe.extractor.Info
-import org.schabi.newpipe.extractor.ServiceList
-import org.schabi.newpipe.extractor.ServiceList.YouTube
-import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
-import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException
-import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
-import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
-import org.schabi.newpipe.extractor.exceptions.ExtractionException
-import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException
-import org.schabi.newpipe.extractor.exceptions.PaidContentException
-import org.schabi.newpipe.extractor.exceptions.PrivateContentException
-import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
-import org.schabi.newpipe.extractor.exceptions.SignInConfirmNotBotException
-import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException
-import org.schabi.newpipe.extractor.exceptions.UnsupportedContentInCountryException
-import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException
-import org.schabi.newpipe.ktx.isNetworkRelated
-import org.schabi.newpipe.player.mediasource.FailedMediaSource
-import org.schabi.newpipe.player.resolver.PlaybackResolver
-import org.schabi.newpipe.util.text.getText
-
-/**
- * An error has occurred in the app. This class contains plain old parcelable data that can be used
- * to report the error and to show it to the user along with correct action buttons.
- */
-@Parcelize
-class ErrorInfo private constructor(
- val stackTraces: Array,
- val userAction: UserAction,
- val request: String,
- val serviceId: Int?,
- private val message: ErrorMessage,
- /**
- * If `true`, a report button will be shown for this error. Otherwise the error is not something
- * that can really be reported (e.g. a network issue, or content not being available at all).
- */
- val isReportable: Boolean,
- /**
- * If `true`, the process causing this error can be retried, otherwise not.
- */
- val isRetryable: Boolean,
- /**
- * If present, indicates that the exception was a ReCaptchaException, and this is the URL
- * provided by the service that can be used to solve the ReCaptcha challenge.
- */
- val recaptchaUrl: String?,
- /**
- * If present, this resource can alternatively be opened in browser (useful if NewPipe is
- * badly broken).
- */
- val openInBrowserUrl: String?
-) : Parcelable {
-
- @JvmOverloads
- constructor(
- throwable: Throwable,
- userAction: UserAction,
- request: String,
- serviceId: Int? = null,
- openInBrowserUrl: String? = null
- ) : this(
- throwableToStringList(throwable),
- userAction,
- request,
- serviceId,
- getMessage(throwable, userAction, serviceId),
- isReportable(throwable),
- isRetryable(throwable),
- (throwable as? ReCaptchaException)?.url,
- openInBrowserUrl
- )
-
- @JvmOverloads
- constructor(
- throwables: List,
- userAction: UserAction,
- request: String,
- serviceId: Int? = null,
- openInBrowserUrl: String? = null
- ) : this(
- throwableListToStringList(throwables),
- userAction,
- request,
- serviceId,
- getMessage(throwables.firstOrNull(), userAction, serviceId),
- throwables.any(::isReportable),
- throwables.isEmpty() || throwables.any(::isRetryable),
- throwables.firstNotNullOfOrNull { it as? ReCaptchaException }?.url,
- openInBrowserUrl
- )
-
- // constructor to manually build ErrorInfo when no throwable is available
- constructor(
- stackTraces: Array,
- userAction: UserAction,
- request: String,
- serviceId: Int?,
- @StringRes message: Int
- ) :
- this(
- stackTraces, userAction, request, serviceId, ErrorMessage(message),
- true, false, null, null
- )
-
- // constructor with only one throwable to extract service id and openInBrowserUrl from an Info
- constructor(
- throwable: Throwable,
- userAction: UserAction,
- request: String,
- info: Info?
- ) :
- this(throwable, userAction, request, info?.serviceId, info?.url)
-
- // constructor with multiple throwables to extract service id and openInBrowserUrl from an Info
- constructor(
- throwables: List,
- userAction: UserAction,
- request: String,
- info: Info?
- ) :
- this(throwables, userAction, request, info?.serviceId, info?.url)
-
- fun getServiceName(): String {
- return getServiceName(serviceId)
- }
-
- fun getMessage(context: Context): CharSequence {
- return message.getText(context)
- }
-
- companion object {
- @Parcelize
- class ErrorMessage(
- @StringRes
- private val stringRes: Int,
- private vararg val formatArgs: String
- ) : Parcelable {
- fun getText(context: Context): CharSequence {
- // Ensure locale aware context via ContextCompat.getContextForLanguage() (just in case context is not AppCompatActivity)
- val ctx = ContextCompat.getContextForLanguage(context)
- return if (formatArgs.isEmpty()) {
- ctx.getText(stringRes)
- } else {
- // ContextCompat.getString() with formatArgs does not exist, so we just
- // replicate its source code but with formatArgs
- ctx.resources.getText(stringRes, *formatArgs)
- }
- }
- }
-
- const val SERVICE_NONE = ""
-
- const val YOUTUBE_IP_BAN_FAQ_URL = "https://newpipe.net/FAQ/#ip-banned-youtube"
-
- private fun getServiceName(serviceId: Int?) = // not using getNameOfServiceById since we want to accept a nullable serviceId and we
- // want to default to SERVICE_NONE
- ServiceList.all().firstOrNull { it.serviceId == serviceId }?.serviceInfo?.name
- ?: SERVICE_NONE
-
- fun throwableToStringList(throwable: Throwable) = arrayOf(throwable.stackTraceToString())
-
- fun throwableListToStringList(throwableList: List) = throwableList.map { it.stackTraceToString() }.toTypedArray()
-
- fun getMessage(
- throwable: Throwable?,
- action: UserAction?,
- serviceId: Int?
- ): ErrorMessage {
- return when {
- // player exceptions
- // some may be IOException, so do these checks before isNetworkRelated!
- throwable is ExoPlaybackException -> {
- val cause = throwable.cause
- when {
- cause is HttpDataSource.InvalidResponseCodeException -> {
- if (cause.responseCode == 403) {
- if (serviceId == YouTube.serviceId) {
- ErrorMessage(R.string.youtube_player_http_403)
- } else {
- ErrorMessage(R.string.player_http_403)
- }
- } else {
- ErrorMessage(R.string.player_http_invalid_status, cause.responseCode.toString())
- }
- }
-
- cause is Loader.UnexpectedLoaderException && cause.cause is ExtractionException ->
- getMessage(throwable, action, serviceId)
-
- throwable.type == ExoPlaybackException.TYPE_SOURCE ->
- ErrorMessage(R.string.player_stream_failure)
-
- throwable.type == ExoPlaybackException.TYPE_UNEXPECTED ->
- ErrorMessage(R.string.player_recoverable_failure)
-
- else ->
- ErrorMessage(R.string.player_unrecoverable_failure)
- }
- }
-
- throwable is FailedMediaSource.FailedMediaSourceException ->
- getMessage(throwable.cause, action, serviceId)
-
- throwable is PlaybackResolver.ResolverException ->
- ErrorMessage(R.string.player_stream_failure)
-
- // content not available exceptions
- throwable is AccountTerminatedException ->
- throwable.message
- ?.takeIf { reason -> !reason.isEmpty() }
- ?.let { reason ->
- ErrorMessage(
- R.string.account_terminated_service_provides_reason,
- getServiceName(serviceId),
- reason
- )
- }
- ?: ErrorMessage(R.string.account_terminated)
-
- throwable is AgeRestrictedContentException ->
- ErrorMessage(R.string.restricted_video_no_stream)
-
- throwable is GeographicRestrictionException ->
- ErrorMessage(R.string.georestricted_content)
-
- throwable is PaidContentException ->
- ErrorMessage(R.string.paid_content)
-
- throwable is PrivateContentException ->
- ErrorMessage(R.string.private_content)
-
- throwable is SoundCloudGoPlusContentException ->
- ErrorMessage(R.string.soundcloud_go_plus_content)
-
- throwable is UnsupportedContentInCountryException ->
- ErrorMessage(R.string.unsupported_content_in_country)
-
- throwable is YoutubeMusicPremiumContentException ->
- ErrorMessage(R.string.youtube_music_premium_content)
-
- throwable is SignInConfirmNotBotException ->
- ErrorMessage(
- R.string.sign_in_confirm_not_bot_error,
- getServiceName(serviceId),
- YOUTUBE_IP_BAN_FAQ_URL
- )
-
- throwable is ContentNotAvailableException ->
- ErrorMessage(R.string.content_not_available)
-
- // other extractor exceptions
- throwable is ContentNotSupportedException ->
- ErrorMessage(R.string.content_not_supported)
-
- // ReCaptchas will be handled in a special way anyway
- throwable is ReCaptchaException ->
- ErrorMessage(R.string.recaptcha_request_toast)
-
- // test this at the end as many exceptions could be a subclass of IOException
- throwable != null && throwable.isNetworkRelated ->
- ErrorMessage(R.string.network_error)
-
- // an extraction exception unrelated to the network
- // is likely an issue with parsing the website
- throwable is ExtractionException ->
- ErrorMessage(R.string.parsing_error)
-
- // user actions (in case the exception is null or unrecognizable)
- action == UserAction.UI_ERROR ->
- ErrorMessage(R.string.app_ui_crash)
-
- action == UserAction.REQUESTED_COMMENTS ->
- ErrorMessage(R.string.error_unable_to_load_comments)
-
- action == UserAction.SUBSCRIPTION_CHANGE ->
- ErrorMessage(R.string.subscription_change_failed)
-
- action == UserAction.SUBSCRIPTION_UPDATE ->
- ErrorMessage(R.string.subscription_update_failed)
-
- action == UserAction.LOAD_IMAGE ->
- ErrorMessage(R.string.could_not_load_thumbnails)
-
- action == UserAction.DOWNLOAD_OPEN_DIALOG ->
- ErrorMessage(R.string.could_not_setup_download_menu)
-
- else ->
- ErrorMessage(R.string.error_snackbar_message)
- }
- }
-
- fun isReportable(throwable: Throwable?): Boolean {
- return when (throwable) {
- // we don't have an exception, so this is a manually built error, which likely
- // indicates that it's important and is thus reportable
- null -> true
-
- // if the service explicitly said that content is not available (e.g. age
- // restrictions, video deleted, etc.), there is no use in letting users report it
- is ContentNotAvailableException -> !isContentSurelyNotAvailable(throwable)
-
- // we know the content is not supported, no need to let the user report it
- is ContentNotSupportedException -> false
-
- // happens often when there is no internet connection; we don't use
- // `throwable.isNetworkRelated` since any `IOException` would make that function
- // return true, but not all `IOException`s are network related
- is UnknownHostException -> false
-
- // by default, this is an unexpected exception, which the user could report
- else -> true
- }
- }
-
- fun isRetryable(throwable: Throwable?): Boolean {
- return when (throwable) {
- // if we know the content is surely not available, retrying won't help
- is ContentNotAvailableException -> !isContentSurelyNotAvailable(throwable)
-
- // we know the content is not supported, retrying won't help
- is ContentNotSupportedException -> false
-
- // by default (including if throwable is null), enable retrying (though the retry
- // button will be shown only if a way to perform the retry is implemented)
- else -> true
- }
- }
-
- /**
- * Unfortunately sometimes [ContentNotAvailableException] may not indicate that the content
- * is blocked/deleted/paid, but may just indicate that we could not extract it. This is an
- * inconsistency in the exceptions thrown by the extractor, but until it is fixed, this
- * function will distinguish between the two types.
- * @return `true` if the content is not available because of a limitation imposed by the
- * service or the owner, `false` if the extractor could not extract info about it
- */
- fun isContentSurelyNotAvailable(e: ContentNotAvailableException): Boolean {
- return when (e) {
- is AccountTerminatedException,
- is AgeRestrictedContentException,
- is GeographicRestrictionException,
- is PaidContentException,
- is PrivateContentException,
- is SoundCloudGoPlusContentException,
- is UnsupportedContentInCountryException,
- is YoutubeMusicPremiumContentException -> true
-
- else -> false
- }
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt
deleted file mode 100644
index 8136c78d8..000000000
--- a/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt
+++ /dev/null
@@ -1,141 +0,0 @@
-package org.schabi.newpipe.error
-
-import android.content.Context
-import android.content.Intent
-import android.view.View
-import android.widget.Button
-import android.widget.TextView
-import androidx.annotation.StringRes
-import androidx.core.view.isVisible
-import androidx.fragment.app.Fragment
-import com.jakewharton.rxbinding4.view.clicks
-import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
-import io.reactivex.rxjava3.disposables.Disposable
-import java.util.concurrent.TimeUnit
-import org.schabi.newpipe.MainActivity
-import org.schabi.newpipe.R
-import org.schabi.newpipe.ktx.animate
-import org.schabi.newpipe.util.external_communication.ShareUtils
-import org.schabi.newpipe.util.text.setTextWithLinks
-
-class ErrorPanelHelper(
- private val fragment: Fragment,
- rootView: View,
- onRetry: Runnable?
-) {
- private val context: Context = rootView.context!!
-
- private val errorPanelRoot: View = rootView.findViewById(R.id.error_panel)
-
- // the only element that is visible by default
- private val errorTextView: TextView =
- errorPanelRoot.findViewById(R.id.error_message_view)
- private val errorServiceInfoTextView: TextView =
- errorPanelRoot.findViewById(R.id.error_message_service_info_view)
- private val errorServiceExplanationTextView: TextView =
- errorPanelRoot.findViewById(R.id.error_message_service_explanation_view)
- private val errorActionButton: Button =
- errorPanelRoot.findViewById(R.id.error_action_button)
- private val errorRetryButton: Button =
- errorPanelRoot.findViewById(R.id.error_retry_button)
- private val errorOpenInBrowserButton: Button =
- errorPanelRoot.findViewById(R.id.error_open_in_browser)
-
- private var errorDisposable: Disposable? = null
- private var retryShouldBeShown: Boolean = (onRetry != null)
-
- init {
- if (onRetry != null) {
- errorDisposable = errorRetryButton.clicks()
- .debounce(300, TimeUnit.MILLISECONDS)
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe { onRetry.run() }
- }
- }
-
- private fun ensureDefaultVisibility() {
- errorTextView.isVisible = true
-
- errorServiceInfoTextView.isVisible = false
- errorServiceExplanationTextView.isVisible = false
- errorActionButton.isVisible = false
- errorRetryButton.isVisible = false
- errorOpenInBrowserButton.isVisible = false
- }
-
- fun showError(errorInfo: ErrorInfo) {
- ensureDefaultVisibility()
- errorTextView.setTextWithLinks(errorInfo.getMessage(context))
-
- if (errorInfo.recaptchaUrl != null) {
- showAndSetErrorButtonAction(R.string.recaptcha_solve) {
- // Starting ReCaptcha Challenge Activity
- val intent = Intent(context, ReCaptchaActivity::class.java)
- intent.putExtra(ReCaptchaActivity.RECAPTCHA_URL_EXTRA, errorInfo.recaptchaUrl)
- fragment.startActivityForResult(intent, ReCaptchaActivity.RECAPTCHA_REQUEST)
- errorActionButton.setOnClickListener(null)
- }
- } else if (errorInfo.isReportable) {
- showAndSetErrorButtonAction(R.string.error_snackbar_action) {
- ErrorUtil.openActivity(context, errorInfo)
- }
- }
-
- if (errorInfo.isRetryable) {
- errorRetryButton.isVisible = retryShouldBeShown
- }
-
- if (errorInfo.openInBrowserUrl != null) {
- errorOpenInBrowserButton.isVisible = true
- errorOpenInBrowserButton.setOnClickListener {
- ShareUtils.openUrlInBrowser(context, errorInfo.openInBrowserUrl)
- }
- }
-
- setRootVisible()
- }
-
- /**
- * Shows the errorButtonAction, sets a text into it and sets the click listener.
- */
- private fun showAndSetErrorButtonAction(
- @StringRes resid: Int,
- listener: View.OnClickListener
- ) {
- errorActionButton.isVisible = true
- errorActionButton.setText(resid)
- errorActionButton.setOnClickListener(listener)
- }
-
- fun showTextError(errorString: String) {
- ensureDefaultVisibility()
-
- errorTextView.setTextWithLinks(errorString)
-
- setRootVisible()
- }
-
- private fun setRootVisible() {
- errorPanelRoot.animate(true, 300)
- }
-
- fun hide() {
- errorActionButton.setOnClickListener(null)
- errorPanelRoot.animate(false, 150)
- }
-
- fun isVisible(): Boolean {
- return errorPanelRoot.isVisible
- }
-
- fun dispose() {
- errorActionButton.setOnClickListener(null)
- errorRetryButton.setOnClickListener(null)
- errorDisposable?.dispose()
- }
-
- companion object {
- val TAG: String = ErrorPanelHelper::class.simpleName!!
- val DEBUG: Boolean = MainActivity.DEBUG
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt
deleted file mode 100644
index 0fa302623..000000000
--- a/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt
+++ /dev/null
@@ -1,170 +0,0 @@
-package org.schabi.newpipe.error
-
-import android.app.Activity
-import android.app.PendingIntent
-import android.content.Context
-import android.content.Intent
-import android.graphics.Color
-import android.view.View
-import android.widget.Toast
-import androidx.core.app.NotificationCompat
-import androidx.core.app.NotificationManagerCompat
-import androidx.core.app.PendingIntentCompat
-import androidx.core.content.ContextCompat
-import androidx.fragment.app.Fragment
-import androidx.preference.PreferenceManager
-import com.google.android.material.snackbar.Snackbar
-import org.schabi.newpipe.MainActivity
-import org.schabi.newpipe.R
-
-/**
- * This class contains all of the methods that should be used to let the user know that an error has
- * occurred in the least intrusive way possible for each case. This class is for unexpected errors,
- * for handled errors (e.g. network errors) use e.g. [ErrorPanelHelper] instead.
- * - Use a snackbar if the exception is not critical and it happens in a place where a root view
- * is available.
- * - Use a notification if the exception happens inside a background service (player, subscription
- * import, ...) or there is no activity/fragment from which to extract a root view.
- * - Finally use the error activity only as a last resort in case the exception is critical and
- * happens in an open activity (since the workflow would be interrupted anyway in that case).
- */
-class ErrorUtil {
- companion object {
- private const val ERROR_REPORT_NOTIFICATION_ID = 5340681
-
- /**
- * Starts a new error activity allowing the user to report the provided error. Only use this
- * method directly as a last resort in case the exception is critical and happens in an open
- * activity (since the workflow would be interrupted anyway in that case). So never use this
- * for background services.
- *
- * If the crashed occurred while the app was in the background open a notification instead
- *
- * @param context the context to use to start the new activity
- * @param errorInfo the error info to be reported
- */
- @JvmStatic
- fun openActivity(context: Context, errorInfo: ErrorInfo) {
- if (PreferenceManager.getDefaultSharedPreferences(context)
- .getBoolean(MainActivity.KEY_IS_IN_BACKGROUND, true)
- ) {
- createNotification(context, errorInfo)
- } else {
- context.startActivity(getErrorActivityIntent(context, errorInfo))
- }
- }
-
- /**
- * Show a bottom snackbar to the user, with a report button that opens the error activity.
- * Use this method if the exception is not critical and it happens in a place where a root
- * view is available.
- *
- * @param context will be used to obtain the root view if it is an [Activity]; if no root
- * view can be found an error notification is shown instead
- * @param errorInfo the error info to be reported
- */
- @JvmStatic
- fun showSnackbar(context: Context, errorInfo: ErrorInfo) {
- val rootView = (context as? Activity)?.findViewById(android.R.id.content)
- showSnackbar(context, rootView, errorInfo)
- }
-
- /**
- * Show a bottom snackbar to the user, with a report button that opens the error activity.
- * Use this method if the exception is not critical and it happens in a place where a root
- * view is available.
- *
- * @param fragment will be used to obtain the root view if it has a connected [Activity]; if
- * no root view can be found an error notification is shown instead
- * @param errorInfo the error info to be reported
- */
- @JvmStatic
- fun showSnackbar(fragment: Fragment, errorInfo: ErrorInfo) {
- var rootView = fragment.view
- if (rootView == null && fragment.activity != null) {
- rootView = fragment.requireActivity().findViewById(android.R.id.content)
- }
- showSnackbar(fragment.requireContext(), rootView, errorInfo)
- }
-
- /**
- * Shortcut to calling [showSnackbar] with an [ErrorInfo] of type [UserAction.UI_ERROR]
- */
- @JvmStatic
- fun showUiErrorSnackbar(context: Context, request: String, throwable: Throwable) {
- showSnackbar(context, ErrorInfo(throwable, UserAction.UI_ERROR, request))
- }
-
- /**
- * Shortcut to calling [showSnackbar] with an [ErrorInfo] of type [UserAction.UI_ERROR]
- */
- @JvmStatic
- fun showUiErrorSnackbar(fragment: Fragment, request: String, throwable: Throwable) {
- showSnackbar(fragment, ErrorInfo(throwable, UserAction.UI_ERROR, request))
- }
-
- /**
- * Create an error notification. Tapping on the notification opens the error activity. Use
- * this method if the exception happens inside a background service (player, subscription
- * import, ...) or there is no activity/fragment from which to extract a root view.
- *
- * @param context the context to use to show the notification
- * @param errorInfo the error info to be reported; the error message
- * [ErrorInfo.messageStringId] will be shown in the notification
- * description
- */
- @JvmStatic
- fun createNotification(context: Context, errorInfo: ErrorInfo) {
- val notificationBuilder: NotificationCompat.Builder =
- NotificationCompat.Builder(
- context,
- context.getString(R.string.error_report_channel_id)
- )
- .setSmallIcon(R.drawable.ic_bug_report)
- .setContentTitle(context.getString(R.string.error_report_notification_title))
- .setContentText(errorInfo.getMessage(context))
- .setAutoCancel(true)
- .setContentIntent(
- PendingIntentCompat.getActivity(
- context,
- 0,
- getErrorActivityIntent(context, errorInfo),
- PendingIntent.FLAG_UPDATE_CURRENT,
- false
- )
- )
-
- val notificationManager = NotificationManagerCompat.from(context)
- if (notificationManager.areNotificationsEnabled()) {
- notificationManager
- .notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build())
- }
-
- ContextCompat.getMainExecutor(context).execute {
- // since the notification is silent, also show a toast, otherwise the user is confused
- Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT)
- .show()
- }
- }
-
- private fun getErrorActivityIntent(context: Context, errorInfo: ErrorInfo): Intent {
- val intent = Intent(context, ErrorActivity::class.java)
- intent.putExtra(ErrorActivity.ERROR_INFO, errorInfo)
- intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
- return intent
- }
-
- private fun showSnackbar(context: Context, rootView: View?, errorInfo: ErrorInfo) {
- if (rootView == null) {
- // fallback to showing a notification if no root view is available
- createNotification(context, errorInfo)
- } else {
- Snackbar.make(rootView, errorInfo.getMessage(context), Snackbar.LENGTH_LONG)
- .setActionTextColor(Color.YELLOW)
- .setAction(context.getString(R.string.error_snackbar_action).uppercase()) {
- context.startActivity(getErrorActivityIntent(context, errorInfo))
- }.show()
- }
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java b/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java
deleted file mode 100644
index 811671039..000000000
--- a/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java
+++ /dev/null
@@ -1,234 +0,0 @@
-package org.schabi.newpipe.error;
-
-import android.annotation.SuppressLint;
-import android.content.Intent;
-import android.content.SharedPreferences;
-import android.os.Bundle;
-import android.util.Log;
-import android.view.Menu;
-import android.view.MenuItem;
-import android.webkit.CookieManager;
-import android.webkit.WebResourceRequest;
-import android.webkit.WebSettings;
-import android.webkit.WebView;
-import android.webkit.WebViewClient;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.appcompat.app.ActionBar;
-import androidx.appcompat.app.AppCompatActivity;
-import androidx.core.app.NavUtils;
-import androidx.preference.PreferenceManager;
-
-import org.schabi.newpipe.DownloaderImpl;
-import org.schabi.newpipe.MainActivity;
-import org.schabi.newpipe.R;
-import org.schabi.newpipe.databinding.ActivityRecaptchaBinding;
-import org.schabi.newpipe.extractor.utils.Utils;
-import org.schabi.newpipe.util.ThemeHelper;
-
-/*
- * Created by beneth on 06.12.16.
- *
- * Copyright (C) Christian Schabesberger 2015
- * ReCaptchaActivity.java is part of NewPipe.
- *
- * NewPipe is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * NewPipe is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with NewPipe. If not, see .
- */
-public class ReCaptchaActivity extends AppCompatActivity {
- public static final int RECAPTCHA_REQUEST = 10;
- public static final String RECAPTCHA_URL_EXTRA = "recaptcha_url_extra";
- public static final String TAG = ReCaptchaActivity.class.toString();
- public static final String YT_URL = "https://www.youtube.com";
- public static final String RECAPTCHA_COOKIES_KEY = "recaptcha_cookies";
-
- public static String sanitizeRecaptchaUrl(@Nullable final String url) {
- if (url == null || url.trim().isEmpty()) {
- return YT_URL; // YouTube is the most likely service to have thrown a recaptcha
- } else {
- // remove "pbj=1" parameter from YouYube urls, as it makes the page JSON and not HTML
- return url.replace("&pbj=1", "").replace("pbj=1&", "").replace("?pbj=1", "");
- }
- }
-
- private ActivityRecaptchaBinding recaptchaBinding;
- private String foundCookies = "";
-
- @SuppressLint("SetJavaScriptEnabled")
- @Override
- protected void onCreate(final Bundle savedInstanceState) {
- ThemeHelper.setTheme(this);
- super.onCreate(savedInstanceState);
-
- recaptchaBinding = ActivityRecaptchaBinding.inflate(getLayoutInflater());
- setContentView(recaptchaBinding.getRoot());
- setSupportActionBar(recaptchaBinding.toolbar);
-
- final String url = sanitizeRecaptchaUrl(getIntent().getStringExtra(RECAPTCHA_URL_EXTRA));
- // set return to Cancel by default
- setResult(RESULT_CANCELED);
-
- // enable Javascript
- final WebSettings webSettings = recaptchaBinding.reCaptchaWebView.getSettings();
- webSettings.setJavaScriptEnabled(true);
- webSettings.setUserAgentString(DownloaderImpl.USER_AGENT);
-
- recaptchaBinding.reCaptchaWebView.setWebViewClient(new WebViewClient() {
- @Override
- public boolean shouldOverrideUrlLoading(final WebView view,
- final WebResourceRequest request) {
- if (MainActivity.DEBUG) {
- Log.d(TAG, "shouldOverrideUrlLoading: url=" + request.getUrl().toString());
- }
-
- handleCookiesFromUrl(request.getUrl().toString());
- return false;
- }
-
- @Override
- public void onPageFinished(final WebView view, final String url) {
- super.onPageFinished(view, url);
- handleCookiesFromUrl(url);
- }
- });
-
- // cleaning cache, history and cookies from webView
- recaptchaBinding.reCaptchaWebView.clearCache(true);
- recaptchaBinding.reCaptchaWebView.clearHistory();
- CookieManager.getInstance().removeAllCookies(null);
-
- recaptchaBinding.reCaptchaWebView.loadUrl(url);
- }
-
- @Override
- public boolean onCreateOptionsMenu(final Menu menu) {
- getMenuInflater().inflate(R.menu.menu_recaptcha, menu);
-
- final ActionBar actionBar = getSupportActionBar();
- if (actionBar != null) {
- actionBar.setDisplayHomeAsUpEnabled(false);
- actionBar.setTitle(R.string.title_activity_recaptcha);
- actionBar.setSubtitle(R.string.subtitle_activity_recaptcha);
- }
-
- return true;
- }
-
- @Override
- @SuppressLint("MissingSuperCall") // saveCookiesAndFinish method handles back navigation
- public void onBackPressed() {
- saveCookiesAndFinish();
- }
-
- @Override
- public boolean onOptionsItemSelected(final MenuItem item) {
- if (item.getItemId() == R.id.menu_item_done) {
- saveCookiesAndFinish();
- return true;
- }
- return false;
- }
-
- private void saveCookiesAndFinish() {
- // try to get cookies of unclosed page
- handleCookiesFromUrl(recaptchaBinding.reCaptchaWebView.getUrl());
- if (MainActivity.DEBUG) {
- Log.d(TAG, "saveCookiesAndFinish: foundCookies=" + foundCookies);
- }
-
- if (!foundCookies.isEmpty()) {
- // save cookies to preferences
- final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(
- getApplicationContext());
- final String key = getApplicationContext().getString(R.string.recaptcha_cookies_key);
- prefs.edit().putString(key, foundCookies).apply();
-
- // give cookies to Downloader class
- DownloaderImpl.getInstance().setCookie(RECAPTCHA_COOKIES_KEY, foundCookies);
- setResult(RESULT_OK);
- }
-
- // Navigate to blank page (unloads youtube to prevent background playback)
- recaptchaBinding.reCaptchaWebView.loadUrl("about:blank");
-
- final Intent intent = new Intent(this, org.schabi.newpipe.MainActivity.class);
- intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
- NavUtils.navigateUpTo(this, intent);
- }
-
-
- private void handleCookiesFromUrl(@Nullable final String url) {
- if (MainActivity.DEBUG) {
- Log.d(TAG, "handleCookiesFromUrl: url=" + (url == null ? "null" : url));
- }
-
- if (url == null) {
- return;
- }
-
- final String cookies = CookieManager.getInstance().getCookie(url);
- handleCookies(cookies);
-
- // sometimes cookies are inside the url
- final int abuseStart = url.indexOf("google_abuse=");
- if (abuseStart != -1) {
- final int abuseEnd = url.indexOf("+path");
-
- try {
- handleCookies(Utils.decodeUrlUtf8(url.substring(abuseStart + 13, abuseEnd)));
- } catch (final StringIndexOutOfBoundsException e) {
- if (MainActivity.DEBUG) {
- Log.e(TAG, "handleCookiesFromUrl: invalid google abuse starting at "
- + abuseStart + " and ending at " + abuseEnd + " for url " + url, e);
- }
- }
- }
- }
-
- private void handleCookies(@Nullable final String cookies) {
- if (MainActivity.DEBUG) {
- Log.d(TAG, "handleCookies: cookies=" + (cookies == null ? "null" : cookies));
- }
-
- if (cookies == null) {
- return;
- }
-
- addYoutubeCookies(cookies);
- // add here methods to extract cookies for other services
- }
-
- private void addYoutubeCookies(@NonNull final String cookies) {
- if (cookies.contains("s_gl=") || cookies.contains("goojf=")
- || cookies.contains("VISITOR_INFO1_LIVE=")
- || cookies.contains("GOOGLE_ABUSE_EXEMPTION=")) {
- // youtube seems to also need the other cookies:
- addCookie(cookies);
- }
- }
-
- private void addCookie(final String cookie) {
- if (foundCookies.contains(cookie)) {
- return;
- }
-
- if (foundCookies.isEmpty() || foundCookies.endsWith("; ")) {
- foundCookies += cookie;
- } else if (foundCookies.endsWith(";")) {
- foundCookies += " " + cookie;
- } else {
- foundCookies += "; " + cookie;
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/error/UserAction.kt b/app/src/main/java/org/schabi/newpipe/error/UserAction.kt
deleted file mode 100644
index b3f14e2da..000000000
--- a/app/src/main/java/org/schabi/newpipe/error/UserAction.kt
+++ /dev/null
@@ -1,44 +0,0 @@
-/*
- * SPDX-FileCopyrightText: 2017-2025 NewPipe contributors
- * SPDX-License-Identifier: GPL-3.0-or-later
- */
-
-package org.schabi.newpipe.error
-
-/**
- * The user actions that can cause an error.
- */
-enum class UserAction(val message: String) {
- USER_REPORT("user report"),
- UI_ERROR("ui error"),
- DATABASE_IMPORT_EXPORT("database import or export"),
- SUBSCRIPTION_CHANGE("subscription change"),
- SUBSCRIPTION_UPDATE("subscription update"),
- SUBSCRIPTION_GET("get subscription"),
- SUBSCRIPTION_IMPORT_EXPORT("subscription import or export"),
- LOAD_IMAGE("load image"),
- SOMETHING_ELSE("something else"),
- SEARCHED("searched"),
- GET_SUGGESTIONS("get suggestions"),
- REQUESTED_STREAM("requested stream"),
- REQUESTED_CHANNEL("requested channel"),
- REQUESTED_PLAYLIST("requested playlist"),
- REQUESTED_KIOSK("requested kiosk"),
- REQUESTED_COMMENTS("requested comments"),
- REQUESTED_COMMENT_REPLIES("requested comment replies"),
- REQUESTED_FEED("requested feed"),
- REQUESTED_BOOKMARK("bookmark"),
- DELETE_FROM_HISTORY("delete from history"),
- PLAY_STREAM("play stream"),
- DOWNLOAD_OPEN_DIALOG("download open dialog"),
- DOWNLOAD_POSTPROCESSING("download post-processing"),
- DOWNLOAD_FAILED("download failed"),
- NEW_STREAMS_NOTIFICATIONS("new streams notifications"),
- PREFERENCES_MIGRATION("migration of preferences"),
- SHARE_TO_NEWPIPE("share to newpipe"),
- CHECK_FOR_NEW_APP_VERSION("check for new app version"),
- OPEN_INFO_ITEM_DIALOG("open info item dialog"),
- GETTING_MAIN_SCREEN_TAB("getting main screen tab"),
- PLAY_ON_POPUP("play on popup"),
- SUBSCRIPTIONS("loading subscriptions")
-}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/BackPressable.java b/app/src/main/java/org/schabi/newpipe/fragments/BackPressable.java
deleted file mode 100644
index 6add5eb09..000000000
--- a/app/src/main/java/org/schabi/newpipe/fragments/BackPressable.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package org.schabi.newpipe.fragments;
-
-/**
- * Indicates that the current fragment can handle back presses.
- */
-public interface BackPressable {
- /**
- * A back press was delegated to this fragment.
- *
- * @return if the back press was handled
- */
- boolean onBackPressed();
-}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java
deleted file mode 100644
index 8361953b9..000000000
--- a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java
+++ /dev/null
@@ -1,227 +0,0 @@
-package org.schabi.newpipe.fragments;
-
-import static org.schabi.newpipe.ktx.ViewUtils.animate;
-
-import android.os.Bundle;
-import android.util.Log;
-import android.view.View;
-import android.widget.ProgressBar;
-import android.widget.TextView;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.StringRes;
-import androidx.fragment.app.Fragment;
-
-import com.evernote.android.state.State;
-
-import org.schabi.newpipe.BaseFragment;
-import org.schabi.newpipe.R;
-import org.schabi.newpipe.error.ErrorInfo;
-import org.schabi.newpipe.error.ErrorPanelHelper;
-import org.schabi.newpipe.error.ErrorUtil;
-import org.schabi.newpipe.util.InfoCache;
-
-import java.util.concurrent.atomic.AtomicBoolean;
-
-public abstract class BaseStateFragment extends BaseFragment implements ViewContract {
- @State
- protected AtomicBoolean wasLoading = new AtomicBoolean();
- protected AtomicBoolean isLoading = new AtomicBoolean();
-
- @Nullable
- protected View emptyStateView;
- @Nullable
- protected TextView emptyStateMessageView;
- @Nullable
- private ProgressBar loadingProgressBar;
-
- private ErrorPanelHelper errorPanelHelper;
- @Nullable
- @State
- protected ErrorInfo lastPanelError = null;
-
- @Override
- public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
- super.onViewCreated(rootView, savedInstanceState);
- doInitialLoadLogic();
- }
-
- @Override
- public void onPause() {
- super.onPause();
- wasLoading.set(isLoading.get());
- }
-
- @Override
- public void onResume() {
- super.onResume();
- if (lastPanelError != null) {
- showError(lastPanelError);
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Init
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- protected void initViews(final View rootView, final Bundle savedInstanceState) {
- super.initViews(rootView, savedInstanceState);
- emptyStateView = rootView.findViewById(R.id.empty_state_view);
- emptyStateMessageView = rootView.findViewById(R.id.empty_state_message);
- loadingProgressBar = rootView.findViewById(R.id.loading_progress_bar);
- errorPanelHelper = new ErrorPanelHelper(this, rootView, this::onRetryButtonClicked);
- }
-
- @Override
- public void onDestroyView() {
- super.onDestroyView();
- if (errorPanelHelper != null) {
- errorPanelHelper.dispose();
- }
- emptyStateView = null;
- emptyStateMessageView = null;
- }
-
- protected void onRetryButtonClicked() {
- reloadContent();
- }
-
- public void reloadContent() {
- startLoading(true);
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Load
- //////////////////////////////////////////////////////////////////////////*/
-
- protected void doInitialLoadLogic() {
- startLoading(true);
- }
-
- protected void startLoading(final boolean forceLoad) {
- if (DEBUG) {
- Log.d(TAG, "startLoading() called with: forceLoad = [" + forceLoad + "]");
- }
- showLoading();
- isLoading.set(true);
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Contract
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- public void showLoading() {
- if (emptyStateView != null) {
- animate(emptyStateView, false, 150);
- }
- if (loadingProgressBar != null) {
- animate(loadingProgressBar, true, 400);
- }
- hideErrorPanel();
- }
-
- @Override
- public void hideLoading() {
- if (emptyStateView != null) {
- animate(emptyStateView, false, 150);
- }
- if (loadingProgressBar != null) {
- animate(loadingProgressBar, false, 0);
- }
- hideErrorPanel();
- }
-
- @Override
- public void showEmptyState() {
- isLoading.set(false);
- if (emptyStateView != null) {
- animate(emptyStateView, true, 200);
- }
- if (loadingProgressBar != null) {
- animate(loadingProgressBar, false, 0);
- }
- hideErrorPanel();
- }
-
- @Override
- public void handleResult(final I result) {
- if (DEBUG) {
- Log.d(TAG, "handleResult() called with: result = [" + result + "]");
- }
- hideLoading();
- }
-
- @Override
- public void handleError() {
- isLoading.set(false);
- InfoCache.getInstance().clearCache();
- if (emptyStateView != null) {
- animate(emptyStateView, false, 150);
- }
- if (loadingProgressBar != null) {
- animate(loadingProgressBar, false, 0);
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Error handling
- //////////////////////////////////////////////////////////////////////////*/
-
- public final void showError(final ErrorInfo errorInfo) {
- handleError();
-
- if (isDetached() || isRemoving()) {
- if (DEBUG) {
- Log.w(TAG, "showError() is detached or removing = [" + errorInfo + "]");
- }
- return;
- }
-
- errorPanelHelper.showError(errorInfo);
- lastPanelError = errorInfo;
- }
-
- public final void showTextError(@NonNull final String errorString) {
- handleError();
-
- if (isDetached() || isRemoving()) {
- if (DEBUG) {
- Log.w(TAG, "showTextError() is detached or removing = [" + errorString + "]");
- }
- return;
- }
-
- errorPanelHelper.showTextError(errorString);
- }
-
- protected void setEmptyStateMessage(@StringRes final int text) {
- if (emptyStateMessageView != null) {
- emptyStateMessageView.setText(text);
- }
- }
-
- public final void hideErrorPanel() {
- errorPanelHelper.hide();
- lastPanelError = null;
- }
-
- public final boolean isErrorPanelVisible() {
- return errorPanelHelper.isVisible();
- }
-
- /**
- * Directly calls {@link ErrorUtil#showSnackbar(Fragment, ErrorInfo)}, that shows a snackbar if
- * a valid view can be found, otherwise creates an error report notification.
- *
- * @param errorInfo The error information
- */
- public void showSnackBarError(final ErrorInfo errorInfo) {
- if (DEBUG) {
- Log.d(TAG, "showSnackBarError() called with: errorInfo = [" + errorInfo + "]");
- }
- ErrorUtil.showSnackbar(this, errorInfo);
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/BlankFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/BlankFragment.java
deleted file mode 100644
index 66e132aff..000000000
--- a/app/src/main/java/org/schabi/newpipe/fragments/BlankFragment.java
+++ /dev/null
@@ -1,71 +0,0 @@
-package org.schabi.newpipe.fragments;
-
-import android.os.Bundle;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-
-import androidx.annotation.Nullable;
-
-import com.evernote.android.state.State;
-
-import org.schabi.newpipe.BaseFragment;
-import org.schabi.newpipe.R;
-import org.schabi.newpipe.error.ErrorInfo;
-import org.schabi.newpipe.error.ErrorPanelHelper;
-
-public class BlankFragment extends BaseFragment {
-
- @State
- @Nullable
- ErrorInfo errorInfo;
- @Nullable
- ErrorPanelHelper errorPanel = null;
-
- /**
- * Builds a blank fragment that just says the app name and suggests clicking on search.
- */
- public BlankFragment() {
- this(null);
- }
-
- /**
- * @param errorInfo if null acts like {@link BlankFragment}, else shows an error panel.
- */
- public BlankFragment(@Nullable final ErrorInfo errorInfo) {
- this.errorInfo = errorInfo;
- }
-
- @Nullable
- @Override
- public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container,
- final Bundle savedInstanceState) {
- setTitle("NewPipe");
- final View view = inflater.inflate(R.layout.fragment_blank, container, false);
- if (errorInfo != null) {
- errorPanel = new ErrorPanelHelper(this, view, null);
- errorPanel.showError(errorInfo);
- view.findViewById(R.id.blank_page_content).setVisibility(View.GONE);
- }
- return view;
- }
-
- @Override
- public void onDestroyView() {
- super.onDestroyView();
-
- if (errorPanel != null) {
- errorPanel.dispose();
- errorPanel = null;
- }
- }
-
- @Override
- public void onResume() {
- super.onResume();
- setTitle("NewPipe");
- // leave this inline. Will make it harder for copy cats.
- // If you are a Copy cat FUCK YOU.
- // I WILL FIND YOU, AND I WILL ...
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java
deleted file mode 100644
index d4e73bcac..000000000
--- a/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java
+++ /dev/null
@@ -1,33 +0,0 @@
-package org.schabi.newpipe.fragments;
-
-import android.os.Bundle;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-
-import androidx.annotation.Nullable;
-
-import org.schabi.newpipe.BaseFragment;
-import org.schabi.newpipe.R;
-
-public class EmptyFragment extends BaseFragment {
- private static final String SHOW_MESSAGE = "SHOW_MESSAGE";
-
- public static final EmptyFragment newInstance(final boolean showMessage) {
- final EmptyFragment emptyFragment = new EmptyFragment();
- final Bundle bundle = new Bundle(1);
- bundle.putBoolean(SHOW_MESSAGE, showMessage);
- emptyFragment.setArguments(bundle);
- return emptyFragment;
- }
-
- @Override
- public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container,
- final Bundle savedInstanceState) {
- final boolean showMessage = getArguments().getBoolean(SHOW_MESSAGE);
- final View view = inflater.inflate(R.layout.fragment_empty, container, false);
- view.findViewById(R.id.empty_state_view).setVisibility(
- showMessage ? View.VISIBLE : View.GONE);
- return view;
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java
deleted file mode 100644
index 1a5e5aa45..000000000
--- a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java
+++ /dev/null
@@ -1,343 +0,0 @@
-package org.schabi.newpipe.fragments;
-
-import static android.widget.RelativeLayout.ABOVE;
-import static android.widget.RelativeLayout.ALIGN_PARENT_BOTTOM;
-import static android.widget.RelativeLayout.ALIGN_PARENT_TOP;
-import static android.widget.RelativeLayout.BELOW;
-import static com.google.android.material.tabs.TabLayout.INDICATOR_GRAVITY_BOTTOM;
-import static com.google.android.material.tabs.TabLayout.INDICATOR_GRAVITY_TOP;
-
-import android.content.Context;
-import android.content.SharedPreferences;
-import android.content.res.ColorStateList;
-import android.graphics.Color;
-import android.os.Bundle;
-import android.util.Log;
-import android.view.LayoutInflater;
-import android.view.Menu;
-import android.view.MenuInflater;
-import android.view.MenuItem;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.RelativeLayout;
-
-import androidx.annotation.ColorInt;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.appcompat.app.ActionBar;
-import androidx.fragment.app.Fragment;
-import androidx.fragment.app.FragmentManager;
-import androidx.fragment.app.FragmentStatePagerAdapterMenuWorkaround;
-import androidx.preference.PreferenceManager;
-import androidx.viewpager.widget.ViewPager;
-
-import com.google.android.material.tabs.TabLayout;
-
-import org.schabi.newpipe.BaseFragment;
-import org.schabi.newpipe.R;
-import org.schabi.newpipe.databinding.FragmentMainBinding;
-import org.schabi.newpipe.error.ErrorInfo;
-import org.schabi.newpipe.error.ErrorUtil;
-import org.schabi.newpipe.error.UserAction;
-import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
-import org.schabi.newpipe.settings.tabs.Tab;
-import org.schabi.newpipe.settings.tabs.TabsManager;
-import org.schabi.newpipe.util.NavigationHelper;
-import org.schabi.newpipe.util.ServiceHelper;
-import org.schabi.newpipe.util.ThemeHelper;
-import org.schabi.newpipe.views.ScrollableTabLayout;
-
-import java.util.ArrayList;
-import java.util.List;
-
-public class MainFragment extends BaseFragment implements TabLayout.OnTabSelectedListener {
- private FragmentMainBinding binding;
- private SelectedTabsPagerAdapter pagerAdapter;
-
- private final List tabsList = new ArrayList<>();
- private TabsManager tabsManager;
-
- private boolean hasTabsChanged = false;
-
- private SharedPreferences prefs;
- private boolean youtubeRestrictedModeEnabled;
- private String youtubeRestrictedModeEnabledKey;
- private boolean mainTabsPositionBottom;
- private String mainTabsPositionKey;
-
- /*//////////////////////////////////////////////////////////////////////////
- // Fragment's LifeCycle
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- public void onCreate(final Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- setHasOptionsMenu(true);
- tabsManager = TabsManager.getManager(activity);
- tabsManager.setSavedTabsListener(() -> {
- if (DEBUG) {
- Log.d(TAG, "TabsManager.SavedTabsChangeListener: "
- + "onTabsChanged called, isResumed = " + isResumed());
- }
- if (isResumed()) {
- setupTabs();
- } else {
- hasTabsChanged = true;
- }
- });
-
- prefs = PreferenceManager.getDefaultSharedPreferences(requireContext());
- youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled);
- youtubeRestrictedModeEnabled = prefs.getBoolean(youtubeRestrictedModeEnabledKey, false);
- mainTabsPositionKey = getString(R.string.main_tabs_position_key);
- mainTabsPositionBottom = prefs.getBoolean(mainTabsPositionKey, false);
- }
-
- @Override
- public View onCreateView(@NonNull final LayoutInflater inflater,
- @Nullable final ViewGroup container,
- @Nullable final Bundle savedInstanceState) {
- return inflater.inflate(R.layout.fragment_main, container, false);
- }
-
- @Override
- protected void initViews(final View rootView, final Bundle savedInstanceState) {
- super.initViews(rootView, savedInstanceState);
-
- binding = FragmentMainBinding.bind(rootView);
-
- binding.mainTabLayout.setupWithViewPager(binding.pager);
- binding.mainTabLayout.addOnTabSelectedListener(this);
-
- setupTabs();
- updateTabLayoutPosition();
- }
-
- @Override
- public void onResume() {
- super.onResume();
-
- final boolean newYoutubeRestrictedModeEnabled =
- prefs.getBoolean(youtubeRestrictedModeEnabledKey, false);
- if (youtubeRestrictedModeEnabled != newYoutubeRestrictedModeEnabled || hasTabsChanged) {
- youtubeRestrictedModeEnabled = newYoutubeRestrictedModeEnabled;
- setupTabs();
- }
-
- final boolean newMainTabsPosition = prefs.getBoolean(mainTabsPositionKey, false);
- if (mainTabsPositionBottom != newMainTabsPosition) {
- mainTabsPositionBottom = newMainTabsPosition;
- updateTabLayoutPosition();
- }
- }
-
- @Override
- public void onDestroy() {
- super.onDestroy();
- tabsManager.unsetSavedTabsListener();
- if (binding != null) {
- binding.pager.setAdapter(null);
- binding = null;
- }
- }
-
- @Override
- public void onDestroyView() {
- super.onDestroyView();
- binding = null;
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Menu
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- public void onCreateOptionsMenu(@NonNull final Menu menu,
- @NonNull final MenuInflater inflater) {
- super.onCreateOptionsMenu(menu, inflater);
- if (DEBUG) {
- Log.d(TAG, "onCreateOptionsMenu() called with: "
- + "menu = [" + menu + "], inflater = [" + inflater + "]");
- }
- inflater.inflate(R.menu.menu_main_fragment, menu);
-
- final ActionBar supportActionBar = activity.getSupportActionBar();
- if (supportActionBar != null) {
- supportActionBar.setDisplayHomeAsUpEnabled(false);
- }
- }
-
- @Override
- public boolean onOptionsItemSelected(final MenuItem item) {
- if (item.getItemId() == R.id.action_search) {
- try {
- NavigationHelper.openSearchFragment(getFM(),
- ServiceHelper.getSelectedServiceId(activity), "");
- } catch (final Exception e) {
- ErrorUtil.showUiErrorSnackbar(this, "Opening search fragment", e);
- }
- return true;
- }
- return super.onOptionsItemSelected(item);
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Tabs
- //////////////////////////////////////////////////////////////////////////*/
-
- private void setupTabs() {
- tabsList.clear();
- tabsList.addAll(tabsManager.getTabs());
-
- if (pagerAdapter == null || !pagerAdapter.sameTabs(tabsList)) {
- pagerAdapter = new SelectedTabsPagerAdapter(requireContext(),
- getChildFragmentManager(), tabsList);
- }
-
- binding.pager.setAdapter(null);
- binding.pager.setAdapter(pagerAdapter);
-
- updateTabsIconAndDescription();
- updateTitleForTab(binding.pager.getCurrentItem());
-
- hasTabsChanged = false;
- }
-
- private void updateTabsIconAndDescription() {
- for (int i = 0; i < tabsList.size(); i++) {
- final TabLayout.Tab tabToSet = binding.mainTabLayout.getTabAt(i);
- if (tabToSet != null) {
- final Tab tab = tabsList.get(i);
- tabToSet.setIcon(tab.getTabIconRes(requireContext()));
- tabToSet.setContentDescription(tab.getTabName(requireContext()));
- }
- }
- }
-
- private void updateTitleForTab(final int tabPosition) {
- setTitle(tabsList.get(tabPosition).getTabName(requireContext()));
- }
-
- public void commitPlaylistTabs() {
- pagerAdapter.getLocalPlaylistFragments()
- .stream()
- .forEach(LocalPlaylistFragment::saveImmediate);
- }
-
- private void updateTabLayoutPosition() {
- final ScrollableTabLayout tabLayout = binding.mainTabLayout;
- final ViewPager viewPager = binding.pager;
- final boolean bottom = mainTabsPositionBottom;
-
- // change layout params to make the tab layout appear either at the top or at the bottom
- final var tabParams = (RelativeLayout.LayoutParams) tabLayout.getLayoutParams();
- final var pagerParams = (RelativeLayout.LayoutParams) viewPager.getLayoutParams();
-
- tabParams.removeRule(bottom ? ALIGN_PARENT_TOP : ALIGN_PARENT_BOTTOM);
- tabParams.addRule(bottom ? ALIGN_PARENT_BOTTOM : ALIGN_PARENT_TOP);
- pagerParams.removeRule(bottom ? BELOW : ABOVE);
- pagerParams.addRule(bottom ? ABOVE : BELOW, R.id.main_tab_layout);
- tabLayout.setSelectedTabIndicatorGravity(
- bottom ? INDICATOR_GRAVITY_TOP : INDICATOR_GRAVITY_BOTTOM);
-
- tabLayout.setLayoutParams(tabParams);
- viewPager.setLayoutParams(pagerParams);
-
- // change the background and icon color of the tab layout:
- // service-colored at the top, app-background-colored at the bottom
- tabLayout.setBackgroundColor(ThemeHelper.resolveColorFromAttr(requireContext(),
- bottom ? android.R.attr.windowBackground : R.attr.colorPrimary));
-
- @ColorInt final int iconColor = bottom
- ? ThemeHelper.resolveColorFromAttr(requireContext(), android.R.attr.colorAccent)
- : Color.WHITE;
- tabLayout.setTabRippleColor(ColorStateList.valueOf(iconColor).withAlpha(32));
- tabLayout.setTabIconTint(ColorStateList.valueOf(iconColor));
- tabLayout.setSelectedTabIndicatorColor(iconColor);
- }
-
- @Override
- public void onTabSelected(final TabLayout.Tab selectedTab) {
- if (DEBUG) {
- Log.d(TAG, "onTabSelected() called with: selectedTab = [" + selectedTab + "]");
- }
- updateTitleForTab(selectedTab.getPosition());
- }
-
- @Override
- public void onTabUnselected(final TabLayout.Tab tab) { }
-
- @Override
- public void onTabReselected(final TabLayout.Tab tab) {
- if (DEBUG) {
- Log.d(TAG, "onTabReselected() called with: tab = [" + tab + "]");
- }
- updateTitleForTab(tab.getPosition());
- }
-
- public static final class SelectedTabsPagerAdapter
- extends FragmentStatePagerAdapterMenuWorkaround {
- private final Context context;
- private final List internalTabsList;
- /**
- * Keep reference to LocalPlaylistFragments, because their data can be modified by the user
- * during runtime and changes are not committed immediately. However, in some cases,
- * the changes need to be committed immediately by calling
- * {@link LocalPlaylistFragment#saveImmediate()}.
- * The fragments are removed when {@link LocalPlaylistFragment#onDestroy()} is called.
- */
- private final List localPlaylistFragments = new ArrayList<>();
-
- private SelectedTabsPagerAdapter(final Context context,
- final FragmentManager fragmentManager,
- final List tabsList) {
- super(fragmentManager, BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT);
- this.context = context;
- this.internalTabsList = new ArrayList<>(tabsList);
- }
-
- @NonNull
- @Override
- public Fragment getItem(final int position) {
- final Tab tab = internalTabsList.get(position);
-
- final Fragment fragment;
- try {
- fragment = tab.getFragment(context);
- } catch (final Throwable t) {
- return new BlankFragment(new ErrorInfo(t, UserAction.GETTING_MAIN_SCREEN_TAB,
- "Tab " + tab.getClass().getSimpleName() + ":" + tab.getTabName(context)));
- }
-
- if (fragment instanceof BaseFragment) {
- ((BaseFragment) fragment).useAsFrontPage(true);
- }
-
- if (fragment instanceof LocalPlaylistFragment) {
- localPlaylistFragments.add((LocalPlaylistFragment) fragment);
- }
-
- return fragment;
- }
-
- public List getLocalPlaylistFragments() {
- return localPlaylistFragments;
- }
-
- @Override
- public int getItemPosition(@NonNull final Object object) {
- // Causes adapter to reload all Fragments when
- // notifyDataSetChanged is called
- return POSITION_NONE;
- }
-
- @Override
- public int getCount() {
- return internalTabsList.size();
- }
-
- public boolean sameTabs(final List tabsToCompare) {
- return internalTabsList.equals(tabsToCompare);
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.java b/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.java
deleted file mode 100644
index 6b17803c4..000000000
--- a/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.java
+++ /dev/null
@@ -1,47 +0,0 @@
-package org.schabi.newpipe.fragments;
-
-import androidx.annotation.NonNull;
-import androidx.recyclerview.widget.LinearLayoutManager;
-import androidx.recyclerview.widget.RecyclerView;
-import androidx.recyclerview.widget.StaggeredGridLayoutManager;
-
-/**
- * Recycler view scroll listener which calls the method {@link #onScrolledDown(RecyclerView)}
- * if the view is scrolled below the last item.
- */
-public abstract class OnScrollBelowItemsListener extends RecyclerView.OnScrollListener {
- @Override
- public void onScrolled(@NonNull final RecyclerView recyclerView, final int dx, final int dy) {
- super.onScrolled(recyclerView, dx, dy);
- if (dy > 0) {
- int pastVisibleItems = 0;
- final RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
-
- final int visibleItemCount = layoutManager.getChildCount();
- final int totalItemCount = layoutManager.getItemCount();
-
- // Already covers the GridLayoutManager case
- if (layoutManager instanceof LinearLayoutManager) {
- pastVisibleItems = ((LinearLayoutManager) layoutManager)
- .findFirstVisibleItemPosition();
- } else if (layoutManager instanceof StaggeredGridLayoutManager) {
- final int[] positions = ((StaggeredGridLayoutManager) layoutManager)
- .findFirstVisibleItemPositions(null);
- if (positions != null && positions.length > 0) {
- pastVisibleItems = positions[0];
- }
- }
-
- if ((visibleItemCount + pastVisibleItems) >= totalItemCount) {
- onScrolledDown(recyclerView);
- }
- }
- }
-
- /**
- * Called when the recycler view is scrolled below the last item.
- *
- * @param recyclerView the recycler view
- */
- public abstract void onScrolledDown(RecyclerView recyclerView);
-}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/ViewContract.java b/app/src/main/java/org/schabi/newpipe/fragments/ViewContract.java
deleted file mode 100644
index 78f644ffb..000000000
--- a/app/src/main/java/org/schabi/newpipe/fragments/ViewContract.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package org.schabi.newpipe.fragments;
-
-public interface ViewContract {
- void showLoading();
-
- void hideLoading();
-
- void showEmptyState();
-
- void handleResult(I result);
-
- void handleError();
-}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java
deleted file mode 100644
index bd174a121..000000000
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java
+++ /dev/null
@@ -1,281 +0,0 @@
-package org.schabi.newpipe.fragments.detail;
-
-import static android.text.TextUtils.isEmpty;
-import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
-import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD;
-
-import android.graphics.Typeface;
-import android.os.Bundle;
-import android.text.SpannableStringBuilder;
-import android.text.Spanned;
-import android.text.method.LinkMovementMethod;
-import android.text.style.ClickableSpan;
-import android.text.style.StyleSpan;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.LinearLayout;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.StringRes;
-import androidx.appcompat.widget.TooltipCompat;
-import androidx.core.text.HtmlCompat;
-
-import com.google.android.material.chip.Chip;
-
-import org.schabi.newpipe.BaseFragment;
-import org.schabi.newpipe.R;
-import org.schabi.newpipe.databinding.FragmentDescriptionBinding;
-import org.schabi.newpipe.databinding.ItemMetadataBinding;
-import org.schabi.newpipe.databinding.ItemMetadataTagsBinding;
-import org.schabi.newpipe.extractor.Image;
-import org.schabi.newpipe.extractor.StreamingService;
-import org.schabi.newpipe.extractor.stream.Description;
-import org.schabi.newpipe.util.NavigationHelper;
-import org.schabi.newpipe.util.external_communication.ShareUtils;
-import org.schabi.newpipe.util.image.ImageStrategy;
-import org.schabi.newpipe.util.text.TextLinkifier;
-
-import java.util.List;
-
-import io.reactivex.rxjava3.disposables.CompositeDisposable;
-
-public abstract class BaseDescriptionFragment extends BaseFragment {
- private final CompositeDisposable descriptionDisposables = new CompositeDisposable();
- protected FragmentDescriptionBinding binding;
-
- @Override
- public View onCreateView(@NonNull final LayoutInflater inflater,
- @Nullable final ViewGroup container,
- @Nullable final Bundle savedInstanceState) {
- binding = FragmentDescriptionBinding.inflate(inflater, container, false);
- setupDescription();
- setupMetadata(inflater, binding.detailMetadataLayout);
- addTagsMetadataItem(inflater, binding.detailMetadataLayout);
- return binding.getRoot();
- }
-
- @Override
- public void onDestroy() {
- descriptionDisposables.clear();
- super.onDestroy();
- }
-
- /**
- * Get the description to display.
- * @return description object, if available
- */
- @Nullable
- protected abstract Description getDescription();
-
- /**
- * Get the streaming service. Used for generating description links.
- * @return streaming service
- */
- @NonNull
- protected abstract StreamingService getService();
-
- /**
- * Get the streaming service ID. Used for tag links.
- * @return service ID
- */
- protected abstract int getServiceId();
-
- /**
- * Get the URL of the described video or audio, used to generate description links.
- * @return stream URL
- */
- @Nullable
- protected abstract String getStreamUrl();
-
- /**
- * Get the list of tags to display below the description.
- * @return tag list
- */
- @NonNull
- public abstract List getTags();
-
- /**
- * Add additional metadata to display.
- * @param inflater LayoutInflater
- * @param layout detailMetadataLayout
- */
- protected abstract void setupMetadata(LayoutInflater inflater, LinearLayout layout);
-
- private void setupDescription() {
- final Description description = getDescription();
- if (description == null || isEmpty(description.getContent())
- || description == Description.EMPTY_DESCRIPTION) {
- binding.detailDescriptionView.setVisibility(View.GONE);
- binding.detailSelectDescriptionButton.setVisibility(View.GONE);
- return;
- }
-
- // start with disabled state. This also loads description content (!)
- disableDescriptionSelection();
-
- binding.detailSelectDescriptionButton.setOnClickListener(v -> {
- if (binding.detailDescriptionNoteView.getVisibility() == View.VISIBLE) {
- disableDescriptionSelection();
- } else {
- // enable selection only when button is clicked to prevent flickering
- enableDescriptionSelection();
- }
- });
- }
-
- private void enableDescriptionSelection() {
- binding.detailDescriptionNoteView.setVisibility(View.VISIBLE);
- binding.detailDescriptionView.setTextIsSelectable(true);
-
- final String buttonLabel = getString(R.string.description_select_disable);
- binding.detailSelectDescriptionButton.setContentDescription(buttonLabel);
- TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel);
- binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_close);
- }
-
- private void disableDescriptionSelection() {
- // show description content again, otherwise some links are not clickable
- final Description description = getDescription();
- if (description != null) {
- TextLinkifier.fromDescription(binding.detailDescriptionView,
- description, HtmlCompat.FROM_HTML_MODE_LEGACY,
- getService(), getStreamUrl(),
- descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
- }
-
- binding.detailDescriptionNoteView.setVisibility(View.GONE);
- binding.detailDescriptionView.setTextIsSelectable(false);
-
- final String buttonLabel = getString(R.string.description_select_enable);
- binding.detailSelectDescriptionButton.setContentDescription(buttonLabel);
- TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel);
- binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all);
- }
-
- protected void addMetadataItem(final LayoutInflater inflater,
- final LinearLayout layout,
- final boolean linkifyContent,
- @StringRes final int type,
- @NonNull final String content) {
- if (isBlank(content)) {
- return;
- }
-
- final ItemMetadataBinding itemBinding =
- ItemMetadataBinding.inflate(inflater, layout, false);
-
- itemBinding.metadataTypeView.setText(type);
- itemBinding.metadataTypeView.setOnLongClickListener(v -> {
- ShareUtils.copyToClipboard(requireContext(), content);
- return true;
- });
-
- if (linkifyContent) {
- TextLinkifier.fromPlainText(itemBinding.metadataContentView, content, null, null,
- descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
- } else {
- itemBinding.metadataContentView.setText(content);
- }
-
- itemBinding.metadataContentView.setClickable(true);
-
- layout.addView(itemBinding.getRoot());
- }
-
- private String imageSizeToText(final int heightOrWidth) {
- if (heightOrWidth < 0) {
- return getString(R.string.question_mark);
- } else {
- return String.valueOf(heightOrWidth);
- }
- }
-
- protected void addImagesMetadataItem(final LayoutInflater inflater,
- final LinearLayout layout,
- @StringRes final int type,
- final List images) {
- final String preferredImageUrl = ImageStrategy.choosePreferredImage(images);
- if (preferredImageUrl == null) {
- return; // null will be returned in case there is no image
- }
-
- final ItemMetadataBinding itemBinding =
- ItemMetadataBinding.inflate(inflater, layout, false);
- itemBinding.metadataTypeView.setText(type);
-
- final SpannableStringBuilder urls = new SpannableStringBuilder();
- for (final Image image : images) {
- if (urls.length() != 0) {
- urls.append(", ");
- }
- final int entryBegin = urls.length();
-
- if (image.getHeight() != Image.HEIGHT_UNKNOWN
- || image.getWidth() != Image.WIDTH_UNKNOWN
- // if even the resolution level is unknown, ?x? will be shown
- || image.getEstimatedResolutionLevel() == Image.ResolutionLevel.UNKNOWN) {
- urls.append(imageSizeToText(image.getWidth()));
- urls.append('x');
- urls.append(imageSizeToText(image.getHeight()));
- } else {
- switch (image.getEstimatedResolutionLevel()) {
- case LOW -> urls.append(getString(R.string.image_quality_low));
- case MEDIUM -> urls.append(getString(R.string.image_quality_medium));
- case HIGH -> urls.append(getString(R.string.image_quality_high));
- default -> {
- // unreachable, Image.ResolutionLevel.UNKNOWN is already filtered out
- }
- }
- }
-
- urls.setSpan(new ClickableSpan() {
- @Override
- public void onClick(@NonNull final View widget) {
- ShareUtils.openUrlInBrowser(requireContext(), image.getUrl());
- }
- }, entryBegin, urls.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
-
- if (preferredImageUrl.equals(image.getUrl())) {
- urls.setSpan(new StyleSpan(Typeface.BOLD), entryBegin, urls.length(),
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
- }
- }
-
- itemBinding.metadataContentView.setText(urls);
- itemBinding.metadataContentView.setMovementMethod(LinkMovementMethod.getInstance());
- layout.addView(itemBinding.getRoot());
- }
-
- private void addTagsMetadataItem(final LayoutInflater inflater, final LinearLayout layout) {
- final List tags = getTags();
-
- if (!tags.isEmpty()) {
- final var itemBinding = ItemMetadataTagsBinding.inflate(inflater, layout, false);
-
- tags.stream().sorted(String.CASE_INSENSITIVE_ORDER).forEach(tag -> {
- final Chip chip = (Chip) inflater.inflate(R.layout.chip,
- itemBinding.metadataTagsChips, false);
- chip.setText(tag);
- chip.setOnClickListener(this::onTagClick);
- chip.setOnLongClickListener(this::onTagLongClick);
- itemBinding.metadataTagsChips.addView(chip);
- });
-
- layout.addView(itemBinding.getRoot());
- }
- }
-
- private void onTagClick(final View chip) {
- if (getParentFragment() != null) {
- NavigationHelper.openSearchFragment(getParentFragment().getParentFragmentManager(),
- getServiceId(), ((Chip) chip).getText().toString());
- }
- }
-
- private boolean onTagLongClick(final View chip) {
- ShareUtils.copyToClipboard(requireContext(), ((Chip) chip).getText().toString());
- return true;
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java
deleted file mode 100644
index 2b0d22a32..000000000
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java
+++ /dev/null
@@ -1,140 +0,0 @@
-package org.schabi.newpipe.fragments.detail;
-
-import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT;
-import static org.schabi.newpipe.util.Localization.getAppLocale;
-
-import android.view.LayoutInflater;
-import android.view.View;
-import android.widget.LinearLayout;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.StringRes;
-
-import com.evernote.android.state.State;
-
-import org.schabi.newpipe.R;
-import org.schabi.newpipe.extractor.StreamingService;
-import org.schabi.newpipe.extractor.stream.Description;
-import org.schabi.newpipe.extractor.stream.StreamInfo;
-import org.schabi.newpipe.util.Localization;
-
-import java.util.List;
-
-public class DescriptionFragment extends BaseDescriptionFragment {
-
- @State
- StreamInfo streamInfo;
-
- public DescriptionFragment(final StreamInfo streamInfo) {
- this.streamInfo = streamInfo;
- }
-
- public DescriptionFragment() {
- // keep empty constructor for State when resuming fragment from memory
- }
-
-
- @Nullable
- @Override
- protected Description getDescription() {
- return streamInfo.getDescription();
- }
-
- @NonNull
- @Override
- protected StreamingService getService() {
- return streamInfo.getService();
- }
-
- @Override
- protected int getServiceId() {
- return streamInfo.getServiceId();
- }
-
- @NonNull
- @Override
- protected String getStreamUrl() {
- return streamInfo.getUrl();
- }
-
- @NonNull
- @Override
- public List getTags() {
- return streamInfo.getTags();
- }
-
- @Override
- protected void setupMetadata(final LayoutInflater inflater,
- final LinearLayout layout) {
- if (streamInfo != null && streamInfo.getUploadDate() != null) {
- binding.detailUploadDateView.setText(Localization
- .localizeUploadDate(activity, streamInfo.getUploadDate().offsetDateTime()));
- } else {
- binding.detailUploadDateView.setVisibility(View.GONE);
- }
-
- if (streamInfo == null) {
- return;
- }
-
- addMetadataItem(inflater, layout, false, R.string.metadata_category,
- streamInfo.getCategory());
-
- addMetadataItem(inflater, layout, false, R.string.metadata_licence,
- streamInfo.getLicence());
-
- addPrivacyMetadataItem(inflater, layout);
-
- if (streamInfo.getAgeLimit() != NO_AGE_LIMIT) {
- addMetadataItem(inflater, layout, false, R.string.metadata_age_limit,
- String.valueOf(streamInfo.getAgeLimit()));
- }
-
- if (streamInfo.getLanguageInfo() != null) {
- addMetadataItem(inflater, layout, false, R.string.metadata_language,
- streamInfo.getLanguageInfo().getDisplayLanguage(getAppLocale()));
- }
-
- addMetadataItem(inflater, layout, true, R.string.metadata_support,
- streamInfo.getSupportInfo());
- addMetadataItem(inflater, layout, true, R.string.metadata_host,
- streamInfo.getHost());
-
- addImagesMetadataItem(inflater, layout, R.string.metadata_thumbnails,
- streamInfo.getThumbnails());
- addImagesMetadataItem(inflater, layout, R.string.metadata_uploader_avatars,
- streamInfo.getUploaderAvatars());
- addImagesMetadataItem(inflater, layout, R.string.metadata_subchannel_avatars,
- streamInfo.getSubChannelAvatars());
- }
-
- private void addPrivacyMetadataItem(final LayoutInflater inflater, final LinearLayout layout) {
- if (streamInfo.getPrivacy() != null) {
- @StringRes final int contentRes;
- switch (streamInfo.getPrivacy()) {
- case PUBLIC:
- contentRes = R.string.metadata_privacy_public;
- break;
- case UNLISTED:
- contentRes = R.string.metadata_privacy_unlisted;
- break;
- case PRIVATE:
- contentRes = R.string.metadata_privacy_private;
- break;
- case INTERNAL:
- contentRes = R.string.metadata_privacy_internal;
- break;
- case OTHER:
- default:
- contentRes = 0;
- break;
- }
-
- if (contentRes != 0) {
- addMetadataItem(inflater, layout, false, R.string.metadata_privacy,
- getString(contentRes));
- }
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.java
deleted file mode 100644
index 5016a49f6..000000000
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.java
+++ /dev/null
@@ -1,56 +0,0 @@
-package org.schabi.newpipe.fragments.detail;
-
-import androidx.annotation.NonNull;
-
-import org.schabi.newpipe.player.playqueue.PlayQueue;
-
-import java.io.Serializable;
-
-class StackItem implements Serializable {
- private final int serviceId;
- private String url;
- private String title;
- private PlayQueue playQueue;
-
- StackItem(final int serviceId, final String url,
- final String title, final PlayQueue playQueue) {
- this.serviceId = serviceId;
- this.url = url;
- this.title = title;
- this.playQueue = playQueue;
- }
-
- public void setUrl(final String url) {
- this.url = url;
- }
-
- public void setPlayQueue(final PlayQueue queue) {
- this.playQueue = queue;
- }
-
- public int getServiceId() {
- return serviceId;
- }
-
- public String getTitle() {
- return title;
- }
-
- public void setTitle(final String title) {
- this.title = title;
- }
-
- public String getUrl() {
- return url;
- }
-
- public PlayQueue getPlayQueue() {
- return playQueue;
- }
-
- @NonNull
- @Override
- public String toString() {
- return getServiceId() + ":" + getUrl() + " > " + getTitle();
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java
deleted file mode 100644
index 1a11836d4..000000000
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java
+++ /dev/null
@@ -1,96 +0,0 @@
-package org.schabi.newpipe.fragments.detail;
-
-import android.view.ViewGroup;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.fragment.app.Fragment;
-import androidx.fragment.app.FragmentManager;
-import androidx.fragment.app.FragmentPagerAdapter;
-
-import java.util.ArrayList;
-import java.util.List;
-
-public class TabAdapter extends FragmentPagerAdapter {
- private final List mFragmentList = new ArrayList<>();
- private final List mFragmentTitleList = new ArrayList<>();
- private final FragmentManager fragmentManager;
-
- public TabAdapter(final FragmentManager fm) {
- // if changed to BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT => crash if enqueueing stream in
- // the background and then clicking on it to open VideoDetailFragment:
- // "Cannot setMaxLifecycle for Fragment not attached to FragmentManager"
- super(fm, BEHAVIOR_SET_USER_VISIBLE_HINT);
- this.fragmentManager = fm;
- }
-
- @NonNull
- @Override
- public Fragment getItem(final int position) {
- return mFragmentList.get(position);
- }
-
- @Override
- public int getCount() {
- return mFragmentList.size();
- }
-
- public void addFragment(final Fragment fragment, final String title) {
- mFragmentList.add(fragment);
- mFragmentTitleList.add(title);
- }
-
- public void clearAllItems() {
- mFragmentList.clear();
- mFragmentTitleList.clear();
- }
-
- public void removeItem(final int position) {
- mFragmentList.remove(position == 0 ? 0 : position - 1);
- mFragmentTitleList.remove(position == 0 ? 0 : position - 1);
- }
-
- public void updateItem(final int position, final Fragment fragment) {
- mFragmentList.set(position, fragment);
- }
-
- public void updateItem(final String title, final Fragment fragment) {
- final int index = mFragmentTitleList.indexOf(title);
- if (index != -1) {
- updateItem(index, fragment);
- }
- }
-
- @Override
- public int getItemPosition(@NonNull final Object object) {
- if (mFragmentList.contains(object)) {
- return mFragmentList.indexOf(object);
- } else {
- return POSITION_NONE;
- }
- }
-
- public int getItemPositionByTitle(final String title) {
- return mFragmentTitleList.indexOf(title);
- }
-
- @Nullable
- public String getItemTitle(final int position) {
- if (position < 0 || position >= mFragmentTitleList.size()) {
- return null;
- }
- return mFragmentTitleList.get(position);
- }
-
- public void notifyDataSetUpdate() {
- notifyDataSetChanged();
- }
-
- @Override
- public void destroyItem(@NonNull final ViewGroup container,
- final int position,
- @NonNull final Object object) {
- fragmentManager.beginTransaction().remove((Fragment) object).commitNowAllowingStateLoss();
- }
-
-}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
deleted file mode 100644
index ee93e3138..000000000
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
+++ /dev/null
@@ -1,2521 +0,0 @@
-package org.schabi.newpipe.fragments.detail;
-
-import static android.text.TextUtils.isEmpty;
-import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.COMMENTS;
-import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT;
-import static org.schabi.newpipe.ktx.ViewUtils.animate;
-import static org.schabi.newpipe.ktx.ViewUtils.animateRotation;
-import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientationLocked;
-import static org.schabi.newpipe.player.helper.PlayerHelper.isClearingQueueConfirmationRequired;
-import static org.schabi.newpipe.util.DependentPreferenceHelper.getResumePlaybackEnabled;
-import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
-import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams;
-import static org.schabi.newpipe.util.NavigationHelper.openPlayQueue;
-
-import android.animation.ValueAnimator;
-import android.annotation.SuppressLint;
-import android.app.Activity;
-import android.content.BroadcastReceiver;
-import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.content.SharedPreferences;
-import android.content.pm.ActivityInfo;
-import android.database.ContentObserver;
-import android.graphics.Color;
-import android.graphics.Rect;
-import android.net.Uri;
-import android.os.Build;
-import android.os.Bundle;
-import android.os.Handler;
-import android.os.Looper;
-import android.provider.Settings;
-import android.util.DisplayMetrics;
-import android.util.Log;
-import android.util.TypedValue;
-import android.view.LayoutInflater;
-import android.view.MotionEvent;
-import android.view.View;
-import android.view.ViewGroup;
-import android.view.ViewTreeObserver;
-import android.view.WindowManager;
-import android.view.animation.DecelerateInterpolator;
-import android.widget.FrameLayout;
-import android.widget.RelativeLayout;
-import android.widget.Toast;
-
-import androidx.annotation.AttrRes;
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.annotation.StringRes;
-import androidx.appcompat.app.AlertDialog;
-import androidx.appcompat.content.res.AppCompatResources;
-import androidx.appcompat.widget.Toolbar;
-import androidx.coordinatorlayout.widget.CoordinatorLayout;
-import androidx.core.content.ContextCompat;
-import androidx.fragment.app.Fragment;
-import androidx.preference.PreferenceManager;
-
-import com.evernote.android.state.State;
-import com.google.android.exoplayer2.PlaybackException;
-import com.google.android.exoplayer2.PlaybackParameters;
-import com.google.android.material.appbar.AppBarLayout;
-import com.google.android.material.bottomsheet.BottomSheetBehavior;
-import com.google.android.material.tabs.TabLayout;
-
-import org.schabi.newpipe.App;
-import org.schabi.newpipe.R;
-import org.schabi.newpipe.database.stream.model.StreamEntity;
-import org.schabi.newpipe.databinding.FragmentVideoDetailBinding;
-import org.schabi.newpipe.download.DownloadDialog;
-import org.schabi.newpipe.error.ErrorInfo;
-import org.schabi.newpipe.error.ErrorUtil;
-import org.schabi.newpipe.error.ReCaptchaActivity;
-import org.schabi.newpipe.error.UserAction;
-import org.schabi.newpipe.extractor.Image;
-import org.schabi.newpipe.extractor.NewPipe;
-import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
-import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
-import org.schabi.newpipe.extractor.exceptions.ExtractionException;
-import org.schabi.newpipe.extractor.stream.AudioStream;
-import org.schabi.newpipe.extractor.stream.Stream;
-import org.schabi.newpipe.extractor.stream.StreamInfo;
-import org.schabi.newpipe.extractor.stream.StreamType;
-import org.schabi.newpipe.extractor.stream.VideoStream;
-import org.schabi.newpipe.fragments.BackPressable;
-import org.schabi.newpipe.fragments.BaseStateFragment;
-import org.schabi.newpipe.fragments.EmptyFragment;
-import org.schabi.newpipe.fragments.MainFragment;
-import org.schabi.newpipe.fragments.list.comments.CommentsFragment;
-import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment;
-import org.schabi.newpipe.ktx.AnimationType;
-import org.schabi.newpipe.local.dialog.PlaylistDialog;
-import org.schabi.newpipe.local.history.HistoryRecordManager;
-import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
-import org.schabi.newpipe.player.Player;
-import org.schabi.newpipe.player.PlayerIntentType;
-import org.schabi.newpipe.player.PlayerService;
-import org.schabi.newpipe.player.PlayerType;
-import org.schabi.newpipe.player.event.OnKeyDownListener;
-import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener;
-import org.schabi.newpipe.player.helper.PlayerHelper;
-import org.schabi.newpipe.player.helper.PlayerHolder;
-import org.schabi.newpipe.player.playqueue.PlayQueue;
-import org.schabi.newpipe.player.playqueue.PlayQueueItem;
-import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
-import org.schabi.newpipe.player.ui.MainPlayerUi;
-import org.schabi.newpipe.player.ui.VideoPlayerUi;
-import org.schabi.newpipe.util.Constants;
-import org.schabi.newpipe.util.DeviceUtils;
-import org.schabi.newpipe.util.ExtractorHelper;
-import org.schabi.newpipe.util.InfoCache;
-import org.schabi.newpipe.util.ListHelper;
-import org.schabi.newpipe.util.Localization;
-import org.schabi.newpipe.util.NavigationHelper;
-import org.schabi.newpipe.util.PermissionHelper;
-import org.schabi.newpipe.util.PlayButtonHelper;
-import org.schabi.newpipe.util.StreamTypeUtil;
-import org.schabi.newpipe.util.ThemeHelper;
-import org.schabi.newpipe.util.external_communication.KoreUtils;
-import org.schabi.newpipe.util.external_communication.ShareUtils;
-import org.schabi.newpipe.util.image.CoilHelper;
-
-import java.util.ArrayList;
-import java.util.Iterator;
-import java.util.LinkedList;
-import java.util.List;
-import java.util.Objects;
-import java.util.Optional;
-import java.util.concurrent.TimeUnit;
-import java.util.function.Consumer;
-
-import coil3.util.CoilUtils;
-import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
-import io.reactivex.rxjava3.disposables.CompositeDisposable;
-import io.reactivex.rxjava3.disposables.Disposable;
-import io.reactivex.rxjava3.schedulers.Schedulers;
-
-public final class VideoDetailFragment
- extends BaseStateFragment
- implements BackPressable,
- PlayerServiceExtendedEventListener,
- OnKeyDownListener {
- public static final String KEY_SWITCHING_PLAYERS = "switching_players";
-
- private static final float MAX_OVERLAY_ALPHA = 0.9f;
- private static final float MAX_PLAYER_HEIGHT = 0.7f;
-
- public static final String ACTION_SHOW_MAIN_PLAYER =
- App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_SHOW_MAIN_PLAYER";
- public static final String ACTION_HIDE_MAIN_PLAYER =
- App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER";
- public static final String ACTION_PLAYER_STARTED =
- App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_PLAYER_STARTED";
- public static final String ACTION_VIDEO_FRAGMENT_RESUMED =
- App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED";
- public static final String ACTION_VIDEO_FRAGMENT_STOPPED =
- App.PACKAGE_NAME + ".VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED";
-
- private static final String COMMENTS_TAB_TAG = "COMMENTS";
- private static final String RELATED_TAB_TAG = "NEXT VIDEO";
- private static final String DESCRIPTION_TAB_TAG = "DESCRIPTION TAB";
- private static final String EMPTY_TAB_TAG = "EMPTY TAB";
-
- // tabs
- private boolean showComments;
- private boolean showRelatedItems;
- private boolean showDescription;
- private String selectedTabTag;
- @AttrRes
- @NonNull
- final List tabIcons = new ArrayList<>();
- @StringRes
- @NonNull
- final List tabContentDescriptions = new ArrayList<>();
- private boolean tabSettingsChanged = false;
- private int lastAppBarVerticalOffset = Integer.MAX_VALUE; // prevents useless updates
-
- private final SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener =
- (sharedPreferences, key) -> {
- if (getString(R.string.show_comments_key).equals(key)) {
- showComments = sharedPreferences.getBoolean(key, true);
- tabSettingsChanged = true;
- } else if (getString(R.string.show_next_video_key).equals(key)) {
- showRelatedItems = sharedPreferences.getBoolean(key, true);
- tabSettingsChanged = true;
- } else if (getString(R.string.show_description_key).equals(key)) {
- showDescription = sharedPreferences.getBoolean(key, true);
- tabSettingsChanged = true;
- }
- };
-
- @State
- protected int serviceId = Constants.NO_SERVICE_ID;
- @State
- @NonNull
- protected String title = "";
- @State
- @Nullable
- protected String url = null;
- @Nullable
- protected PlayQueue playQueue = null;
- @State
- int bottomSheetState = BottomSheetBehavior.STATE_EXPANDED;
- @State
- int lastStableBottomSheetState = BottomSheetBehavior.STATE_EXPANDED;
- @State
- protected boolean autoPlayEnabled = true;
- private boolean forceFullscreen = false;
-
- @Nullable
- private StreamInfo currentInfo = null;
- private Disposable currentWorker;
- @NonNull
- private final CompositeDisposable disposables = new CompositeDisposable();
- @Nullable
- private Disposable positionSubscriber = null;
-
- private BottomSheetBehavior bottomSheetBehavior;
- private BottomSheetBehavior.BottomSheetCallback bottomSheetCallback;
- private BroadcastReceiver broadcastReceiver;
-
- /*//////////////////////////////////////////////////////////////////////////
- // Views
- //////////////////////////////////////////////////////////////////////////*/
-
- private FragmentVideoDetailBinding binding;
-
- private TabAdapter pageAdapter;
-
- private ContentObserver settingsContentObserver;
- @Nullable
- private PlayerService playerService;
- private Player player;
- private final PlayerHolder playerHolder = PlayerHolder.getInstance();
-
- /*//////////////////////////////////////////////////////////////////////////
- // Service management
- //////////////////////////////////////////////////////////////////////////*/
- @Override
- public void onServiceConnected(@NonNull final PlayerService connectedPlayerService) {
- playerService = connectedPlayerService;
- }
-
- @Override
- public void onPlayerConnected(@NonNull final Player connectedPlayer,
- final boolean playAfterConnect) {
- player = connectedPlayer;
-
- // It will do nothing if the player is not in fullscreen mode
- hideSystemUiIfNeeded();
-
- final Optional playerUi = player.UIs().get(MainPlayerUi.class);
- if (!player.videoPlayerSelected() && !playAfterConnect) {
- return;
- }
-
- if (DeviceUtils.isLandscape(requireContext())) {
- // If the video is playing but orientation changed
- // let's make the video in fullscreen again
- checkLandscape();
- } else if (playerUi.map(ui -> ui.isFullscreen() && !ui.isVerticalVideo()).orElse(false)
- // Tablet UI has orientation-independent fullscreen
- && !DeviceUtils.isTablet(activity)) {
- // Device is in portrait orientation after rotation but UI is in fullscreen.
- // Return back to non-fullscreen state
- playerUi.ifPresent(MainPlayerUi::toggleFullscreen);
- }
-
- if (playAfterConnect
- || (currentInfo != null
- && isAutoplayEnabled()
- && playerUi.isEmpty())) {
- autoPlayEnabled = true; // forcefully start playing
- openVideoPlayerAutoFullscreen();
- }
- updateOverlayPlayQueueButtonVisibility();
- }
-
- @Override
- public void onPlayerDisconnected() {
- player = null;
- // the binding could be null at this point, if the app is finishing
- if (binding != null) {
- restoreDefaultBrightness();
- }
- }
-
- @Override
- public void onServiceDisconnected() {
- playerService = null;
- }
-
-
- /*////////////////////////////////////////////////////////////////////////*/
-
- public static VideoDetailFragment getInstance(final int serviceId,
- @Nullable final String url,
- @NonNull final String name,
- @Nullable final PlayQueue queue) {
- final VideoDetailFragment instance = new VideoDetailFragment();
- instance.setInitialData(serviceId, url, name, queue);
- return instance;
- }
-
- public static VideoDetailFragment getInstanceInCollapsedState() {
- final VideoDetailFragment instance = new VideoDetailFragment();
- instance.updateBottomSheetState(BottomSheetBehavior.STATE_COLLAPSED);
- return instance;
- }
-
-
- /*//////////////////////////////////////////////////////////////////////////
- // Fragment's Lifecycle
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- public void onCreate(final Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
-
- final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity);
- showComments = prefs.getBoolean(getString(R.string.show_comments_key), true);
- showRelatedItems = prefs.getBoolean(getString(R.string.show_next_video_key), true);
- showDescription = prefs.getBoolean(getString(R.string.show_description_key), true);
- selectedTabTag = prefs.getString(
- getString(R.string.stream_info_selected_tab_key), COMMENTS_TAB_TAG);
- prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener);
-
- setupBroadcastReceiver();
-
- settingsContentObserver = new ContentObserver(new Handler()) {
- @Override
- public void onChange(final boolean selfChange) {
- if (activity != null && !globalScreenOrientationLocked(activity)) {
- activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
- }
- }
- };
- activity.getContentResolver().registerContentObserver(
- Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false,
- settingsContentObserver);
- }
-
- @Override
- public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container,
- final Bundle savedInstanceState) {
- binding = FragmentVideoDetailBinding.inflate(inflater, container, false);
- return binding.getRoot();
- }
-
- @Override
- public void onPause() {
- super.onPause();
- if (currentWorker != null) {
- currentWorker.dispose();
- }
- restoreDefaultBrightness();
- PreferenceManager.getDefaultSharedPreferences(requireContext())
- .edit()
- .putString(getString(R.string.stream_info_selected_tab_key),
- pageAdapter.getItemTitle(binding.viewPager.getCurrentItem()))
- .apply();
- }
-
- @Override
- public void onResume() {
- super.onResume();
- if (DEBUG) {
- Log.d(TAG, "onResume() called");
- }
-
- activity.sendBroadcast(new Intent(ACTION_VIDEO_FRAGMENT_RESUMED));
-
- updateOverlayPlayQueueButtonVisibility();
-
- setupBrightness();
-
- if (tabSettingsChanged) {
- tabSettingsChanged = false;
- initTabs();
- if (currentInfo != null) {
- updateTabs(currentInfo);
- }
- }
-
- // Check if it was loading when the fragment was stopped/paused
- if (wasLoading.getAndSet(false) && !wasCleared()) {
- startLoading(false);
- }
- }
-
- @Override
- public void onStop() {
- super.onStop();
-
- if (!activity.isChangingConfigurations()) {
- activity.sendBroadcast(new Intent(ACTION_VIDEO_FRAGMENT_STOPPED));
- }
- }
-
- @Override
- public void onDestroy() {
- super.onDestroy();
-
- // Stop the service when user leaves the app with double back press
- // if video player is selected. Otherwise unbind
- if (activity.isFinishing() && isPlayerAvailable() && player.videoPlayerSelected()) {
- playerHolder.stopService();
- } else {
- playerHolder.setListener(null);
- }
-
- PreferenceManager.getDefaultSharedPreferences(activity)
- .unregisterOnSharedPreferenceChangeListener(preferenceChangeListener);
- activity.unregisterReceiver(broadcastReceiver);
- activity.getContentResolver().unregisterContentObserver(settingsContentObserver);
-
- if (positionSubscriber != null) {
- positionSubscriber.dispose();
- }
- if (currentWorker != null) {
- currentWorker.dispose();
- }
- disposables.clear();
- positionSubscriber = null;
- currentWorker = null;
- bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback);
-
- if (activity.isFinishing()) {
- playQueue = null;
- currentInfo = null;
- stack = new LinkedList<>();
- }
- }
-
- @Override
- public void onDestroyView() {
- super.onDestroyView();
- binding = null;
- }
-
- @Override
- public void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
- super.onActivityResult(requestCode, resultCode, data);
- switch (requestCode) {
- case ReCaptchaActivity.RECAPTCHA_REQUEST:
- if (resultCode == Activity.RESULT_OK) {
- NavigationHelper.openVideoDetailFragment(requireContext(), getFM(),
- serviceId, url, title, null, false);
- } else {
- Log.e(TAG, "ReCaptcha failed");
- }
- break;
- default:
- Log.e(TAG, "Request code from activity not supported [" + requestCode + "]");
- break;
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // OnClick
- //////////////////////////////////////////////////////////////////////////*/
-
- private void setOnClickListeners() {
- binding.detailTitleRootLayout.setOnClickListener(v -> toggleTitleAndSecondaryControls());
- binding.detailUploaderRootLayout.setOnClickListener(makeOnClickListener(info -> {
- if (isEmpty(info.getSubChannelUrl())) {
- if (!isEmpty(info.getUploaderUrl())) {
- openChannel(info.getUploaderUrl(), info.getUploaderName());
- }
-
- if (DEBUG) {
- Log.i(TAG, "Can't open sub-channel because we got no channel URL");
- }
- } else {
- openChannel(info.getSubChannelUrl(), info.getSubChannelName());
- }
- }));
- binding.detailThumbnailRootLayout.setOnClickListener(v -> {
- autoPlayEnabled = true; // forcefully start playing
- // FIXME Workaround #7427
- if (isPlayerAvailable()) {
- player.setRecovery();
- }
- openVideoPlayerAutoFullscreen();
- });
-
- binding.detailControlsBackground.setOnClickListener(v -> openBackgroundPlayer(false));
- binding.detailControlsPopup.setOnClickListener(v -> openPopupPlayer(false));
- binding.detailControlsPlaylistAppend.setOnClickListener(makeOnClickListener(info -> {
- if (getFM() != null && currentInfo != null) {
- final Fragment fragment = getParentFragmentManager().
- findFragmentById(R.id.fragment_holder);
-
- // commit previous pending changes to database
- if (fragment instanceof LocalPlaylistFragment) {
- ((LocalPlaylistFragment) fragment).saveImmediate();
- } else if (fragment instanceof MainFragment) {
- ((MainFragment) fragment).commitPlaylistTabs();
- }
-
- disposables.add(PlaylistDialog.createCorrespondingDialog(requireContext(),
- List.of(new StreamEntity(info)),
- dialog -> dialog.show(getParentFragmentManager(), TAG)));
- }
- }));
- binding.detailControlsDownload.setOnClickListener(v -> {
- if (PermissionHelper.checkStoragePermissions(activity,
- PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) {
- openDownloadDialog();
- }
- });
- binding.detailControlsShare.setOnClickListener(makeOnClickListener(info ->
- ShareUtils.shareText(requireContext(), info.getName(), info.getUrl(),
- info.getThumbnails())));
- binding.detailControlsOpenInBrowser.setOnClickListener(makeOnClickListener(info ->
- ShareUtils.openUrlInBrowser(requireContext(), info.getUrl())));
- binding.detailControlsPlayWithKodi.setOnClickListener(makeOnClickListener(info ->
- KoreUtils.playWithKore(requireContext(), Uri.parse(info.getUrl()))));
- if (DEBUG) {
- binding.detailControlsCrashThePlayer.setOnClickListener(v ->
- VideoDetailPlayerCrasher.onCrashThePlayer(requireContext(), player));
- }
-
- final View.OnClickListener overlayListener = v -> bottomSheetBehavior
- .setState(BottomSheetBehavior.STATE_EXPANDED);
- binding.overlayThumbnail.setOnClickListener(overlayListener);
- binding.overlayMetadataLayout.setOnClickListener(overlayListener);
- binding.overlayButtonsLayout.setOnClickListener(overlayListener);
- binding.overlayCloseButton.setOnClickListener(v -> bottomSheetBehavior
- .setState(BottomSheetBehavior.STATE_HIDDEN));
- binding.overlayPlayQueueButton.setOnClickListener(v -> openPlayQueue(requireContext()));
- binding.overlayPlayPauseButton.setOnClickListener(v -> {
- if (playerIsNotStopped()) {
- player.playPause();
- player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0));
- showSystemUi();
- } else {
- autoPlayEnabled = true; // forcefully start playing
- openVideoPlayer(false);
- }
-
- setOverlayPlayPauseImage(isPlayerAvailable() && player.isPlaying());
- });
- }
-
- private View.OnClickListener makeOnClickListener(final Consumer consumer) {
- return v -> {
- if (!isLoading.get() && currentInfo != null) {
- consumer.accept(currentInfo);
- }
- };
- }
-
- private void setOnLongClickListeners() {
- binding.detailTitleRootLayout.setOnLongClickListener(makeOnLongClickListener(info ->
- ShareUtils.copyToClipboard(requireContext(),
- binding.detailVideoTitleView.getText().toString())));
- binding.detailUploaderRootLayout.setOnLongClickListener(makeOnLongClickListener(info -> {
- if (isEmpty(info.getSubChannelUrl())) {
- Log.w(TAG, "Can't open parent channel because we got no parent channel URL");
- } else {
- openChannel(info.getUploaderUrl(), info.getUploaderName());
- }
- }));
-
- binding.detailControlsBackground.setOnLongClickListener(makeOnLongClickListener(info ->
- openBackgroundPlayer(true)
- ));
- binding.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(info ->
- openPopupPlayer(true)
- ));
- binding.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(info ->
- NavigationHelper.openDownloads(activity)));
-
- final View.OnLongClickListener overlayListener = makeOnLongClickListener(info ->
- openChannel(info.getUploaderUrl(), info.getUploaderName()));
- binding.overlayThumbnail.setOnLongClickListener(overlayListener);
- binding.overlayMetadataLayout.setOnLongClickListener(overlayListener);
- }
-
- private View.OnLongClickListener makeOnLongClickListener(final Consumer consumer) {
- return v -> {
- if (isLoading.get() || currentInfo == null) {
- return false;
- }
- consumer.accept(currentInfo);
- return true;
- };
- }
-
- private void openChannel(final String subChannelUrl, final String subChannelName) {
- try {
- NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(),
- subChannelUrl, subChannelName);
- } catch (final Exception e) {
- ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e);
- }
- }
-
- private void toggleTitleAndSecondaryControls() {
- if (binding.detailSecondaryControlPanel.getVisibility() == View.GONE) {
- binding.detailVideoTitleView.setMaxLines(10);
- animateRotation(binding.detailToggleSecondaryControlsView,
- VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 180);
- binding.detailSecondaryControlPanel.setVisibility(View.VISIBLE);
- } else {
- binding.detailVideoTitleView.setMaxLines(1);
- animateRotation(binding.detailToggleSecondaryControlsView,
- VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 0);
- binding.detailSecondaryControlPanel.setVisibility(View.GONE);
- }
- // view pager height has changed, update the tab layout
- updateTabLayoutVisibility();
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Init
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override // called from onViewCreated in {@link BaseFragment#onViewCreated}
- protected void initViews(final View rootView, final Bundle savedInstanceState) {
- super.initViews(rootView, savedInstanceState);
-
- pageAdapter = new TabAdapter(getChildFragmentManager());
- binding.viewPager.setAdapter(pageAdapter);
- binding.tabLayout.setupWithViewPager(binding.viewPager);
-
- binding.detailThumbnailRootLayout.requestFocus();
-
- binding.detailControlsPlayWithKodi.setVisibility(
- KoreUtils.shouldShowPlayWithKodi(requireContext(), serviceId)
- ? View.VISIBLE
- : View.GONE
- );
- binding.detailControlsCrashThePlayer.setVisibility(
- DEBUG && PreferenceManager.getDefaultSharedPreferences(getContext())
- .getBoolean(getString(R.string.show_crash_the_player_key), false)
- ? View.VISIBLE
- : View.GONE
- );
- accommodateForTvAndDesktopMode();
- }
-
- @Override
- @SuppressLint("ClickableViewAccessibility")
- protected void initListeners() {
- super.initListeners();
-
- // Workaround for #5600
- // Forcefully catch click events uncaught by children because otherwise
- // they will be caught by underlying view and "click through" will happen
- binding.getRoot().setOnClickListener(v -> { });
- binding.getRoot().setOnLongClickListener(v -> true);
-
- setOnClickListeners();
- setOnLongClickListeners();
-
- final View.OnTouchListener controlsTouchListener = (view, motionEvent) -> {
- if (motionEvent.getAction() == MotionEvent.ACTION_DOWN
- && PlayButtonHelper.shouldShowHoldToAppendTip(activity)) {
-
- animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA, 0, () ->
- animate(binding.touchAppendDetail, false, 1500, AnimationType.ALPHA, 1000));
- }
- return false;
- };
- binding.detailControlsBackground.setOnTouchListener(controlsTouchListener);
- binding.detailControlsPopup.setOnTouchListener(controlsTouchListener);
-
- binding.appBarLayout.addOnOffsetChangedListener((layout, verticalOffset) -> {
- // prevent useless updates to tab layout visibility if nothing changed
- if (verticalOffset != lastAppBarVerticalOffset) {
- lastAppBarVerticalOffset = verticalOffset;
- // the view was scrolled
- updateTabLayoutVisibility();
- }
- });
-
- setupBottomPlayer();
- if (!playerHolder.isBound()) {
- setHeightThumbnail();
- } else {
- playerHolder.startService(false, this);
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // OwnStack
- //////////////////////////////////////////////////////////////////////////*/
-
- /**
- * Stack that contains the "navigation history".
- * The peek is the current video.
- */
- private static LinkedList stack = new LinkedList<>();
-
- @Override
- public boolean onKeyDown(final int keyCode) {
- return isPlayerAvailable()
- && player.UIs().get(VideoPlayerUi.class)
- .map(playerUi -> playerUi.onKeyDown(keyCode)).orElse(false);
- }
-
- @Override
- public boolean onBackPressed() {
- if (DEBUG) {
- Log.d(TAG, "onBackPressed() called");
- }
-
- // If we are in fullscreen mode just exit from it via first back press
- if (isFullscreen()) {
- if (!DeviceUtils.isTablet(activity)) {
- player.pause();
- }
- restoreDefaultOrientation();
- setAutoPlay(false);
- return true;
- }
-
- // If we have something in history of played items we replay it here
- if (isPlayerAvailable()
- && player.getPlayQueue() != null
- && player.videoPlayerSelected()
- && player.getPlayQueue().previous()) {
- return true; // no code here, as previous() was used in the if
- }
-
- // That means that we are on the start of the stack,
- if (stack.size() <= 1) {
- restoreDefaultOrientation();
- return false; // let MainActivity handle the onBack (e.g. to minimize the mini player)
- }
-
- // Remove top
- stack.pop();
- // Get stack item from the new top
- setupFromHistoryItem(Objects.requireNonNull(stack.peek()));
-
- return true;
- }
-
- private void setupFromHistoryItem(final StackItem item) {
- setAutoPlay(false);
- hideMainPlayerOnLoadingNewStream();
-
- setInitialData(item.getServiceId(), item.getUrl(),
- item.getTitle() == null ? "" : item.getTitle(), item.getPlayQueue());
- startLoading(false);
-
- // Maybe an item was deleted in background activity
- if (item.getPlayQueue().getItem() == null) {
- return;
- }
-
- final PlayQueueItem playQueueItem = item.getPlayQueue().getItem();
- // Update title, url, uploader from the last item in the stack (it's current now)
- final boolean isPlayerStopped = !isPlayerAvailable() || player.isStopped();
- if (playQueueItem != null && isPlayerStopped) {
- updateOverlayData(playQueueItem.getTitle(),
- playQueueItem.getUploader(), playQueueItem.getThumbnails());
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Info loading and handling
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- protected void doInitialLoadLogic() {
- if (wasCleared()) {
- return;
- }
-
- if (currentInfo == null) {
- prepareAndLoadInfo();
- } else {
- prepareAndHandleInfoIfNeededAfterDelay(currentInfo, false, 50);
- }
- }
-
- public void selectAndLoadVideo(final int newServiceId,
- @Nullable final String newUrl,
- @NonNull final String newTitle,
- @Nullable final PlayQueue newQueue) {
- if (isPlayerAvailable() && newQueue != null && playQueue != null
- && playQueue.getItem() != null && !playQueue.getItem().getUrl().equals(newUrl)) {
- // Preloading can be disabled since playback is surely being replaced.
- player.disablePreloadingOfCurrentTrack();
- }
-
- setInitialData(newServiceId, newUrl, newTitle, newQueue);
- startLoading(false, true);
- }
-
- private void prepareAndHandleInfoIfNeededAfterDelay(final StreamInfo info,
- final boolean scrollToTop,
- final long delay) {
- new Handler(Looper.getMainLooper()).postDelayed(() -> {
- if (activity == null) {
- return;
- }
- // Data can already be drawn, don't spend time twice
- if (info.getName().equals(binding.detailVideoTitleView.getText().toString())) {
- return;
- }
- prepareAndHandleInfo(info, scrollToTop);
- }, delay);
- }
-
- private void prepareAndHandleInfo(final StreamInfo info, final boolean scrollToTop) {
- if (DEBUG) {
- Log.d(TAG, "prepareAndHandleInfo() called with: "
- + "info = [" + info + "], scrollToTop = [" + scrollToTop + "]");
- }
-
- showLoading();
- initTabs();
-
- if (scrollToTop) {
- scrollToTop();
- }
- handleResult(info);
- showContent();
-
- }
-
- protected void prepareAndLoadInfo() {
- scrollToTop();
- startLoading(false);
- }
-
- @Override
- public void startLoading(final boolean forceLoad) {
- super.startLoading(forceLoad);
-
- initTabs();
- currentInfo = null;
- if (currentWorker != null) {
- currentWorker.dispose();
- }
-
- runWorker(forceLoad, stack.isEmpty());
- }
-
- private void startLoading(final boolean forceLoad, final boolean addToBackStack) {
- super.startLoading(forceLoad);
-
- initTabs();
- currentInfo = null;
- if (currentWorker != null) {
- currentWorker.dispose();
- }
-
- runWorker(forceLoad, addToBackStack);
- }
-
- private void runWorker(final boolean forceLoad, final boolean addToBackStack) {
- final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity);
- currentWorker = ExtractorHelper.getStreamInfo(serviceId, url, forceLoad)
- .subscribeOn(Schedulers.io())
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(result -> {
- isLoading.set(false);
- hideMainPlayerOnLoadingNewStream();
- if (result.getAgeLimit() != NO_AGE_LIMIT && !prefs.getBoolean(
- getString(R.string.show_age_restricted_content), false)) {
- hideAgeRestrictedContent();
- } else {
- handleResult(result);
- showContent();
- if (addToBackStack) {
- if (playQueue == null) {
- playQueue = new SinglePlayQueue(result);
- }
- if (stack.isEmpty() || !stack.peek().getPlayQueue()
- .equalStreams(playQueue)) {
- stack.push(new StackItem(serviceId, url, title, playQueue));
- }
- }
-
- if (isAutoplayEnabled() || forceFullscreen) {
- openVideoPlayerAutoFullscreen();
- }
- }
- }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_STREAM,
- url == null ? "no url" : url, serviceId, url)));
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Tabs
- //////////////////////////////////////////////////////////////////////////*/
-
- private void initTabs() {
- if (pageAdapter.getCount() != 0) {
- selectedTabTag = pageAdapter.getItemTitle(binding.viewPager.getCurrentItem());
- }
- pageAdapter.clearAllItems();
- tabIcons.clear();
- tabContentDescriptions.clear();
-
- if (shouldShowComments()) {
- pageAdapter.addFragment(
- CommentsFragment.getInstance(serviceId, url, title), COMMENTS_TAB_TAG);
- tabIcons.add(R.drawable.ic_comment);
- tabContentDescriptions.add(R.string.comments_tab_description);
- }
-
- if (showRelatedItems && binding.relatedItemsLayout == null) {
- // temp empty fragment. will be updated in handleResult
- pageAdapter.addFragment(EmptyFragment.newInstance(false), RELATED_TAB_TAG);
- tabIcons.add(R.drawable.ic_art_track);
- tabContentDescriptions.add(R.string.related_items_tab_description);
- }
-
- if (showDescription) {
- // temp empty fragment. will be updated in handleResult
- pageAdapter.addFragment(EmptyFragment.newInstance(false), DESCRIPTION_TAB_TAG);
- tabIcons.add(R.drawable.ic_description);
- tabContentDescriptions.add(R.string.description_tab_description);
- }
-
- if (pageAdapter.getCount() == 0) {
- pageAdapter.addFragment(EmptyFragment.newInstance(true), EMPTY_TAB_TAG);
- }
- pageAdapter.notifyDataSetUpdate();
-
- if (pageAdapter.getCount() >= 2) {
- final int position = pageAdapter.getItemPositionByTitle(selectedTabTag);
- if (position != -1) {
- binding.viewPager.setCurrentItem(position);
- }
- updateTabIconsAndContentDescriptions();
- }
- // the page adapter now contains tabs: show the tab layout
- updateTabLayoutVisibility();
- }
-
- /**
- * To be called whenever {@link #pageAdapter} is modified, since that triggers a refresh in
- * {@link FragmentVideoDetailBinding#tabLayout} resetting all tab's icons and content
- * descriptions. This reads icons from {@link #tabIcons} and content descriptions from
- * {@link #tabContentDescriptions}, which are all set in {@link #initTabs()}.
- */
- private void updateTabIconsAndContentDescriptions() {
- for (int i = 0; i < tabIcons.size(); ++i) {
- final TabLayout.Tab tab = binding.tabLayout.getTabAt(i);
- if (tab != null) {
- tab.setIcon(tabIcons.get(i));
- tab.setContentDescription(tabContentDescriptions.get(i));
- }
- }
- }
-
- private void updateTabs(@NonNull final StreamInfo info) {
- if (showRelatedItems) {
- if (binding.relatedItemsLayout == null) { // phone
- pageAdapter.updateItem(RELATED_TAB_TAG, RelatedItemsFragment.getInstance(info));
- } else { // tablet + TV
- getChildFragmentManager().beginTransaction()
- .replace(R.id.relatedItemsLayout, RelatedItemsFragment.getInstance(info))
- .commitAllowingStateLoss();
- binding.relatedItemsLayout.setVisibility(isFullscreen() ? View.GONE : View.VISIBLE);
- }
- }
-
- if (showDescription) {
- pageAdapter.updateItem(DESCRIPTION_TAB_TAG, new DescriptionFragment(info));
- }
-
- binding.viewPager.setVisibility(View.VISIBLE);
- // make sure the tab layout is visible
- updateTabLayoutVisibility();
- pageAdapter.notifyDataSetUpdate();
- updateTabIconsAndContentDescriptions();
- }
-
- private boolean shouldShowComments() {
- try {
- return showComments && NewPipe.getService(serviceId)
- .getServiceInfo()
- .getMediaCapabilities()
- .contains(COMMENTS);
- } catch (final ExtractionException e) {
- return false;
- }
- }
-
- public void updateTabLayoutVisibility() {
-
- if (binding == null) {
- //If binding is null we do not need to and should not do anything with its object(s)
- return;
- }
-
- if (pageAdapter.getCount() < 2 || binding.viewPager.getVisibility() != View.VISIBLE) {
- // hide tab layout if there is only one tab or if the view pager is also hidden
- binding.tabLayout.setVisibility(View.GONE);
- } else {
- // call `post()` to be sure `viewPager.getHitRect()`
- // is up to date and not being currently recomputed
- binding.tabLayout.post(() -> {
- final var activity = getActivity();
- if (activity != null) {
- final Rect pagerHitRect = new Rect();
- binding.viewPager.getHitRect(pagerHitRect);
-
- final int height = DeviceUtils.getWindowHeight(activity.getWindowManager());
- final int viewPagerVisibleHeight = height - pagerHitRect.top;
- // see TabLayout.DEFAULT_HEIGHT, which is equal to 48dp
- final float tabLayoutHeight = TypedValue.applyDimension(
- TypedValue.COMPLEX_UNIT_DIP, 48, getResources().getDisplayMetrics());
-
- if (viewPagerVisibleHeight > tabLayoutHeight * 2) {
- // no translation at all when viewPagerVisibleHeight > tabLayout.height * 3
- binding.tabLayout.setTranslationY(
- Math.max(0, tabLayoutHeight * 3 - viewPagerVisibleHeight));
- binding.tabLayout.setVisibility(View.VISIBLE);
- } else {
- // view pager is not visible enough
- binding.tabLayout.setVisibility(View.GONE);
- }
- }
- });
- }
- }
-
- public void scrollToTop() {
- binding.appBarLayout.setExpanded(true, true);
- // notify tab layout of scrolling
- updateTabLayoutVisibility();
- }
-
- public void scrollToComment(final CommentsInfoItem comment) {
- final int commentsTabPos = pageAdapter.getItemPositionByTitle(COMMENTS_TAB_TAG);
- final Fragment fragment = pageAdapter.getItem(commentsTabPos);
- if (!(fragment instanceof CommentsFragment)) {
- return;
- }
-
- // unexpand the app bar only if scrolling to the comment succeeded
- if (((CommentsFragment) fragment).scrollToComment(comment)) {
- binding.appBarLayout.setExpanded(false, false);
- binding.viewPager.setCurrentItem(commentsTabPos, false);
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Play Utils
- //////////////////////////////////////////////////////////////////////////*/
-
- private void toggleFullscreenIfInFullscreenMode() {
- // If a user watched video inside fullscreen mode and than chose another player
- // return to non-fullscreen mode
- if (isPlayerAvailable()) {
- player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> {
- if (playerUi.isFullscreen()) {
- playerUi.toggleFullscreen();
- }
- });
- }
- }
-
- private void openBackgroundPlayer(final boolean append) {
- final boolean useExternalAudioPlayer = PreferenceManager
- .getDefaultSharedPreferences(activity)
- .getBoolean(activity.getString(R.string.use_external_audio_player_key), false);
-
- toggleFullscreenIfInFullscreenMode();
-
- if (isPlayerAvailable()) {
- // FIXME Workaround #7427
- player.setRecovery();
- }
-
- if (useExternalAudioPlayer) {
- showExternalAudioPlaybackDialog();
- } else {
- openNormalBackgroundPlayer(append);
- }
- }
-
- private void openPopupPlayer(final boolean append) {
- if (!PermissionHelper.isPopupEnabledElseAsk(activity)) {
- return;
- }
-
- // See UI changes while remote playQueue changes
- if (!isPlayerAvailable()) {
- playerHolder.startService(false, this);
- } else {
- // FIXME Workaround #7427
- player.setRecovery();
- }
-
- toggleFullscreenIfInFullscreenMode();
-
- final PlayQueue queue = setupPlayQueueForIntent(append);
- if (append) { //resumePlayback: false
- NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.POPUP);
- } else {
- replaceQueueIfUserConfirms(() -> NavigationHelper
- .playOnPopupPlayer(activity, queue, true));
- }
- }
-
- /**
- * Opens the video player, in fullscreen if needed. In order to open fullscreen, the activity
- * is toggled to landscape orientation (which will then cause fullscreen mode).
- *
- * @param directlyFullscreenIfApplicable whether to open fullscreen if we are not already
- * in landscape and screen orientation is locked
- */
- public void openVideoPlayer(final boolean directlyFullscreenIfApplicable) {
- if (directlyFullscreenIfApplicable
- && !DeviceUtils.isLandscape(requireContext())
- && PlayerHelper.globalScreenOrientationLocked(requireContext())) {
- // Make sure the bottom sheet turns out expanded. When this code kicks in the bottom
- // sheet could not have fully expanded yet, and thus be in the STATE_SETTLING state.
- // When the activity is rotated, and its state is saved and then restored, the bottom
- // sheet would forget what it was doing, since even if STATE_SETTLING is restored, it
- // doesn't tell which state it was settling to, and thus the bottom sheet settles to
- // STATE_COLLAPSED. This can be solved by manually setting the state that will be
- // restored (i.e. bottomSheetState) to STATE_EXPANDED.
- updateBottomSheetState(BottomSheetBehavior.STATE_EXPANDED);
- // toggle landscape in order to open directly in fullscreen
- onScreenRotationButtonClicked();
- }
-
- if (PreferenceManager.getDefaultSharedPreferences(activity)
- .getBoolean(this.getString(R.string.use_external_video_player_key), false)) {
- showExternalVideoPlaybackDialog();
- } else {
- replaceQueueIfUserConfirms(this::openMainPlayer);
- }
- }
-
- /**
- * If the option to start directly fullscreen is enabled, or if {@code forceFullscreen} is
- * {@code true} (e.g. when switching from popup player to main player with a different video),
- * calls {@link #openVideoPlayer(boolean)} with {@code directlyFullscreenIfApplicable = true},
- * so that if the user is not already in landscape and he has screen orientation locked the
- * activity rotates and fullscreen starts. Otherwise, if the option to start directly fullscreen
- * is disabled and {@code forceFullscreen} is {@code false}, calls
- * {@link #openVideoPlayer(boolean)} with {@code directlyFullscreenIfApplicable = false},
- * hence preventing it from going directly fullscreen.
- * {@code forceFullscreen} is reset to {@code false} after this call.
- */
- public void openVideoPlayerAutoFullscreen() {
- openVideoPlayer(forceFullscreen
- || PlayerHelper.isStartMainPlayerFullscreenEnabled(requireContext()));
- forceFullscreen = false;
- }
-
- public void setForceFullscreen(final boolean force) {
- this.forceFullscreen = force;
- }
-
- @Nullable
- public String getUrl() {
- return url;
- }
-
- private void openNormalBackgroundPlayer(final boolean append) {
- // See UI changes while remote playQueue changes
- if (!isPlayerAvailable()) {
- playerHolder.startService(false, this);
- }
-
- final PlayQueue queue = setupPlayQueueForIntent(append);
- if (append) {
- NavigationHelper.enqueueOnPlayer(activity, queue, PlayerType.AUDIO);
- } else {
- replaceQueueIfUserConfirms(() -> NavigationHelper
- .playOnBackgroundPlayer(activity, queue, true));
- }
- }
-
- private void openMainPlayer() {
- if (!isPlayerServiceAvailable()) {
- playerHolder.startService(autoPlayEnabled, this);
- return;
- }
- if (currentInfo == null) {
- return;
- }
-
- final PlayQueue queue = setupPlayQueueForIntent(false);
- tryAddVideoPlayerView();
-
- final Context context = requireContext();
- final Intent playerIntent =
- NavigationHelper.getPlayerIntent(context, PlayerService.class, queue,
- PlayerIntentType.AllOthers)
- .putExtra(Player.PLAY_WHEN_READY, autoPlayEnabled)
- .putExtra(Player.RESUME_PLAYBACK, true);
- ContextCompat.startForegroundService(activity, playerIntent);
- }
-
- /**
- * When the video detail fragment is already showing details for a video and the user opens a
- * new one, the video detail fragment changes all of its old data to the new stream, so if there
- * is a video player currently open it should be hidden. This method does exactly that. If
- * autoplay is enabled, the underlying player is not stopped completely, since it is going to
- * be reused in a few milliseconds and the flickering would be annoying.
- */
- private void hideMainPlayerOnLoadingNewStream() {
- final var root = getRoot();
- if (!isPlayerServiceAvailable() || root.isEmpty() || !player.videoPlayerSelected()) {
- return;
- }
-
- removeVideoPlayerView();
- if (isAutoplayEnabled()) {
- playerService.stopForImmediateReusing();
- root.ifPresent(view -> view.setVisibility(View.GONE));
- } else {
- playerHolder.stopService();
- }
- }
-
- private PlayQueue setupPlayQueueForIntent(final boolean append) {
- if (append) {
- return new SinglePlayQueue(currentInfo);
- }
-
- PlayQueue queue = playQueue;
- // Size can be 0 because queue removes bad stream automatically when error occurs
- if (queue == null || queue.isEmpty()) {
- queue = new SinglePlayQueue(currentInfo);
- }
-
- return queue;
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Utils
- //////////////////////////////////////////////////////////////////////////*/
-
- public void setAutoPlay(final boolean autoPlay) {
- this.autoPlayEnabled = autoPlay;
- }
-
- private void startOnExternalPlayer(@NonNull final Context context,
- @NonNull final StreamInfo info,
- @NonNull final Stream selectedStream) {
- NavigationHelper.playOnExternalPlayer(context, currentInfo.getName(),
- currentInfo.getSubChannelName(), selectedStream);
-
- final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext());
- disposables.add(recordManager.onViewed(info).onErrorComplete()
- .subscribe(
- ignored -> { /* successful */ },
- error -> showSnackBarError(
- new ErrorInfo(
- error,
- UserAction.PLAY_STREAM,
- "Got an error when modifying history on viewed"
- )
- )
- ));
- }
-
- private boolean isExternalPlayerEnabled() {
- return PreferenceManager.getDefaultSharedPreferences(requireContext())
- .getBoolean(getString(R.string.use_external_video_player_key), false);
- }
-
- // This method overrides default behaviour when setAutoPlay() is called.
- // Don't auto play if the user selected an external player or disabled it in settings
- private boolean isAutoplayEnabled() {
- return autoPlayEnabled
- && !isExternalPlayerEnabled()
- && (!isPlayerAvailable() || player.videoPlayerSelected())
- && bottomSheetState != BottomSheetBehavior.STATE_HIDDEN
- && PlayerHelper.isAutoplayAllowedByUser(requireContext());
- }
-
- private void tryAddVideoPlayerView() {
- if (isPlayerAvailable() && getView() != null) {
- // Setup the surface view height, so that it fits the video correctly; this is done also
- // here, and not only in the Handler, to avoid a choppy fullscreen rotation animation.
- setHeightThumbnail();
- }
-
- // do all the null checks in the posted lambda, too, since the player, the binding and the
- // view could be set or unset before the lambda gets executed on the next main thread cycle
- new Handler(Looper.getMainLooper()).post(() -> {
- if (!isPlayerAvailable() || getView() == null) {
- return;
- }
-
- // setup the surface view height, so that it fits the video correctly
- setHeightThumbnail();
-
- player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> {
- // sometimes binding would be null here, even though getView() != null above u.u
- if (binding != null) {
- // prevent from re-adding a view multiple times
- playerUi.removeViewFromParent();
- binding.playerPlaceholder.addView(playerUi.getBinding().getRoot());
- playerUi.setupVideoSurfaceIfNeeded();
- }
- });
- });
- }
-
- private void removeVideoPlayerView() {
- makeDefaultHeightForVideoPlaceholder();
-
- if (player != null) {
- player.UIs().get(VideoPlayerUi.class).ifPresent(VideoPlayerUi::removeViewFromParent);
- }
- }
-
- private void makeDefaultHeightForVideoPlaceholder() {
- if (getView() == null) {
- return;
- }
-
- binding.playerPlaceholder.getLayoutParams().height = FrameLayout.LayoutParams.MATCH_PARENT;
- binding.playerPlaceholder.requestLayout();
- }
-
- private final ViewTreeObserver.OnPreDrawListener preDrawListener =
- new ViewTreeObserver.OnPreDrawListener() {
- @Override
- public boolean onPreDraw() {
- final DisplayMetrics metrics = getResources().getDisplayMetrics();
-
- if (getView() != null) {
- final int height = (DeviceUtils.isInMultiWindow(activity)
- ? requireView()
- : activity.getWindow().getDecorView()).getHeight();
- setHeightThumbnail(height, metrics);
- getView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener);
- }
- return false;
- }
- };
-
- /**
- * Method which controls the size of thumbnail and the size of main player inside
- * a layout with thumbnail. It decides what height the player should have in both
- * screen orientations. It knows about multiWindow feature
- * and about videos with aspectRatio ZOOM (the height for them will be a bit higher,
- * {@link #MAX_PLAYER_HEIGHT})
- */
- private void setHeightThumbnail() {
- final DisplayMetrics metrics = getResources().getDisplayMetrics();
- final boolean isPortrait = metrics.heightPixels > metrics.widthPixels;
- requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener);
-
- if (isFullscreen()) {
- final int height = (DeviceUtils.isInMultiWindow(activity)
- ? requireView()
- : activity.getWindow().getDecorView()).getHeight();
- // Height is zero when the view is not yet displayed like after orientation change
- if (height != 0) {
- setHeightThumbnail(height, metrics);
- } else {
- requireView().getViewTreeObserver().addOnPreDrawListener(preDrawListener);
- }
- } else {
- final int height = (int) (isPortrait
- ? metrics.widthPixels / (16.0f / 9.0f)
- : metrics.heightPixels / 2.0f);
- setHeightThumbnail(height, metrics);
- }
- }
-
- private void setHeightThumbnail(final int newHeight, final DisplayMetrics metrics) {
- binding.detailThumbnailImageView.setLayoutParams(
- new FrameLayout.LayoutParams(
- RelativeLayout.LayoutParams.MATCH_PARENT, newHeight));
- binding.detailThumbnailImageView.setMinimumHeight(newHeight);
- if (isPlayerAvailable()) {
- final int maxHeight = (int) (metrics.heightPixels * MAX_PLAYER_HEIGHT);
- player.UIs().get(VideoPlayerUi.class).ifPresent(ui ->
- ui.getBinding().surfaceView.setHeights(newHeight,
- ui.isFullscreen() ? newHeight : maxHeight));
- }
- }
-
- private void showContent() {
- binding.detailContentRootHiding.setVisibility(View.VISIBLE);
- }
-
- protected void setInitialData(final int newServiceId,
- @Nullable final String newUrl,
- @NonNull final String newTitle,
- @Nullable final PlayQueue newPlayQueue) {
- this.serviceId = newServiceId;
- this.url = newUrl;
- this.title = newTitle;
- this.playQueue = newPlayQueue;
- }
-
- private void setErrorImage(final int imageResource) {
- if (binding == null || activity == null) {
- return;
- }
-
- binding.detailThumbnailImageView.setImageDrawable(
- AppCompatResources.getDrawable(requireContext(), imageResource));
- animate(binding.detailThumbnailImageView, false, 0, AnimationType.ALPHA,
- 0, () -> animate(binding.detailThumbnailImageView, true, 500));
- }
-
- @Override
- public void handleError() {
- super.handleError();
- setErrorImage(R.drawable.not_available_monkey);
-
- if (binding.relatedItemsLayout != null) { // hide related streams for tablets
- binding.relatedItemsLayout.setVisibility(View.INVISIBLE);
- }
-
- // hide comments / related streams / description tabs
- binding.viewPager.setVisibility(View.GONE);
- binding.tabLayout.setVisibility(View.GONE);
- }
-
- private void hideAgeRestrictedContent() {
- showTextError(getString(R.string.restricted_video,
- getString(R.string.show_age_restricted_content_title)));
- }
-
- private void setupBroadcastReceiver() {
- broadcastReceiver = new BroadcastReceiver() {
- @Override
- public void onReceive(final Context context, final Intent intent) {
- switch (intent.getAction()) {
- case ACTION_SHOW_MAIN_PLAYER:
- bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
- break;
- case ACTION_HIDE_MAIN_PLAYER:
- bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
- break;
- case ACTION_PLAYER_STARTED:
- // If the state is not hidden we don't need to show the mini player
- if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_HIDDEN) {
- bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
- }
- // Rebound to the service if it was closed via notification or mini player
- if (!playerHolder.isBound()) {
- playerHolder.startService(
- false, VideoDetailFragment.this);
- }
- break;
- }
- }
- };
- final IntentFilter intentFilter = new IntentFilter();
- intentFilter.addAction(ACTION_SHOW_MAIN_PLAYER);
- intentFilter.addAction(ACTION_HIDE_MAIN_PLAYER);
- intentFilter.addAction(ACTION_PLAYER_STARTED);
- ContextCompat.registerReceiver(activity, broadcastReceiver, intentFilter,
- ContextCompat.RECEIVER_EXPORTED);
- }
-
-
- /*//////////////////////////////////////////////////////////////////////////
- // Orientation listener
- //////////////////////////////////////////////////////////////////////////*/
-
- private void restoreDefaultOrientation() {
- if (isPlayerAvailable() && player.videoPlayerSelected()) {
- toggleFullscreenIfInFullscreenMode();
- }
-
- // This will show systemUI and pause the player.
- // User can tap on Play button and video will be in fullscreen mode again
- // Note for tablet: trying to avoid orientation changes since it's not easy
- // to physically rotate the tablet every time
- if (activity != null && !DeviceUtils.isTablet(activity)) {
- activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Contract
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- public void showLoading() {
-
- super.showLoading();
-
- //if data is already cached, transition from VISIBLE -> INVISIBLE -> VISIBLE is not required
- if (!ExtractorHelper.isCached(serviceId, url, InfoCache.Type.STREAM)) {
- binding.detailContentRootHiding.setVisibility(View.INVISIBLE);
- }
-
- animate(binding.detailThumbnailPlayButton, false, 50);
- animate(binding.detailDurationView, false, 100);
- binding.detailPositionView.setVisibility(View.GONE);
- binding.positionView.setVisibility(View.GONE);
-
- binding.detailVideoTitleView.setText(title);
- binding.detailVideoTitleView.setMaxLines(1);
- animate(binding.detailVideoTitleView, true, 0);
-
- binding.detailToggleSecondaryControlsView.setVisibility(View.GONE);
- binding.detailTitleRootLayout.setClickable(false);
- binding.detailSecondaryControlPanel.setVisibility(View.GONE);
-
- if (binding.relatedItemsLayout != null) {
- if (showRelatedItems) {
- binding.relatedItemsLayout.setVisibility(
- isFullscreen() ? View.GONE : View.INVISIBLE);
- } else {
- binding.relatedItemsLayout.setVisibility(View.GONE);
- }
- }
-
- CoilUtils.dispose(binding.detailThumbnailImageView);
- CoilUtils.dispose(binding.detailSubChannelThumbnailView);
- CoilUtils.dispose(binding.overlayThumbnail);
- CoilUtils.dispose(binding.detailUploaderThumbnailView);
- binding.detailThumbnailImageView.setImageBitmap(null);
- binding.detailSubChannelThumbnailView.setImageBitmap(null);
- }
-
- @Override
- public void handleResult(@NonNull final StreamInfo info) {
- super.handleResult(info);
-
- currentInfo = info;
- setInitialData(info.getServiceId(), info.getOriginalUrl(), info.getName(), playQueue);
-
- updateTabs(info);
-
- animate(binding.detailThumbnailPlayButton, true, 200);
- binding.detailVideoTitleView.setText(title);
-
- binding.detailSubChannelThumbnailView.setVisibility(View.GONE);
-
- if (!isEmpty(info.getSubChannelName())) {
- displayBothUploaderAndSubChannel(info);
- } else {
- displayUploaderAsSubChannel(info);
- }
-
- if (info.getViewCount() >= 0) {
- if (info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) {
- binding.detailViewCountView.setText(Localization.listeningCount(activity,
- info.getViewCount()));
- } else if (info.getStreamType().equals(StreamType.LIVE_STREAM)) {
- binding.detailViewCountView.setText(Localization
- .localizeWatchingCount(activity, info.getViewCount()));
- } else {
- binding.detailViewCountView.setText(Localization
- .localizeViewCount(activity, info.getViewCount()));
- }
- binding.detailViewCountView.setVisibility(View.VISIBLE);
- } else {
- binding.detailViewCountView.setVisibility(View.GONE);
- }
-
- if (info.getDislikeCount() == -1 && info.getLikeCount() == -1) {
- binding.detailThumbsDownImgView.setVisibility(View.VISIBLE);
- binding.detailThumbsUpImgView.setVisibility(View.VISIBLE);
- binding.detailThumbsUpCountView.setVisibility(View.GONE);
- binding.detailThumbsDownCountView.setVisibility(View.GONE);
-
- binding.detailThumbsDisabledView.setVisibility(View.VISIBLE);
- } else {
- if (info.getDislikeCount() >= 0) {
- binding.detailThumbsDownCountView.setText(Localization
- .shortCount(activity, info.getDislikeCount()));
- binding.detailThumbsDownCountView.setVisibility(View.VISIBLE);
- binding.detailThumbsDownImgView.setVisibility(View.VISIBLE);
- } else {
- binding.detailThumbsDownCountView.setVisibility(View.GONE);
- binding.detailThumbsDownImgView.setVisibility(View.GONE);
- }
-
- if (info.getLikeCount() >= 0) {
- binding.detailThumbsUpCountView.setText(Localization.shortCount(activity,
- info.getLikeCount()));
- binding.detailThumbsUpCountView.setVisibility(View.VISIBLE);
- binding.detailThumbsUpImgView.setVisibility(View.VISIBLE);
- } else {
- binding.detailThumbsUpCountView.setVisibility(View.GONE);
- binding.detailThumbsUpImgView.setVisibility(View.GONE);
- }
- binding.detailThumbsDisabledView.setVisibility(View.GONE);
- }
-
- if (info.getDuration() > 0) {
- binding.detailDurationView.setText(Localization.getDurationString(info.getDuration()));
- binding.detailDurationView.setBackgroundColor(
- ContextCompat.getColor(activity, R.color.duration_background_color));
- animate(binding.detailDurationView, true, 100);
- } else if (info.getStreamType() == StreamType.LIVE_STREAM) {
- binding.detailDurationView.setText(R.string.duration_live);
- binding.detailDurationView.setBackgroundColor(
- ContextCompat.getColor(activity, R.color.live_duration_background_color));
- animate(binding.detailDurationView, true, 100);
- } else {
- binding.detailDurationView.setVisibility(View.GONE);
- }
-
- binding.detailTitleRootLayout.setClickable(true);
- binding.detailToggleSecondaryControlsView.setRotation(0);
- binding.detailToggleSecondaryControlsView.setVisibility(View.VISIBLE);
- binding.detailSecondaryControlPanel.setVisibility(View.GONE);
-
- checkUpdateProgressInfo(info);
- CoilHelper.INSTANCE.loadDetailsThumbnail(binding.detailThumbnailImageView,
- info.getThumbnails());
- showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView,
- binding.detailMetaInfoSeparator, disposables);
-
- if (!isPlayerAvailable() || player.isStopped()) {
- updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails());
- }
-
- if (!info.getErrors().isEmpty()) {
- // Bandcamp fan pages are not yet supported and thus a ContentNotAvailableException is
- // thrown. This is not an error and thus should not be shown to the user.
- for (final Throwable throwable : info.getErrors()) {
- if (throwable instanceof ContentNotSupportedException
- && "Fan pages are not supported".equals(throwable.getMessage())) {
- info.getErrors().remove(throwable);
- }
- }
-
- if (!info.getErrors().isEmpty()) {
- showSnackBarError(new ErrorInfo(info.getErrors(), UserAction.REQUESTED_STREAM,
- "Some info not extracted: " + info.getUrl(), info));
- }
- }
-
- binding.detailControlsDownload.setVisibility(
- StreamTypeUtil.isLiveStream(info.getStreamType()) ? View.GONE : View.VISIBLE);
- binding.detailControlsBackground.setVisibility(
- info.getAudioStreams().isEmpty() && info.getVideoStreams().isEmpty()
- ? View.GONE : View.VISIBLE);
-
- final boolean noVideoStreams =
- info.getVideoStreams().isEmpty() && info.getVideoOnlyStreams().isEmpty();
- binding.detailControlsPopup.setVisibility(noVideoStreams ? View.GONE : View.VISIBLE);
- binding.detailThumbnailPlayButton.setImageResource(
- noVideoStreams ? R.drawable.ic_headset_shadow : R.drawable.ic_play_arrow_shadow);
- }
-
- private void displayUploaderAsSubChannel(final StreamInfo info) {
- binding.detailSubChannelTextView.setText(info.getUploaderName());
- binding.detailSubChannelTextView.setVisibility(View.VISIBLE);
- binding.detailSubChannelTextView.setSelected(true);
-
- if (info.getUploaderSubscriberCount() > -1) {
- binding.detailUploaderTextView.setText(
- Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount()));
- binding.detailUploaderTextView.setVisibility(View.VISIBLE);
- } else {
- binding.detailUploaderTextView.setVisibility(View.GONE);
- }
-
- CoilHelper.INSTANCE.loadAvatar(binding.detailSubChannelThumbnailView,
- info.getUploaderAvatars());
- binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE);
- binding.detailUploaderThumbnailView.setVisibility(View.GONE);
- }
-
- private void displayBothUploaderAndSubChannel(final StreamInfo info) {
- binding.detailSubChannelTextView.setText(info.getSubChannelName());
- binding.detailSubChannelTextView.setVisibility(View.VISIBLE);
- binding.detailSubChannelTextView.setSelected(true);
-
- final StringBuilder subText = new StringBuilder();
- if (!isEmpty(info.getUploaderName())) {
- subText.append(
- String.format(getString(R.string.video_detail_by), info.getUploaderName()));
- }
- if (info.getUploaderSubscriberCount() > -1) {
- if (subText.length() > 0) {
- subText.append(Localization.DOT_SEPARATOR);
- }
- subText.append(
- Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount()));
- }
-
- if (subText.length() > 0) {
- binding.detailUploaderTextView.setText(subText);
- binding.detailUploaderTextView.setVisibility(View.VISIBLE);
- binding.detailUploaderTextView.setSelected(true);
- } else {
- binding.detailUploaderTextView.setVisibility(View.GONE);
- }
-
- CoilHelper.INSTANCE.loadAvatar(binding.detailSubChannelThumbnailView,
- info.getSubChannelAvatars());
- binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE);
- CoilHelper.INSTANCE.loadAvatar(binding.detailUploaderThumbnailView,
- info.getUploaderAvatars());
- binding.detailUploaderThumbnailView.setVisibility(View.VISIBLE);
- }
-
- public void openDownloadDialog() {
- if (currentInfo == null) {
- return;
- }
-
- try {
- final DownloadDialog downloadDialog = new DownloadDialog(activity, currentInfo);
- downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog");
- } catch (final Exception e) {
- ErrorUtil.showSnackbar(activity, new ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG,
- "Showing download dialog", currentInfo));
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Stream Results
- //////////////////////////////////////////////////////////////////////////*/
-
- private void checkUpdateProgressInfo(@NonNull final StreamInfo info) {
- if (positionSubscriber != null) {
- positionSubscriber.dispose();
- }
- if (!getResumePlaybackEnabled(activity)) {
- binding.positionView.setVisibility(View.GONE);
- binding.detailPositionView.setVisibility(View.GONE);
- return;
- }
- final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext());
- positionSubscriber = recordManager.loadStreamState(info)
- .subscribeOn(Schedulers.io())
- .onErrorComplete()
- .observeOn(AndroidSchedulers.mainThread())
- .subscribe(state -> {
- updatePlaybackProgress(
- state.getProgressMillis(), info.getDuration() * 1000);
- }, e -> {
- // impossible since the onErrorComplete()
- }, () -> {
- binding.positionView.setVisibility(View.GONE);
- binding.detailPositionView.setVisibility(View.GONE);
- });
- }
-
- private void updatePlaybackProgress(final long progress, final long duration) {
- if (!getResumePlaybackEnabled(activity)) {
- return;
- }
- final int progressSeconds = (int) TimeUnit.MILLISECONDS.toSeconds(progress);
- final int durationSeconds = (int) TimeUnit.MILLISECONDS.toSeconds(duration);
- // If the old and the new progress values have a big difference then use animation.
- // Otherwise don't because it affects CPU
- final int progressDifference = Math.abs(binding.positionView.getProgress()
- - progressSeconds);
- binding.positionView.setMax(durationSeconds);
- if (progressDifference > 2) {
- binding.positionView.setProgressAnimated(progressSeconds);
- } else {
- binding.positionView.setProgress(progressSeconds);
- }
- final String position = Localization.getDurationString(progressSeconds);
- if (position != binding.detailPositionView.getText()) {
- binding.detailPositionView.setText(position);
- }
- if (binding.positionView.getVisibility() != View.VISIBLE) {
- animate(binding.positionView, true, 100);
- animate(binding.detailPositionView, true, 100);
- }
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Player event listener
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- public void onViewCreated() {
- tryAddVideoPlayerView();
- }
-
- @Override
- public void onQueueUpdate(final PlayQueue queue) {
- playQueue = queue;
- if (DEBUG) {
- Log.d(TAG, "onQueueUpdate() called with: serviceId = ["
- + serviceId + "], url = [" + url + "], name = ["
- + title + "], playQueue = [" + playQueue + "]");
- }
-
- // Register broadcast receiver to listen to playQueue changes
- // and hide the overlayPlayQueueButton when the playQueue is empty / destroyed.
- if (playQueue != null && playQueue.getBroadcastReceiver() != null) {
- playQueue.getBroadcastReceiver().subscribe(
- event -> updateOverlayPlayQueueButtonVisibility()
- );
- }
-
- // This should be the only place where we push data to stack.
- // It will allow to have live instance of PlayQueue with actual information about
- // deleted/added items inside Channel/Playlist queue and makes possible to have
- // a history of played items
- @Nullable final StackItem stackPeek = stack.peek();
- if (stackPeek != null && !stackPeek.getPlayQueue().equalStreams(queue)) {
- @Nullable final PlayQueueItem playQueueItem = queue.getItem();
- if (playQueueItem != null) {
- stack.push(new StackItem(playQueueItem.getServiceId(), playQueueItem.getUrl(),
- playQueueItem.getTitle(), queue));
- return;
- } // else continue below
- }
-
- @Nullable final StackItem stackWithQueue = findQueueInStack(queue);
- if (stackWithQueue != null) {
- // On every MainPlayer service's destroy() playQueue gets disposed and
- // no longer able to track progress. That's why we update our cached disposed
- // queue with the new one that is active and have the same history.
- // Without that the cached playQueue will have an old recovery position
- stackWithQueue.setPlayQueue(queue);
- }
- }
-
- @Override
- public void onPlaybackUpdate(final int state,
- final int repeatMode,
- final boolean shuffled,
- final PlaybackParameters parameters) {
- setOverlayPlayPauseImage(player != null && player.isPlaying());
-
- switch (state) {
- case Player.STATE_PLAYING:
- if (binding.positionView.getAlpha() != 1.0f
- && player.getPlayQueue() != null
- && player.getPlayQueue().getItem() != null
- && player.getPlayQueue().getItem().getUrl().equals(url)) {
- animate(binding.positionView, true, 100);
- animate(binding.detailPositionView, true, 100);
- }
- break;
- }
- }
-
- @Override
- public void onProgressUpdate(final int currentProgress,
- final int duration,
- final int bufferPercent) {
- // Progress updates every second even if media is paused. It's useless until playing
- if (!player.isPlaying() || playQueue == null) {
- return;
- }
-
- if (player.getPlayQueue().getItem().getUrl().equals(url)) {
- updatePlaybackProgress(currentProgress, duration);
- }
- }
-
- @Override
- public void onMetadataUpdate(final StreamInfo info, final PlayQueue queue) {
- final StackItem item = findQueueInStack(queue);
- if (item != null) {
- // When PlayQueue can have multiple streams (PlaylistPlayQueue or ChannelPlayQueue)
- // every new played stream gives new title and url.
- // StackItem contains information about first played stream. Let's update it here
- item.setTitle(info.getName());
- item.setUrl(info.getUrl());
- }
- // They are not equal when user watches something in popup while browsing in fragment and
- // then changes screen orientation. In that case the fragment will set itself as
- // a service listener and will receive initial call to onMetadataUpdate()
- if (!queue.equalStreams(playQueue)) {
- return;
- }
-
- updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails());
- if (currentInfo != null && info.getUrl().equals(currentInfo.getUrl())) {
- return;
- }
-
- currentInfo = info;
- setInitialData(info.getServiceId(), info.getUrl(), info.getName(), queue);
- setAutoPlay(false);
- // Delay execution just because it freezes the main thread, and while playing
- // next/previous video you see visual glitches
- // (when non-vertical video goes after vertical video)
- prepareAndHandleInfoIfNeededAfterDelay(info, true, 200);
- }
-
- @Override
- public void onPlayerError(final PlaybackException error, final boolean isCatchableException) {
- if (!isCatchableException) {
- // Properly exit from fullscreen
- toggleFullscreenIfInFullscreenMode();
- hideMainPlayerOnLoadingNewStream();
- }
- }
-
- @Override
- public void onServiceStopped() {
- // the binding could be null at this point, if the app is finishing
- if (binding != null) {
- setOverlayPlayPauseImage(false);
- if (currentInfo != null) {
- updateOverlayData(currentInfo.getName(),
- currentInfo.getUploaderName(),
- currentInfo.getThumbnails());
- }
- updateOverlayPlayQueueButtonVisibility();
- }
- }
-
- @Override
- public void onFullscreenStateChanged(final boolean fullscreen) {
- setupBrightness();
- if (!isPlayerAndPlayerServiceAvailable()
- || player.UIs().get(MainPlayerUi.class).isEmpty()
- || getRoot().map(View::getParent).isEmpty()) {
- return;
- }
-
- if (fullscreen) {
- hideSystemUiIfNeeded();
- binding.overlayPlayPauseButton.requestFocus();
- } else {
- showSystemUi();
- }
-
- if (binding.relatedItemsLayout != null) {
- if (showRelatedItems) {
- binding.relatedItemsLayout.setVisibility(fullscreen ? View.GONE : View.VISIBLE);
- } else {
- binding.relatedItemsLayout.setVisibility(View.GONE);
- }
- }
- scrollToTop();
-
- tryAddVideoPlayerView();
- }
-
- @Override
- public void onScreenRotationButtonClicked() {
- // On Android TV screen rotation is not supported
- // In tablet user experience will be better if screen will not be rotated
- // from landscape to portrait every time.
- // Just turn on fullscreen mode in landscape orientation
- // or portrait & unlocked global orientation
- final boolean isLandscape = DeviceUtils.isLandscape(requireContext());
- if (DeviceUtils.isTv(activity) || DeviceUtils.isTablet(activity)
- && (!globalScreenOrientationLocked(activity) || isLandscape)) {
- player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::toggleFullscreen);
- return;
- }
-
- final int newOrientation = isLandscape
- ? ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
- : ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE;
-
- activity.setRequestedOrientation(newOrientation);
- }
-
- /*
- * Will scroll down to description view after long click on moreOptionsButton
- * */
- @Override
- public void onMoreOptionsLongClicked() {
- final CoordinatorLayout.LayoutParams params =
- (CoordinatorLayout.LayoutParams) binding.appBarLayout.getLayoutParams();
- final AppBarLayout.Behavior behavior = (AppBarLayout.Behavior) params.getBehavior();
- final ValueAnimator valueAnimator = ValueAnimator
- .ofInt(0, -binding.playerPlaceholder.getHeight());
- valueAnimator.setInterpolator(new DecelerateInterpolator());
- valueAnimator.addUpdateListener(animation -> {
- behavior.setTopAndBottomOffset((int) animation.getAnimatedValue());
- binding.appBarLayout.requestLayout();
- });
- valueAnimator.setInterpolator(new DecelerateInterpolator());
- valueAnimator.setDuration(500);
- valueAnimator.start();
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Player related utils
- //////////////////////////////////////////////////////////////////////////*/
-
- private void showSystemUi() {
- if (DEBUG) {
- Log.d(TAG, "showSystemUi() called");
- }
-
- if (activity == null) {
- return;
- }
-
- // Prevent jumping of the player on devices with cutout
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- activity.getWindow().getAttributes().layoutInDisplayCutoutMode =
- WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT;
- }
- activity.getWindow().getDecorView().setSystemUiVisibility(0);
- activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
- activity.getWindow().setStatusBarColor(ThemeHelper.resolveColorFromAttr(
- requireContext(), android.R.attr.colorPrimary));
- }
-
- private void hideSystemUi() {
- if (DEBUG) {
- Log.d(TAG, "hideSystemUi() called");
- }
-
- if (activity == null) {
- return;
- }
-
- // Prevent jumping of the player on devices with cutout
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
- activity.getWindow().getAttributes().layoutInDisplayCutoutMode =
- WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
- }
- int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
- | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
- | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
- | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
- | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
-
- // In multiWindow mode status bar is not transparent for devices with cutout
- // if I include this flag. So without it is better in this case
- final boolean isInMultiWindow = DeviceUtils.isInMultiWindow(activity);
- if (!isInMultiWindow) {
- visibility |= View.SYSTEM_UI_FLAG_FULLSCREEN;
- }
- activity.getWindow().getDecorView().setSystemUiVisibility(visibility);
-
- if (isInMultiWindow || isFullscreen()) {
- activity.getWindow().setStatusBarColor(Color.TRANSPARENT);
- activity.getWindow().setNavigationBarColor(Color.TRANSPARENT);
- }
- activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
- }
-
- // Listener implementation
- @Override
- public void hideSystemUiIfNeeded() {
- if (isFullscreen()
- && bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) {
- hideSystemUi();
- }
- }
-
- private boolean isFullscreen() {
- return isPlayerAvailable() && player.UIs().get(VideoPlayerUi.class)
- .map(VideoPlayerUi::isFullscreen).orElse(false);
- }
-
- private boolean playerIsNotStopped() {
- return isPlayerAvailable() && !player.isStopped();
- }
-
- private void restoreDefaultBrightness() {
- final WindowManager.LayoutParams lp = activity.getWindow().getAttributes();
- if (lp.screenBrightness == -1) {
- return;
- }
-
- // Restore the old brightness when fragment.onPause() called or
- // when a player is in portrait
- lp.screenBrightness = -1;
- activity.getWindow().setAttributes(lp);
- }
-
- private void setupBrightness() {
- if (activity == null) {
- return;
- }
-
- final WindowManager.LayoutParams lp = activity.getWindow().getAttributes();
- if (!isFullscreen() || bottomSheetState != BottomSheetBehavior.STATE_EXPANDED) {
- // Apply system brightness when the player is not in fullscreen
- restoreDefaultBrightness();
- } else {
- // Do not restore if user has disabled brightness gesture
- if (!PlayerHelper.getActionForRightGestureSide(activity)
- .equals(getString(R.string.brightness_control_key))
- && !PlayerHelper.getActionForLeftGestureSide(activity)
- .equals(getString(R.string.brightness_control_key))) {
- return;
- }
- // Restore already saved brightness level
- final float brightnessLevel = PlayerHelper.getScreenBrightness(activity);
- if (brightnessLevel == lp.screenBrightness) {
- return;
- }
- lp.screenBrightness = brightnessLevel;
- activity.getWindow().setAttributes(lp);
- }
- }
-
- /**
- * Make changes to the UI to accommodate for better usability on bigger screens such as TVs
- * or in Android's desktop mode (DeX etc).
- */
- private void accommodateForTvAndDesktopMode() {
- if (DeviceUtils.isTv(getContext())) {
- // remove ripple effects from detail controls
- final int transparent = ContextCompat.getColor(requireContext(),
- R.color.transparent_background_color);
- binding.detailControlsPlaylistAppend.setBackgroundColor(transparent);
- binding.detailControlsBackground.setBackgroundColor(transparent);
- binding.detailControlsPopup.setBackgroundColor(transparent);
- binding.detailControlsDownload.setBackgroundColor(transparent);
- binding.detailControlsShare.setBackgroundColor(transparent);
- binding.detailControlsOpenInBrowser.setBackgroundColor(transparent);
- binding.detailControlsPlayWithKodi.setBackgroundColor(transparent);
- }
- if (DeviceUtils.isDesktopMode(getContext())) {
- // Remove the "hover" overlay (since it is visible on all mouse events and interferes
- // with the video content being played)
- binding.detailThumbnailRootLayout.setForeground(null);
- }
- }
-
- private void checkLandscape() {
- if ((!player.isPlaying() && player.getPlayQueue() != playQueue)
- || player.getPlayQueue() == null) {
- setAutoPlay(true);
- }
-
- player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::checkLandscape);
- // Let's give a user time to look at video information page if video is not playing
- if (globalScreenOrientationLocked(activity) && !player.isPlaying()) {
- player.play();
- }
- }
-
- /*
- * Means that the player fragment was swiped away via BottomSheetLayout
- * and is empty but ready for any new actions. See cleanUp()
- * */
- private boolean wasCleared() {
- return url == null;
- }
-
- @Nullable
- private StackItem findQueueInStack(final PlayQueue queue) {
- StackItem item = null;
- final Iterator