diff --git a/app/build.gradle b/app/build.gradle index a1afd63a2..3b0b8fe6c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -62,7 +62,8 @@ dependencies { exclude module: 'support-annotations' }) - implementation 'com.github.TeamNewPipe:NewPipeExtractor:5c420340ceb39' + implementation 'com.github.TeamNewPipe:NewPipeExtractor:43b54cc' + testImplementation 'junit:junit:4.12' testImplementation 'org.mockito:mockito-core:2.23.0' diff --git a/app/src/main/java/org/schabi/newpipe/App.java b/app/src/main/java/org/schabi/newpipe/App.java index 8698b3c93..7f050e6c7 100644 --- a/app/src/main/java/org/schabi/newpipe/App.java +++ b/app/src/main/java/org/schabi/newpipe/App.java @@ -6,9 +6,9 @@ import android.app.NotificationChannel; import android.app.NotificationManager; import android.content.Context; import android.os.Build; +import android.util.Log; import androidx.annotation.Nullable; -import android.util.Log; import com.nostra13.universalimageloader.cache.memory.impl.LRULimitedMemoryCache; import com.nostra13.universalimageloader.core.ImageLoader; @@ -29,6 +29,7 @@ import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.settings.SettingsActivity; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.StateSaver; import java.io.IOException; @@ -103,6 +104,8 @@ public class App extends Application { StateSaver.init(this); initNotificationChannel(); + ServiceHelper.initServices(this); + // Initialize image loader ImageLoader.getInstance().init(getImageLoaderConfigurations(10, 50)); diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index 8d2702d0b..bda6132d1 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -32,14 +32,18 @@ import android.preference.PreferenceManager; import android.util.Log; import android.view.Gravity; import android.view.KeyEvent; +import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.Window; import android.view.WindowManager; +import android.widget.AdapterView; +import android.widget.ArrayAdapter; import android.widget.Button; import android.widget.ImageView; +import android.widget.Spinner; import android.widget.TextView; import androidx.annotation.NonNull; @@ -50,12 +54,15 @@ import androidx.appcompat.widget.Toolbar; import androidx.core.view.GravityCompat; import androidx.drawerlayout.widget.DrawerLayout; import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; import com.google.android.material.navigation.NavigationView; import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; import org.schabi.newpipe.fragments.BackPressable; import org.schabi.newpipe.fragments.MainFragment; import org.schabi.newpipe.fragments.detail.VideoDetailFragment; @@ -65,12 +72,16 @@ import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.FireTvUtils; import org.schabi.newpipe.util.KioskTranslator; import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.PeertubeHelper; import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.StateSaver; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.views.FocusOverlayView; +import java.util.ArrayList; +import java.util.List; + public class MainActivity extends AppCompatActivity { private static final String TAG = "MainActivity"; public static final boolean DEBUG = !BuildConfig.BUILD_TYPE.equals("release"); @@ -309,13 +320,57 @@ public class MainActivity extends AppCompatActivity { final String title = s.getServiceInfo().getName() + (ServiceHelper.isBeta(s) ? " (beta)" : ""); - drawerItems.getMenu() + MenuItem menuItem = drawerItems.getMenu() .add(R.id.menu_services_group, s.getServiceId(), ORDER, title) .setIcon(ServiceHelper.getIcon(s.getServiceId())); + + // peertube specifics + if(s.getServiceId() == 3){ + enhancePeertubeMenu(s, menuItem); + } } drawerItems.getMenu().getItem(ServiceHelper.getSelectedServiceId(this)).setChecked(true); } + private void enhancePeertubeMenu(StreamingService s, MenuItem menuItem) { + PeertubeInstance currentInstace = PeertubeHelper.getCurrentInstance(); + menuItem.setTitle(currentInstace.getName() + (ServiceHelper.isBeta(s) ? " (beta)" : "")); + Spinner spinner = (Spinner) LayoutInflater.from(this).inflate(R.layout.instance_spinner_layout, null); + List instances = PeertubeHelper.getInstanceList(this); + List items = new ArrayList<>(); + int defaultSelect = 0; + for(PeertubeInstance instance: instances){ + items.add(instance.getName()); + if(instance.getUrl().equals(currentInstace.getUrl())){ + defaultSelect = items.size()-1; + } + } + 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(AdapterView parent, View view, int position, long id) { + PeertubeInstance newInstance = instances.get(position); + if(newInstance.getUrl().equals(PeertubeHelper.getCurrentInstance().getUrl())) return; + PeertubeHelper.selectInstance(newInstance, getApplicationContext()); + changeService(menuItem); + drawer.closeDrawers(); + new Handler(Looper.getMainLooper()).postDelayed(() -> { + getSupportFragmentManager().popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE); + recreate(); + }, 300); + } + + @Override + public void onNothingSelected(AdapterView parent) { + + } + }); + menuItem.setActionView(spinner); + } + private void showTabs() throws ExtractionException { serviceArrow.setImageResource(R.drawable.ic_arrow_down_white); @@ -376,6 +431,7 @@ public class MainActivity extends AppCompatActivity { String selectedServiceName = NewPipe.getService( ServiceHelper.getSelectedServiceId(this)).getServiceInfo().getName(); headerServiceView.setText(selectedServiceName); + headerServiceView.post(() -> headerServiceView.setSelected(true)); toggleServiceButton.setContentDescription( getString(R.string.drawer_header_description) + selectedServiceName); } catch (Exception e) { diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java index 59bffa933..29208b0e0 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -68,6 +68,7 @@ import java.util.Locale; import icepick.Icepick; import icepick.State; import io.reactivex.disposables.CompositeDisposable; +import us.shandian.giga.get.MissionRecoveryInfo; import us.shandian.giga.io.StoredDirectoryHelper; import us.shandian.giga.io.StoredFileHelper; import us.shandian.giga.postprocessing.Postprocessing; @@ -762,12 +763,13 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck } Stream selectedStream; + Stream secondaryStream = null; char kind; int threads = threadsSeekBar.getProgress() + 1; String[] urls; + MissionRecoveryInfo[] recoveryInfo; String psName = null; String[] psArgs = null; - String secondaryStreamUrl = null; long nearLength = 0; // more download logic: select muxer, subtitle converter, etc. @@ -778,18 +780,20 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck if (selectedStream.getFormat() == MediaFormat.M4A) { psName = Postprocessing.ALGORITHM_M4A_NO_DASH; + } else if (selectedStream.getFormat() == MediaFormat.WEBMA_OPUS) { + psName = Postprocessing.ALGORITHM_OGG_FROM_WEBM_DEMUXER; } break; case R.id.video_button: kind = 'v'; selectedStream = videoStreamsAdapter.getItem(selectedVideoIndex); - SecondaryStreamHelper secondaryStream = videoStreamsAdapter + SecondaryStreamHelper secondary = videoStreamsAdapter .getAllSecondary() .get(wrappedVideoStreams.getStreamsList().indexOf(selectedStream)); - if (secondaryStream != null) { - secondaryStreamUrl = secondaryStream.getStream().getUrl(); + if (secondary != null) { + secondaryStream = secondary.getStream(); if (selectedStream.getFormat() == MediaFormat.MPEG_4) psName = Postprocessing.ALGORITHM_MP4_FROM_DASH_MUXER; @@ -801,8 +805,8 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck // 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 (secondaryStream.getSizeInBytes() > 0 && videoSize > 0) { - nearLength = secondaryStream.getSizeInBytes() + videoSize; + if (secondary.getSizeInBytes() > 0 && videoSize > 0) { + nearLength = secondary.getSizeInBytes() + videoSize; } } break; @@ -824,13 +828,25 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck return; } - if (secondaryStreamUrl == null) { - urls = new String[]{selectedStream.getUrl()}; + if (secondaryStream == null) { + urls = new String[]{ + selectedStream.getUrl() + }; + recoveryInfo = new MissionRecoveryInfo[]{ + new MissionRecoveryInfo(selectedStream) + }; } else { - urls = new String[]{selectedStream.getUrl(), secondaryStreamUrl}; + urls = new String[]{ + selectedStream.getUrl(), secondaryStream.getUrl() + }; + recoveryInfo = new MissionRecoveryInfo[]{ + new MissionRecoveryInfo(selectedStream), new MissionRecoveryInfo(secondaryStream) + }; } - DownloadManagerService.startMission(context, urls, storage, kind, threads, currentInfo.getUrl(), psName, psArgs, nearLength); + DownloadManagerService.startMission( + context, urls, storage, kind, threads, currentInfo.getUrl(), psName, psArgs, nearLength, recoveryInfo + ); dismiss(); } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java index 95aef4764..c20ff0fc2 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java @@ -29,6 +29,7 @@ import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.channel.ChannelInfo; +import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; @@ -98,7 +99,7 @@ public class ChannelFragment extends BaseListInfoFragment { @Override public void setUserVisibleHint(boolean isVisibleToUser) { super.setUserVisibleHint(isVisibleToUser); - if(activity != null + if (activity != null && useAsFrontPage && isVisibleToUser) { setTitle(currentInfo != null ? currentInfo.getName() : name); @@ -152,7 +153,7 @@ public class ChannelFragment extends BaseListInfoFragment { public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); ActionBar supportActionBar = activity.getSupportActionBar(); - if(useAsFrontPage && supportActionBar != null) { + if (useAsFrontPage && supportActionBar != null) { supportActionBar.setDisplayHomeAsUpEnabled(false); } else { inflater.inflate(R.menu.menu_channel, menu); @@ -165,7 +166,7 @@ public class ChannelFragment extends BaseListInfoFragment { private void openRssFeed() { final ChannelInfo info = currentInfo; - if(info != null) { + if (info != null) { Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(info.getFeedUrl())); startActivity(intent); } @@ -178,10 +179,14 @@ public class ChannelFragment extends BaseListInfoFragment { openRssFeed(); break; case R.id.menu_item_openInBrowser: - ShareUtils.openUrlInBrowser(this.getContext(), currentInfo.getOriginalUrl()); + if (currentInfo != null) { + ShareUtils.openUrlInBrowser(this.getContext(), currentInfo.getOriginalUrl()); + } break; case R.id.menu_item_share: - ShareUtils.shareUrl(this.getContext(), name, currentInfo.getOriginalUrl()); + if (currentInfo != null) { + ShareUtils.shareUrl(this.getContext(), name, currentInfo.getOriginalUrl()); + } break; default: return super.onOptionsItemSelected(item); @@ -218,7 +223,7 @@ public class ChannelFragment extends BaseListInfoFragment { .debounce(100, TimeUnit.MILLISECONDS) .observeOn(AndroidSchedulers.mainThread()) .subscribe((List subscriptionEntities) -> - updateSubscribeButton(!subscriptionEntities.isEmpty()) + updateSubscribeButton(!subscriptionEntities.isEmpty()) , onError)); } @@ -359,9 +364,9 @@ public class ChannelFragment extends BaseListInfoFragment { headerRootLayout.setVisibility(View.VISIBLE); imageLoader.displayImage(result.getBannerUrl(), headerChannelBanner, - ImageDisplayConstants.DISPLAY_BANNER_OPTIONS); + ImageDisplayConstants.DISPLAY_BANNER_OPTIONS); imageLoader.displayImage(result.getAvatarUrl(), headerAvatarView, - ImageDisplayConstants.DISPLAY_AVATAR_OPTIONS); + ImageDisplayConstants.DISPLAY_AVATAR_OPTIONS); headerSubscribersTextView.setVisibility(View.VISIBLE); if (result.getSubscriberCount() >= 0) { @@ -397,8 +402,8 @@ public class ChannelFragment extends BaseListInfoFragment { private PlayQueue getPlayQueue(final int index) { final List streamItems = new ArrayList<>(); - for(InfoItem i : infoListAdapter.getItemsList()) { - if(i instanceof StreamInfoItem) { + for (InfoItem i : infoListAdapter.getItemsList()) { + if (i instanceof StreamInfoItem) { streamItems.add((StreamInfoItem) i); } } @@ -432,12 +437,16 @@ public class ChannelFragment extends BaseListInfoFragment { protected boolean onError(Throwable exception) { if (super.onError(exception)) return true; - int errorId = exception instanceof ExtractionException ? R.string.parsing_error : R.string.general_error; - onUnrecoverableError(exception, - UserAction.REQUESTED_CHANNEL, - NewPipe.getNameOfService(serviceId), - url, - errorId); + if (exception instanceof ContentNotAvailableException) { + showError(getString(R.string.content_not_available), false); + } else { + int errorId = exception instanceof ExtractionException ? R.string.parsing_error : R.string.general_error; + onUnrecoverableError(exception, + UserAction.REQUESTED_CHANNEL, + NewPipe.getNameOfService(serviceId), + url, + errorId); + } return true; } diff --git a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java index 0d9c14058..a81340b8d 100644 --- a/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/VideoPlayer.java @@ -26,14 +26,13 @@ import android.animation.PropertyValuesHolder; import android.animation.ValueAnimator; import android.content.Context; import android.content.Intent; +import android.content.SharedPreferences; import android.graphics.Bitmap; import android.graphics.Color; import android.graphics.PorterDuff; import android.os.Build; import android.os.Handler; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; +import android.preference.PreferenceManager; import android.util.Log; import android.view.Menu; import android.view.MenuItem; @@ -45,6 +44,10 @@ import android.widget.ProgressBar; import android.widget.SeekBar; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; + import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player; @@ -286,6 +289,17 @@ public abstract class VideoPlayer extends BasePlayer if (captionPopupMenu == null) return; captionPopupMenu.getMenu().removeGroup(captionPopupMenuGroupId); + String userPreferredLanguage = PreferenceManager.getDefaultSharedPreferences(context) + .getString(context.getString(R.string.caption_user_set_key), null); + /* + * only search for autogenerated cc as fallback + * if "(auto-generated)" was not already selected + * we are only looking for "(" instead of "(auto-generated)" to hopefully get all + * internationalized variants such as "(automatisch-erzeugt)" and so on + */ + boolean searchForAutogenerated = userPreferredLanguage != null && + !userPreferredLanguage.contains("("); + // Add option for turning off caption MenuItem captionOffItem = captionPopupMenu.getMenu().add(captionPopupMenuGroupId, 0, Menu.NONE, R.string.caption_none); @@ -295,6 +309,8 @@ public abstract class VideoPlayer extends BasePlayer trackSelector.setParameters(trackSelector.buildUponParameters() .setRendererDisabled(textRendererIndex, true)); } + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + prefs.edit().remove(context.getString(R.string.caption_user_set_key)).commit(); return true; }); @@ -309,9 +325,26 @@ public abstract class VideoPlayer extends BasePlayer trackSelector.setPreferredTextLanguage(captionLanguage); trackSelector.setParameters(trackSelector.buildUponParameters() .setRendererDisabled(textRendererIndex, false)); + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + prefs.edit().putString(context.getString(R.string.caption_user_set_key), + captionLanguage).commit(); } return true; }); + // apply caption language from previous user preference + if (userPreferredLanguage != null && (captionLanguage.equals(userPreferredLanguage) || + searchForAutogenerated && captionLanguage.startsWith(userPreferredLanguage) || + userPreferredLanguage.contains("(") && + captionLanguage.startsWith(userPreferredLanguage.substring(0, + userPreferredLanguage.indexOf('('))))) { + final int textRendererIndex = getRendererIndex(C.TRACK_TYPE_TEXT); + if (textRendererIndex != RENDERER_UNAVAILABLE) { + trackSelector.setPreferredTextLanguage(captionLanguage); + trackSelector.setParameters(trackSelector.buildUponParameters() + .setRendererDisabled(textRendererIndex, false)); + } + searchForAutogenerated = false; + } } captionPopupMenu.setOnDismissListener(this); } diff --git a/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java b/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java new file mode 100644 index 000000000..d8c36e5cb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java @@ -0,0 +1,417 @@ +package org.schabi.newpipe.settings; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.preference.PreferenceManager; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.RadioButton; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.widget.AppCompatImageView; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; +import com.grack.nanojson.JsonStringWriter; +import com.grack.nanojson.JsonWriter; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; +import org.schabi.newpipe.util.Constants; +import org.schabi.newpipe.util.PeertubeHelper; +import org.schabi.newpipe.util.ThemeHelper; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import io.reactivex.Single; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; + +public class PeertubeInstanceListFragment extends Fragment { + + private List instanceList = new ArrayList<>(); + private PeertubeInstance selectedInstance; + private String savedInstanceListKey; + public InstanceListAdapter instanceListAdapter; + + private ProgressBar progressBar; + private SharedPreferences sharedPreferences; + + private CompositeDisposable disposables = new CompositeDisposable(); + + /*////////////////////////////////////////////////////////////////////////// + // Lifecycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); + savedInstanceListKey = getString(R.string.peertube_instance_list_key); + selectedInstance = PeertubeHelper.getCurrentInstance(); + updateInstanceList(); + + setHasOptionsMenu(true); + } + + @Override + public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_instance_list, container, false); + } + + @Override + public void onViewCreated(@NonNull View rootView, @Nullable Bundle savedInstanceState) { + super.onViewCreated(rootView, savedInstanceState); + + initButton(rootView); + + RecyclerView listInstances = rootView.findViewById(R.id.instances); + listInstances.setLayoutManager(new LinearLayoutManager(requireContext())); + + ItemTouchHelper itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); + itemTouchHelper.attachToRecyclerView(listInstances); + + instanceListAdapter = new InstanceListAdapter(requireContext(), itemTouchHelper); + listInstances.setAdapter(instanceListAdapter); + + progressBar = rootView.findViewById(R.id.loading_progress_bar); + } + + @Override + public void onResume() { + super.onResume(); + updateTitle(); + } + + @Override + public void onPause() { + super.onPause(); + saveChanges(); + } + + @Override + public void onDestroy() { + super.onDestroy(); + if (disposables != null) disposables.clear(); + disposables = null; + } + /*////////////////////////////////////////////////////////////////////////// + // Menu + //////////////////////////////////////////////////////////////////////////*/ + + private final int MENU_ITEM_RESTORE_ID = 123456; + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + super.onCreateOptionsMenu(menu, inflater); + + final MenuItem restoreItem = menu.add(Menu.NONE, MENU_ITEM_RESTORE_ID, Menu.NONE, R.string.restore_defaults); + restoreItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); + + final int restoreIcon = ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_restore_defaults); + restoreItem.setIcon(AppCompatResources.getDrawable(requireContext(), restoreIcon)); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == MENU_ITEM_RESTORE_ID) { + restoreDefaults(); + return true; + } + + return super.onOptionsItemSelected(item); + } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + private void updateInstanceList() { + instanceList.clear(); + instanceList.addAll(PeertubeHelper.getInstanceList(requireContext())); + } + + private void selectInstance(PeertubeInstance instance) { + selectedInstance = PeertubeHelper.selectInstance(instance, requireContext()); + sharedPreferences.edit().putBoolean(Constants.KEY_MAIN_PAGE_CHANGE, true).apply(); + } + + private void updateTitle() { + if (getActivity() instanceof AppCompatActivity) { + ActionBar actionBar = ((AppCompatActivity) getActivity()).getSupportActionBar(); + if (actionBar != null) actionBar.setTitle(R.string.peertube_instance_url_title); + } + } + + private void saveChanges() { + JsonStringWriter jsonWriter = JsonWriter.string().object().array("instances"); + for (PeertubeInstance instance : instanceList) { + jsonWriter.object(); + jsonWriter.value("name", instance.getName()); + jsonWriter.value("url", instance.getUrl()); + jsonWriter.end(); + } + String jsonToSave = jsonWriter.end().end().done(); + sharedPreferences.edit().putString(savedInstanceListKey, jsonToSave).apply(); + } + + private void restoreDefaults() { + new AlertDialog.Builder(requireContext(), ThemeHelper.getDialogTheme(requireContext())) + .setTitle(R.string.restore_defaults) + .setMessage(R.string.restore_defaults_confirmation) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.yes, (dialog, which) -> { + sharedPreferences.edit().remove(savedInstanceListKey).apply(); + selectInstance(PeertubeInstance.defaultInstance); + updateInstanceList(); + instanceListAdapter.notifyDataSetChanged(); + }) + .show(); + } + + private void initButton(View rootView) { + final FloatingActionButton fab = rootView.findViewById(R.id.addInstanceButton); + fab.setOnClickListener(v -> { + showAddItemDialog(requireContext()); + }); + } + + private void showAddItemDialog(Context c) { + final EditText urlET = new EditText(c); + urlET.setHint(R.string.peertube_instance_add_help); + AlertDialog dialog = new AlertDialog.Builder(c) + .setTitle(R.string.peertube_instance_add_title) + .setIcon(R.drawable.place_holder_peertube) + .setView(urlET) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.finish, (dialog1, which) -> { + String url = urlET.getText().toString(); + addInstance(url); + }) + .create(); + dialog.show(); + } + + private void addInstance(String url) { + String cleanUrl = cleanUrl(url); + if(null == cleanUrl) return; + progressBar.setVisibility(View.VISIBLE); + Disposable disposable = Single.fromCallable(() -> { + PeertubeInstance instance = new PeertubeInstance(cleanUrl); + instance.fetchInstanceMetaData(); + return instance; + }).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe((instance) -> { + progressBar.setVisibility(View.GONE); + add(instance); + }, e -> { + progressBar.setVisibility(View.GONE); + Toast.makeText(getActivity(), R.string.peertube_instance_add_fail, Toast.LENGTH_SHORT).show(); + }); + disposables.add(disposable); + } + + @Nullable + private String cleanUrl(String url){ + // if protocol not present, add https + if(!url.startsWith("http")){ + url = "https://" + url; + } + // remove trailing slash + url = url.replaceAll("/$", ""); + // only allow https + if (!url.startsWith("https://")) { + Toast.makeText(getActivity(), R.string.peertube_instance_add_https_only, Toast.LENGTH_SHORT).show(); + return null; + } + // only allow if not already exists + for (PeertubeInstance instance : instanceList) { + if (instance.getUrl().equals(url)) { + Toast.makeText(getActivity(), R.string.peertube_instance_add_exists, Toast.LENGTH_SHORT).show(); + return null; + } + } + return url; + } + + private void add(final PeertubeInstance instance) { + instanceList.add(instance); + instanceListAdapter.notifyDataSetChanged(); + } + + /*////////////////////////////////////////////////////////////////////////// + // List Handling + //////////////////////////////////////////////////////////////////////////*/ + + private class InstanceListAdapter extends RecyclerView.Adapter { + private ItemTouchHelper itemTouchHelper; + private final LayoutInflater inflater; + private RadioButton lastChecked; + + InstanceListAdapter(Context context, ItemTouchHelper itemTouchHelper) { + this.itemTouchHelper = itemTouchHelper; + this.inflater = LayoutInflater.from(context); + } + + public void swapItems(int fromPosition, int toPosition) { + Collections.swap(instanceList, fromPosition, toPosition); + notifyItemMoved(fromPosition, toPosition); + } + + @NonNull + @Override + public InstanceListAdapter.TabViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + View view = inflater.inflate(R.layout.item_instance, parent, false); + return new InstanceListAdapter.TabViewHolder(view); + } + + @Override + public void onBindViewHolder(@NonNull InstanceListAdapter.TabViewHolder holder, int position) { + holder.bind(position, holder); + } + + @Override + public int getItemCount() { + return instanceList.size(); + } + + class TabViewHolder extends RecyclerView.ViewHolder { + private AppCompatImageView instanceIconView; + private TextView instanceNameView; + private TextView instanceUrlView; + private RadioButton instanceRB; + private ImageView handle; + + TabViewHolder(View itemView) { + super(itemView); + + instanceIconView = itemView.findViewById(R.id.instanceIcon); + instanceNameView = itemView.findViewById(R.id.instanceName); + instanceUrlView = itemView.findViewById(R.id.instanceUrl); + instanceRB = itemView.findViewById(R.id.selectInstanceRB); + handle = itemView.findViewById(R.id.handle); + } + + @SuppressLint("ClickableViewAccessibility") + void bind(int position, TabViewHolder holder) { + handle.setOnTouchListener(getOnTouchListener(holder)); + + final PeertubeInstance instance = instanceList.get(position); + instanceNameView.setText(instance.getName()); + instanceUrlView.setText(instance.getUrl()); + instanceRB.setOnCheckedChangeListener(null); + if (selectedInstance.getUrl().equals(instance.getUrl())) { + if (lastChecked != null && lastChecked != instanceRB) { + lastChecked.setChecked(false); + } + instanceRB.setChecked(true); + lastChecked = instanceRB; + } + instanceRB.setOnCheckedChangeListener((buttonView, isChecked) -> { + if (isChecked) { + selectInstance(instance); + if (lastChecked != null && lastChecked != instanceRB) { + lastChecked.setChecked(false); + } + lastChecked = instanceRB; + } + }); + instanceIconView.setImageResource(R.drawable.place_holder_peertube); + } + + @SuppressLint("ClickableViewAccessibility") + private View.OnTouchListener getOnTouchListener(final RecyclerView.ViewHolder item) { + return (view, motionEvent) -> { + if (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { + if (itemTouchHelper != null && getItemCount() > 1) { + itemTouchHelper.startDrag(item); + return true; + } + } + return false; + }; + } + } + } + + private ItemTouchHelper.SimpleCallback getItemTouchCallback() { + return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, + ItemTouchHelper.START | ItemTouchHelper.END) { + @Override + public int interpolateOutOfBoundsScroll(RecyclerView recyclerView, int viewSize, + int viewSizeOutOfBounds, int totalSize, + long msSinceStartScroll) { + final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, + viewSizeOutOfBounds, totalSize, msSinceStartScroll); + final int minimumAbsVelocity = Math.max(12, + Math.abs(standardSpeed)); + return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); + } + + @Override + public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder source, + RecyclerView.ViewHolder target) { + if (source.getItemViewType() != target.getItemViewType() || + instanceListAdapter == null) { + return false; + } + + final int sourceIndex = source.getAdapterPosition(); + final int targetIndex = target.getAdapterPosition(); + instanceListAdapter.swapItems(sourceIndex, targetIndex); + return true; + } + + @Override + public boolean isLongPressDragEnabled() { + return false; + } + + @Override + public boolean isItemViewSwipeEnabled() { + return true; + } + + @Override + public void onSwiped(RecyclerView.ViewHolder viewHolder, int swipeDir) { + int position = viewHolder.getAdapterPosition(); + // do not allow swiping the selected instance + if(instanceList.get(position).getUrl().equals(selectedInstance.getUrl())) { + instanceListAdapter.notifyItemChanged(position); + return; + } + instanceList.remove(position); + instanceListAdapter.notifyItemRemoved(position); + + if (instanceList.isEmpty()) { + instanceList.add(selectedInstance); + instanceListAdapter.notifyItemInserted(0); + } + } + }; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java index c52ebf3aa..0cfd856e1 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java +++ b/app/src/main/java/org/schabi/newpipe/streams/Mp4DashReader.java @@ -15,7 +15,6 @@ import java.util.NoSuchElementException; */ public class Mp4DashReader { - // private static final int ATOM_MOOF = 0x6D6F6F66; private static final int ATOM_MFHD = 0x6D666864; private static final int ATOM_TRAF = 0x74726166; @@ -50,7 +49,7 @@ public class Mp4DashReader { private static final int HANDLER_VIDE = 0x76696465; private static final int HANDLER_SOUN = 0x736F756E; private static final int HANDLER_SUBT = 0x73756274; - // + private final DataReader stream; @@ -293,7 +292,8 @@ public class Mp4DashReader { return null; } - // + + private long readUint() throws IOException { return stream.readInt() & 0xffffffffL; } @@ -392,9 +392,7 @@ public class Mp4DashReader { return readBox(); } - // - // private Moof parse_moof(Box ref, int trackId) throws IOException { Moof obj = new Moof(); @@ -795,9 +793,8 @@ public class Mp4DashReader { return readFullBox(b); } - // - // + class Box { int type; @@ -1013,5 +1010,5 @@ public class Mp4DashReader { public TrunEntry info; public byte[] data; } -// + } diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java index 03aab447c..818f6148e 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java @@ -6,6 +6,7 @@ import org.schabi.newpipe.streams.Mp4DashReader.Mp4DashChunk; import org.schabi.newpipe.streams.Mp4DashReader.Mp4DashSample; import org.schabi.newpipe.streams.Mp4DashReader.Mp4Track; import org.schabi.newpipe.streams.Mp4DashReader.TrunEntry; +import org.schabi.newpipe.streams.Mp4DashReader.TrackKind; import org.schabi.newpipe.streams.io.SharpStream; import java.io.IOException; @@ -22,6 +23,7 @@ public class Mp4FromDashWriter { private final static byte SAMPLES_PER_CHUNK = 6;// ffmpeg uses 2, basic uses 1 (with 60fps uses 21 or 22). NewPipe will use 6 private final static long THRESHOLD_FOR_CO64 = 0xFFFEFFFFL;// near 3.999 GiB private final static int THRESHOLD_MOOV_LENGTH = (256 * 1024) + (2048 * 1024); // 2.2 MiB enough for: 1080p 60fps 00h35m00s + private final static short SINGLE_CHUNK_SAMPLE_BUFFER = 256; private final long time; @@ -145,7 +147,7 @@ public class Mp4FromDashWriter { // not allowed for very short tracks (less than 0.5 seconds) // outStream = output; - int read = 8;// mdat box header size + long read = 8;// mdat box header size long totalSampleSize = 0; int[] sampleExtra = new int[readers.length]; int[] defaultMediaTime = new int[readers.length]; @@ -157,7 +159,9 @@ public class Mp4FromDashWriter { tablesInfo[i] = new TablesInfo(); } - // + boolean singleChunk = tracks.length == 1 && tracks[0].kind == TrackKind.Audio; + + for (int i = 0; i < readers.length; i++) { int samplesSize = 0; int sampleSizeChanges = 0; @@ -210,14 +214,21 @@ public class Mp4FromDashWriter { tablesInfo[i].stco = (tmp / SAMPLES_PER_CHUNK) + 1;// +1 for samples in first chunk tmp = tmp % SAMPLES_PER_CHUNK; - if (tmp == 0) { + if (singleChunk) { + // avoid split audio streams in chunks + tablesInfo[i].stsc = 1; + tablesInfo[i].stsc_bEntries = new int[]{ + 1, tablesInfo[i].stsz, 1 + }; + tablesInfo[i].stco = 1; + } else if (tmp == 0) { tablesInfo[i].stsc = 2;// first chunk (init) and succesive chunks tablesInfo[i].stsc_bEntries = new int[]{ 1, SAMPLES_PER_CHUNK_INIT, 1, 2, SAMPLES_PER_CHUNK, 1 }; } else { - tablesInfo[i].stsc = 3;// first chunk (init) and succesive chunks and remain chunk + tablesInfo[i].stsc = 3;// first chunk (init) and successive chunks and remain chunk tablesInfo[i].stsc_bEntries = new int[]{ 1, SAMPLES_PER_CHUNK_INIT, 1, 2, SAMPLES_PER_CHUNK, 1, @@ -244,7 +255,7 @@ public class Mp4FromDashWriter { tracks[i].trak.tkhd.duration = sampleExtra[i];// this never should happen } } - // + boolean is64 = read > THRESHOLD_FOR_CO64; @@ -268,10 +279,10 @@ public class Mp4FromDashWriter { } else {*/ if (auxSize > 0) { int length = auxSize; - byte[] buffer = new byte[8 * 1024];// 8 KiB + byte[] buffer = new byte[64 * 1024];// 64 KiB while (length > 0) { int count = Math.min(length, buffer.length); - outWrite(buffer, 0, count); + outWrite(buffer, count); length -= count; } } @@ -280,7 +291,7 @@ public class Mp4FromDashWriter { outSeek(ftyp_size); } - // tablesInfo contais row counts + // tablesInfo contains row counts // and after returning from make_moov() will contain table offsets make_moov(defaultMediaTime, tablesInfo, is64); @@ -291,7 +302,7 @@ public class Mp4FromDashWriter { writeEntryArray(tablesInfo[i].stsc, tablesInfo[i].stsc_bEntries.length, tablesInfo[i].stsc_bEntries); tablesInfo[i].stsc_bEntries = null; if (tablesInfo[i].ctts > 0) { - sampleCount[i] = 1;// index is not base zero + sampleCount[i] = 1;// the index is not base zero sampleExtra[i] = -1; } } @@ -303,8 +314,8 @@ public class Mp4FromDashWriter { outWrite(make_mdat(totalSampleSize, is64)); int[] sampleIndex = new int[readers.length]; - int[] sizes = new int[SAMPLES_PER_CHUNK]; - int[] sync = new int[SAMPLES_PER_CHUNK]; + int[] sizes = new int[singleChunk ? SINGLE_CHUNK_SAMPLE_BUFFER : SAMPLES_PER_CHUNK]; + int[] sync = new int[singleChunk ? SINGLE_CHUNK_SAMPLE_BUFFER : SAMPLES_PER_CHUNK]; int written = readers.length; while (written > 0) { @@ -317,7 +328,12 @@ public class Mp4FromDashWriter { long chunkOffset = writeOffset; int syncCount = 0; - int limit = sampleIndex[i] == 0 ? SAMPLES_PER_CHUNK_INIT : SAMPLES_PER_CHUNK; + int limit; + if (singleChunk) { + limit = SINGLE_CHUNK_SAMPLE_BUFFER; + } else { + limit = sampleIndex[i] == 0 ? SAMPLES_PER_CHUNK_INIT : SAMPLES_PER_CHUNK; + } int j = 0; for (; j < limit; j++) { @@ -354,7 +370,7 @@ public class Mp4FromDashWriter { sizes[j] = sample.data.length; } - outWrite(sample.data, 0, sample.data.length); + outWrite(sample.data, sample.data.length); } if (j > 0) { @@ -368,10 +384,16 @@ public class Mp4FromDashWriter { tablesInfo[i].stss = writeEntryArray(tablesInfo[i].stss, syncCount, sync); } - if (is64) { - tablesInfo[i].stco = writeEntry64(tablesInfo[i].stco, chunkOffset); - } else { - tablesInfo[i].stco = writeEntryArray(tablesInfo[i].stco, 1, (int) chunkOffset); + if (tablesInfo[i].stco > 0) { + if (is64) { + tablesInfo[i].stco = writeEntry64(tablesInfo[i].stco, chunkOffset); + } else { + tablesInfo[i].stco = writeEntryArray(tablesInfo[i].stco, 1, (int) chunkOffset); + } + + if (singleChunk) { + tablesInfo[i].stco = -1; + } } outRestore(); @@ -404,7 +426,7 @@ public class Mp4FromDashWriter { } } - // + private int writeEntry64(int offset, long value) throws IOException { outBackup(); @@ -447,16 +469,16 @@ public class Mp4FromDashWriter { lastWriteOffset = -1; } } - // - // + + private void outWrite(byte[] buffer) throws IOException { - outWrite(buffer, 0, buffer.length); + outWrite(buffer, buffer.length); } - private void outWrite(byte[] buffer, int offset, int count) throws IOException { + private void outWrite(byte[] buffer, int count) throws IOException { writeOffset += count; - outStream.write(buffer, offset, count); + outStream.write(buffer, 0, count); } private void outSeek(long offset) throws IOException { @@ -509,7 +531,6 @@ public class Mp4FromDashWriter { ); if (extra >= 0) { - //size += 4;// commented for auxiliar buffer !!! offset += 4; auxWrite(extra); } @@ -531,7 +552,7 @@ public class Mp4FromDashWriter { if (moovSimulation) { writeOffset += buffer.length; } else if (auxBuffer == null) { - outWrite(buffer, 0, buffer.length); + outWrite(buffer, buffer.length); } else { auxBuffer.put(buffer); } @@ -560,9 +581,9 @@ public class Mp4FromDashWriter { private int auxOffset() { return auxBuffer == null ? (int) writeOffset : auxBuffer.position(); } - // - // + + private int make_ftyp() throws IOException { byte[] buffer = new byte[]{ 0x00, 0x00, 0x00, 0x1C, 0x66, 0x74, 0x79, 0x70,// ftyp @@ -703,7 +724,7 @@ public class Mp4FromDashWriter { int mediaTime; if (tracks[index].trak.edst_elst == null) { - // is a audio track ¿is edst/elst opcional for audio tracks? + // is a audio track ¿is edst/elst optional for audio tracks? mediaTime = 0x00;// ffmpeg set this value as zero, instead of defaultMediaTime bMediaRate = 0x00010000; } else { @@ -794,17 +815,17 @@ public class Mp4FromDashWriter { return buffer.array(); } - // + class TablesInfo { - public int stts; - public int stsc; - public int[] stsc_bEntries; - public int ctts; - public int stsz; - public int stsz_default; - public int stss; - public int stco; + int stts; + int stsc; + int[] stsc_bEntries; + int ctts; + int stsz; + int stsz_default; + int stss; + int stco; } } diff --git a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java new file mode 100644 index 000000000..20e88c4c7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java @@ -0,0 +1,431 @@ +package org.schabi.newpipe.streams; + +import androidx.annotation.NonNull; + +import org.schabi.newpipe.streams.WebMReader.Cluster; +import org.schabi.newpipe.streams.WebMReader.Segment; +import org.schabi.newpipe.streams.WebMReader.SimpleBlock; +import org.schabi.newpipe.streams.WebMReader.WebMTrack; +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.Closeable; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +import javax.annotation.Nullable; + +/** + * @author kapodamy + */ +public class OggFromWebMWriter implements Closeable { + + private static final byte FLAG_UNSET = 0x00; + //private static final byte FLAG_CONTINUED = 0x01; + private static final byte FLAG_FIRST = 0x02; + private static final byte FLAG_LAST = 0x04; + + private final static byte HEADER_CHECKSUM_OFFSET = 22; + private final static byte HEADER_SIZE = 27; + + private final static int TIME_SCALE_NS = 1000000000; + + private boolean done = false; + private boolean parsed = false; + + private SharpStream source; + private SharpStream output; + + private int sequence_count = 0; + private final int STREAM_ID; + private byte packet_flag = FLAG_FIRST; + + private WebMReader webm = null; + private WebMTrack webm_track = null; + private Segment webm_segment = null; + private Cluster webm_cluster = null; + private SimpleBlock webm_block = null; + + private long webm_block_last_timecode = 0; + private long webm_block_near_duration = 0; + + private short segment_table_size = 0; + private final byte[] segment_table = new byte[255]; + private long segment_table_next_timestamp = TIME_SCALE_NS; + + private final int[] crc32_table = new int[256]; + + public OggFromWebMWriter(@NonNull SharpStream source, @NonNull SharpStream target) { + if (!source.canRead() || !source.canRewind()) { + throw new IllegalArgumentException("source stream must be readable and allows seeking"); + } + if (!target.canWrite() || !target.canRewind()) { + throw new IllegalArgumentException("output stream must be writable and allows seeking"); + } + + this.source = source; + this.output = target; + + this.STREAM_ID = (int) System.currentTimeMillis(); + + populate_crc32_table(); + } + + public boolean isDone() { + return done; + } + + public boolean isParsed() { + return parsed; + } + + public WebMTrack[] getTracksFromSource() throws IllegalStateException { + if (!parsed) { + throw new IllegalStateException("source must be parsed first"); + } + + return webm.getAvailableTracks(); + } + + public void parseSource() throws IOException, IllegalStateException { + if (done) { + throw new IllegalStateException("already done"); + } + if (parsed) { + throw new IllegalStateException("already parsed"); + } + + try { + webm = new WebMReader(source); + webm.parse(); + webm_segment = webm.getNextSegment(); + } finally { + parsed = true; + } + } + + public void selectTrack(int trackIndex) throws IOException { + if (!parsed) { + throw new IllegalStateException("source must be parsed first"); + } + if (done) { + throw new IOException("already done"); + } + if (webm_track != null) { + throw new IOException("tracks already selected"); + } + + switch (webm.getAvailableTracks()[trackIndex].kind) { + case Audio: + case Video: + break; + default: + throw new UnsupportedOperationException("the track must an audio or video stream"); + } + + try { + webm_track = webm.selectTrack(trackIndex); + } finally { + parsed = true; + } + } + + @Override + public void close() throws IOException { + done = true; + parsed = true; + + webm_track = null; + webm = null; + + if (!output.isClosed()) { + output.flush(); + } + + source.close(); + output.close(); + } + + public void build() throws IOException { + float resolution; + SimpleBlock bloq; + ByteBuffer header = ByteBuffer.allocate(27 + (255 * 255)); + ByteBuffer page = ByteBuffer.allocate(64 * 1024); + + header.order(ByteOrder.LITTLE_ENDIAN); + + /* step 1: get the amount of frames per seconds */ + switch (webm_track.kind) { + case Audio: + resolution = getSampleFrequencyFromTrack(webm_track.bMetadata); + if (resolution == 0f) { + throw new RuntimeException("cannot get the audio sample rate"); + } + break; + case Video: + // WARNING: untested + if (webm_track.defaultDuration == 0) { + throw new RuntimeException("missing default frame time"); + } + resolution = 1000f / ((float) webm_track.defaultDuration / webm_segment.info.timecodeScale); + break; + default: + throw new RuntimeException("not implemented"); + } + + /* step 2: create packet with code init data */ + if (webm_track.codecPrivate != null) { + addPacketSegment(webm_track.codecPrivate.length); + make_packetHeader(0x00, header, webm_track.codecPrivate); + write(header); + output.write(webm_track.codecPrivate); + } + + /* step 3: create packet with metadata */ + byte[] buffer = make_metadata(); + if (buffer != null) { + addPacketSegment(buffer.length); + make_packetHeader(0x00, header, buffer); + write(header); + output.write(buffer); + } + + /* step 4: calculate amount of packets */ + while (webm_segment != null) { + bloq = getNextBlock(); + + if (bloq != null && addPacketSegment(bloq)) { + int pos = page.position(); + //noinspection ResultOfMethodCallIgnored + bloq.data.read(page.array(), pos, bloq.dataSize); + page.position(pos + bloq.dataSize); + continue; + } + + // calculate the current packet duration using the next block + double elapsed_ns = webm_track.codecDelay; + + if (bloq == null) { + packet_flag = FLAG_LAST;// note: if the flag is FLAG_CONTINUED, is changed + elapsed_ns += webm_block_last_timecode; + + if (webm_track.defaultDuration > 0) { + elapsed_ns += webm_track.defaultDuration; + } else { + // hardcoded way, guess the sample duration + elapsed_ns += webm_block_near_duration; + } + } else { + elapsed_ns += bloq.absoluteTimeCodeNs; + } + + // get the sample count in the page + elapsed_ns = elapsed_ns / TIME_SCALE_NS; + elapsed_ns = Math.ceil(elapsed_ns * resolution); + + // create header and calculate page checksum + int checksum = make_packetHeader((long) elapsed_ns, header, null); + checksum = calc_crc32(checksum, page.array(), page.position()); + + header.putInt(HEADER_CHECKSUM_OFFSET, checksum); + + // dump data + write(header); + write(page); + + webm_block = bloq; + } + } + + private int make_packetHeader(long gran_pos, @NonNull ByteBuffer buffer, byte[] immediate_page) { + short length = HEADER_SIZE; + + buffer.putInt(0x5367674f);// "OggS" binary string in little-endian + buffer.put((byte) 0x00);// version + buffer.put(packet_flag);// type + + buffer.putLong(gran_pos);// granulate position + + buffer.putInt(STREAM_ID);// bitstream serial number + buffer.putInt(sequence_count++);// page sequence number + + buffer.putInt(0x00);// page checksum + + buffer.put((byte) segment_table_size);// segment table + buffer.put(segment_table, 0, segment_table_size);// segment size + + length += segment_table_size; + + clearSegmentTable();// clear segment table for next header + + int checksum_crc32 = calc_crc32(0x00, buffer.array(), length); + + if (immediate_page != null) { + checksum_crc32 = calc_crc32(checksum_crc32, immediate_page, immediate_page.length); + buffer.putInt(HEADER_CHECKSUM_OFFSET, checksum_crc32); + segment_table_next_timestamp -= TIME_SCALE_NS; + } + + return checksum_crc32; + } + + @Nullable + private byte[] make_metadata() { + if ("A_OPUS".equals(webm_track.codecId)) { + return new byte[]{ + 0x4F, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73,// "OpusTags" binary string + 0x07, 0x00, 0x00, 0x00,// writting application string size + 0x4E, 0x65, 0x77, 0x50, 0x69, 0x70, 0x65,// "NewPipe" binary string + 0x00, 0x00, 0x00, 0x00// additional tags count (zero means no tags) + }; + } else if ("A_VORBIS".equals(webm_track.codecId)) { + return new byte[]{ + 0x03,// ???????? + 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73,// "vorbis" binary string + 0x07, 0x00, 0x00, 0x00,// writting application string size + 0x4E, 0x65, 0x77, 0x50, 0x69, 0x70, 0x65,// "NewPipe" binary string + 0x01, 0x00, 0x00, 0x00,// additional tags count (zero means no tags) + + /* + // whole file duration (not implemented) + 0x44,// tag string size + 0x55, 0x52, 0x41, 0x54, 0x49, 0x4F, 0x4E, 0x3D, 0x30, 0x30, 0x3A, 0x30, 0x30, 0x3A, 0x30, + 0x30, 0x2E, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30, 0x30 + */ + 0x0F,// tag string size + 0x00, 0x00, 0x00, 0x45, 0x4E, 0x43, 0x4F, 0x44, 0x45, 0x52, 0x3D,// "ENCODER=" binary string + 0x4E, 0x65, 0x77, 0x50, 0x69, 0x70, 0x65,// "NewPipe" binary string + 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00// ???????? + }; + } + + // not implemented for the desired codec + return null; + } + + private void write(ByteBuffer buffer) throws IOException { + output.write(buffer.array(), 0, buffer.position()); + buffer.position(0); + } + + + + @Nullable + private SimpleBlock getNextBlock() throws IOException { + SimpleBlock res; + + if (webm_block != null) { + res = webm_block; + webm_block = null; + return res; + } + + if (webm_segment == null) { + webm_segment = webm.getNextSegment(); + if (webm_segment == null) { + return null;// no more blocks in the selected track + } + } + + if (webm_cluster == null) { + webm_cluster = webm_segment.getNextCluster(); + if (webm_cluster == null) { + webm_segment = null; + return getNextBlock(); + } + } + + res = webm_cluster.getNextSimpleBlock(); + if (res == null) { + webm_cluster = null; + return getNextBlock(); + } + + webm_block_near_duration = res.absoluteTimeCodeNs - webm_block_last_timecode; + webm_block_last_timecode = res.absoluteTimeCodeNs; + + return res; + } + + private float getSampleFrequencyFromTrack(byte[] bMetadata) { + // hardcoded way + ByteBuffer buffer = ByteBuffer.wrap(bMetadata); + + while (buffer.remaining() >= 6) { + int id = buffer.getShort() & 0xFFFF; + if (id == 0x0000B584) { + return buffer.getFloat(); + } + } + + return 0f; + } + + private void clearSegmentTable() { + segment_table_next_timestamp += TIME_SCALE_NS; + packet_flag = FLAG_UNSET; + segment_table_size = 0; + } + + private boolean addPacketSegment(SimpleBlock block) { + long timestamp = block.absoluteTimeCodeNs + webm_track.codecDelay; + + if (timestamp >= segment_table_next_timestamp) { + return false; + } + + return addPacketSegment(block.dataSize); + } + + private boolean addPacketSegment(int size) { + if (size > 65025) { + throw new UnsupportedOperationException("page size cannot be larger than 65025"); + } + + int available = (segment_table.length - segment_table_size) * 255; + boolean extra = (size % 255) == 0; + + if (extra) { + // add a zero byte entry in the table + // required to indicate the sample size is multiple of 255 + available -= 255; + } + + // check if possible add the segment, without overflow the table + if (available < size) { + return false;// not enough space on the page + } + + for (; size > 0; size -= 255) { + segment_table[segment_table_size++] = (byte) Math.min(size, 255); + } + + if (extra) { + segment_table[segment_table_size++] = 0x00; + } + + return true; + } + + private void populate_crc32_table() { + for (int i = 0; i < 0x100; i++) { + int crc = i << 24; + for (int j = 0; j < 8; j++) { + long b = crc >>> 31; + crc <<= 1; + crc ^= (int) (0x100000000L - b) & 0x04c11db7; + } + crc32_table[i] = crc; + } + } + + private int calc_crc32(int initial_crc, byte[] buffer, int size) { + for (int i = 0; i < size; i++) { + int reg = (initial_crc >>> 24) & 0xff; + initial_crc = (initial_crc << 8) ^ crc32_table[reg ^ (buffer[i] & 0xff)]; + } + + return initial_crc; + } + +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java b/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java index 0c635ebe3..42875c364 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java +++ b/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java @@ -15,7 +15,6 @@ import java.util.NoSuchElementException; */ public class WebMReader { - // private final static int ID_EMBL = 0x0A45DFA3; private final static int ID_EMBLReadVersion = 0x02F7; private final static int ID_EMBLDocType = 0x0282; @@ -37,11 +36,14 @@ public class WebMReader { private final static int ID_Audio = 0x61; private final static int ID_DefaultDuration = 0x3E383; private final static int ID_FlagLacing = 0x1C; + private final static int ID_CodecDelay = 0x16AA; private final static int ID_Cluster = 0x0F43B675; private final static int ID_Timecode = 0x67; private final static int ID_SimpleBlock = 0x23; -// + private final static int ID_Block = 0x21; + private final static int ID_GroupBlock = 0x20; + public enum TrackKind { Audio/*2*/, Video/*1*/, Other @@ -96,7 +98,7 @@ public class WebMReader { } ensure(segment.ref); - + // WARNING: track cannot be the same or have different index in new segments Element elem = untilElement(null, ID_Segment); if (elem == null) { done = true; @@ -107,7 +109,8 @@ public class WebMReader { return segment; } - // + + private long readNumber(Element parent) throws IOException { int length = (int) parent.contentSize; long value = 0; @@ -189,6 +192,9 @@ public class WebMReader { Element elem; while (ref == null ? stream.available() : (stream.position() < (ref.offset + ref.size))) { elem = readElement(); + if (expected.length < 1) { + return elem; + } for (int type : expected) { if (elem.type == type) { return elem; @@ -219,9 +225,9 @@ public class WebMReader { stream.skipBytes(skip); } -// - // + + private boolean readEbml(Element ref, int minReadVersion, int minDocTypeVersion) throws IOException { Element elem = untilElement(ref, ID_EMBLReadVersion); if (elem == null) { @@ -300,9 +306,7 @@ public class WebMReader { WebMTrack entry = new WebMTrack(); boolean drop = false; Element elem; - while ((elem = untilElement(elem_trackEntry, - ID_TrackNumber, ID_TrackType, ID_CodecID, ID_CodecPrivate, ID_FlagLacing, ID_DefaultDuration, ID_Audio, ID_Video - )) != null) { + while ((elem = untilElement(elem_trackEntry)) != null) { switch (elem.type) { case ID_TrackNumber: entry.trackNumber = readNumber(elem); @@ -326,8 +330,9 @@ public class WebMReader { case ID_FlagLacing: drop = readNumber(elem) != lacingExpected; break; + case ID_CodecDelay: + entry.codecDelay = readNumber(elem); default: - System.out.println(); break; } ensure(elem); @@ -360,12 +365,13 @@ public class WebMReader { private SimpleBlock readSimpleBlock(Element ref) throws IOException { SimpleBlock obj = new SimpleBlock(ref); - obj.dataSize = stream.position(); obj.trackNumber = readEncodedNumber(); obj.relativeTimeCode = stream.readShort(); obj.flags = (byte) stream.read(); - obj.dataSize = (ref.offset + ref.size) - stream.position(); + obj.dataSize = (int) ((ref.offset + ref.size) - stream.position()); + obj.createdFromBlock = ref.type == ID_Block; + // NOTE: lacing is not implemented, and will be mixed with the stream data if (obj.dataSize < 0) { throw new IOException(String.format("Unexpected SimpleBlock element size, missing %s bytes", -obj.dataSize)); } @@ -383,9 +389,9 @@ public class WebMReader { return obj; } -// - // + + class Element { int type; @@ -409,6 +415,7 @@ public class WebMReader { public byte[] bMetadata; public TrackKind kind; public long defaultDuration; + public long codecDelay; } public class Segment { @@ -448,6 +455,7 @@ public class WebMReader { public class SimpleBlock { public InputStream data; + public boolean createdFromBlock; SimpleBlock(Element ref) { this.ref = ref; @@ -455,8 +463,9 @@ public class WebMReader { public long trackNumber; public short relativeTimeCode; + public long absoluteTimeCodeNs; public byte flags; - public long dataSize; + public int dataSize; private final Element ref; public boolean isKeyframe() { @@ -468,33 +477,55 @@ public class WebMReader { Element ref; SimpleBlock currentSimpleBlock = null; + Element currentBlockGroup = null; public long timecode; Cluster(Element ref) { this.ref = ref; } - boolean check() { + boolean insideClusterBounds() { return stream.position() >= (ref.offset + ref.size); } public SimpleBlock getNextSimpleBlock() throws IOException { - if (check()) { + if (insideClusterBounds()) { return null; } - if (currentSimpleBlock != null) { + + if (currentBlockGroup != null) { + ensure(currentBlockGroup); + currentBlockGroup = null; + currentSimpleBlock = null; + } else if (currentSimpleBlock != null) { ensure(currentSimpleBlock.ref); } - while (!check()) { - Element elem = untilElement(ref, ID_SimpleBlock); + while (!insideClusterBounds()) { + Element elem = untilElement(ref, ID_SimpleBlock, ID_GroupBlock); if (elem == null) { return null; } + if (elem.type == ID_GroupBlock) { + currentBlockGroup = elem; + elem = untilElement(currentBlockGroup, ID_Block); + + if (elem == null) { + ensure(currentBlockGroup); + currentBlockGroup = null; + continue; + } + } + currentSimpleBlock = readSimpleBlock(elem); if (currentSimpleBlock.trackNumber == tracks[selectedTrack].trackNumber) { currentSimpleBlock.data = stream.getView((int) currentSimpleBlock.dataSize); + + // calculate the timestamp in nanoseconds + currentSimpleBlock.absoluteTimeCodeNs = currentSimpleBlock.relativeTimeCode + this.timecode; + currentSimpleBlock.absoluteTimeCodeNs *= segment.info.timecodeScale; + return currentSimpleBlock; } @@ -505,5 +536,5 @@ public class WebMReader { } } -// + } diff --git a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java index e5881fd0b..8525fabd2 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java @@ -8,6 +8,7 @@ import org.schabi.newpipe.streams.WebMReader.SimpleBlock; import org.schabi.newpipe.streams.WebMReader.WebMTrack; import org.schabi.newpipe.streams.io.SharpStream; +import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; @@ -17,7 +18,7 @@ import java.util.ArrayList; /** * @author kapodamy */ -public class WebMWriter { +public class WebMWriter implements Closeable { private final static int BUFFER_SIZE = 8 * 1024; private final static int DEFAULT_TIMECODE_SCALE = 1000000; @@ -35,7 +36,7 @@ public class WebMWriter { private long written = 0; private Segment[] readersSegment; - private Cluster[] readersCluter; + private Cluster[] readersCluster; private int[] predefinedDurations; @@ -81,7 +82,7 @@ public class WebMWriter { public void selectTracks(int... trackIndex) throws IOException { try { readersSegment = new Segment[readers.length]; - readersCluter = new Cluster[readers.length]; + readersCluster = new Cluster[readers.length]; predefinedDurations = new int[readers.length]; for (int i = 0; i < readers.length; i++) { @@ -102,6 +103,7 @@ public class WebMWriter { return parsed; } + @Override public void close() { done = true; parsed = true; @@ -114,7 +116,7 @@ public class WebMWriter { readers = null; infoTracks = null; readersSegment = null; - readersCluter = null; + readersCluster = null; outBuffer = null; } @@ -247,7 +249,7 @@ public class WebMWriter { nextCueTime += DEFAULT_CUES_EACH_MS; } keyFrames.add( - new KeyFrame(baseSegmentOffset, currentClusterOffset - 7, written, bTimecode.length, bloq.absoluteTimecode) + new KeyFrame(baseSegmentOffset, currentClusterOffset - 8, written, bTimecode.length, bloq.absoluteTimecode) ); } } @@ -334,17 +336,17 @@ public class WebMWriter { } } - if (readersCluter[internalTrackId] == null) { - readersCluter[internalTrackId] = readersSegment[internalTrackId].getNextCluster(); - if (readersCluter[internalTrackId] == null) { + if (readersCluster[internalTrackId] == null) { + readersCluster[internalTrackId] = readersSegment[internalTrackId].getNextCluster(); + if (readersCluster[internalTrackId] == null) { readersSegment[internalTrackId] = null; return getNextBlockFrom(internalTrackId); } } - SimpleBlock res = readersCluter[internalTrackId].getNextSimpleBlock(); + SimpleBlock res = readersCluster[internalTrackId].getNextSimpleBlock(); if (res == null) { - readersCluter[internalTrackId] = null; + readersCluster[internalTrackId] = null; return new Block();// fake block to indicate the end of the cluster } @@ -353,16 +355,11 @@ public class WebMWriter { bloq.dataSize = (int) res.dataSize; bloq.trackNumber = internalTrackId; bloq.flags = res.flags; - bloq.absoluteTimecode = convertTimecode(res.relativeTimeCode, readersSegment[internalTrackId].info.timecodeScale); - bloq.absoluteTimecode += readersCluter[internalTrackId].timecode; + bloq.absoluteTimecode = res.absoluteTimeCodeNs / DEFAULT_TIMECODE_SCALE; return bloq; } - private short convertTimecode(int time, long oldTimeScale) { - return (short) (time * (DEFAULT_TIMECODE_SCALE / oldTimeScale)); - } - private void seekTo(SharpStream stream, long offset) throws IOException { if (stream.canSeek()) { stream.seek(offset); diff --git a/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java b/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java index a04e1145f..18c95e394 100644 --- a/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java +++ b/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java @@ -31,6 +31,12 @@ public class KioskTranslator { return c.getString(R.string.top_50); case "New & hot": return c.getString(R.string.new_and_hot); + case "Local": + return c.getString(R.string.local); + case "Recently added": + return c.getString(R.string.recently_added); + case "Most liked": + return c.getString(R.string.most_liked); case "conferences": return c.getString(R.string.conferences); default: @@ -46,6 +52,12 @@ public class KioskTranslator { return ThemeHelper.resolveResourceIdFromAttr(c, R.attr.ic_hot); case "New & hot": return ThemeHelper.resolveResourceIdFromAttr(c, R.attr.ic_hot); + case "Local": + return ThemeHelper.resolveResourceIdFromAttr(c, R.attr.ic_kiosk_local); + case "Recently added": + return ThemeHelper.resolveResourceIdFromAttr(c, R.attr.ic_kiosk_recent); + case "Most liked": + return ThemeHelper.resolveResourceIdFromAttr(c, R.attr.thumbs_up); case "conferences": return ThemeHelper.resolveResourceIdFromAttr(c, R.attr.ic_hot); default: diff --git a/app/src/main/java/org/schabi/newpipe/util/PeertubeHelper.java b/app/src/main/java/org/schabi/newpipe/util/PeertubeHelper.java new file mode 100644 index 000000000..0d695e275 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/PeertubeHelper.java @@ -0,0 +1,65 @@ +package org.schabi.newpipe.util; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import com.grack.nanojson.JsonArray; +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonParser; +import com.grack.nanojson.JsonParserException; +import com.grack.nanojson.JsonStringWriter; +import com.grack.nanojson.JsonWriter; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.ServiceList; +import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class PeertubeHelper { + + public static List getInstanceList(Context context) { + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + String savedInstanceListKey = context.getString(R.string.peertube_instance_list_key); + final String savedJson = sharedPreferences.getString(savedInstanceListKey, null); + if (null == savedJson) { + return Collections.singletonList(getCurrentInstance()); + } + + try { + JsonArray array = JsonParser.object().from(savedJson).getArray("instances"); + List result = new ArrayList<>(); + for (Object o : array) { + if (o instanceof JsonObject) { + JsonObject instance = (JsonObject) o; + String name = instance.getString("name"); + String url = instance.getString("url"); + result.add(new PeertubeInstance(url, name)); + } + } + return result; + } catch (JsonParserException e) { + return Collections.singletonList(getCurrentInstance()); + } + + } + + public static PeertubeInstance selectInstance(PeertubeInstance instance, Context context) { + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + String selectedInstanceKey = context.getString(R.string.peertube_selected_instance_key); + JsonStringWriter jsonWriter = JsonWriter.string().object(); + jsonWriter.value("name", instance.getName()); + jsonWriter.value("url", instance.getUrl()); + String jsonToSave = jsonWriter.end().done(); + sharedPreferences.edit().putString(selectedInstanceKey, jsonToSave).apply(); + ServiceList.PeerTube.setInstance(instance); + return instance; + } + + public static PeertubeInstance getCurrentInstance(){ + return ServiceList.PeerTube.getInstance(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java index d2ebcd9f8..ab58bc917 100644 --- a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java @@ -52,10 +52,12 @@ public class SecondaryStreamHelper { } } + if (m4v) return null; + // retry, but this time in reverse order for (int i = audioStreams.size() - 1; i >= 0; i--) { AudioStream audio = audioStreams.get(i); - if (audio.getFormat() == (m4v ? MediaFormat.MP3 : MediaFormat.OPUS)) { + if (audio.getFormat() == MediaFormat.WEBMA_OPUS) { return audio; } } diff --git a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java index 27e2f8422..8929cc654 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java @@ -1,15 +1,22 @@ package org.schabi.newpipe.util; import android.content.Context; +import android.content.SharedPreferences; import android.preference.PreferenceManager; + import androidx.annotation.DrawableRes; import androidx.annotation.StringRes; +import com.grack.nanojson.JsonObject; +import com.grack.nanojson.JsonParser; +import com.grack.nanojson.JsonParserException; + import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; import java.util.concurrent.TimeUnit; @@ -27,13 +34,15 @@ public class ServiceHelper { return R.drawable.place_holder_cloud; case 2: return R.drawable.place_holder_gadse; + case 3: + return R.drawable.place_holder_peertube; default: return R.drawable.place_holder_circle; } } public static String getTranslatedFilterString(String filter, Context c) { - switch(filter) { + switch (filter) { case "all": return c.getString(R.string.all); case "videos": return c.getString(R.string.videos); case "channels": return c.getString(R.string.channels); @@ -126,9 +135,36 @@ public class ServiceHelper { } public static boolean isBeta(final StreamingService s) { - switch(s.getServiceInfo().getName()) { + switch (s.getServiceInfo().getName()) { case "YouTube": return false; default: return true; } } + + public static void initService(Context context, int serviceId) { + if (serviceId == ServiceList.PeerTube.getServiceId()) { + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(context); + String json = sharedPreferences.getString(context.getString(R.string.peertube_selected_instance_key), null); + if (null == json) { + return; + } + + JsonObject jsonObject = null; + try { + jsonObject = JsonParser.object().from(json); + } catch (JsonParserException e) { + return; + } + String name = jsonObject.getString("name"); + String url = jsonObject.getString("url"); + PeertubeInstance instance = new PeertubeInstance(url, name); + ServiceList.PeerTube.setInstance(instance); + } + } + + public static void initServices(Context context) { + for (StreamingService s : ServiceList.all()) { + initService(context, s.getServiceId()); + } + } } diff --git a/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java b/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java index 247faeb6d..618200f27 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java @@ -1,8 +1,10 @@ package us.shandian.giga.get; -import androidx.annotation.NonNull; +import android.text.TextUtils; import android.util.Log; +import androidx.annotation.NonNull; + import org.schabi.newpipe.streams.io.SharpStream; import java.io.IOException; @@ -13,6 +15,7 @@ import java.nio.channels.ClosedByInterruptException; import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; +import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_FORBIDDEN; public class DownloadInitializer extends Thread { private final static String TAG = "DownloadInitializer"; @@ -28,9 +31,9 @@ public class DownloadInitializer extends Thread { mConn = null; } - private static void safeClose(HttpURLConnection con) { + private void dispose() { try { - con.getInputStream().close(); + mConn.getInputStream().close(); } catch (Exception e) { // nothing to do } @@ -51,9 +54,9 @@ public class DownloadInitializer extends Thread { long lowestSize = Long.MAX_VALUE; for (int i = 0; i < mMission.urls.length && mMission.running; i++) { - mConn = mMission.openConnection(mMission.urls[i], mId, -1, -1); + mConn = mMission.openConnection(mMission.urls[i], true, -1, -1); mMission.establishConnection(mId, mConn); - safeClose(mConn); + dispose(); if (Thread.interrupted()) return; long length = Utility.getContentLength(mConn); @@ -81,9 +84,9 @@ public class DownloadInitializer extends Thread { } } else { // ask for the current resource length - mConn = mMission.openConnection(mId, -1, -1); + mConn = mMission.openConnection(true, -1, -1); mMission.establishConnection(mId, mConn); - safeClose(mConn); + dispose(); if (!mMission.running || Thread.interrupted()) return; @@ -107,9 +110,9 @@ public class DownloadInitializer extends Thread { } } else { // Open again - mConn = mMission.openConnection(mId, mMission.length - 10, mMission.length); + mConn = mMission.openConnection(true, mMission.length - 10, mMission.length); mMission.establishConnection(mId, mConn); - safeClose(mConn); + dispose(); if (!mMission.running || Thread.interrupted()) return; @@ -151,12 +154,33 @@ public class DownloadInitializer extends Thread { if (!mMission.running || Thread.interrupted()) return; + if (!mMission.unknownLength && mMission.recoveryInfo != null) { + String entityTag = mConn.getHeaderField("ETAG"); + String lastModified = mConn.getHeaderField("Last-Modified"); + MissionRecoveryInfo recovery = mMission.recoveryInfo[mMission.current]; + + if (!TextUtils.isEmpty(entityTag)) { + recovery.validateCondition = entityTag; + } else if (!TextUtils.isEmpty(lastModified)) { + recovery.validateCondition = lastModified;// Note: this is less precise + } else { + recovery.validateCondition = null; + } + } + mMission.running = false; break; } catch (InterruptedIOException | ClosedByInterruptException e) { return; } catch (Exception e) { - if (!mMission.running) return; + if (!mMission.running || super.isInterrupted()) return; + + if (e instanceof DownloadMission.HttpError && ((DownloadMission.HttpError) e).statusCode == ERROR_HTTP_FORBIDDEN) { + // for youtube streams. The url has expired + interrupt(); + mMission.doRecover(ERROR_HTTP_FORBIDDEN); + return; + } if (e instanceof IOException && e.getMessage().contains("Permission denied")) { mMission.notifyError(DownloadMission.ERROR_PERMISSION_DENIED, e); @@ -179,13 +203,6 @@ public class DownloadInitializer extends Thread { @Override public void interrupt() { super.interrupt(); - - if (mConn != null) { - try { - mConn.disconnect(); - } catch (Exception e) { - // nothing to do - } - } + if (mConn != null) dispose(); } } diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMission.java b/app/src/main/java/us/shandian/giga/get/DownloadMission.java index d78f8e32b..c0f85b321 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -1,21 +1,27 @@ package us.shandian.giga.get; +import android.os.Build; import android.os.Handler; +import android.system.ErrnoException; +import android.system.OsConstants; import android.util.Log; import androidx.annotation.Nullable; +import androidx.annotation.NonNull; import org.schabi.newpipe.DownloaderImpl; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; +import java.io.InterruptedIOException; import java.io.Serializable; import java.net.ConnectException; import java.net.HttpURLConnection; import java.net.SocketTimeoutException; import java.net.URL; import java.net.UnknownHostException; +import java.nio.channels.ClosedByInterruptException; import javax.net.ssl.SSLException; @@ -27,14 +33,11 @@ import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; public class DownloadMission extends Mission { - private static final long serialVersionUID = 5L;// last bump: 30 june 2019 + private static final long serialVersionUID = 6L;// last bump: 07 october 2019 static final int BUFFER_SIZE = 64 * 1024; static final int BLOCK_SIZE = 512 * 1024; - @SuppressWarnings("SpellCheckingInspection") - private static final String INSUFFICIENT_STORAGE = "ENOSPC"; - private static final String TAG = "DownloadMission"; public static final int ERROR_NOTHING = -1; @@ -51,8 +54,9 @@ public class DownloadMission extends Mission { public static final int ERROR_INSUFFICIENT_STORAGE = 1010; public static final int ERROR_PROGRESS_LOST = 1011; public static final int ERROR_TIMEOUT = 1012; + public static final int ERROR_RESOURCE_GONE = 1013; public static final int ERROR_HTTP_NO_CONTENT = 204; - public static final int ERROR_HTTP_UNSUPPORTED_RANGE = 206; + static final int ERROR_HTTP_FORBIDDEN = 403; /** * The urls of the file to download @@ -60,9 +64,9 @@ public class DownloadMission extends Mission { public String[] urls; /** - * Number of bytes downloaded + * Number of bytes downloaded and written */ - public long done; + public volatile long done; /** * Indicates a file generated dynamically on the web server @@ -118,31 +122,36 @@ public class DownloadMission extends Mission { /** * Download/File resume offset in fallback mode (if applicable) {@link DownloadRunnableFallback} */ - long fallbackResumeOffset; + volatile long fallbackResumeOffset; /** * Maximum of download threads running, chosen by the user */ public int threadCount = 3; + /** + * information required to recover a download + */ + public MissionRecoveryInfo[] recoveryInfo; + private transient int finishCount; - public transient boolean running; + public transient volatile boolean running; public boolean enqueued; public int errCode = ERROR_NOTHING; public Exception errObject = null; - public transient boolean recovered; public transient Handler mHandler; - private transient boolean mWritingToFile; private transient boolean[] blockAcquired; + private transient long writingToFileNext; + private transient volatile boolean writingToFile; + final Object LOCK = new Lock(); - private transient boolean deleted; - - public transient volatile Thread[] threads = new Thread[0]; - private transient Thread init = null; + @NonNull + public transient Thread[] threads = new Thread[0]; + public transient Thread init = null; public DownloadMission(String[] urls, StoredFileHelper storage, char kind, Postprocessing psInstance) { if (urls == null) throw new NullPointerException("urls is null"); @@ -197,37 +206,34 @@ public class DownloadMission extends Mission { } /** - * Open connection + * Opens a connection * - * @param threadId id of the calling thread, used only for debug - * @param rangeStart range start - * @param rangeEnd range end + * @param headRequest {@code true} for use {@code HEAD} request method, otherwise, {@code GET} is used + * @param rangeStart range start + * @param rangeEnd range end * @return a {@link java.net.URLConnection URLConnection} linking to the URL. * @throws IOException if an I/O exception occurs. */ - HttpURLConnection openConnection(int threadId, long rangeStart, long rangeEnd) throws IOException { - return openConnection(urls[current], threadId, rangeStart, rangeEnd); + HttpURLConnection openConnection(boolean headRequest, long rangeStart, long rangeEnd) throws IOException { + return openConnection(urls[current], headRequest, rangeStart, rangeEnd); } - HttpURLConnection openConnection(String url, int threadId, long rangeStart, long rangeEnd) throws IOException { + HttpURLConnection openConnection(String url, boolean headRequest, long rangeStart, long rangeEnd) throws IOException { HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); conn.setInstanceFollowRedirects(true); conn.setRequestProperty("User-Agent", DownloaderImpl.USER_AGENT); conn.setRequestProperty("Accept", "*/*"); + if (headRequest) conn.setRequestMethod("HEAD"); + // BUG workaround: switching between networks can freeze the download forever conn.setConnectTimeout(30000); - conn.setReadTimeout(10000); if (rangeStart >= 0) { String req = "bytes=" + rangeStart + "-"; if (rangeEnd > 0) req += rangeEnd; conn.setRequestProperty("Range", req); - - if (DEBUG) { - Log.d(TAG, threadId + ":" + conn.getRequestProperty("Range")); - } } return conn; @@ -240,18 +246,21 @@ public class DownloadMission extends Mission { * @throws HttpError if the HTTP Status-Code is not satisfiable */ void establishConnection(int threadId, HttpURLConnection conn) throws IOException, HttpError { - conn.connect(); int statusCode = conn.getResponseCode(); if (DEBUG) { - Log.d(TAG, threadId + ":Content-Length=" + conn.getContentLength() + " Code:" + statusCode); + Log.d(TAG, threadId + ":[request] Range=" + conn.getRequestProperty("Range")); + Log.d(TAG, threadId + ":[response] Code=" + statusCode); + Log.d(TAG, threadId + ":[response] Content-Length=" + conn.getContentLength()); + Log.d(TAG, threadId + ":[response] Content-Range=" + conn.getHeaderField("Content-Range")); } + switch (statusCode) { case 204: case 205: case 207: - throw new HttpError(conn.getResponseCode()); + throw new HttpError(statusCode); case 416: return;// let the download thread handle this error default: @@ -268,28 +277,19 @@ public class DownloadMission extends Mission { } synchronized void notifyProgress(long deltaLen) { - if (!running) return; - - if (recovered) { - recovered = false; - } - if (unknownLength) { length += deltaLen;// Update length before proceeding } done += deltaLen; - if (done > length) { - done = length; - } + if (metadata == null) return; - if (done != length && !deleted && !mWritingToFile) { - mWritingToFile = true; - runAsync(-2, this::writeThisToFile); + if (!writingToFile && (done > writingToFileNext || deltaLen < 0)) { + writingToFile = true; + writingToFileNext = done + BLOCK_SIZE; + writeThisToFileAsync(); } - - notify(DownloadManagerService.MESSAGE_PROGRESS); } synchronized void notifyError(Exception err) { @@ -314,13 +314,29 @@ public class DownloadMission extends Mission { public synchronized void notifyError(int code, Exception err) { Log.e(TAG, "notifyError() code = " + code, err); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (err != null && err.getCause() instanceof ErrnoException) { + int errno = ((ErrnoException) err.getCause()).errno; + if (errno == OsConstants.ENOSPC) { + code = ERROR_INSUFFICIENT_STORAGE; + err = null; + } else if (errno == OsConstants.EACCES) { + code = ERROR_PERMISSION_DENIED; + err = null; + } + } + } + if (err instanceof IOException) { - if (!storage.canWrite() || err.getMessage().contains("Permission denied")) { + if (err.getMessage().contains("Permission denied")) { code = ERROR_PERMISSION_DENIED; err = null; - } else if (err.getMessage().contains(INSUFFICIENT_STORAGE)) { + } else if (err.getMessage().contains("ENOSPC")) { code = ERROR_INSUFFICIENT_STORAGE; err = null; + } else if (!storage.canWrite()) { + code = ERROR_FILE_CREATION; + err = null; } } @@ -342,44 +358,42 @@ public class DownloadMission extends Mission { notify(DownloadManagerService.MESSAGE_ERROR); - if (running) { - running = false; - recovered = true; - if (threads != null) selfPause(); - } + if (running) pauseThreads(); } synchronized void notifyFinished() { - if (errCode > ERROR_NOTHING) return; - - finishCount++; - - if (blocks.length < 1 || threads == null || finishCount == threads.length) { - if (errCode != ERROR_NOTHING) return; + if (current < urls.length) { + if (++finishCount < threads.length) return; if (DEBUG) { - Log.d(TAG, "onFinish: " + (current + 1) + "/" + urls.length); - } - - if ((current + 1) < urls.length) { - // prepare next sub-mission - long current_offset = offsets[current++]; - offsets[current] = current_offset + length; - initializer(); - return; + Log.d(TAG, "onFinish: downloaded " + (current + 1) + "/" + urls.length); } current++; - unknownLength = false; - - if (!doPostprocessing()) return; - - enqueued = false; - running = false; - deleteThisFromFile(); - - notify(DownloadManagerService.MESSAGE_FINISHED); + if (current < urls.length) { + // prepare next sub-mission + offsets[current] = offsets[current - 1] + length; + initializer(); + return; + } } + + if (psAlgorithm != null && psState == 0) { + threads = new Thread[]{ + runAsync(1, this::doPostprocessing) + }; + return; + } + + + // this mission is fully finished + + unknownLength = false; + enqueued = false; + running = false; + + deleteThisFromFile(); + notify(DownloadManagerService.MESSAGE_FINISHED); } private void notifyPostProcessing(int state) { @@ -397,10 +411,15 @@ public class DownloadMission extends Mission { Log.d(TAG, action + " postprocessing on " + storage.getName()); + if (state == 2) { + psState = state; + return; + } + synchronized (LOCK) { // don't return without fully write the current state psState = state; - Utility.writeToFile(metadata, DownloadMission.this); + writeThisToFile(); } } @@ -409,14 +428,10 @@ public class DownloadMission extends Mission { * Start downloading with multiple threads. */ public void start() { - if (running || isFinished()) return; + if (running || isFinished() || urls.length < 1) return; // ensure that the previous state is completely paused. - joinForThread(init); - if (threads != null) { - for (Thread thread : threads) joinForThread(thread); - threads = null; - } + joinForThreads(10000); running = true; errCode = ERROR_NOTHING; @@ -427,7 +442,14 @@ public class DownloadMission extends Mission { } if (current >= urls.length) { - runAsync(1, this::notifyFinished); + notifyFinished(); + return; + } + + notify(DownloadManagerService.MESSAGE_RUNNING); + + if (urls[current] == null) { + doRecover(ERROR_RESOURCE_GONE); return; } @@ -441,18 +463,13 @@ public class DownloadMission extends Mission { blockAcquired = new boolean[blocks.length]; if (blocks.length < 1) { - if (unknownLength) { - done = 0; - length = 0; - } - threads = new Thread[]{runAsync(1, new DownloadRunnableFallback(this))}; } else { int remainingBlocks = 0; for (int block : blocks) if (block >= 0) remainingBlocks++; if (remainingBlocks < 1) { - runAsync(1, this::notifyFinished); + notifyFinished(); return; } @@ -478,7 +495,7 @@ public class DownloadMission extends Mission { } running = false; - recovered = true; + notify(DownloadManagerService.MESSAGE_PAUSED); if (init != null && init.isAlive()) { // NOTE: if start() method is running ¡will no have effect! @@ -493,29 +510,14 @@ public class DownloadMission extends Mission { Log.w(TAG, "pausing a download that can not be resumed (range requests not allowed by the server)."); } - // check if the calling thread (alias UI thread) is interrupted - if (Thread.currentThread().isInterrupted()) { - writeThisToFile(); - return; - } - - // wait for all threads are suspended before save the state - if (threads != null) runAsync(-1, this::selfPause); + init = null; + pauseThreads(); } - private void selfPause() { - try { - for (Thread thread : threads) { - if (thread.isAlive()) { - thread.interrupt(); - thread.join(5000); - } - } - } catch (Exception e) { - // nothing to do - } finally { - writeThisToFile(); - } + private void pauseThreads() { + running = false; + joinForThreads(-1); + writeThisToFile(); } /** @@ -523,9 +525,10 @@ public class DownloadMission extends Mission { */ @Override public boolean delete() { - deleted = true; if (psAlgorithm != null) psAlgorithm.cleanupTemporalDir(); + notify(DownloadManagerService.MESSAGE_DELETED); + boolean res = deleteThisFromFile(); if (!super.delete()) return false; @@ -540,35 +543,37 @@ public class DownloadMission extends Mission { * @param persistChanges {@code true} to commit changes to the metadata file, otherwise, {@code false} */ public void resetState(boolean rollback, boolean persistChanges, int errorCode) { - done = 0; + length = 0; errCode = errorCode; errObject = null; unknownLength = false; - threads = null; + threads = new Thread[0]; fallbackResumeOffset = 0; blocks = null; blockAcquired = null; if (rollback) current = 0; - - if (persistChanges) - Utility.writeToFile(metadata, DownloadMission.this); + if (persistChanges) writeThisToFile(); } private void initializer() { init = runAsync(DownloadInitializer.mId, new DownloadInitializer(this)); } + private void writeThisToFileAsync() { + runAsync(-2, this::writeThisToFile); + } + /** * Write this {@link DownloadMission} to the meta file asynchronously * if no thread is already running. */ - private void writeThisToFile() { + void writeThisToFile() { synchronized (LOCK) { - if (deleted) return; - Utility.writeToFile(metadata, DownloadMission.this); + if (metadata == null) return; + Utility.writeToFile(metadata, this); + writingToFile = false; } - mWritingToFile = false; } /** @@ -621,11 +626,10 @@ public class DownloadMission extends Mission { public long getLength() { long calculated; if (psState == 1 || psState == 3) { - calculated = length; - } else { - calculated = offsets[current < offsets.length ? current : (offsets.length - 1)] + length; + return length; } + calculated = offsets[current < offsets.length ? current : (offsets.length - 1)] + length; calculated -= offsets[0];// don't count reserved space return calculated > nearLength ? calculated : nearLength; @@ -638,7 +642,7 @@ public class DownloadMission extends Mission { */ public void setEnqueued(boolean queue) { enqueued = queue; - runAsync(-2, this::writeThisToFile); + writeThisToFileAsync(); } /** @@ -667,24 +671,29 @@ public class DownloadMission extends Mission { * @return {@code true} is this mission its "healthy", otherwise, {@code false} */ public boolean isCorrupt() { + if (urls.length < 1) return false; return (isPsFailed() || errCode == ERROR_POSTPROCESSING_HOLD) || isFinished(); } - private boolean doPostprocessing() { - if (psAlgorithm == null || psState == 2) return true; + /** + * Indicates if mission urls has expired and there an attempt to renovate them + * + * @return {@code true} if the mission is running a recovery procedure, otherwise, {@code false} + */ + public boolean isRecovering() { + return threads.length > 0 && threads[0] instanceof DownloadMissionRecover && threads[0].isAlive(); + } + private void doPostprocessing() { + errCode = ERROR_NOTHING; errObject = null; + Thread thread = Thread.currentThread(); notifyPostProcessing(1); - notifyProgress(0); - if (DEBUG) - Thread.currentThread().setName("[" + TAG + "] ps = " + - psAlgorithm.getClass().getSimpleName() + - " filename = " + storage.getName() - ); - - threads = new Thread[]{Thread.currentThread()}; + if (DEBUG) { + thread.setName("[" + TAG + "] ps = " + psAlgorithm + " filename = " + storage.getName()); + } Exception exception = null; @@ -693,6 +702,11 @@ public class DownloadMission extends Mission { } catch (Exception err) { Log.e(TAG, "Post-processing failed. " + psAlgorithm.toString(), err); + if (err instanceof InterruptedIOException || err instanceof ClosedByInterruptException || thread.isInterrupted()) { + notifyError(DownloadMission.ERROR_POSTPROCESSING_STOPPED, null); + return; + } + if (errCode == ERROR_NOTHING) errCode = ERROR_POSTPROCESSING; exception = err; @@ -703,16 +717,38 @@ public class DownloadMission extends Mission { if (errCode != ERROR_NOTHING) { if (exception == null) exception = errObject; notifyError(ERROR_POSTPROCESSING, exception); - - return false; + return; } - return true; + notifyFinished(); + } + + /** + * Attempts to recover the download + * + * @param errorCode error code which trigger the recovery procedure + */ + void doRecover(int errorCode) { + Log.i(TAG, "Attempting to recover the mission: " + storage.getName()); + + if (recoveryInfo == null) { + notifyError(errorCode, null); + urls = new String[0];// mark this mission as dead + return; + } + + joinForThreads(0); + + threads = new Thread[]{ + runAsync(DownloadMissionRecover.mID, new DownloadMissionRecover(this, errorCode)) + }; } private boolean deleteThisFromFile() { synchronized (LOCK) { - return metadata.delete(); + boolean res = metadata.delete(); + metadata = null; + return res; } } @@ -722,8 +758,8 @@ public class DownloadMission extends Mission { * @param id id of new thread (used for debugging only) * @param who the Runnable whose {@code run} method is invoked. */ - private void runAsync(int id, Runnable who) { - runAsync(id, new Thread(who)); + private Thread runAsync(int id, Runnable who) { + return runAsync(id, new Thread(who)); } /** @@ -749,25 +785,47 @@ public class DownloadMission extends Mission { return who; } - private void joinForThread(Thread thread) { - if (thread == null || !thread.isAlive()) return; - if (thread == Thread.currentThread()) return; + /** + * Waits at most {@code millis} milliseconds for the thread to die + * + * @param millis the time to wait in milliseconds + */ + private void joinForThreads(int millis) { + final Thread currentThread = Thread.currentThread(); - if (DEBUG) { - Log.w(TAG, "a thread is !still alive!: " + thread.getName()); + if (init != null && init != currentThread && init.isAlive()) { + init.interrupt(); + + if (millis > 0) { + try { + init.join(millis); + } catch (InterruptedException e) { + Log.w(TAG, "Initializer thread is still running", e); + return; + } + } } - // still alive, this should not happen. - // Possible reasons: + // if a thread is still alive, possible reasons: // slow device // the user is spamming start/pause buttons // start() method called quickly after pause() + for (Thread thread : threads) { + if (!thread.isAlive() || thread == Thread.currentThread()) continue; + thread.interrupt(); + } + try { - thread.join(10000); + for (Thread thread : threads) { + if (!thread.isAlive()) continue; + if (DEBUG) { + Log.w(TAG, "thread alive: " + thread.getName()); + } + if (millis > 0) thread.join(millis); + } } catch (InterruptedException e) { - Log.d(TAG, "timeout on join : " + thread.getName()); - throw new RuntimeException("A thread is still running:\n" + thread.getName()); + throw new RuntimeException("A download thread is still running", e); } } @@ -785,9 +843,9 @@ public class DownloadMission extends Mission { } } - static class Block { - int position; - int done; + public static class Block { + public int position; + public int done; } private static class Lock implements Serializable { diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java b/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java new file mode 100644 index 000000000..14ac392a0 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java @@ -0,0 +1,313 @@ +package us.shandian.giga.get; + +import android.util.Log; + +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.StreamExtractor; +import org.schabi.newpipe.extractor.stream.SubtitlesStream; +import org.schabi.newpipe.extractor.stream.VideoStream; + +import java.io.IOException; +import java.io.InterruptedIOException; +import java.net.HttpURLConnection; +import java.nio.channels.ClosedByInterruptException; +import java.util.List; + +import us.shandian.giga.get.DownloadMission.HttpError; + +import static us.shandian.giga.get.DownloadMission.ERROR_RESOURCE_GONE; + +public class DownloadMissionRecover extends Thread { + private static final String TAG = "DownloadMissionRecover"; + static final int mID = -3; + + private final DownloadMission mMission; + private final boolean mNotInitialized; + + private final int mErrCode; + + private HttpURLConnection mConn; + private MissionRecoveryInfo mRecovery; + private StreamExtractor mExtractor; + + DownloadMissionRecover(DownloadMission mission, int errCode) { + mMission = mission; + mNotInitialized = mission.blocks == null && mission.current == 0; + mErrCode = errCode; + } + + @Override + public void run() { + if (mMission.source == null) { + mMission.notifyError(mErrCode, null); + return; + } + + Exception err = null; + int attempt = 0; + + while (attempt++ < mMission.maxRetry) { + try { + tryRecover(); + return; + } catch (InterruptedIOException | ClosedByInterruptException e) { + return; + } catch (Exception e) { + if (!mMission.running || super.isInterrupted()) return; + err = e; + } + } + + // give up + mMission.notifyError(mErrCode, err); + } + + private void tryRecover() throws ExtractionException, IOException, HttpError { + if (mExtractor == null) { + try { + StreamingService svr = NewPipe.getServiceByUrl(mMission.source); + mExtractor = svr.getStreamExtractor(mMission.source); + mExtractor.fetchPage(); + } catch (ExtractionException e) { + mExtractor = null; + throw e; + } + } + + // maybe the following check is redundant + if (!mMission.running || super.isInterrupted()) return; + + if (!mNotInitialized) { + // set the current download url to null in case if the recovery + // process is canceled. Next time start() method is called the + // recovery will be executed, saving time + mMission.urls[mMission.current] = null; + + mRecovery = mMission.recoveryInfo[mMission.current]; + resolveStream(); + return; + } + + Log.w(TAG, "mission is not fully initialized, this will take a while"); + + try { + for (; mMission.current < mMission.urls.length; mMission.current++) { + mRecovery = mMission.recoveryInfo[mMission.current]; + + if (test()) continue; + if (!mMission.running) return; + + resolveStream(); + if (!mMission.running) return; + + // before continue, check if the current stream was resolved + if (mMission.urls[mMission.current] == null) { + break; + } + } + } finally { + mMission.current = 0; + } + + mMission.writeThisToFile(); + + if (!mMission.running || super.isInterrupted()) return; + + mMission.running = false; + mMission.start(); + } + + private void resolveStream() throws IOException, ExtractionException, HttpError { + // FIXME: this getErrorMessage() always returns "video is unavailable" + /*if (mExtractor.getErrorMessage() != null) { + mMission.notifyError(mErrCode, new ExtractionException(mExtractor.getErrorMessage())); + return; + }*/ + + String url = null; + + switch (mRecovery.kind) { + case 'a': + for (AudioStream audio : mExtractor.getAudioStreams()) { + if (audio.average_bitrate == mRecovery.desiredBitrate && audio.getFormat() == mRecovery.format) { + url = audio.getUrl(); + break; + } + } + break; + case 'v': + List videoStreams; + if (mRecovery.desired2) + videoStreams = mExtractor.getVideoOnlyStreams(); + else + videoStreams = mExtractor.getVideoStreams(); + for (VideoStream video : videoStreams) { + if (video.resolution.equals(mRecovery.desired) && video.getFormat() == mRecovery.format) { + url = video.getUrl(); + break; + } + } + break; + case 's': + for (SubtitlesStream subtitles : mExtractor.getSubtitles(mRecovery.format)) { + String tag = subtitles.getLanguageTag(); + if (tag.equals(mRecovery.desired) && subtitles.isAutoGenerated() == mRecovery.desired2) { + url = subtitles.getURL(); + break; + } + } + break; + default: + throw new RuntimeException("Unknown stream type"); + } + + resolve(url); + } + + private void resolve(String url) throws IOException, HttpError { + if (mRecovery.validateCondition == null) { + Log.w(TAG, "validation condition not defined, the resource can be stale"); + } + + if (mMission.unknownLength || mRecovery.validateCondition == null) { + recover(url, false); + return; + } + + /////////////////////////////////////////////////////////////////////// + ////// Validate the http resource doing a range request + ///////////////////// + try { + mConn = mMission.openConnection(url, true, mMission.length - 10, mMission.length); + mConn.setRequestProperty("If-Range", mRecovery.validateCondition); + mMission.establishConnection(mID, mConn); + + int code = mConn.getResponseCode(); + + switch (code) { + case 200: + case 413: + // stale + recover(url, true); + return; + case 206: + // in case of validation using the Last-Modified date, check the resource length + long[] contentRange = parseContentRange(mConn.getHeaderField("Content-Range")); + boolean lengthMismatch = contentRange[2] != -1 && contentRange[2] != mMission.length; + + recover(url, lengthMismatch); + return; + } + + throw new HttpError(code); + } finally { + disconnect(); + } + } + + private void recover(String url, boolean stale) { + Log.i(TAG, + String.format("recover() name=%s isStale=%s url=%s", mMission.storage.getName(), stale, url) + ); + + mMission.urls[mMission.current] = url; + + if (url == null) { + mMission.urls = new String[0]; + mMission.notifyError(ERROR_RESOURCE_GONE, null); + return; + } + + if (mNotInitialized) return; + + if (stale) { + mMission.resetState(false, false, DownloadMission.ERROR_NOTHING); + } + + mMission.writeThisToFile(); + + if (!mMission.running || super.isInterrupted()) return; + + mMission.running = false; + mMission.start(); + } + + private long[] parseContentRange(String value) { + long[] range = new long[3]; + + if (value == null) { + // this never should happen + return range; + } + + try { + value = value.trim(); + + if (!value.startsWith("bytes")) { + return range;// unknown range type + } + + int space = value.lastIndexOf(' ') + 1; + int dash = value.indexOf('-', space) + 1; + int bar = value.indexOf('/', dash); + + // start + range[0] = Long.parseLong(value.substring(space, dash - 1)); + + // end + range[1] = Long.parseLong(value.substring(dash, bar)); + + // resource length + value = value.substring(bar + 1); + if (value.equals("*")) { + range[2] = -1;// unknown length received from the server but should be valid + } else { + range[2] = Long.parseLong(value); + } + } catch (Exception e) { + // nothing to do + } + + return range; + } + + private boolean test() { + if (mMission.urls[mMission.current] == null) return false; + + try { + mConn = mMission.openConnection(mMission.urls[mMission.current], true, -1, -1); + mMission.establishConnection(mID, mConn); + + if (mConn.getResponseCode() == 200) return true; + } catch (Exception e) { + // nothing to do + } finally { + disconnect(); + } + + return false; + } + + private void disconnect() { + try { + try { + mConn.getInputStream().close(); + } finally { + mConn.disconnect(); + } + } catch (Exception e) { + // nothing to do + } finally { + mConn = null; + } + } + + @Override + public void interrupt() { + super.interrupt(); + if (mConn != null) disconnect(); + } +} diff --git a/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java b/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java index f5b9b06d4..4aa6e912e 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadRunnable.java @@ -10,8 +10,10 @@ import java.net.HttpURLConnection; import java.nio.channels.ClosedByInterruptException; import us.shandian.giga.get.DownloadMission.Block; +import us.shandian.giga.get.DownloadMission.HttpError; import static org.schabi.newpipe.BuildConfig.DEBUG; +import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_FORBIDDEN; /** @@ -19,7 +21,7 @@ import static org.schabi.newpipe.BuildConfig.DEBUG; * an error occurs or the process is stopped. */ public class DownloadRunnable extends Thread { - private static final String TAG = DownloadRunnable.class.getSimpleName(); + private static final String TAG = "DownloadRunnable"; private final DownloadMission mMission; private final int mId; @@ -41,13 +43,7 @@ public class DownloadRunnable extends Thread { public void run() { boolean retry = false; Block block = null; - int retryCount = 0; - - if (DEBUG) { - Log.d(TAG, mId + ":recovered: " + mMission.recovered); - } - SharpStream f; try { @@ -84,13 +80,14 @@ public class DownloadRunnable extends Thread { } try { - mConn = mMission.openConnection(mId, start, end); + mConn = mMission.openConnection(false, start, end); mMission.establishConnection(mId, mConn); // check if the download can be resumed if (mConn.getResponseCode() == 416) { if (block.done > 0) { // try again from the start (of the block) + mMission.notifyProgress(-block.done); block.done = 0; retry = true; mConn.disconnect(); @@ -118,7 +115,7 @@ public class DownloadRunnable extends Thread { int len; // use always start <= end - // fixes a deadlock in DownloadRunnable because youtube is sending one byte alone after downloading 26MiB exactly + // fixes a deadlock because in some videos, youtube is sending one byte alone while (start <= end && mMission.running && (len = is.read(buf, 0, buf.length)) != -1) { f.write(buf, 0, len); start += len; @@ -133,6 +130,17 @@ public class DownloadRunnable extends Thread { } catch (Exception e) { if (!mMission.running || e instanceof ClosedByInterruptException) break; + if (e instanceof HttpError && ((HttpError) e).statusCode == ERROR_HTTP_FORBIDDEN) { + // for youtube streams. The url has expired, recover + f.close(); + + if (mId == 1) { + // only the first thread will execute the recovery procedure + mMission.doRecover(ERROR_HTTP_FORBIDDEN); + } + return; + } + if (retryCount++ >= mMission.maxRetry) { mMission.notifyError(e); break; @@ -144,11 +152,7 @@ public class DownloadRunnable extends Thread { } } - try { - f.close(); - } catch (Exception err) { - // ¿ejected media storage? ¿file deleted? ¿storage ran out of space? - } + f.close(); if (DEBUG) { Log.d(TAG, "thread " + mId + " exited from main download loop"); diff --git a/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java b/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java index 7fb1f0c77..9cb40cb32 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadRunnableFallback.java @@ -1,8 +1,9 @@ package us.shandian.giga.get; -import androidx.annotation.NonNull; import android.util.Log; +import androidx.annotation.NonNull; + import org.schabi.newpipe.streams.io.SharpStream; import java.io.IOException; @@ -10,9 +11,11 @@ import java.io.InputStream; import java.net.HttpURLConnection; import java.nio.channels.ClosedByInterruptException; +import us.shandian.giga.get.DownloadMission.HttpError; import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; +import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_FORBIDDEN; /** * Single-threaded fallback mode @@ -33,7 +36,11 @@ public class DownloadRunnableFallback extends Thread { private void dispose() { try { - if (mIs != null) mIs.close(); + try { + if (mIs != null) mIs.close(); + } finally { + mConn.disconnect(); + } } catch (IOException e) { // nothing to do } @@ -41,22 +48,10 @@ public class DownloadRunnableFallback extends Thread { if (mF != null) mF.close(); } - private long loadPosition() { - synchronized (mMission.LOCK) { - return mMission.fallbackResumeOffset; - } - } - - private void savePosition(long position) { - synchronized (mMission.LOCK) { - mMission.fallbackResumeOffset = position; - } - } - @Override public void run() { boolean done; - long start = loadPosition(); + long start = mMission.fallbackResumeOffset; if (DEBUG && !mMission.unknownLength && start > 0) { Log.i(TAG, "Resuming a single-thread download at " + start); @@ -66,11 +61,18 @@ public class DownloadRunnableFallback extends Thread { long rangeStart = (mMission.unknownLength || start < 1) ? -1 : start; int mId = 1; - mConn = mMission.openConnection(mId, rangeStart, -1); + mConn = mMission.openConnection(false, rangeStart, -1); + + if (mRetryCount == 0 && rangeStart == -1) { + // workaround: bypass android connection pool + mConn.setRequestProperty("Range", "bytes=0-"); + } + mMission.establishConnection(mId, mConn); // check if the download can be resumed if (mConn.getResponseCode() == 416 && start > 0) { + mMission.notifyProgress(-start); start = 0; mRetryCount--; throw new DownloadMission.HttpError(416); @@ -80,12 +82,17 @@ public class DownloadRunnableFallback extends Thread { if (!mMission.unknownLength) mMission.unknownLength = Utility.getContentLength(mConn) == -1; + if (mMission.unknownLength || mConn.getResponseCode() == 200) { + // restart amount of bytes downloaded + mMission.done = mMission.offsets[mMission.current] - mMission.offsets[0]; + } + mF = mMission.storage.getStream(); mF.seek(mMission.offsets[mMission.current] + start); mIs = mConn.getInputStream(); - byte[] buf = new byte[64 * 1024]; + byte[] buf = new byte[DownloadMission.BUFFER_SIZE]; int len = 0; while (mMission.running && (len = mIs.read(buf, 0, buf.length)) != -1) { @@ -94,15 +101,24 @@ public class DownloadRunnableFallback extends Thread { mMission.notifyProgress(len); } + dispose(); + // if thread goes interrupted check if the last part is written. This avoid re-download the whole file done = len == -1; } catch (Exception e) { dispose(); - savePosition(start); + mMission.fallbackResumeOffset = start; if (!mMission.running || e instanceof ClosedByInterruptException) return; + if (e instanceof HttpError && ((HttpError) e).statusCode == ERROR_HTTP_FORBIDDEN) { + // for youtube streams. The url has expired, recover + dispose(); + mMission.doRecover(ERROR_HTTP_FORBIDDEN); + return; + } + if (mRetryCount++ >= mMission.maxRetry) { mMission.notifyError(e); return; @@ -116,12 +132,10 @@ public class DownloadRunnableFallback extends Thread { return; } - dispose(); - if (done) { mMission.notifyFinished(); } else { - savePosition(start); + mMission.fallbackResumeOffset = start; } } diff --git a/app/src/main/java/us/shandian/giga/get/FinishedMission.java b/app/src/main/java/us/shandian/giga/get/FinishedMission.java index b468f3c76..6bc5423b8 100644 --- a/app/src/main/java/us/shandian/giga/get/FinishedMission.java +++ b/app/src/main/java/us/shandian/giga/get/FinishedMission.java @@ -2,17 +2,17 @@ package us.shandian.giga.get; import androidx.annotation.NonNull; -public class FinishedMission extends Mission { +public class FinishedMission extends Mission { public FinishedMission() { } public FinishedMission(@NonNull DownloadMission mission) { source = mission.source; - length = mission.length;// ¿or mission.done? + length = mission.length; timestamp = mission.timestamp; kind = mission.kind; storage = mission.storage; - } + } diff --git a/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.java b/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.java new file mode 100644 index 000000000..e52f35cc6 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.java @@ -0,0 +1,115 @@ +package us.shandian.giga.get; + +import android.os.Parcel; +import android.os.Parcelable; + +import androidx.annotation.NonNull; + +import org.schabi.newpipe.extractor.MediaFormat; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.Stream; +import org.schabi.newpipe.extractor.stream.SubtitlesStream; +import org.schabi.newpipe.extractor.stream.VideoStream; + +import java.io.Serializable; + +public class MissionRecoveryInfo implements Serializable, Parcelable { + private static final long serialVersionUID = 0L; + + MediaFormat format; + String desired; + boolean desired2; + int desiredBitrate; + byte kind; + String validateCondition = null; + + public MissionRecoveryInfo(@NonNull Stream stream) { + if (stream instanceof AudioStream) { + desiredBitrate = ((AudioStream) stream).average_bitrate; + desired2 = false; + kind = 'a'; + } else if (stream instanceof VideoStream) { + desired = ((VideoStream) stream).getResolution(); + desired2 = ((VideoStream) stream).isVideoOnly(); + kind = 'v'; + } else if (stream instanceof SubtitlesStream) { + desired = ((SubtitlesStream) stream).getLanguageTag(); + desired2 = ((SubtitlesStream) stream).isAutoGenerated(); + kind = 's'; + } else { + throw new RuntimeException("Unknown stream kind"); + } + + format = stream.getFormat(); + if (format == null) throw new NullPointerException("Stream format cannot be null"); + } + + @NonNull + @Override + public String toString() { + String info; + StringBuilder str = new StringBuilder(); + str.append("{type="); + switch (kind) { + case 'a': + str.append("audio"); + info = "bitrate=" + desiredBitrate; + break; + case 'v': + str.append("video"); + info = "quality=" + desired + " videoOnly=" + desired2; + break; + case 's': + str.append("subtitles"); + info = "language=" + desired + " autoGenerated=" + desired2; + break; + default: + info = ""; + str.append("other"); + } + + str.append(" format=") + .append(format.getName()) + .append(' ') + .append(info) + .append('}'); + + return str.toString(); + } + + @Override + public int describeContents() { + return 0; + } + + @Override + public void writeToParcel(Parcel parcel, int flags) { + parcel.writeInt(this.format.ordinal()); + parcel.writeString(this.desired); + parcel.writeInt(this.desired2 ? 0x01 : 0x00); + parcel.writeInt(this.desiredBitrate); + parcel.writeByte(this.kind); + parcel.writeString(this.validateCondition); + } + + private MissionRecoveryInfo(Parcel parcel) { + this.format = MediaFormat.values()[parcel.readInt()]; + this.desired = parcel.readString(); + this.desired2 = parcel.readInt() != 0x00; + this.desiredBitrate = parcel.readInt(); + this.kind = parcel.readByte(); + this.validateCondition = parcel.readString(); + } + + public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { + @Override + public MissionRecoveryInfo createFromParcel(Parcel source) { + return new MissionRecoveryInfo(source); + } + + @Override + public MissionRecoveryInfo[] newArray(int size) { + return new MissionRecoveryInfo[size]; + } + }; +} diff --git a/app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java b/app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java index 16a90fcee..98015e37e 100644 --- a/app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java +++ b/app/src/main/java/us/shandian/giga/io/ChunkFileInputStream.java @@ -5,21 +5,23 @@ import org.schabi.newpipe.streams.io.SharpStream; import java.io.IOException; public class ChunkFileInputStream extends SharpStream { + private static final int REPORT_INTERVAL = 256 * 1024; private SharpStream source; private final long offset; private final long length; private long position; - public ChunkFileInputStream(SharpStream target, long start) throws IOException { - this(target, start, target.length()); - } + private long progressReport; + private final ProgressReport onProgress; - public ChunkFileInputStream(SharpStream target, long start, long end) throws IOException { + public ChunkFileInputStream(SharpStream target, long start, long end, ProgressReport callback) throws IOException { source = target; offset = start; length = end - start; position = 0; + onProgress = callback; + progressReport = REPORT_INTERVAL; if (length < 1) { source.close(); @@ -60,12 +62,12 @@ public class ChunkFileInputStream extends SharpStream { } @Override - public int read(byte b[]) throws IOException { + public int read(byte[] b) throws IOException { return read(b, 0, b.length); } @Override - public int read(byte b[], int off, int len) throws IOException { + public int read(byte[] b, int off, int len) throws IOException { if ((position + len) > length) { len = (int) (length - position); } @@ -76,6 +78,11 @@ public class ChunkFileInputStream extends SharpStream { int res = source.read(b, off, len); position += res; + if (onProgress != null && position > progressReport) { + onProgress.report(position); + progressReport = position + REPORT_INTERVAL; + } + return res; } diff --git a/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java b/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java index e2afb9202..102580570 100644 --- a/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java +++ b/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java @@ -174,12 +174,12 @@ public class CircularFileWriter extends SharpStream { } @Override - public void write(byte b[]) throws IOException { + public void write(byte[] b) throws IOException { write(b, 0, b.length); } @Override - public void write(byte b[], int off, int len) throws IOException { + public void write(byte[] b, int off, int len) throws IOException { if (len == 0) { return; } @@ -261,7 +261,7 @@ public class CircularFileWriter extends SharpStream { @Override public void rewind() throws IOException { if (onProgress != null) { - onProgress.report(-out.length - aux.length);// rollback the whole progress + onProgress.report(0);// rollback the whole progress } seek(0); @@ -357,16 +357,6 @@ public class CircularFileWriter extends SharpStream { long check(); } - public interface ProgressReport { - - /** - * Report the size of the new file - * - * @param progress the new size - */ - void report(long progress); - } - public interface WriteErrorHandle { /** @@ -381,10 +371,10 @@ public class CircularFileWriter extends SharpStream { class BufferedFile { - protected final SharpStream target; + final SharpStream target; private long offset; - protected long length; + long length; private byte[] queue = new byte[QUEUE_BUFFER_SIZE]; private int queueSize; @@ -397,16 +387,16 @@ public class CircularFileWriter extends SharpStream { this.target = target; } - protected long getOffset() { + long getOffset() { return offset + queueSize;// absolute offset in the file } - protected void close() { + void close() { queue = null; target.close(); } - protected void write(byte b[], int off, int len) throws IOException { + void write(byte[] b, int off, int len) throws IOException { while (len > 0) { // if the queue is full, the method available() will flush the queue int read = Math.min(available(), len); @@ -436,7 +426,7 @@ public class CircularFileWriter extends SharpStream { target.seek(0); } - protected int available() throws IOException { + int available() throws IOException { if (queueSize >= queue.length) { flush(); return queue.length; @@ -451,7 +441,7 @@ public class CircularFileWriter extends SharpStream { target.seek(0); } - protected void seek(long absoluteOffset) throws IOException { + void seek(long absoluteOffset) throws IOException { if (absoluteOffset == offset) { return;// nothing to do } diff --git a/app/src/main/java/us/shandian/giga/io/ProgressReport.java b/app/src/main/java/us/shandian/giga/io/ProgressReport.java new file mode 100644 index 000000000..14ae9ded9 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/io/ProgressReport.java @@ -0,0 +1,11 @@ +package us.shandian.giga.io; + +public interface ProgressReport { + + /** + * Report the size of the new file + * + * @param progress the new size + */ + void report(long progress); +} \ No newline at end of file diff --git a/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java b/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java new file mode 100644 index 000000000..04958c495 --- /dev/null +++ b/app/src/main/java/us/shandian/giga/postprocessing/OggFromWebmDemuxer.java @@ -0,0 +1,44 @@ +package us.shandian.giga.postprocessing; + +import androidx.annotation.NonNull; + +import org.schabi.newpipe.streams.OggFromWebMWriter; +import org.schabi.newpipe.streams.io.SharpStream; + +import java.io.IOException; +import java.nio.ByteBuffer; + +class OggFromWebmDemuxer extends Postprocessing { + + OggFromWebmDemuxer() { + super(true, true, ALGORITHM_OGG_FROM_WEBM_DEMUXER); + } + + @Override + boolean test(SharpStream... sources) throws IOException { + ByteBuffer buffer = ByteBuffer.allocate(4); + sources[0].read(buffer.array()); + + // youtube uses WebM as container, but the file extension (format suffix) is "*.opus" + // check if the file is a webm/mkv file before proceed + + switch (buffer.getInt()) { + case 0x1a45dfa3: + return true;// webm/mkv + case 0x4F676753: + return false;// ogg + } + + throw new UnsupportedOperationException("file not recognized, failed to demux the audio stream"); + } + + @Override + int process(SharpStream out, @NonNull SharpStream... sources) throws IOException { + OggFromWebMWriter demuxer = new OggFromWebMWriter(sources[0], out); + demuxer.parseSource(); + demuxer.selectTrack(0); + demuxer.build(); + + return OK_RESULT; + } +} diff --git a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java index 22cc325d5..773ff92d1 100644 --- a/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java +++ b/app/src/main/java/us/shandian/giga/postprocessing/Postprocessing.java @@ -1,9 +1,9 @@ package us.shandian.giga.postprocessing; -import android.os.Message; -import androidx.annotation.NonNull; import android.util.Log; +import androidx.annotation.NonNull; + import org.schabi.newpipe.streams.io.SharpStream; import java.io.File; @@ -14,11 +14,11 @@ import us.shandian.giga.get.DownloadMission; import us.shandian.giga.io.ChunkFileInputStream; import us.shandian.giga.io.CircularFileWriter; import us.shandian.giga.io.CircularFileWriter.OffsetChecker; -import us.shandian.giga.service.DownloadManagerService; +import us.shandian.giga.io.ProgressReport; import static us.shandian.giga.get.DownloadMission.ERROR_NOTHING; +import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING; import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_HOLD; -import static us.shandian.giga.get.DownloadMission.ERROR_UNKNOWN_EXCEPTION; public abstract class Postprocessing implements Serializable { @@ -28,6 +28,7 @@ public abstract class Postprocessing implements Serializable { public transient static final String ALGORITHM_WEBM_MUXER = "webm"; public transient static final String ALGORITHM_MP4_FROM_DASH_MUXER = "mp4D-mp4"; public transient static final String ALGORITHM_M4A_NO_DASH = "mp4D-m4a"; + public transient static final String ALGORITHM_OGG_FROM_WEBM_DEMUXER = "webm-ogg-d"; public static Postprocessing getAlgorithm(@NonNull String algorithmName, String[] args) { Postprocessing instance; @@ -45,6 +46,9 @@ public abstract class Postprocessing implements Serializable { case ALGORITHM_M4A_NO_DASH: instance = new M4aNoDash(); break; + case ALGORITHM_OGG_FROM_WEBM_DEMUXER: + instance = new OggFromWebmDemuxer(); + break; /*case "example-algorithm": instance = new ExampleAlgorithm();*/ default: @@ -59,22 +63,22 @@ public abstract class Postprocessing implements Serializable { * Get a boolean value that indicate if the given algorithm work on the same * file */ - public final boolean worksOnSameFile; + public boolean worksOnSameFile; /** * Indicates whether the selected algorithm needs space reserved at the beginning of the file */ - public final boolean reserveSpace; + public boolean reserveSpace; /** * Gets the given algorithm short name */ - private final String name; + private String name; private String[] args; - protected transient DownloadMission mission; + private transient DownloadMission mission; private File tempFile; @@ -105,16 +109,24 @@ public abstract class Postprocessing implements Serializable { long finalLength = -1; mission.done = 0; - mission.length = mission.storage.length(); + + long length = mission.storage.length() - mission.offsets[0]; + mission.length = length > mission.nearLength ? length : mission.nearLength; + + final ProgressReport readProgress = (long position) -> { + position -= mission.offsets[0]; + if (position > mission.done) mission.done = position; + }; if (worksOnSameFile) { ChunkFileInputStream[] sources = new ChunkFileInputStream[mission.urls.length]; try { - int i = 0; - for (; i < sources.length - 1; i++) { - sources[i] = new ChunkFileInputStream(mission.storage.getStream(), mission.offsets[i], mission.offsets[i + 1]); + for (int i = 0, j = 1; i < sources.length; i++, j++) { + SharpStream source = mission.storage.getStream(); + long end = j < sources.length ? mission.offsets[j] : source.length(); + + sources[i] = new ChunkFileInputStream(source, mission.offsets[i], end, readProgress); } - sources[i] = new ChunkFileInputStream(mission.storage.getStream(), mission.offsets[i]); if (test(sources)) { for (SharpStream source : sources) source.rewind(); @@ -136,7 +148,7 @@ public abstract class Postprocessing implements Serializable { }; out = new CircularFileWriter(mission.storage.getStream(), tempFile, checker); - out.onProgress = this::progressReport; + out.onProgress = (long position) -> mission.done = position; out.onWriteError = (err) -> { mission.psState = 3; @@ -183,11 +195,10 @@ public abstract class Postprocessing implements Serializable { if (result == OK_RESULT) { if (finalLength != -1) { - mission.done = finalLength; mission.length = finalLength; } } else { - mission.errCode = ERROR_UNKNOWN_EXCEPTION; + mission.errCode = ERROR_POSTPROCESSING; mission.errObject = new RuntimeException("post-processing algorithm returned " + result); } @@ -212,7 +223,7 @@ public abstract class Postprocessing implements Serializable { * * @param out output stream * @param sources files to be processed - * @return a error code, 0 means the operation was successful + * @return an error code, {@code OK_RESULT} means the operation was successful * @throws IOException if an I/O error occurs. */ abstract int process(SharpStream out, SharpStream... sources) throws IOException; @@ -225,23 +236,12 @@ public abstract class Postprocessing implements Serializable { return args[index]; } - private void progressReport(long done) { - mission.done = done; - if (mission.length < mission.done) mission.length = mission.done; - - Message m = new Message(); - m.what = DownloadManagerService.MESSAGE_PROGRESS; - m.obj = mission; - - mission.mHandler.sendMessage(m); - } - @NonNull @Override public String toString() { StringBuilder str = new StringBuilder(); - str.append("name=").append(name).append('['); + str.append("{ name=").append(name).append('['); if (args != null) { for (String arg : args) { @@ -251,6 +251,6 @@ public abstract class Postprocessing implements Serializable { str.delete(0, 1); } - return str.append(']').toString(); + return str.append("] }").toString(); } } diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManager.java b/app/src/main/java/us/shandian/giga/service/DownloadManager.java index 3d34411b9..e8bc468e9 100644 --- a/app/src/main/java/us/shandian/giga/service/DownloadManager.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManager.java @@ -2,13 +2,11 @@ package us.shandian.giga.service; import android.content.Context; import android.os.Handler; +import android.util.Log; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.recyclerview.widget.DiffUtil; -import android.util.Log; -import android.widget.Toast; - -import org.schabi.newpipe.R; import java.io.File; import java.io.IOException; @@ -37,6 +35,7 @@ public class DownloadManager { public static final String TAG_AUDIO = "audio"; public static final String TAG_VIDEO = "video"; + private static final String DOWNLOADS_METADATA_FOLDER = "pending_downloads"; private final FinishedMissionStore mFinishedMissionStore; @@ -74,25 +73,35 @@ public class DownloadManager { mMissionsFinished = loadFinishedMissions(); mPendingMissionsDir = getPendingDir(context); - if (!Utility.mkdir(mPendingMissionsDir, false)) { - throw new RuntimeException("failed to create pending_downloads in data directory"); - } - loadPendingMissions(context); } private static File getPendingDir(@NonNull Context context) { - //File dir = new File(ContextCompat.getDataDir(context), "pending_downloads"); - File dir = context.getExternalFilesDir("pending_downloads"); + File dir = context.getExternalFilesDir(DOWNLOADS_METADATA_FOLDER); + if (testDir(dir)) return dir; - if (dir == null) { - // One of the following paths are not accessible ¿unmounted internal memory? - // /storage/emulated/0/Android/data/org.schabi.newpipe[.debug]/pending_downloads - // /sdcard/Android/data/org.schabi.newpipe[.debug]/pending_downloads - Log.w(TAG, "path to pending downloads are not accessible"); + dir = new File(context.getFilesDir(), DOWNLOADS_METADATA_FOLDER); + if (testDir(dir)) return dir; + + throw new RuntimeException("path to pending downloads are not accessible"); + } + + private static boolean testDir(@Nullable File dir) { + if (dir == null) return false; + + try { + if (!Utility.mkdir(dir, false)) { + Log.e(TAG, "testDir() cannot create the directory in path: " + dir.getAbsolutePath()); + return false; + } + + File tmp = new File(dir, ".tmp"); + if (!tmp.createNewFile()) return false; + return tmp.delete();// if the file was created, SHOULD BE deleted too + } catch (Exception e) { + Log.e(TAG, "testDir() failed: " + dir.getAbsolutePath(), e); + return false; } - - return dir; } /** @@ -132,6 +141,7 @@ public class DownloadManager { for (File sub : subs) { if (!sub.isFile()) continue; + if (sub.getName().equals(".tmp")) continue; DownloadMission mis = Utility.readFromFile(sub); if (mis == null || mis.isFinished()) { @@ -140,6 +150,8 @@ public class DownloadManager { continue; } + mis.threads = new Thread[0]; + boolean exists; try { mis.storage = StoredFileHelper.deserialize(mis.storage, ctx); @@ -158,8 +170,6 @@ public class DownloadManager { // is Java IO (avoid showing the "Save as..." dialog) if (exists && mis.storage.isDirect() && !mis.storage.delete()) Log.w(TAG, "Unable to delete incomplete download file: " + sub.getPath()); - - exists = true; } mis.psState = 0; @@ -177,7 +187,6 @@ public class DownloadManager { mis.psAlgorithm.setTemporalDir(pickAvailableTemporalDir(ctx)); } - mis.recovered = exists; mis.metadata = sub; mis.maxRetry = mPrefMaxRetry; mis.mHandler = mHandler; @@ -232,7 +241,6 @@ public class DownloadManager { boolean start = !mPrefQueueLimit || getRunningMissionsCount() < 1; if (canDownloadInCurrentNetwork() && start) { - mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PROGRESS); mission.start(); } } @@ -241,7 +249,6 @@ public class DownloadManager { public void resumeMission(DownloadMission mission) { if (!mission.running) { - mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PROGRESS); mission.start(); } } @@ -250,7 +257,6 @@ public class DownloadManager { if (mission.running) { mission.setEnqueued(false); mission.pause(); - mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PAUSED); } } @@ -263,7 +269,6 @@ public class DownloadManager { mFinishedMissionStore.deleteMission(mission); } - mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_DELETED); mission.delete(); } } @@ -280,7 +285,6 @@ public class DownloadManager { mFinishedMissionStore.deleteMission(mission); } - mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_DELETED); mission.storage = null; mission.delete(); } @@ -363,35 +367,29 @@ public class DownloadManager { } public void pauseAllMissions(boolean force) { - boolean flag = false; - synchronized (this) { for (DownloadMission mission : mMissionsPending) { if (!mission.running || mission.isPsRunning() || mission.isFinished()) continue; - if (force) mission.threads = null;// avoid waiting for threads + if (force) { + // avoid waiting for threads + mission.init = null; + mission.threads = new Thread[0]; + } mission.pause(); - flag = true; } } - - if (flag) mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PAUSED); } public void startAllMissions() { - boolean flag = false; - synchronized (this) { for (DownloadMission mission : mMissionsPending) { if (mission.running || mission.isCorrupt()) continue; - flag = true; mission.start(); } } - - if (flag) mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PROGRESS); } /** @@ -472,28 +470,18 @@ public class DownloadManager { boolean isMetered = mPrefMeteredDownloads && mLastNetworkStatus == NetworkState.MeteredOperating; - int running = 0; - int paused = 0; synchronized (this) { for (DownloadMission mission : mMissionsPending) { if (mission.isCorrupt() || mission.isPsRunning()) continue; if (mission.running && isMetered) { - paused++; mission.pause(); } else if (!mission.running && !isMetered && mission.enqueued) { - running++; mission.start(); if (mPrefQueueLimit) break; } } } - - if (running > 0) { - mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PROGRESS); - return; - } - if (paused > 0) mHandler.sendEmptyMessage(DownloadManagerService.MESSAGE_PAUSED); } void updateMaximumAttempts() { @@ -502,22 +490,6 @@ public class DownloadManager { } } - /** - * Fast check for pending downloads. If exists, the user will be notified - * TODO: call this method in somewhere - * - * @param context the application context - */ - public static void notifyUserPendingDownloads(Context context) { - int pending = getPendingDir(context).list().length; - if (pending < 1) return; - - Toast.makeText(context, context.getString( - R.string.msg_pending_downloads, - String.valueOf(pending) - ), Toast.LENGTH_LONG).show(); - } - public MissionState checkForExistingMission(StoredFileHelper storage) { synchronized (this) { DownloadMission pending = getPendingMission(storage); diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java index 461787b62..3da0e75b8 100755 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -23,15 +23,17 @@ import android.os.Handler; import android.os.Handler.Callback; import android.os.IBinder; import android.os.Message; +import android.os.Parcelable; import android.preference.PreferenceManager; +import android.util.Log; +import android.util.SparseArray; +import android.widget.Toast; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat.Builder; -import android.util.Log; -import android.util.SparseArray; -import android.widget.Toast; import org.schabi.newpipe.R; import org.schabi.newpipe.download.DownloadActivity; @@ -42,6 +44,7 @@ import java.io.IOException; import java.util.ArrayList; import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.get.MissionRecoveryInfo; import us.shandian.giga.io.StoredDirectoryHelper; import us.shandian.giga.io.StoredFileHelper; import us.shandian.giga.postprocessing.Postprocessing; @@ -54,11 +57,11 @@ public class DownloadManagerService extends Service { private static final String TAG = "DownloadManagerService"; + public static final int MESSAGE_RUNNING = 0; public static final int MESSAGE_PAUSED = 1; public static final int MESSAGE_FINISHED = 2; - public static final int MESSAGE_PROGRESS = 3; - public static final int MESSAGE_ERROR = 4; - public static final int MESSAGE_DELETED = 5; + public static final int MESSAGE_ERROR = 3; + public static final int MESSAGE_DELETED = 4; private static final int FOREGROUND_NOTIFICATION_ID = 1000; private static final int DOWNLOADS_NOTIFICATION_ID = 1001; @@ -73,6 +76,7 @@ public class DownloadManagerService extends Service { private static final String EXTRA_PATH = "DownloadManagerService.extra.storagePath"; private static final String EXTRA_PARENT_PATH = "DownloadManagerService.extra.storageParentPath"; private static final String EXTRA_STORAGE_TAG = "DownloadManagerService.extra.storageTag"; + private static final String EXTRA_RECOVERY_INFO = "DownloadManagerService.extra.recoveryInfo"; private static final String ACTION_RESET_DOWNLOAD_FINISHED = APPLICATION_ID + ".reset_download_finished"; private static final String ACTION_OPEN_DOWNLOADS_FINISHED = APPLICATION_ID + ".open_downloads_finished"; @@ -212,9 +216,11 @@ public class DownloadManagerService extends Service { .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) ); } + return START_NOT_STICKY; } } - return START_NOT_STICKY; + + return START_STICKY; } @Override @@ -245,6 +251,7 @@ public class DownloadManagerService extends Service { if (icDownloadFailed != null) icDownloadFailed.recycle(); if (icLauncher != null) icLauncher.recycle(); + mHandler = null; mManager.pauseAllMissions(true); } @@ -269,6 +276,8 @@ public class DownloadManagerService extends Service { } private boolean handleMessage(@NonNull Message msg) { + if (mHandler == null) return true; + DownloadMission mission = (DownloadMission) msg.obj; switch (msg.what) { @@ -279,7 +288,7 @@ public class DownloadManagerService extends Service { handleConnectivityState(false); updateForegroundState(mManager.runMissions()); break; - case MESSAGE_PROGRESS: + case MESSAGE_RUNNING: updateForegroundState(true); break; case MESSAGE_ERROR: @@ -295,11 +304,8 @@ public class DownloadManagerService extends Service { if (msg.what != MESSAGE_ERROR) mFailedDownloads.delete(mFailedDownloads.indexOfValue(mission)); - synchronized (mEchoObservers) { - for (Callback observer : mEchoObservers) { - observer.handleMessage(msg); - } - } + for (Callback observer : mEchoObservers) + observer.handleMessage(msg); return true; } @@ -364,18 +370,20 @@ public class DownloadManagerService extends Service { /** * Start a new download mission * - * @param context the activity context - * @param urls the list of urls to download - * @param storage where the file is saved - * @param kind type of file (a: audio v: video s: subtitle ?: file-extension defined) - * @param threads the number of threads maximal used to download chunks of the file. - * @param psName the name of the required post-processing algorithm, or {@code null} to ignore. - * @param source source url of the resource - * @param psArgs the arguments for the post-processing algorithm. - * @param nearLength the approximated final length of the file + * @param context the activity context + * @param urls array of urls to download + * @param storage where the file is saved + * @param kind type of file (a: audio v: video s: subtitle ?: file-extension defined) + * @param threads the number of threads maximal used to download chunks of the file. + * @param psName the name of the required post-processing algorithm, or {@code null} to ignore. + * @param source source url of the resource + * @param psArgs the arguments for the post-processing algorithm. + * @param nearLength the approximated final length of the file + * @param recoveryInfo array of MissionRecoveryInfo, in case is required recover the download */ - public static void startMission(Context context, String[] urls, StoredFileHelper storage, char kind, - int threads, String source, String psName, String[] psArgs, long nearLength) { + public static void startMission(Context context, String[] urls, StoredFileHelper storage, + char kind, int threads, String source, String psName, + String[] psArgs, long nearLength, MissionRecoveryInfo[] recoveryInfo) { Intent intent = new Intent(context, DownloadManagerService.class); intent.setAction(Intent.ACTION_RUN); intent.putExtra(EXTRA_URLS, urls); @@ -385,6 +393,7 @@ public class DownloadManagerService extends Service { intent.putExtra(EXTRA_POSTPROCESSING_NAME, psName); intent.putExtra(EXTRA_POSTPROCESSING_ARGS, psArgs); intent.putExtra(EXTRA_NEAR_LENGTH, nearLength); + intent.putExtra(EXTRA_RECOVERY_INFO, recoveryInfo); intent.putExtra(EXTRA_PARENT_PATH, storage.getParentUri()); intent.putExtra(EXTRA_PATH, storage.getUri()); @@ -404,6 +413,7 @@ public class DownloadManagerService extends Service { String source = intent.getStringExtra(EXTRA_SOURCE); long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0); String tag = intent.getStringExtra(EXTRA_STORAGE_TAG); + Parcelable[] parcelRecovery = intent.getParcelableArrayExtra(EXTRA_RECOVERY_INFO); StoredFileHelper storage; try { @@ -418,10 +428,15 @@ public class DownloadManagerService extends Service { else ps = Postprocessing.getAlgorithm(psName, psArgs); + MissionRecoveryInfo[] recovery = new MissionRecoveryInfo[parcelRecovery.length]; + for (int i = 0; i < parcelRecovery.length; i++) + recovery[i] = (MissionRecoveryInfo) parcelRecovery[i]; + final DownloadMission mission = new DownloadMission(urls, storage, kind, ps); mission.threadCount = threads; mission.source = source; mission.nearLength = nearLength; + mission.recoveryInfo = recovery; if (ps != null) ps.setTemporalDir(DownloadManager.pickAvailableTemporalDir(this)); @@ -509,16 +524,6 @@ public class DownloadManagerService extends Service { return PendingIntent.getService(this, intent.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT); } - private void manageObservers(Callback handler, boolean add) { - synchronized (mEchoObservers) { - if (add) { - mEchoObservers.add(handler); - } else { - mEchoObservers.remove(handler); - } - } - } - private void manageLock(boolean acquire) { if (acquire == mLockAcquired) return; @@ -591,11 +596,11 @@ public class DownloadManagerService extends Service { } public void addMissionEventListener(Callback handler) { - manageObservers(handler, true); + mEchoObservers.add(handler); } public void removeMissionEventListener(Callback handler) { - manageObservers(handler, false); + mEchoObservers.remove(handler); } public void clearDownloadNotifications() { diff --git a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java index 6d1169031..8420e343b 100644 --- a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java +++ b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java @@ -10,16 +10,6 @@ import android.os.AsyncTask; import android.os.Build; import android.os.Handler; import android.os.Message; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.core.content.FileProvider; -import androidx.core.view.ViewCompat; -import androidx.appcompat.app.AlertDialog; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.RecyclerView.Adapter; -import androidx.recyclerview.widget.RecyclerView.ViewHolder; import android.util.Log; import android.util.SparseArray; import android.view.HapticFeedbackConstants; @@ -34,8 +24,20 @@ import android.widget.PopupMenu; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.FileProvider; +import androidx.core.view.ViewCompat; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.Adapter; +import androidx.recyclerview.widget.RecyclerView.ViewHolder; + import org.schabi.newpipe.BuildConfig; import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.report.UserAction; import org.schabi.newpipe.util.NavigationHelper; @@ -44,11 +46,11 @@ import java.io.File; import java.lang.ref.WeakReference; import java.net.URI; import java.util.ArrayList; -import java.util.Collections; import us.shandian.giga.get.DownloadMission; import us.shandian.giga.get.FinishedMission; import us.shandian.giga.get.Mission; +import us.shandian.giga.get.MissionRecoveryInfo; import us.shandian.giga.io.StoredFileHelper; import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManagerService; @@ -62,7 +64,6 @@ import static android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION; import static us.shandian.giga.get.DownloadMission.ERROR_CONNECT_HOST; import static us.shandian.giga.get.DownloadMission.ERROR_FILE_CREATION; import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_NO_CONTENT; -import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_UNSUPPORTED_RANGE; import static us.shandian.giga.get.DownloadMission.ERROR_INSUFFICIENT_STORAGE; import static us.shandian.giga.get.DownloadMission.ERROR_NOTHING; import static us.shandian.giga.get.DownloadMission.ERROR_PATH_CREATION; @@ -71,6 +72,7 @@ import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING; import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_HOLD; import static us.shandian.giga.get.DownloadMission.ERROR_POSTPROCESSING_STOPPED; import static us.shandian.giga.get.DownloadMission.ERROR_PROGRESS_LOST; +import static us.shandian.giga.get.DownloadMission.ERROR_RESOURCE_GONE; import static us.shandian.giga.get.DownloadMission.ERROR_SSL_EXCEPTION; import static us.shandian.giga.get.DownloadMission.ERROR_TIMEOUT; import static us.shandian.giga.get.DownloadMission.ERROR_UNKNOWN_EXCEPTION; @@ -81,6 +83,7 @@ public class MissionAdapter extends Adapter implements Handler.Callb private static final String TAG = "MissionAdapter"; private static final String UNDEFINED_PROGRESS = "--.-%"; private static final String DEFAULT_MIME_TYPE = "*/*"; + private static final String UNDEFINED_ETA = "--:--"; static { @@ -102,10 +105,11 @@ public class MissionAdapter extends Adapter implements Handler.Callb private View mEmptyMessage; private RecoverHelper mRecover; - public MissionAdapter(Context context, @NonNull DownloadManager downloadManager, View emptyMessage) { + private final Runnable rUpdater = this::updater; + + public MissionAdapter(Context context, @NonNull DownloadManager downloadManager, View emptyMessage, View root) { mContext = context; mDownloadManager = downloadManager; - mDeleter = null; mInflater = (LayoutInflater) mContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE); mLayout = R.layout.mission_item; @@ -116,7 +120,10 @@ public class MissionAdapter extends Adapter implements Handler.Callb mIterator = downloadManager.getIterator(); + mDeleter = new Deleter(root, mContext, this, mDownloadManager, mIterator, mHandler); + checkEmptyMessageVisibility(); + onResume(); } @Override @@ -141,17 +148,13 @@ public class MissionAdapter extends Adapter implements Handler.Callb if (h.item.mission instanceof DownloadMission) { mPendingDownloadsItems.remove(h); if (mPendingDownloadsItems.size() < 1) { - setAutoRefresh(false); checkMasterButtonsVisibility(); } } h.popupMenu.dismiss(); h.item = null; - h.lastTimeStamp = -1; - h.lastDone = -1; - h.lastCurrent = -1; - h.state = 0; + h.resetSpeedMeasure(); } @Override @@ -190,7 +193,6 @@ public class MissionAdapter extends Adapter implements Handler.Callb h.size.setText(length); h.pause.setTitle(mission.unknownLength ? R.string.stop : R.string.pause); - h.lastCurrent = mission.current; updateProgress(h); mPendingDownloadsItems.add(h); } else { @@ -215,40 +217,27 @@ public class MissionAdapter extends Adapter implements Handler.Callb private void updateProgress(ViewHolderItem h) { if (h == null || h.item == null || h.item.mission instanceof FinishedMission) return; - long now = System.currentTimeMillis(); DownloadMission mission = (DownloadMission) h.item.mission; - - if (h.lastCurrent != mission.current) { - h.lastCurrent = mission.current; - h.lastTimeStamp = now; - h.lastDone = 0; - } else { - if (h.lastTimeStamp == -1) h.lastTimeStamp = now; - if (h.lastDone == -1) h.lastDone = mission.done; - } - - long deltaTime = now - h.lastTimeStamp; - long deltaDone = mission.done - h.lastDone; + double done = mission.done; + long length = mission.getLength(); + long now = System.currentTimeMillis(); boolean hasError = mission.errCode != ERROR_NOTHING; // hide on error // show if current resource length is not fetched // show if length is unknown - h.progress.setMarquee(!hasError && (!mission.isInitialized() || mission.unknownLength)); + h.progress.setMarquee(mission.isRecovering() || !hasError && (!mission.isInitialized() || mission.unknownLength)); - float progress; + double progress; if (mission.unknownLength) { - progress = Float.NaN; + progress = Double.NaN; h.progress.setProgress(0f); } else { - progress = (float) ((double) mission.done / mission.length); - if (mission.urls.length > 1 && mission.current < mission.urls.length) { - progress = (progress / mission.urls.length) + ((float) mission.current / mission.urls.length); - } + progress = done / length; } if (hasError) { - h.progress.setProgress(isNotFinite(progress) ? 1f : progress); + h.progress.setProgress(isNotFinite(progress) ? 1d : progress); h.status.setText(R.string.msg_error); } else if (isNotFinite(progress)) { h.status.setText(UNDEFINED_PROGRESS); @@ -257,59 +246,78 @@ public class MissionAdapter extends Adapter implements Handler.Callb h.progress.setProgress(progress); } - long length = mission.getLength(); + @StringRes int state; + String sizeStr = Utility.formatBytes(length).concat(" "); - int state; if (mission.isPsFailed() || mission.errCode == ERROR_POSTPROCESSING_HOLD) { - state = 0; + h.size.setText(sizeStr); + return; } else if (!mission.running) { - state = mission.enqueued ? 1 : 2; + state = mission.enqueued ? R.string.queued : R.string.paused; } else if (mission.isPsRunning()) { - state = 3; + state = R.string.post_processing; + } else if (mission.isRecovering()) { + state = R.string.recovering; } else { state = 0; } if (state != 0) { // update state without download speed - if (h.state != state) { - String statusStr; - h.state = state; + h.size.setText(sizeStr.concat("(").concat(mContext.getString(state)).concat(")")); + h.resetSpeedMeasure(); + return; + } - switch (state) { - case 1: - statusStr = mContext.getString(R.string.queued); - break; - case 2: - statusStr = mContext.getString(R.string.paused); - break; - case 3: - statusStr = mContext.getString(R.string.post_processing); - break; - default: - statusStr = "?"; - break; - } + if (h.lastTimestamp < 0) { + h.size.setText(sizeStr); + h.lastTimestamp = now; + h.lastDone = done; + return; + } - h.size.setText(Utility.formatBytes(length).concat(" (").concat(statusStr).concat(")")); - } else if (deltaDone > 0) { - h.lastTimeStamp = now; - h.lastDone = mission.done; - } + long deltaTime = now - h.lastTimestamp; + double deltaDone = done - h.lastDone; + if (h.lastDone > done) { + h.lastDone = done; + h.size.setText(sizeStr); return; } if (deltaDone > 0 && deltaTime > 0) { - float speed = (deltaDone * 1000f) / deltaTime; + float speed = (float) ((deltaDone * 1000d) / deltaTime); + float averageSpeed = speed; - String speedStr = Utility.formatSpeed(speed); - String sizeStr = Utility.formatBytes(length); + if (h.lastSpeedIdx < 0) { + for (int i = 0; i < h.lastSpeed.length; i++) { + h.lastSpeed[i] = speed; + } + h.lastSpeedIdx = 0; + } else { + for (int i = 0; i < h.lastSpeed.length; i++) { + averageSpeed += h.lastSpeed[i]; + } + averageSpeed /= h.lastSpeed.length + 1f; + } - h.size.setText(sizeStr.concat(" ").concat(speedStr)); + String speedStr = Utility.formatSpeed(averageSpeed); + String etaStr; - h.lastTimeStamp = now; - h.lastDone = mission.done; + if (mission.unknownLength) { + etaStr = ""; + } else { + long eta = (long) Math.ceil((length - done) / averageSpeed); + etaStr = Utility.formatBytes((long) done) + "/" + Utility.stringifySeconds(eta) + " "; + } + + h.size.setText(sizeStr.concat(etaStr).concat(speedStr)); + + h.lastTimestamp = now; + h.lastDone = done; + h.lastSpeed[h.lastSpeedIdx++] = speed; + + if (h.lastSpeedIdx >= h.lastSpeed.length) h.lastSpeedIdx = 0; } } @@ -388,6 +396,13 @@ public class MissionAdapter extends Adapter implements Handler.Callb return true; } + private ViewHolderItem getViewHolder(Object mission) { + for (ViewHolderItem h : mPendingDownloadsItems) { + if (h.item.mission == mission) return h; + } + return null; + } + @Override public boolean handleMessage(@NonNull Message msg) { if (mStartButton != null && mPauseButton != null) { @@ -395,33 +410,28 @@ public class MissionAdapter extends Adapter implements Handler.Callb } switch (msg.what) { - case DownloadManagerService.MESSAGE_PROGRESS: case DownloadManagerService.MESSAGE_ERROR: case DownloadManagerService.MESSAGE_FINISHED: + case DownloadManagerService.MESSAGE_DELETED: + case DownloadManagerService.MESSAGE_PAUSED: break; default: return false; } - if (msg.what == DownloadManagerService.MESSAGE_PROGRESS) { - setAutoRefresh(true); - return true; - } + ViewHolderItem h = getViewHolder(msg.obj); + if (h == null) return false; - for (ViewHolderItem h : mPendingDownloadsItems) { - if (h.item.mission != msg.obj) continue; - - if (msg.what == DownloadManagerService.MESSAGE_FINISHED) { + switch (msg.what) { + case DownloadManagerService.MESSAGE_FINISHED: + case DownloadManagerService.MESSAGE_DELETED: // DownloadManager should mark the download as finished applyChanges(); return true; - } - - updateProgress(h); - return true; } - return false; + updateProgress(h); + return true; } private void showError(@NonNull DownloadMission mission) { @@ -430,7 +440,7 @@ public class MissionAdapter extends Adapter implements Handler.Callb switch (mission.errCode) { case 416: - msg = R.string.error_http_requested_range_not_satisfiable; + msg = R.string.error_http_unsupported_range; break; case 404: msg = R.string.error_http_not_found; @@ -443,9 +453,6 @@ public class MissionAdapter extends Adapter implements Handler.Callb case ERROR_HTTP_NO_CONTENT: msg = R.string.error_http_no_content; break; - case ERROR_HTTP_UNSUPPORTED_RANGE: - msg = R.string.error_http_unsupported_range; - break; case ERROR_PATH_CREATION: msg = R.string.error_path_creation; break; @@ -466,27 +473,35 @@ public class MissionAdapter extends Adapter implements Handler.Callb break; case ERROR_POSTPROCESSING: case ERROR_POSTPROCESSING_HOLD: - showError(mission.errObject, UserAction.DOWNLOAD_POSTPROCESSING, R.string.error_postprocessing_failed); + showError(mission, UserAction.DOWNLOAD_POSTPROCESSING, R.string.error_postprocessing_failed); return; case ERROR_INSUFFICIENT_STORAGE: msg = R.string.error_insufficient_storage; break; case ERROR_UNKNOWN_EXCEPTION: - showError(mission.errObject, UserAction.DOWNLOAD_FAILED, R.string.general_error); - return; + if (mission.errObject != null) { + showError(mission, UserAction.DOWNLOAD_FAILED, R.string.general_error); + return; + } else { + msg = R.string.msg_error; + break; + } case ERROR_PROGRESS_LOST: msg = R.string.error_progress_lost; break; case ERROR_TIMEOUT: msg = R.string.error_timeout; break; + case ERROR_RESOURCE_GONE: + msg = R.string.error_download_resource_gone; + break; default: if (mission.errCode >= 100 && mission.errCode < 600) { msgEx = "HTTP " + mission.errCode; } else if (mission.errObject == null) { msgEx = "(not_decelerated_error_code)"; } else { - showError(mission.errObject, UserAction.DOWNLOAD_FAILED, msg); + showError(mission, UserAction.DOWNLOAD_FAILED, msg); return; } break; @@ -503,7 +518,7 @@ public class MissionAdapter extends Adapter implements Handler.Callb if (mission.errObject != null && (mission.errCode < 100 || mission.errCode >= 600)) { @StringRes final int mMsg = msg; builder.setPositiveButton(R.string.error_report_title, (dialog, which) -> - showError(mission.errObject, UserAction.DOWNLOAD_FAILED, mMsg) + showError(mission, UserAction.DOWNLOAD_FAILED, mMsg) ); } @@ -513,13 +528,32 @@ public class MissionAdapter extends Adapter implements Handler.Callb .show(); } - private void showError(Exception exception, UserAction action, @StringRes int reason) { + private void showError(DownloadMission mission, UserAction action, @StringRes int reason) { + StringBuilder request = new StringBuilder(256); + request.append(mission.source); + + request.append(" ["); + if (mission.recoveryInfo != null) { + for (MissionRecoveryInfo recovery : mission.recoveryInfo) + request.append(' ') + .append(recovery.toString()) + .append(' '); + } + request.append("]"); + + String service; + try { + service = NewPipe.getServiceByUrl(mission.source).getServiceInfo().getName(); + } catch (Exception e) { + service = "-"; + } + ErrorActivity.reportError( mContext, - Collections.singletonList(exception), + mission.errObject, null, null, - ErrorActivity.ErrorInfo.make(action, "-", "-", reason) + ErrorActivity.ErrorInfo.make(action, service, request.toString(), reason) ); } @@ -538,16 +572,10 @@ public class MissionAdapter extends Adapter implements Handler.Callb switch (id) { case R.id.start: h.status.setText(UNDEFINED_PROGRESS); - h.state = -1; - h.size.setText(Utility.formatBytes(mission.getLength())); mDownloadManager.resumeMission(mission); return true; case R.id.pause: - h.state = -1; mDownloadManager.pauseMission(mission); - updateProgress(h); - h.lastTimeStamp = -1; - h.lastDone = -1; return true; case R.id.error_message_view: showError(mission); @@ -580,12 +608,9 @@ public class MissionAdapter extends Adapter implements Handler.Callb shareFile(h.item.mission); return true; case R.id.delete: - if (mDeleter == null) { - mDownloadManager.deleteMission(h.item.mission); - } else { - mDeleter.append(h.item.mission); - } + mDeleter.append(h.item.mission); applyChanges(); + checkMasterButtonsVisibility(); return true; case R.id.md5: case R.id.sha1: @@ -621,7 +646,7 @@ public class MissionAdapter extends Adapter implements Handler.Callb mIterator.end(); for (ViewHolderItem item : mPendingDownloadsItems) { - item.lastTimeStamp = -1; + item.resetSpeedMeasure(); } notifyDataSetChanged(); @@ -654,6 +679,7 @@ public class MissionAdapter extends Adapter implements Handler.Callb public void checkMasterButtonsVisibility() { boolean[] state = mIterator.hasValidPendingMissions(); + Log.d(TAG, "checkMasterButtonsVisibility() running=" + state[0] + " paused=" + state[1]); setButtonVisible(mPauseButton, state[0]); setButtonVisible(mStartButton, state[1]); } @@ -663,86 +689,57 @@ public class MissionAdapter extends Adapter implements Handler.Callb button.setVisible(visible); } - public void ensurePausedMissions() { + public void refreshMissionItems() { for (ViewHolderItem h : mPendingDownloadsItems) { if (((DownloadMission) h.item.mission).running) continue; updateProgress(h); - h.lastTimeStamp = -1; - h.lastDone = -1; + h.resetSpeedMeasure(); } } - public void deleterDispose(boolean commitChanges) { - if (mDeleter != null) mDeleter.dispose(commitChanges); + public void onDestroy() { + mDeleter.dispose(); } - public void deleterLoad(View view) { - if (mDeleter == null) - mDeleter = new Deleter(view, mContext, this, mDownloadManager, mIterator, mHandler); + public void onResume() { + mDeleter.resume(); + mHandler.post(rUpdater); } - public void deleterResume() { - if (mDeleter != null) mDeleter.resume(); - } - - public void recoverMission(DownloadMission mission) { - for (ViewHolderItem h : mPendingDownloadsItems) { - if (mission != h.item.mission) continue; - - mission.errObject = null; - mission.resetState(true, false, DownloadMission.ERROR_NOTHING); - - h.status.setText(UNDEFINED_PROGRESS); - h.state = -1; - h.size.setText(Utility.formatBytes(mission.getLength())); - h.progress.setMarquee(true); - - mDownloadManager.resumeMission(mission); - return; - } - - } - - - private boolean mUpdaterRunning = false; - private final Runnable rUpdater = this::updater; - public void onPaused() { - setAutoRefresh(false); + mDeleter.pause(); + mHandler.removeCallbacks(rUpdater); } - private void setAutoRefresh(boolean enabled) { - if (enabled && !mUpdaterRunning) { - mUpdaterRunning = true; - updater(); - } else if (!enabled && mUpdaterRunning) { - mUpdaterRunning = false; - mHandler.removeCallbacks(rUpdater); - } + + public void recoverMission(DownloadMission mission) { + ViewHolderItem h = getViewHolder(mission); + if (h == null) return; + + mission.errObject = null; + mission.resetState(true, false, DownloadMission.ERROR_NOTHING); + + h.status.setText(UNDEFINED_PROGRESS); + h.size.setText(Utility.formatBytes(mission.getLength())); + h.progress.setMarquee(true); + + mDownloadManager.resumeMission(mission); } private void updater() { - if (!mUpdaterRunning) return; - - boolean running = false; for (ViewHolderItem h : mPendingDownloadsItems) { // check if the mission is running first if (!((DownloadMission) h.item.mission).running) continue; updateProgress(h); - running = true; } - if (running) { - mHandler.postDelayed(rUpdater, 1000); - } else { - mUpdaterRunning = false; - } + mHandler.postDelayed(rUpdater, 1000); } - private boolean isNotFinite(Float value) { - return Float.isNaN(value) || Float.isInfinite(value); + private boolean isNotFinite(double value) { + return Double.isNaN(value) || Double.isInfinite(value); } public void setRecover(@NonNull RecoverHelper callback) { @@ -771,10 +768,11 @@ public class MissionAdapter extends Adapter implements Handler.Callb MenuItem source; MenuItem checksum; - long lastTimeStamp = -1; - long lastDone = -1; - int lastCurrent = -1; - int state = 0; + long lastTimestamp = -1; + double lastDone; + int lastSpeedIdx; + float[] lastSpeed = new float[3]; + String estimatedTimeArrival = UNDEFINED_ETA; ViewHolderItem(View view) { super(view); @@ -859,7 +857,7 @@ public class MissionAdapter extends Adapter implements Handler.Callb delete.setVisible(true); - boolean flag = !mission.isPsFailed(); + boolean flag = !mission.isPsFailed() && mission.urls.length > 0; start.setVisible(flag); queue.setVisible(flag); } @@ -884,6 +882,12 @@ public class MissionAdapter extends Adapter implements Handler.Callb return popup; } + + private void resetSpeedMeasure() { + estimatedTimeArrival = UNDEFINED_ETA; + lastTimestamp = -1; + lastSpeedIdx = -1; + } } class ViewHolderHeader extends RecyclerView.ViewHolder { diff --git a/app/src/main/java/us/shandian/giga/ui/common/Deleter.java b/app/src/main/java/us/shandian/giga/ui/common/Deleter.java index 81b4e33e8..a0828c23d 100644 --- a/app/src/main/java/us/shandian/giga/ui/common/Deleter.java +++ b/app/src/main/java/us/shandian/giga/ui/common/Deleter.java @@ -4,9 +4,10 @@ import android.content.Context; import android.content.Intent; import android.graphics.Color; import android.os.Handler; -import com.google.android.material.snackbar.Snackbar; import android.view.View; +import com.google.android.material.snackbar.Snackbar; + import org.schabi.newpipe.R; import java.util.ArrayList; @@ -113,7 +114,7 @@ public class Deleter { show(); } - private void pause() { + public void pause() { running = false; mHandler.removeCallbacks(rNext); mHandler.removeCallbacks(rShow); @@ -126,13 +127,11 @@ public class Deleter { mHandler.postDelayed(rShow, DELAY_RESUME); } - public void dispose(boolean commitChanges) { + public void dispose() { if (items.size() < 1) return; pause(); - if (!commitChanges) return; - for (Mission mission : items) mDownloadManager.deleteMission(mission); items = null; } diff --git a/app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.java b/app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.java index a0ff24aaa..3f638d418 100644 --- a/app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.java +++ b/app/src/main/java/us/shandian/giga/ui/common/ProgressDrawable.java @@ -9,6 +9,7 @@ import android.graphics.Rect; import android.graphics.drawable.Drawable; import android.os.Handler; import android.os.Looper; + import androidx.annotation.ColorInt; import androidx.annotation.NonNull; @@ -35,8 +36,8 @@ public class ProgressDrawable extends Drawable { mForegroundColor = foreground; } - public void setProgress(float progress) { - mProgress = progress; + public void setProgress(double progress) { + mProgress = (float) progress; invalidateSelf(); } diff --git a/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java b/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java index 26da47b1f..921eaff5c 100644 --- a/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java +++ b/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java @@ -12,11 +12,6 @@ import android.os.Bundle; import android.os.Environment; import android.os.IBinder; import android.preference.PreferenceManager; -import androidx.annotation.NonNull; -import androidx.fragment.app.Fragment; -import androidx.recyclerview.widget.GridLayoutManager; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuItem; @@ -24,6 +19,12 @@ import android.view.View; import android.view.ViewGroup; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + import com.nononsenseapps.filepicker.Utils; import org.schabi.newpipe.R; @@ -72,8 +73,7 @@ public class MissionsFragment extends Fragment { mBinder = (DownloadManagerBinder) binder; mBinder.clearDownloadNotifications(); - mAdapter = new MissionAdapter(mContext, mBinder.getDownloadManager(), mEmpty); - mAdapter.deleterLoad(getView()); + mAdapter = new MissionAdapter(mContext, mBinder.getDownloadManager(), mEmpty, getView()); mAdapter.setRecover(MissionsFragment.this::recoverMission); @@ -132,7 +132,7 @@ public class MissionsFragment extends Fragment { * Added in API level 23. */ @Override - public void onAttach(Context context) { + public void onAttach(@NonNull Context context) { super.onAttach(context); // Bug: in api< 23 this is never called @@ -147,7 +147,7 @@ public class MissionsFragment extends Fragment { */ @SuppressWarnings("deprecation") @Override - public void onAttach(Activity activity) { + public void onAttach(@NonNull Activity activity) { super.onAttach(activity); mContext = activity; @@ -162,7 +162,7 @@ public class MissionsFragment extends Fragment { mBinder.removeMissionEventListener(mAdapter); mBinder.enableNotifications(true); mContext.unbindService(mConnection); - mAdapter.deleterDispose(true); + mAdapter.onDestroy(); mBinder = null; mAdapter = null; @@ -196,13 +196,11 @@ public class MissionsFragment extends Fragment { prompt.create().show(); return true; case R.id.start_downloads: - item.setVisible(false); mBinder.getDownloadManager().startAllMissions(); return true; case R.id.pause_downloads: - item.setVisible(false); mBinder.getDownloadManager().pauseAllMissions(false); - mAdapter.ensurePausedMissions();// update items view + mAdapter.refreshMissionItems();// update items view default: return super.onOptionsItemSelected(item); } @@ -271,23 +269,12 @@ public class MissionsFragment extends Fragment { } } - @Override - public void onSaveInstanceState(@NonNull Bundle outState) { - super.onSaveInstanceState(outState); - - if (mAdapter != null) { - mAdapter.deleterDispose(false); - mForceUpdate = true; - mBinder.removeMissionEventListener(mAdapter); - } - } - @Override public void onResume() { super.onResume(); if (mAdapter != null) { - mAdapter.deleterResume(); + mAdapter.onResume(); if (mForceUpdate) { mForceUpdate = false; @@ -303,7 +290,13 @@ public class MissionsFragment extends Fragment { @Override public void onPause() { super.onPause(); - if (mAdapter != null) mAdapter.onPaused(); + + if (mAdapter != null) { + mForceUpdate = true; + mBinder.removeMissionEventListener(mAdapter); + mAdapter.onPaused(); + } + if (mBinder != null) mBinder.enableNotifications(true); } diff --git a/app/src/main/java/us/shandian/giga/util/Utility.java b/app/src/main/java/us/shandian/giga/util/Utility.java index 21fdd72ad..46207777a 100644 --- a/app/src/main/java/us/shandian/giga/util/Utility.java +++ b/app/src/main/java/us/shandian/giga/util/Utility.java @@ -4,13 +4,14 @@ import android.content.ClipData; import android.content.ClipboardManager; import android.content.Context; import android.os.Build; +import android.util.Log; +import android.widget.Toast; + import androidx.annotation.ColorInt; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; -import android.util.Log; -import android.widget.Toast; import org.schabi.newpipe.R; import org.schabi.newpipe.streams.io.SharpStream; @@ -26,6 +27,7 @@ import java.io.Serializable; import java.net.HttpURLConnection; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Locale; import us.shandian.giga.io.StoredFileHelper; @@ -39,26 +41,28 @@ public class Utility { } public static String formatBytes(long bytes) { + Locale locale = Locale.getDefault(); if (bytes < 1024) { - return String.format("%d B", bytes); + return String.format(locale, "%d B", bytes); } else if (bytes < 1024 * 1024) { - return String.format("%.2f kB", bytes / 1024d); + return String.format(locale, "%.2f kB", bytes / 1024d); } else if (bytes < 1024 * 1024 * 1024) { - return String.format("%.2f MB", bytes / 1024d / 1024d); + return String.format(locale, "%.2f MB", bytes / 1024d / 1024d); } else { - return String.format("%.2f GB", bytes / 1024d / 1024d / 1024d); + return String.format(locale, "%.2f GB", bytes / 1024d / 1024d / 1024d); } } - public static String formatSpeed(float speed) { + public static String formatSpeed(double speed) { + Locale locale = Locale.getDefault(); if (speed < 1024) { - return String.format("%.2f B/s", speed); + return String.format(locale, "%.2f B/s", speed); } else if (speed < 1024 * 1024) { - return String.format("%.2f kB/s", speed / 1024); + return String.format(locale, "%.2f kB/s", speed / 1024); } else if (speed < 1024 * 1024 * 1024) { - return String.format("%.2f MB/s", speed / 1024 / 1024); + return String.format(locale, "%.2f MB/s", speed / 1024 / 1024); } else { - return String.format("%.2f GB/s", speed / 1024 / 1024 / 1024); + return String.format(locale, "%.2f GB/s", speed / 1024 / 1024 / 1024); } } @@ -188,12 +192,11 @@ public class Utility { switch (type) { case MUSIC: return R.drawable.music; + default: case VIDEO: return R.drawable.video; case SUBTITLE: return R.drawable.subtitle; - default: - return R.drawable.video; } } @@ -274,4 +277,25 @@ public class Utility { return -1; } + + private static String pad(int number) { + return number < 10 ? ("0" + number) : String.valueOf(number); + } + + public static String stringifySeconds(double seconds) { + int h = (int) Math.floor(seconds / 3600); + int m = (int) Math.floor((seconds - (h * 3600)) / 60); + int s = (int) (seconds - (h * 3600) - (m * 60)); + + String str = ""; + + if (h < 1 && m < 1) { + str = "00:"; + } else { + if (h > 0) str = pad(h) + ":"; + if (m > 0) str += pad(m) + ":"; + } + + return str + pad(s); + } } diff --git a/app/src/main/res/drawable-hdpi/ic_kiosk_local_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_kiosk_local_black_24dp.png new file mode 100755 index 000000000..a9e2993eb Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_kiosk_local_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_kiosk_local_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_kiosk_local_white_24dp.png new file mode 100755 index 000000000..a9af000b4 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_kiosk_local_white_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_kiosk_recent_black_24dp.png b/app/src/main/res/drawable-hdpi/ic_kiosk_recent_black_24dp.png new file mode 100755 index 000000000..13813ff82 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_kiosk_recent_black_24dp.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_kiosk_recent_white_24dp.png b/app/src/main/res/drawable-hdpi/ic_kiosk_recent_white_24dp.png new file mode 100755 index 000000000..9054e0042 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_kiosk_recent_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_kiosk_local_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_kiosk_local_black_24dp.png new file mode 100755 index 000000000..1eba63792 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_kiosk_local_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_kiosk_local_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_kiosk_local_white_24dp.png new file mode 100755 index 000000000..23d8145f5 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_kiosk_local_white_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_kiosk_recent_black_24dp.png b/app/src/main/res/drawable-mdpi/ic_kiosk_recent_black_24dp.png new file mode 100755 index 000000000..adc36b227 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_kiosk_recent_black_24dp.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_kiosk_recent_white_24dp.png b/app/src/main/res/drawable-mdpi/ic_kiosk_recent_white_24dp.png new file mode 100755 index 000000000..c19bfe964 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_kiosk_recent_white_24dp.png differ diff --git a/app/src/main/res/drawable-nodpi/place_holder_peertube.png b/app/src/main/res/drawable-nodpi/place_holder_peertube.png new file mode 100644 index 000000000..68850054d Binary files /dev/null and b/app/src/main/res/drawable-nodpi/place_holder_peertube.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_kiosk_local_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_kiosk_local_black_24dp.png new file mode 100755 index 000000000..e20865ab0 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_kiosk_local_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_kiosk_local_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_kiosk_local_white_24dp.png new file mode 100755 index 000000000..2d3474832 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_kiosk_local_white_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_kiosk_recent_black_24dp.png b/app/src/main/res/drawable-xhdpi/ic_kiosk_recent_black_24dp.png new file mode 100755 index 000000000..54e815980 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_kiosk_recent_black_24dp.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_kiosk_recent_white_24dp.png b/app/src/main/res/drawable-xhdpi/ic_kiosk_recent_white_24dp.png new file mode 100755 index 000000000..3141a790d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_kiosk_recent_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_kiosk_local_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_kiosk_local_black_24dp.png new file mode 100755 index 000000000..bcbeb199c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_kiosk_local_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_kiosk_local_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_kiosk_local_white_24dp.png new file mode 100755 index 000000000..6b27fb23c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_kiosk_local_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_kiosk_recent_black_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_kiosk_recent_black_24dp.png new file mode 100755 index 000000000..92fc748ec Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_kiosk_recent_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_kiosk_recent_white_24dp.png b/app/src/main/res/drawable-xxhdpi/ic_kiosk_recent_white_24dp.png new file mode 100755 index 000000000..5b0aa6ae2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_kiosk_recent_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_kiosk_local_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_kiosk_local_black_24dp.png new file mode 100755 index 000000000..e208010a2 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_kiosk_local_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_kiosk_local_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_kiosk_local_white_24dp.png new file mode 100755 index 000000000..b04fd7a88 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_kiosk_local_white_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_kiosk_recent_black_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_kiosk_recent_black_24dp.png new file mode 100755 index 000000000..152259fab Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_kiosk_recent_black_24dp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_kiosk_recent_white_24dp.png b/app/src/main/res/drawable-xxxhdpi/ic_kiosk_recent_white_24dp.png new file mode 100755 index 000000000..1aac3b986 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_kiosk_recent_white_24dp.png differ diff --git a/app/src/main/res/layout-v21/drawer_header.xml b/app/src/main/res/layout-v21/drawer_header.xml index 22e81883d..9ed9f833a 100644 --- a/app/src/main/res/layout-v21/drawer_header.xml +++ b/app/src/main/res/layout-v21/drawer_header.xml @@ -47,15 +47,22 @@ + android:textStyle="italic" + android:ellipsize="marquee" + android:fadingEdge="horizontal" + android:marqueeRepeatLimit="marquee_forever" + android:scrollHorizontally="true" + android:singleLine="true" /> + android:textStyle="italic" + android:ellipsize="marquee" + android:fadingEdge="horizontal" + android:marqueeRepeatLimit="marquee_forever" + android:scrollHorizontally="true" + android:singleLine="true" /> + app:headerLayout="@layout/drawer_header" + android:theme="@style/NavViewTextStyle"/> + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/instance_spinner_item.xml b/app/src/main/res/layout/instance_spinner_item.xml new file mode 100644 index 000000000..1edac71af --- /dev/null +++ b/app/src/main/res/layout/instance_spinner_item.xml @@ -0,0 +1,6 @@ + + diff --git a/app/src/main/res/layout/instance_spinner_layout.xml b/app/src/main/res/layout/instance_spinner_layout.xml new file mode 100644 index 000000000..63e910d96 --- /dev/null +++ b/app/src/main/res/layout/instance_spinner_layout.xml @@ -0,0 +1,9 @@ + + diff --git a/app/src/main/res/layout/item_instance.xml b/app/src/main/res/layout/item_instance.xml new file mode 100644 index 000000000..b0e4e25bd --- /dev/null +++ b/app/src/main/res/layout/item_instance.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 7156d08ba..86cbbb59a 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -468,11 +468,9 @@ لا يمكن الاتصال بالخادم الخادم لايقوم بإرسال البيانات الخادم لا يقبل التنزيل المتعدد، إعادة المحاولة مع @string/msg_threads = 1 - عدم استيفاء النطاق المطلوب غير موجود فشلت المعالجة الاولية حذف التنزيلات المنتهية - "قم بإستكمال %s حيثما يتم التحويل من التنزيلات" توقف أقصى عدد للمحاولات الحد الأقصى لعدد محاولات قبل إلغاء التحميل diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 93307cbcf..1cf3abd7e 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -455,11 +455,9 @@ Немагчыма злучыцца з серверам Не атрымалася атрымаць дадзеныя з сервера Сервер не падтрымлівае шматструменную загрузку, паспрабуйце з @string/msg_threads = 1 - Запытаны дыяпазон недапушчальны Не знойдзена Пасляапрацоўка не ўдалася Ачысціць завершаныя - Аднавіць прыпыненыя загрузкі (%s) Спыніць Максімум спробаў Колькасць спробаў перад адменай загрузкі diff --git a/app/src/main/res/values-cmn/strings.xml b/app/src/main/res/values-cmn/strings.xml index 49801a190..3ff479bfd 100644 --- a/app/src/main/res/values-cmn/strings.xml +++ b/app/src/main/res/values-cmn/strings.xml @@ -460,8 +460,6 @@ NewPipe 更新可用! 无法创建目标文件夹 服务器不接受多线程下载, 请使用 @string/msg_threads = 1重试 - 请求范围无法满足 - 继续进行%s个待下载转移 切换至移动数据时有用,尽管一些下载无法被暂停 显示评论 禁用停止显示评论 diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index d539923fe..9a9cc8654 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -463,11 +463,9 @@ otevření ve vyskakovacím okně Nelze se připojit k serveru Server neposílá data Server neakceptuje vícevláknové stahování, opakujte akci s @string/msg_threads = 1 - Požadovaný rozsah nelze splnit Nenalezeno Post-processing selhal Vyčistit dokončená stahování - Pokračovat ve stahování %s souborů, čekajících na stažení Zastavit Maximální počet pokusů o opakování Maximální počet pokusů před zrušením stahování diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 42ffd474b..5e44aab61 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -380,7 +380,6 @@ Kan ikke forbinde til serveren Serveren sender ikke data Serveren accepterer ikke multitrådede downloads; prøv igen med @string/msg_threads = 1 - Det anmodede interval er ikke gyldigt Ikke fundet Efterbehandling fejlede Stop @@ -448,7 +447,6 @@ sat på pause sat i kø Ryd færdige downloads - Fortsæt dine %s ventende overførsler fra Downloads Maksimalt antal genforsøg Maksimalt antal forsøg før downloaden opgives Sæt på pause ved skift til mobildata diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 2d6b5b6d2..0dc0de8b4 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -454,11 +454,9 @@ Kann nicht mit dem Server verbinden Der Server sendet keine Daten Der Server erlaubt kein mehrfädiges Herunterladen – wiederhole mit @string/msg_threads = 1 - Gewünschter Bereich ist nicht verfügbar Nicht gefunden Nachbearbeitung fehlgeschlagen Um fertige Downloads bereinigen - Setze deine %s ausstehenden Übertragungen von Downloads fort Anhalten Maximale Wiederholungen Maximalanzahl der Versuche, bevor der Download abgebrochen wird diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 4f3499cfd..115b8d0b3 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -456,11 +456,9 @@ Αδυναμία σύνδεσης με τον εξυπηρετητή Ο εξυπηρετητής δεν μπορεί να στείλει τα δεδομένα Ο εξυπηρετητής δέν υποστηρίζει πολυνηματικές λήψεις, ξαναπροσπαθήστε με @string/msg_threads = 1 - Το ζητούμενο εύρος δεν μπορεί να εξυπηρετηθεί Δεν βρέθηκε Μετεπεξεργασία απέτυχε Εκκαθάριση ολοκληρωμένων λήψεων - Συνέχιση των %s εκκρεμών σας λήψεων Διακοπή Μέγιστες επαναπροσπάθειες Μέγιστος αριθμός προσπαθειών προτού γίνει ακύρωση της λήψης diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3aa0bac66..6fcbc9fa7 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -351,8 +351,8 @@ \n3. Inicie sesión cuando se le pida \n4. Copie la URL del perfil a la que fue redireccionado. suID, soundcloud.com/suID - Observe que esta operación puede causar un uso intensivo de la red. -\n + Observe que esta operación puede causar un uso intensivo de la red. +\n \n¿Quiere continuar\? Cargar miniaturas Desactívela para evitar la carga de miniaturas y ahorrar datos y memoria. Se vaciará la antememoria de imágenes en la memoria volátil y en el disco. @@ -406,6 +406,7 @@ pausado en cola posprocesamiento + recuperando Añadir a cola Acción denegada por el sistema Se eliminó el archivo @@ -424,7 +425,6 @@ Mostrar como grilla Mostrar como lista Limpiar descargas finalizadas - Tienes %s descargas pendientes, ve a Descargas para continuarlas ¿Lo confirma\? Detener Intentos máximos @@ -444,8 +444,8 @@ Fallo la conexión segura No se pudo encontrar el servidor No se puede conectar con el servidor - El servidor no está enviando datos - El servidor no acepta descargas multiproceso; intente de nuevo con @string/msg_threads = 1 + El servidor no devolvio datos + El servidor no acepta descargas multi-hilos, intente de nuevo con @string/msg_threads = 1 No se puede satisfacer el intervalo seleccionado No encontrado Falló el posprocesamiento @@ -453,6 +453,7 @@ No hay suficiente espacio disponible en el dispositivo Se perdió el progreso porque el archivo fue eliminado Tiempo de espera excedido + No se puede recuperar esta descarga Preguntar dónde descargar Se preguntará dónde guardar cada descarga Se le preguntará dónde guardar cada descarga. diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index baad94b5d..99dc6cc80 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -457,11 +457,9 @@ Serveriga ei saadud ühendust Server ei saada andmeid Server ei toeta mitmelõimelisi allalaadimisi. Proovi uuesti kasutades @string/msg_threads = 1 - Taotletud vahemik ei ole rahuldatav Ei leitud Järeltöötlemine nurjus Eemalda lõpetatud allalaadimised - Jätka %s pooleliolevat allalaadimist Stopp Korduskatseid Suurim katsete arv enne allalaadimise tühistamist diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 7da39393e..743c6b3fb 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -456,11 +456,9 @@ Ezin da zerbitzariarekin konektatu Zerbitzariak ez du daturik bidaltzen Zerbitzariak ez ditu hainbat hariko deskargak onartzen, saiatu @string/msg_threads = 1 erabilita - Eskatutako barrutia ezin da bete Ez aurkitua Post-prozesuak huts egin du Garbitu amaitutako deskargak - Berrekin burutzeke dauden %s transferentzia deskargetatik Gelditu Gehienezko saiakerak Deskarga ezeztatu aurretik saiatu beharreko aldi kopurua diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 147502088..2091a62fe 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -466,8 +466,6 @@ Nombre maximum de tentatives avant d’annuler le téléchargement Utilisation des onglets par défaut, erreur lors de la lecture des onglets enregistrés Le serveur n’accepte pas les téléchargements multi-fils, veuillez réessayer avec @string/msg_threads = 1 - Continuer vos %s transferts en attente depuis Téléchargement - Le domaine désiré n\'est pas disponible Afficher les commentaires Désactiver pour ne pas afficher les commentaires Lecture automatique diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index b5a0778d4..565f815a1 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -461,11 +461,9 @@ לא ניתן להתחבר לשרת השרת לא שולח נתונים "השרת לא מקבל הורדות רב ערוציות, מוטב לנסות שוב עם ‎@string/msg_threads = 1 " - הטווח המבוקש לא מתאים לא נמצא העיבוד המאוחר נכשל פינוי ההורדות שהסתיימו - ניתן להמשיך את %s ההורדות הממתינות שלך דרך ההורדות עצירה מספר הניסיונות החוזרים המרבי מספר הניסיונות החוזרים המרבי בטרם ביטול ההורדה diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index e85d5810e..a981dcf5e 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -454,11 +454,9 @@ Nije moguće povezati se s serverom Server ne šalje podatke Poslužitelj ne prihvaća preuzimanja s više niti, pokušaj ponovo s @string/msg_threads = 1 - Traženi raspon nije zadovoljavajući Nije pronađeno Naknadna obrada nije uspjela Obriši završena preuzimanja - Nastavite s prijenosima na čekanju za %s s preuzimanja Stop Maksimalnih ponovnih pokušaja Maksimalni broj pokušaja prije poništavanja preuzimanja diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index db738d749..5fbdcffc1 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -450,11 +450,9 @@ Tidak dapat terhubung ke server Server tidak mengirim data Server tidak menerima unduhan multi-utas, coba lagi dengan @string/msg_threads = 1 - Rentang yang diminta tidak memuaskan Tidak ditemukan Pengolahan-pasca gagal Hapus unduhan yang sudah selesai - Lanjutkan %s transfer anda yang tertunda dari Unduhan Berhenti Percobaan maksimum Jumlah upaya maksimum sebelum membatalkan unduhan diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 35fdebeda..73633ab03 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -454,11 +454,9 @@ Impossibile connettersi al server Il server non invia dati Il server non accetta download multipli, riprovare con @string/msg_threads = 1 - Intervallo richiesto non soddisfatto Non trovato Post-processing fallito Pulisci i download completati - Continua i %s trasferimenti in corso dai Download Ferma Tentativi massimi Tentativi massimi prima di cancellare il download diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index b67da798c..4c3aeb5c1 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -440,7 +440,6 @@ サーバに接続できません サーバがデータを送信していません サーバが同時接続ダウンロードを受け付けません。再試行してください @string/msg_threads = 1 - 必要な範囲が満たされていません 見つかりません 保存処理に失敗しました 完了済みを一覧から削除します @@ -457,7 +456,6 @@ デフォルトのタブを使用します。保存されたタブの読み込みエラーが発生しました メインページに表示されるタブ 新しいバージョンが利用可能なときにアプリの更新を確認する通知を表示します - ダウンロードから %s の保留中の転送を続行します 従量制課金ネットワークの割り込み モバイルデータ通信に切り替える場合に便利ですが、一部のダウンロードは一時停止できません コメントを表示 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 333891910..39b08347c 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -451,11 +451,9 @@ 서버에 접속할 수 없습니다 서버가 데이터를 전송하지 않고 있습니다 서버가 다중 스레드 다운로드를 받아들이지 않습니다, @string/msg_threads = 1 를 사용해 다시 시도해보세요 - 요청된 HTTP 범위가 충분하지 않습니다 HTTP 찾을 수 없습니다 후처리 작업이 실패하였습니다 완료된 다운로드 비우기 - 대기중인 %s 다운로드를 지속하세요 멈추기 최대 재시도 횟수 다운로드를 취소하기 전까지 다시 시도할 최대 횟수 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index c7fa5de92..354e7b7de 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -450,11 +450,9 @@ Tidak dapat menyambung ke server Server tidak menghantar data Server tidak menerima muat turun berbilang thread, cuba lagi dengan @string/msg_threads = 1 - Julat yang diminta tidak memuaskan Tidak ditemui Pemprosesan-pasca gagal Hapuskan senarai muat turun yang selesai - Teruskan %s pemindahan anda yang menunggu dari muat turun Berhenti Percubaan maksimum Jumlah percubaan maksimum sebelum membatalkan muat turun diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index d26886844..e0a08d0a7 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -458,7 +458,6 @@ Ikke funnet Etterbehandling mislyktes Tøm fullførte nedlastinger - Fortsett dine %s ventende overføringer fra Nedlastinger Stopp Maksimalt antall forsøk Maksimalt antall tilkoblingsforsøk før nedlastingen avblåses @@ -496,7 +495,7 @@ Sett nedlastinger på pause Spør om hvor ting skal lastes ned til Du vil bli spurt om hvor hver nedlasting skal plasseres - Du vil bli spurt om hvor hver nedlasting skal plasseres. + Du vil bli spurt om hvor hver nedlasting skal plasseres. \nSkru på SAF hvis du vil laste ned til eksternt SD-kort Bruk SAF Lagringstilgangsrammeverk (SAF) tillater nedlastinger til eksternt SD-kort. diff --git a/app/src/main/res/values-nl-rBE/strings.xml b/app/src/main/res/values-nl-rBE/strings.xml index 94feb4915..5c42bfd23 100644 --- a/app/src/main/res/values-nl-rBE/strings.xml +++ b/app/src/main/res/values-nl-rBE/strings.xml @@ -454,11 +454,9 @@ Kan geen verbinding maken met de server De server verzendt geen gegevens De server aanvaardt geen meerdradige downloads, probeert het opnieuw met @string/msg_threads = 1 - Gevraagd bereik niet beschikbaar Niet gevonden Nabewerking mislukt Voltooide downloads wissen - Zet uw %s wachtende downloads verder via Downloads Stoppen Maximaal aantal pogingen Maximaal aantal pogingen vooraleer dat den download wordt geannuleerd diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index f7acba6ae..b9b86a292 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -454,11 +454,9 @@ Kan niet met de server verbinden De server verzendt geen gegevens De server accepteert geen multi-threaded downloads, probeer het opnieuw met @string/msg_threads = 1 - Gevraagde bereik niet beschikbaar Niet gevonden Nabewerking mislukt Voltooide downloads wissen - Zet je %s wachtende downloads voort via Downloads Stop Maximum aantal keer proberen Maximum aantal pogingen voordat de download wordt geannuleerd diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index c31eb805d..0e579720a 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -450,11 +450,9 @@ ਸਰਵਰ ਨਾਲ ਜੁੜ ਨਹੀਂ ਸਕਦਾ ਸਰਵਰ ਨੇ ਡਾਟਾ ਨਹੀਂ ਭੇਜਿਆ ਸਰਵਰ ਮਲਟੀ-Threaded ਡਾਊਨਲੋਡਸ ਨੂੰ ਸਵੀਕਾਰ ਨਹੀਂ ਕਰਦਾ, ਇਸ ਨਾਲ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ @string/msg_threads = 1 - ਬੇਨਤੀ ਕੀਤੀ ਸੀਮਾ ਤਸੱਲੀਬਖਸ਼ ਨਹੀਂ ਹੈ ਨਹੀਂ ਲਭਿਆ Post-processing ਫੇਲ੍ਹ ਮੁਕੰਮਲ ਹੋਈਆਂ ਡਾਊਨਲੋਡ ਸਾਫ਼ ਕਰੋ - ਡਾਉਨਲੋਡਸ ਤੋਂ ਆਪਣੀਆਂ %s ਬਕਾਇਆ ਟ੍ਰਾਂਸਫਰ ਜਾਰੀ ਰੱਖੋ ਰੁੱਕੋ ਵੱਧ ਤੋਂ ਵੱਧ ਕੋਸ਼ਿਸ਼ਾਂ ਡਾਉਨਲੋਡ ਰੱਦ ਕਰਨ ਤੋਂ ਪਹਿਲਾਂ ਵੱਧ ਤੋਂ ਵੱਧ ਕੋਸ਼ਿਸ਼ਾਂ diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index d3c84aa22..b7086b34f 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -456,11 +456,9 @@ Nie można połączyć się z serwerem Serwer nie wysyła danych Serwer nie akceptuje pobierania wielowątkowego, spróbuj ponownie za pomocą @string/msg_threads = 1 - Niewłaściwy zakres Nie znaleziono Przetwarzanie końcowe nie powiodło się Wyczyść ukończone pobieranie - Kontynuuj %s oczekujące transfery z plików do pobrania Zatrzymaj Maksymalna liczba powtórzeń Maksymalna liczba prób przed anulowaniem pobierania diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index aaac4fd4c..5de1e6610 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -463,11 +463,9 @@ abrir em modo popup Não foi possível conectar ao servidor O servidor não envia dados O servidor não aceita downloads em multi-thread, tente com @string/msg_threads = 1 - Intervalo solicitado não aceito Não encontrado Falha no pós processamento Limpar downloads finalizados - Continuar seus %s downloads pendentes Parar Tentativas Máximas Número máximo de tentativas antes de cancelar o download diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 5d7cd8146..88fbb72a6 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -452,11 +452,9 @@ Não é possível ligar ao servidor O servidor não envia dados O servidor não aceita transferências de vários processos, tente novamente com @string/msg_threads = 1 - Intervalo solicitado não satisfatório Não encontrado Pós-processamento falhado Limpar transferências concluídas - Continue as suas %s transferências pendentes das Transferências Parar Tentativas máximas Número máximo de tentativas antes de cancelar a transferência diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 6f079a221..80b587657 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -454,7 +454,6 @@ Доступ запрещён системой Сервер не найден Сервер не принимает многопоточные загрузки, повторная попытка с @string/msg_threads = 1 - Запрашиваемый диапазон недопустим Не найдено Очистить завершённые Остановить @@ -465,7 +464,6 @@ Загрузка завершена %s загрузок завершено Создать уникальное имя - Возобновить приостановленные загрузки (%s) Максимум попыток Количество попыток перед отменой загрузки Некоторые загрузки не поддерживают докачку и начнутся с начала diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 09502f60a..cbc201fd5 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -462,11 +462,9 @@ Nepodarilo sa pripojiť k serveru Server neposiela údaje Server neakceptuje preberanie viacerých vlákien, zopakujte s @string/msg_threads = 1 - Požadovaný rozsah nie je uspokojivý Nenájdené Post-spracovanie zlyhalo Vyčistiť dokončené sťahovania - Pokračujte v preberaní %s zo súborov na prevzatie Stop Maximum opakovaní Maximálny počet pokusov pred zrušením stiahnutia diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index c17b58f50..1cb6fafd4 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -449,11 +449,9 @@ Sunucuya bağlanılamıyor Sunucu veri göndermiyor Sunucu, çok iş parçacıklı indirmeleri kabul etmez, @string/msg_threads = 1 ile yeniden deneyin - İstenen aralık karşılanamıyor Bulunamadı İşlem sonrası başarısız Tamamlanan indirmeleri temizle - Beklemedeki %s transferinize İndirmeler\'den devam edin Durdur Azami deneme sayısı İndirmeyi iptal etmeden önce maksimum deneme sayısı diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 375557b04..d43b8be66 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -471,8 +471,6 @@ Помилка зчитування збережених вкладок. Використовую типові вкладки. Вкладки, що відображаються на головній сторінці Показувати сповіщення з пропозицією оновити застосунок за наявності нової версії - Запитуваний діапазон неприпустимий - Продовжити ваші %s відкладених переміщень із Завантажень Корисно під час переходу на мобільні дані, хоча деякі завантаження не можуть бути призупинені Показувати коментарі Вимнути відображення дописів diff --git a/app/src/main/res/values-v21/styles_services.xml b/app/src/main/res/values-v21/styles_services.xml index 6c118bc09..176bc1f51 100644 --- a/app/src/main/res/values-v21/styles_services.xml +++ b/app/src/main/res/values-v21/styles_services.xml @@ -31,6 +31,25 @@ @color/dark_soundcloud_accent_color + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 74b8b395c..ab0983e7a 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -449,11 +449,9 @@ Không thế kết nối với máy chủ Máy chủ không gửi dữ liệu về Máy chủ không chấp nhận tải đa luồng, thử lại với số luồng = 1 - (HTTP) Không thể đáp ứng khoảng dữ liệu đã yêu cầu Không tìm thấy Xử lý thất bại Dọn các tải về đã hoàn thành - Hãy tiếp tục %s tải về đang chờ Dừng Số lượt thử lại tối đa Số lượt thử lại trước khi hủy tải về diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index fe4c1b00a..98b9cf381 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -447,11 +447,9 @@ 無法連線到伺服器 伺服器沒有傳送資料 伺服器不接受多執行緒下載,請以 @string/msg_threads = 1 重試 - 請求範圍無法滿足 找不到 後處理失敗 清除已結束的下載 - 繼續從您所擱置中的下載 %s 傳輸 停止 最大重試次數 在取消下載前的最大嘗試數 diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 30bf8f43f..c64ed1256 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -29,6 +29,8 @@ + + diff --git a/app/src/main/res/values/colors_services.xml b/app/src/main/res/values/colors_services.xml index ea90cb083..0126ee9ae 100644 --- a/app/src/main/res/values/colors_services.xml +++ b/app/src/main/res/values/colors_services.xml @@ -22,6 +22,17 @@ #FFFFFF #ff9100 + + #ff6f00 + #c43e00 + #000000 + #ff833a + + #ff6f00 + #c43e00 + #FFFFFF + #ff833a + #9e9e9e #616161 diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml index 80f2bb1f4..8dcc2ce31 100644 --- a/app/src/main/res/values/settings_keys.xml +++ b/app/src/main/res/values/settings_keys.xml @@ -133,6 +133,7 @@ caption_settings_key + caption_user_set_key show_search_suggestions @@ -144,6 +145,9 @@ en GB content_language + peertube_instance_setup + peertube_selected_instance + peertube_instance_list content_country show_age_restricted_content use_tor diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a34b00ea9..5e47f875c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -109,6 +109,14 @@ Default content country Service Default content language + PeerTube instances + Set your favorite peertube instances + Find the instances that best suit you on https://joinpeertube.org/instances#instances-list + Add instance + Enter instance url + Failed to validate instance + Only https urls are supported + Instance already exists Player Behavior Video & audio @@ -400,6 +408,9 @@ Trending Top 50 New & hot + Local + Recently added + Most liked Conferences %1$s/%2$s @@ -526,6 +537,7 @@ paused queued post-processing + recovering Queue Action denied by the system @@ -551,16 +563,15 @@ Can not connect to the server The server does not send data The server does not accept multi-threaded downloads, retry with @string/msg_threads = 1 - Requested range not satisfiable Not found Post-processing failed NewPipe was closed while working on the file No space left on device Progress lost, because the file was deleted Connection timeout + Cannot recover this download Clear finished downloads Are you sure? - Continue your %s pending transfers from Downloads Stop Maximum retries Maximum number of attempts before canceling the download @@ -576,4 +587,6 @@ You will be asked where to save each download.\nChoose SAF if you want to download to an external SD card Use SAF The Storage Access Framework allows downloads to an external SD card.\nNote: some devices are not compatible + Choose an instance + diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 360a00f93..ba3fe78d5 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -44,6 +44,8 @@ @drawable/ic_pause_black_24dp @drawable/ic_settings_black_24dp @drawable/ic_whatshot_black_24dp + @drawable/ic_kiosk_local_black_24dp + @drawable/ic_kiosk_recent_black_24dp @drawable/ic_channel_black_24dp @drawable/ic_bookmark_black_24dp @drawable/ic_playlist_add_black_24dp @@ -108,6 +110,8 @@ @drawable/ic_play_arrow_white_24dp @drawable/ic_settings_white_24dp @drawable/ic_whatshot_white_24dp + @drawable/ic_kiosk_local_white_24dp + @drawable/ic_kiosk_recent_white_24dp @drawable/ic_channel_white_24dp @drawable/ic_bookmark_white_24dp @drawable/ic_playlist_add_white_24dp @@ -233,4 +237,8 @@ true @null + + diff --git a/app/src/main/res/values/styles_services.xml b/app/src/main/res/values/styles_services.xml index d6ab239e4..28490d7c6 100644 --- a/app/src/main/res/values/styles_services.xml +++ b/app/src/main/res/values/styles_services.xml @@ -32,6 +32,25 @@ @drawable/progress_soundcloud_horizontal_dark + + + + + + +