+ * 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
new file mode 100644
index 000000000..6527bd2ae
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.kt
@@ -0,0 +1,80 @@
+/*
+ * 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
new file mode 100644
index 000000000..4cdcc6c69
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt
@@ -0,0 +1,186 @@
+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
new file mode 100644
index 000000000..f0d1af81a
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/PanicResponderActivity.java
@@ -0,0 +1,44 @@
+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
new file mode 100644
index 000000000..3eeb912c8
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java
@@ -0,0 +1,94 @@
+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
new file mode 100644
index 000000000..2997f937f
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java
@@ -0,0 +1,1078 @@
+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
new file mode 100644
index 000000000..ed5951f04
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt
@@ -0,0 +1,260 @@
+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
new file mode 100644
index 000000000..fc50c646d
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/about/License.kt
@@ -0,0 +1,11 @@
+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
new file mode 100644
index 000000000..bd0632c13
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt
@@ -0,0 +1,142 @@
+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
new file mode 100644
index 000000000..32e4f812f
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt
@@ -0,0 +1,55 @@
+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
new file mode 100644
index 000000000..a43ddfd5e
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/about/SoftwareComponent.kt
@@ -0,0 +1,17 @@
+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
new file mode 100644
index 000000000..c5b9618fe
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.kt
@@ -0,0 +1,21 @@
+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
new file mode 100644
index 000000000..286eddf7b
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.kt
@@ -0,0 +1,68 @@
+/*
+ * 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
new file mode 100644
index 000000000..74c7cc87c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/BasicDAO.kt
@@ -0,0 +1,42 @@
+/*
+ * 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
new file mode 100644
index 000000000..f9cbb1de2
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/Converters.kt
@@ -0,0 +1,52 @@
+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
new file mode 100644
index 000000000..944b247bf
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt
@@ -0,0 +1,19 @@
+/*
+ * 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
new file mode 100644
index 000000000..414f74893
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/Migrations.kt
@@ -0,0 +1,351 @@
+/*
+ * 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
new file mode 100644
index 000000000..5861fa767
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt
@@ -0,0 +1,237 @@
+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
new file mode 100644
index 000000000..217eef03f
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedGroupDAO.kt
@@ -0,0 +1,67 @@
+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
new file mode 100644
index 000000000..86568bc90
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedEntity.kt
@@ -0,0 +1,50 @@
+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
new file mode 100644
index 000000000..1dd26946a
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupEntity.kt
@@ -0,0 +1,39 @@
+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
new file mode 100644
index 000000000..6dac3c89c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupSubscriptionEntity.kt
@@ -0,0 +1,50 @@
+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
new file mode 100644
index 000000000..fc0ee6742
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedLastUpdatedEntity.kt
@@ -0,0 +1,39 @@
+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
new file mode 100644
index 000000000..ddcb00489
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.kt
@@ -0,0 +1,43 @@
+/*
+ * 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
new file mode 100644
index 000000000..916d4e5ed
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt
@@ -0,0 +1,61 @@
+/*
+ * 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
new file mode 100644
index 000000000..eee213453
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/history/model/SearchHistoryEntry.kt
@@ -0,0 +1,47 @@
+/*
+ * 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
new file mode 100644
index 000000000..deba7dd3a
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.kt
@@ -0,0 +1,56 @@
+/*
+ * 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
new file mode 100644
index 000000000..816b25c2a
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntry.kt
@@ -0,0 +1,44 @@
+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
new file mode 100644
index 000000000..84972a89e
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.kt
@@ -0,0 +1,54 @@
+/*
+ * 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
new file mode 100644
index 000000000..4f2f79aa0
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt
@@ -0,0 +1,16 @@
+/*
+ * 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
new file mode 100644
index 000000000..9b62c1380
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.kt
@@ -0,0 +1,42 @@
+/*
+ * 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
new file mode 100644
index 000000000..90fdee2d3
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt
@@ -0,0 +1,49 @@
+/*
+ * 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
new file mode 100644
index 000000000..9c2dd89a8
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt
@@ -0,0 +1,48 @@
+/*
+ * 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
new file mode 100644
index 000000000..36a80bc91
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt
@@ -0,0 +1,55 @@
+/*
+ * 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
new file mode 100644
index 000000000..c6b6e37a4
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt
@@ -0,0 +1,136 @@
+/*
+ * 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
new file mode 100644
index 000000000..1f1862f4f
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.kt
@@ -0,0 +1,54 @@
+/*
+ * 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
new file mode 100644
index 000000000..254fa425a
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.kt
@@ -0,0 +1,100 @@
+/*
+ * 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
new file mode 100644
index 000000000..6ab1b6ac4
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.kt
@@ -0,0 +1,68 @@
+/*
+ * 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
new file mode 100644
index 000000000..ce74678ca
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt
@@ -0,0 +1,59 @@
+/*
+ * 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
new file mode 100644
index 000000000..abeabf888
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/stream/StreamWithState.kt
@@ -0,0 +1,14 @@
+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
new file mode 100644
index 000000000..86ba262f5
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt
@@ -0,0 +1,148 @@
+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
new file mode 100644
index 000000000..f3c44f1f2
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt
@@ -0,0 +1,45 @@
+/*
+ * 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
new file mode 100644
index 000000000..067f666b6
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.kt
@@ -0,0 +1,132 @@
+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
new file mode 100644
index 000000000..759a2dcec
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.kt
@@ -0,0 +1,85 @@
+/*
+ * 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
new file mode 100644
index 000000000..f9bb18c0c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.kt
@@ -0,0 +1,18 @@
+/*
+ * 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
new file mode 100644
index 000000000..72bdbcf5c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt
@@ -0,0 +1,112 @@
+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
new file mode 100644
index 000000000..7df9830e4
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt
@@ -0,0 +1,87 @@
+/*
+ * 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
new file mode 100644
index 000000000..33702a6a3
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/download/DownloadActivity.java
@@ -0,0 +1,94 @@
+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
new file mode 100644
index 000000000..178fcefe1
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
@@ -0,0 +1,1127 @@
+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
new file mode 100644
index 000000000..9e6861908
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/download/LoadingDialog.java
@@ -0,0 +1,87 @@
+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
new file mode 100644
index 000000000..90d8f4797
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java
@@ -0,0 +1,43 @@
+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
new file mode 100644
index 000000000..e63d55063
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/error/AcraReportSenderFactory.java
@@ -0,0 +1,44 @@
+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
new file mode 100644
index 000000000..c68a2cfd1
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.kt
@@ -0,0 +1,282 @@
+/*
+ * 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
new file mode 100644
index 000000000..82f7d84bf
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt
@@ -0,0 +1,364 @@
+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
new file mode 100644
index 000000000..8136c78d8
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt
@@ -0,0 +1,141 @@
+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
new file mode 100644
index 000000000..0fa302623
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt
@@ -0,0 +1,170 @@
+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
new file mode 100644
index 000000000..811671039
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java
@@ -0,0 +1,234 @@
+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
new file mode 100644
index 000000000..b3f14e2da
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/error/UserAction.kt
@@ -0,0 +1,44 @@
+/*
+ * 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
new file mode 100644
index 000000000..6add5eb09
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/fragments/BackPressable.java
@@ -0,0 +1,13 @@
+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
new file mode 100644
index 000000000..8361953b9
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java
@@ -0,0 +1,227 @@
+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
new file mode 100644
index 000000000..66e132aff
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/fragments/BlankFragment.java
@@ -0,0 +1,71 @@
+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
new file mode 100644
index 000000000..d4e73bcac
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java
@@ -0,0 +1,33 @@
+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
new file mode 100644
index 000000000..1a5e5aa45
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java
@@ -0,0 +1,343 @@
+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
new file mode 100644
index 000000000..6b17803c4
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/fragments/OnScrollBelowItemsListener.java
@@ -0,0 +1,47 @@
+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
new file mode 100644
index 000000000..78f644ffb
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/fragments/ViewContract.java
@@ -0,0 +1,13 @@
+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
new file mode 100644
index 000000000..bd174a121
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java
@@ -0,0 +1,281 @@
+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
new file mode 100644
index 000000000..2b0d22a32
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java
@@ -0,0 +1,140 @@
+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
new file mode 100644
index 000000000..5016a49f6
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/StackItem.java
@@ -0,0 +1,56 @@
+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
new file mode 100644
index 000000000..1a11836d4
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/TabAdapter.java
@@ -0,0 +1,96 @@
+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
new file mode 100644
index 000000000..ee93e3138
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
@@ -0,0 +1,2521 @@
+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