diff --git a/app/src/main/java/org/schabi/newpipe/Downloader.java b/app/src/main/java/org/schabi/newpipe/Downloader.java index d9537c6b6..17dc5859d 100644 --- a/app/src/main/java/org/schabi/newpipe/Downloader.java +++ b/app/src/main/java/org/schabi/newpipe/Downloader.java @@ -73,6 +73,31 @@ public class Downloader implements org.schabi.newpipe.extractor.Downloader { mCookies = cookies; } + /** + * Get the size of the content that the url is pointing by firing a HEAD request. + * + * @param url an url pointing to the content + * @return the size of the content, in bytes + */ + public long getContentLength(String url) throws IOException { + Response response = null; + try { + final Request request = new Request.Builder() + .head().url(url) + .addHeader("User-Agent", USER_AGENT) + .build(); + response = client.newCall(request).execute(); + + return Long.parseLong(response.header("Content-Length")); + } catch (NumberFormatException e) { + throw new IOException("Invalid content length", e); + } finally { + if (response != null) { + response.close(); + } + } + } + /** * Download the text file at the supplied URL as in download(String), * but set the HTTP header field "Accept-Language" to the supplied string. diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index 8285a445e..dd067b9b6 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -22,11 +22,13 @@ package org.schabi.newpipe; import android.content.Intent; import android.content.SharedPreferences; +import android.content.pm.PackageManager; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.preference.PreferenceManager; +import android.support.annotation.NonNull; import android.support.design.widget.NavigationView; import android.support.v4.app.Fragment; import android.support.v4.view.GravityCompat; @@ -54,6 +56,7 @@ import org.schabi.newpipe.fragments.list.search.SearchFragment; import org.schabi.newpipe.report.ErrorActivity; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.StateSaver; import org.schabi.newpipe.util.ThemeHelper; @@ -94,10 +97,9 @@ public class MainActivity extends AppCompatActivity { drawerItems = findViewById(R.id.navigation); for(StreamingService s : NewPipe.getServices()) { - String title = - s.getServiceInfo().getName() + - (ServiceHelper.isBeta(s) ? " (beta)" : ""); - MenuItem item = drawerItems.getMenu() + final String title = s.getServiceInfo().getName() + + (ServiceHelper.isBeta(s) ? " (beta)" : ""); + final MenuItem item = drawerItems.getMenu() .add(R.id.menu_services_group, s.getServiceId(), 0, title); item.setIcon(ServiceHelper.getIcon(s.getServiceId())); } @@ -233,6 +235,26 @@ public class MainActivity extends AppCompatActivity { } else super.onBackPressed(); } + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + for (int i: grantResults){ + if (i == PackageManager.PERMISSION_DENIED){ + return; + } + } + switch (requestCode) { + case PermissionHelper.DOWNLOADS_REQUEST_CODE: + NavigationHelper.openDownloads(this); + break; + case PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE: + Fragment fragment = getSupportFragmentManager().findFragmentById(R.id.fragment_holder); + if (fragment instanceof VideoDetailFragment) { + ((VideoDetailFragment) fragment).openDownloadDialog(); + } + break; + } + } + /** * Implement the following diagram behavior for the up button: *
@@ -313,6 +335,9 @@ public class MainActivity extends AppCompatActivity {
             case R.id.action_about:
                 NavigationHelper.openAbout(this);
                 return true;
+            case R.id.action_history:
+                NavigationHelper.openHistory(this);
+                return true;
             default:
                 return super.onOptionsItemSelected(item);
         }
diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
index 9bcd0bcb7..75f05cd16 100644
--- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
@@ -1,56 +1,61 @@
 package org.schabi.newpipe.download;
 
+import android.content.Context;
 import android.os.Bundle;
 import android.support.annotation.IdRes;
+import android.support.annotation.NonNull;
 import android.support.annotation.Nullable;
 import android.support.v4.app.DialogFragment;
 import android.support.v7.widget.Toolbar;
 import android.util.Log;
 import android.view.LayoutInflater;
-import android.view.MenuItem;
 import android.view.View;
 import android.view.ViewGroup;
 import android.widget.AdapterView;
-import android.widget.ArrayAdapter;
 import android.widget.EditText;
 import android.widget.RadioButton;
 import android.widget.RadioGroup;
 import android.widget.SeekBar;
 import android.widget.Spinner;
 import android.widget.TextView;
+import android.widget.Toast;
 
 import org.schabi.newpipe.MainActivity;
 import org.schabi.newpipe.R;
-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.StreamInfo;
 import org.schabi.newpipe.extractor.stream.VideoStream;
-import org.schabi.newpipe.fragments.detail.SpinnerToolbarAdapter;
 import org.schabi.newpipe.settings.NewPipeSettings;
 import org.schabi.newpipe.util.FilenameUtils;
 import org.schabi.newpipe.util.ListHelper;
 import org.schabi.newpipe.util.PermissionHelper;
+import org.schabi.newpipe.util.StreamItemAdapter;
+import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper;
 import org.schabi.newpipe.util.ThemeHelper;
 
-import java.io.Serializable;
 import java.util.ArrayList;
 import java.util.List;
 
+import icepick.Icepick;
+import icepick.State;
+import io.reactivex.disposables.CompositeDisposable;
 import us.shandian.giga.service.DownloadManagerService;
 
 public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheckedChangeListener, AdapterView.OnItemSelectedListener {
     private static final String TAG = "DialogFragment";
     private static final boolean DEBUG = MainActivity.DEBUG;
 
-    private static final String INFO_KEY = "info_key";
-    private static final String SORTED_VIDEOS_LIST_KEY = "sorted_videos_list_key";
-    private static final String SELECTED_VIDEO_KEY = "selected_video_key";
-    private static final String SELECTED_AUDIO_KEY = "selected_audio_key";
+    @State protected StreamInfo currentInfo;
+    @State protected StreamSizeWrapper wrappedAudioStreams = StreamSizeWrapper.empty();
+    @State protected StreamSizeWrapper wrappedVideoStreams = StreamSizeWrapper.empty();
+    @State protected int selectedVideoIndex = 0;
+    @State protected int selectedAudioIndex = 0;
 
-    private StreamInfo currentInfo;
-    private ArrayList sortedStreamVideosList;
-    private int selectedVideoIndex;
-    private int selectedAudioIndex;
+    private StreamItemAdapter audioStreamsAdapter;
+    private StreamItemAdapter videoStreamsAdapter;
+
+    private CompositeDisposable disposables = new CompositeDisposable();
 
     private EditText nameEditText;
     private Spinner streamsSpinner;
@@ -58,17 +63,50 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck
     private TextView threadsCountTextView;
     private SeekBar threadsSeekBar;
 
-    public static DownloadDialog newInstance(StreamInfo info, ArrayList sortedStreamVideosList, int selectedVideoIndex) {
+    public static DownloadDialog newInstance(StreamInfo info) {
         DownloadDialog dialog = new DownloadDialog();
-        dialog.setInfo(info, sortedStreamVideosList, selectedVideoIndex);
-        dialog.setStyle(DialogFragment.STYLE_NO_TITLE, 0);
+        dialog.setInfo(info);
         return dialog;
     }
 
-    private void setInfo(StreamInfo info, ArrayList sortedStreamVideosList, int selectedVideoIndex) {
+    public static DownloadDialog newInstance(Context context, StreamInfo info) {
+        final ArrayList streamsList = new ArrayList<>(ListHelper.getSortedStreamVideosList(context,
+                info.getVideoStreams(), info.getVideoOnlyStreams(), false));
+        final int selectedStreamIndex = ListHelper.getDefaultResolutionIndex(context, streamsList);
+
+        final DownloadDialog instance = newInstance(info);
+        instance.setVideoStreams(streamsList);
+        instance.setSelectedVideoStream(selectedStreamIndex);
+        instance.setAudioStreams(info.getAudioStreams());
+        return instance;
+    }
+
+    private void setInfo(StreamInfo info) {
         this.currentInfo = info;
+    }
+
+    public void setAudioStreams(List audioStreams) {
+        setAudioStreams(new StreamSizeWrapper<>(audioStreams));
+    }
+
+    public void setAudioStreams(StreamSizeWrapper wrappedAudioStreams) {
+        this.wrappedAudioStreams = wrappedAudioStreams;
+    }
+
+    public void setVideoStreams(List videoStreams) {
+        setVideoStreams(new StreamSizeWrapper<>(videoStreams));
+    }
+
+    public void setVideoStreams(StreamSizeWrapper wrappedVideoStreams) {
+        this.wrappedVideoStreams = wrappedVideoStreams;
+    }
+
+    public void setSelectedVideoStream(int selectedVideoIndex) {
         this.selectedVideoIndex = selectedVideoIndex;
-        this.sortedStreamVideosList = sortedStreamVideosList;
+    }
+
+    public void setSelectedAudioStream(int selectedAudioIndex) {
+        this.selectedAudioIndex = selectedAudioIndex;
     }
 
     /*//////////////////////////////////////////////////////////////////////////
@@ -79,33 +117,26 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck
     public void onCreate(@Nullable Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
         if (DEBUG) Log.d(TAG, "onCreate() called with: savedInstanceState = [" + savedInstanceState + "]");
-        if (!PermissionHelper.checkStoragePermissions(getActivity())) {
+        if (!PermissionHelper.checkStoragePermissions(getActivity(), PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) {
             getDialog().dismiss();
             return;
         }
 
-        if (savedInstanceState != null) {
-            Serializable serial = savedInstanceState.getSerializable(INFO_KEY);
-            if (serial instanceof StreamInfo) currentInfo = (StreamInfo) serial;
+        setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(getContext()));
+        Icepick.restoreInstanceState(this, savedInstanceState);
 
-            serial = savedInstanceState.getSerializable(SORTED_VIDEOS_LIST_KEY);
-            if (serial instanceof ArrayList) { //noinspection unchecked
-                sortedStreamVideosList = (ArrayList) serial;
-            }
-
-            selectedVideoIndex = savedInstanceState.getInt(SELECTED_VIDEO_KEY, 0);
-            selectedAudioIndex = savedInstanceState.getInt(SELECTED_AUDIO_KEY, 0);
-        }
+        this.videoStreamsAdapter = new StreamItemAdapter<>(getContext(), wrappedVideoStreams, true);
+        this.audioStreamsAdapter = new StreamItemAdapter<>(getContext(), wrappedAudioStreams);
     }
 
     @Override
-    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+    public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
         if (DEBUG) Log.d(TAG, "onCreateView() called with: inflater = [" + inflater + "], container = [" + container + "], savedInstanceState = [" + savedInstanceState + "]");
-        return inflater.inflate(R.layout.dialog_url, container);
+        return inflater.inflate(R.layout.download_dialog, container);
     }
 
     @Override
-    public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
+    public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
         super.onViewCreated(view, savedInstanceState);
         nameEditText = view.findViewById(R.id.file_name);
         nameEditText.setText(FilenameUtils.createFilename(getContext(), currentInfo.getName()));
@@ -116,12 +147,12 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck
 
         threadsCountTextView = view.findViewById(R.id.threads_count);
         threadsSeekBar = view.findViewById(R.id.threads);
+
         radioVideoAudioGroup = view.findViewById(R.id.video_audio_group);
         radioVideoAudioGroup.setOnCheckedChangeListener(this);
 
-        initToolbar(view.findViewById(R.id.toolbar));
-        checkDownloadOptions(view);
-        setupVideoSpinner(sortedStreamVideosList, streamsSpinner);
+        initToolbar(view.findViewById(R.id.toolbar));
+        setupDownloadOptions();
 
         int def = 3;
         threadsCountTextView.setText(String.valueOf(def));
@@ -141,15 +172,35 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck
             public void onStopTrackingTouch(SeekBar p1) {
             }
         });
+
+        fetchStreamsSize();
+    }
+
+    private void fetchStreamsSize() {
+        disposables.clear();
+
+        disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedVideoStreams).subscribe(result -> {
+            if (radioVideoAudioGroup.getCheckedRadioButtonId() == R.id.video_button) {
+                setupVideoSpinner();
+            }
+        }));
+        disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedAudioStreams).subscribe(result -> {
+            if (radioVideoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) {
+                setupAudioSpinner();
+            }
+        }));
+    }
+
+    @Override
+    public void onDestroy() {
+        super.onDestroy();
+        disposables.clear();
     }
 
     @Override
     public void onSaveInstanceState(Bundle outState) {
         super.onSaveInstanceState(outState);
-        outState.putSerializable(INFO_KEY, currentInfo);
-        outState.putSerializable(SORTED_VIDEOS_LIST_KEY, sortedStreamVideosList);
-        outState.putInt(SELECTED_VIDEO_KEY, selectedVideoIndex);
-        outState.putInt(SELECTED_AUDIO_KEY, selectedAudioIndex);
+        Icepick.saveInstanceState(this, outState);
     }
 
     /*//////////////////////////////////////////////////////////////////////////
@@ -161,39 +212,31 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck
         toolbar.setTitle(R.string.download_dialog_title);
         toolbar.setNavigationIcon(ThemeHelper.isLightThemeSelected(getActivity()) ? R.drawable.ic_arrow_back_black_24dp : R.drawable.ic_arrow_back_white_24dp);
         toolbar.inflateMenu(R.menu.dialog_url);
-        toolbar.setNavigationOnClickListener(new View.OnClickListener() {
-            @Override
-            public void onClick(View v) {
-                getDialog().dismiss();
-            }
-        });
+        toolbar.setNavigationOnClickListener(v -> getDialog().dismiss());
 
-        toolbar.setOnMenuItemClickListener(new Toolbar.OnMenuItemClickListener() {
-            @Override
-            public boolean onMenuItemClick(MenuItem item) {
-                if (item.getItemId() == R.id.okay) {
-                    downloadSelected();
-                    return true;
-                } else return false;
+        toolbar.setOnMenuItemClickListener(item -> {
+            if (item.getItemId() == R.id.okay) {
+                downloadSelected();
+                return true;
             }
+            return false;
         });
     }
 
-    public void setupAudioSpinner(final List audioStreams, Spinner spinner) {
-        String[] items = new String[audioStreams.size()];
-        for (int i = 0; i < audioStreams.size(); i++) {
-            AudioStream audioStream = audioStreams.get(i);
-            items[i] = audioStream.getFormat().getName() + " " + audioStream.getAverageBitrate() + "kbps";
-        }
+    private void setupAudioSpinner() {
+        if (getContext() == null) return;
 
-        ArrayAdapter itemAdapter = new ArrayAdapter<>(getContext(), android.R.layout.simple_spinner_dropdown_item, items);
-        spinner.setAdapter(itemAdapter);
-        spinner.setSelection(selectedAudioIndex);
+        streamsSpinner.setAdapter(audioStreamsAdapter);
+        streamsSpinner.setSelection(selectedAudioIndex);
+        setRadioButtonsState(true);
     }
 
-    public void setupVideoSpinner(final List videoStreams, Spinner spinner) {
-        spinner.setAdapter(new SpinnerToolbarAdapter(getContext(), videoStreams, true));
-        spinner.setSelection(selectedVideoIndex);
+    private void setupVideoSpinner() {
+        if (getContext() == null) return;
+
+        streamsSpinner.setAdapter(videoStreamsAdapter);
+        streamsSpinner.setSelection(selectedVideoIndex);
+        setRadioButtonsState(true);
     }
 
     /*//////////////////////////////////////////////////////////////////////////
@@ -205,10 +248,10 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck
         if (DEBUG) Log.d(TAG, "onCheckedChanged() called with: group = [" + group + "], checkedId = [" + checkedId + "]");
         switch (checkedId) {
             case R.id.audio_button:
-                setupAudioSpinner(currentInfo.getAudioStreams(), streamsSpinner);
+                setupAudioSpinner();
                 break;
             case R.id.video_button:
-                setupVideoSpinner(sortedStreamVideosList, streamsSpinner);
+                setupVideoSpinner();
                 break;
         }
     }
@@ -238,37 +281,53 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheck
     // Utils
     //////////////////////////////////////////////////////////////////////////*/
 
-    protected void checkDownloadOptions(View view) {
-        RadioButton audioButton = view.findViewById(R.id.audio_button);
-        RadioButton videoButton = view.findViewById(R.id.video_button);
+    protected void setupDownloadOptions() {
+        setRadioButtonsState(false);
 
-        if (currentInfo.getAudioStreams() == null || currentInfo.getAudioStreams().size() == 0) {
-            audioButton.setVisibility(View.GONE);
+        final RadioButton audioButton = radioVideoAudioGroup.findViewById(R.id.audio_button);
+        final RadioButton videoButton = radioVideoAudioGroup.findViewById(R.id.video_button);
+        final boolean isVideoStreamsAvailable = videoStreamsAdapter.getCount() > 0;
+        final boolean isAudioStreamsAvailable = audioStreamsAdapter.getCount() > 0;
+
+        audioButton.setVisibility(isAudioStreamsAvailable ? View.VISIBLE : View.GONE);
+        videoButton.setVisibility(isVideoStreamsAvailable ? View.VISIBLE : View.GONE);
+
+        if (isVideoStreamsAvailable) {
             videoButton.setChecked(true);
-        } else if (sortedStreamVideosList == null || sortedStreamVideosList.size() == 0) {
-            videoButton.setVisibility(View.GONE);
+            setupVideoSpinner();
+        } else if (isAudioStreamsAvailable) {
             audioButton.setChecked(true);
+            setupAudioSpinner();
+        } else {
+            Toast.makeText(getContext(), R.string.no_streams_available_download, Toast.LENGTH_SHORT).show();
+            getDialog().dismiss();
         }
     }
 
+    private void setRadioButtonsState(boolean enabled) {
+        radioVideoAudioGroup.findViewById(R.id.audio_button).setEnabled(enabled);
+        radioVideoAudioGroup.findViewById(R.id.video_button).setEnabled(enabled);
+    }
 
     private void downloadSelected() {
-        String url, location;
+        Stream stream;
+        String location;
 
         String fileName = nameEditText.getText().toString().trim();
         if (fileName.isEmpty()) fileName = FilenameUtils.createFilename(getContext(), currentInfo.getName());
 
         boolean isAudio = radioVideoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button;
         if (isAudio) {
-            url = currentInfo.getAudioStreams().get(selectedAudioIndex).getUrl();
+            stream = audioStreamsAdapter.getItem(selectedAudioIndex);
             location = NewPipeSettings.getAudioDownloadPath(getContext());
-            fileName += "." + currentInfo.getAudioStreams().get(selectedAudioIndex).getFormat().getSuffix();
         } else {
-            url = sortedStreamVideosList.get(selectedVideoIndex).getUrl();
+            stream = videoStreamsAdapter.getItem(selectedVideoIndex);
             location = NewPipeSettings.getVideoDownloadPath(getContext());
-            fileName += "." + sortedStreamVideosList.get(selectedVideoIndex).getFormat().getSuffix();
         }
 
+        String url = stream.getUrl();
+        fileName += "." + stream.getFormat().getSuffix();
+
         DownloadManagerService.startMission(getContext(), url, location, fileName, isAudio, threadsSeekBar.getProgress() + 1);
         getDialog().dismiss();
     }
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/SpinnerToolbarAdapter.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/SpinnerToolbarAdapter.java
deleted file mode 100644
index 33f87be70..000000000
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/SpinnerToolbarAdapter.java
+++ /dev/null
@@ -1,74 +0,0 @@
-package org.schabi.newpipe.fragments.detail;
-
-import android.content.Context;
-import android.view.LayoutInflater;
-import android.view.View;
-import android.view.ViewGroup;
-import android.widget.BaseAdapter;
-import android.widget.ImageView;
-import android.widget.Spinner;
-import android.widget.TextView;
-
-import org.schabi.newpipe.R;
-import org.schabi.newpipe.extractor.MediaFormat;
-import org.schabi.newpipe.extractor.stream.VideoStream;
-
-import java.util.List;
-
-public class SpinnerToolbarAdapter extends BaseAdapter {
-    private final List videoStreams;
-    private final boolean showIconNoAudio;
-
-    private final Context context;
-
-    public SpinnerToolbarAdapter(Context context, List videoStreams, boolean showIconNoAudio) {
-        this.context = context;
-        this.videoStreams = videoStreams;
-        this.showIconNoAudio = showIconNoAudio;
-    }
-
-    @Override
-    public int getCount() {
-        return videoStreams.size();
-    }
-
-    @Override
-    public Object getItem(int position) {
-        return videoStreams.get(position);
-    }
-
-    @Override
-    public long getItemId(int position) {
-        return position;
-    }
-
-    @Override
-    public View getDropDownView(int position, View convertView, ViewGroup parent) {
-        return getCustomView(position, convertView, parent, true);
-    }
-
-    @Override
-    public View getView(int position, View convertView, ViewGroup parent) {
-        return getCustomView(((Spinner) parent).getSelectedItemPosition(), convertView, parent, false);
-    }
-
-    private View getCustomView(int position, View convertView, ViewGroup parent, boolean isDropdownItem) {
-        if (convertView == null) {
-            convertView = LayoutInflater.from(context).inflate(R.layout.resolutions_spinner_item, parent, false);
-        }
-
-        ImageView woSoundIcon = convertView.findViewById(R.id.wo_sound_icon);
-        TextView text = convertView.findViewById(android.R.id.text1);
-        VideoStream item = (VideoStream) getItem(position);
-        text.setText(item.getFormat().getName() + " " + item.getResolution());
-
-        int visibility = !showIconNoAudio ? View.GONE
-                : item.isVideoOnly ? View.VISIBLE
-                : isDropdownItem ? View.INVISIBLE
-                : View.GONE;
-        woSoundIcon.setVisibility(visibility);
-
-        return convertView;
-    }
-
-}
\ No newline at end of file
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
index 611cd8bfb..e3b826feb 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
@@ -12,6 +12,7 @@ import android.preference.PreferenceManager;
 import android.support.annotation.DrawableRes;
 import android.support.annotation.FloatRange;
 import android.support.annotation.NonNull;
+import android.support.annotation.Nullable;
 import android.support.v4.content.ContextCompat;
 import android.support.v4.view.animation.FastOutSlowInInterpolator;
 import android.support.v7.app.ActionBar;
@@ -61,6 +62,8 @@ import org.schabi.newpipe.extractor.stream.StreamType;
 import org.schabi.newpipe.extractor.stream.VideoStream;
 import org.schabi.newpipe.fragments.BackPressable;
 import org.schabi.newpipe.fragments.BaseStateFragment;
+import org.schabi.newpipe.util.StreamItemAdapter;
+import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper;
 import org.schabi.newpipe.fragments.local.dialog.PlaylistAppendDialog;
 import org.schabi.newpipe.info_list.InfoItemBuilder;
 import org.schabi.newpipe.info_list.InfoItemDialog;
@@ -83,9 +86,9 @@ import org.schabi.newpipe.util.PermissionHelper;
 import org.schabi.newpipe.util.ThemeHelper;
 
 import java.io.Serializable;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.LinkedList;
+import java.util.List;
 
 import icepick.State;
 import io.reactivex.Single;
@@ -107,8 +110,6 @@ public class VideoDetailFragment
     // Amount of videos to show on start
     private static final int INITIAL_RELATED_VIDEOS = 8;
 
-    private ArrayList sortedStreamVideosList;
-
     private InfoItemBuilder infoItemBuilder = null;
 
     private int updateFlags = 0;
@@ -120,18 +121,16 @@ public class VideoDetailFragment
     private boolean showRelatedStreams;
     private boolean wasRelatedStreamsExpanded = false;
 
-    @State
-    protected int serviceId = Constants.NO_SERVICE_ID;
-    @State
-    protected String name;
-    @State
-    protected String url;
+    @State protected int serviceId = Constants.NO_SERVICE_ID;
+    @State protected String name;
+    @State protected String url;
 
     private StreamInfo currentInfo;
     private Disposable currentWorker;
     private CompositeDisposable disposables = new CompositeDisposable();
 
-    private int selectedVideoStream = -1;
+    private List sortedVideoStreams;
+    private int selectedVideoStreamIndex = -1;
 
     /*//////////////////////////////////////////////////////////////////////////
     // Views
@@ -355,21 +354,8 @@ public class VideoDetailFragment
                 }
                 break;
             case R.id.detail_controls_download:
-                if (!PermissionHelper.checkStoragePermissions(activity)) {
-                    return;
-                }
-
-                try {
-                    DownloadDialog downloadDialog =
-                            DownloadDialog.newInstance(currentInfo,
-                                    sortedStreamVideosList,
-                                    selectedVideoStream);
-                    downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog");
-                } catch (Exception e) {
-                    Toast.makeText(activity,
-                            R.string.could_not_setup_download_menu,
-                            Toast.LENGTH_LONG).show();
-                    e.printStackTrace();
+                if (PermissionHelper.checkStoragePermissions(activity, PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) {
+                    this.openDownloadDialog();
                 }
                 break;
             case R.id.detail_uploader_root_layout:
@@ -411,6 +397,9 @@ public class VideoDetailFragment
             case R.id.detail_controls_popup:
                 openPopupPlayer(true);
                 break;
+            case R.id.detail_controls_download:
+                NavigationHelper.openDownloads(getActivity());
+                break;
         }
 
         return true;
@@ -532,6 +521,7 @@ public class VideoDetailFragment
         detailControlsPopup.setOnClickListener(this);
         detailControlsAddToPlaylist.setOnClickListener(this);
         detailControlsDownload.setOnClickListener(this);
+        detailControlsDownload.setOnLongClickListener(this);
         relatedStreamExpandButton.setOnClickListener(this);
 
         detailControlsBackground.setLongClickable(true);
@@ -721,27 +711,25 @@ public class VideoDetailFragment
 
     private void setupActionBar(final StreamInfo info) {
         if (DEBUG) Log.d(TAG, "setupActionBarHandler() called with: info = [" + info + "]");
-        sortedStreamVideosList = new ArrayList<>(ListHelper.getSortedStreamVideosList(
-                activity, info.getVideoStreams(), info.getVideoOnlyStreams(), false));
-
-        selectedVideoStream = ListHelper.getDefaultResolutionIndex(activity, sortedStreamVideosList);
-
         boolean isExternalPlayerEnabled = PreferenceManager.getDefaultSharedPreferences(activity)
                 .getBoolean(activity.getString(R.string.use_external_video_player_key), false);
-        spinnerToolbar.setAdapter(new SpinnerToolbarAdapter(activity, sortedStreamVideosList,
-                isExternalPlayerEnabled));
-        spinnerToolbar.setSelection(selectedVideoStream);
+
+        sortedVideoStreams = ListHelper.getSortedStreamVideosList(activity, info.getVideoStreams(), info.getVideoOnlyStreams(), false);
+        selectedVideoStreamIndex = ListHelper.getDefaultResolutionIndex(activity, sortedVideoStreams);
+
+        final StreamItemAdapter streamsAdapter = new StreamItemAdapter<>(activity, new StreamSizeWrapper<>(sortedVideoStreams), isExternalPlayerEnabled);
+        spinnerToolbar.setAdapter(streamsAdapter);
+        spinnerToolbar.setSelection(selectedVideoStreamIndex);
         spinnerToolbar.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
             @Override
             public void onItemSelected(AdapterView parent, View view, int position, long id) {
-                selectedVideoStream = position;
+                selectedVideoStreamIndex = position;
             }
 
             @Override
             public void onNothingSelected(AdapterView parent) {
             }
         });
-
     }
 
     /*//////////////////////////////////////////////////////////////////////////
@@ -953,8 +941,9 @@ public class VideoDetailFragment
         this.autoPlayEnabled = autoplay;
     }
 
+    @Nullable
     private VideoStream getSelectedVideoStream() {
-        return sortedStreamVideosList.get(selectedVideoStream);
+        return sortedVideoStreams != null ? sortedVideoStreams.get(selectedVideoStreamIndex) : null;
     }
 
     private void prepareDescription(final String descriptionHtml) {
@@ -1227,6 +1216,23 @@ public class VideoDetailFragment
         }
     }
 
+
+    public void openDownloadDialog() {
+            try {
+                DownloadDialog downloadDialog = DownloadDialog.newInstance(currentInfo);
+                downloadDialog.setVideoStreams(sortedVideoStreams);
+                downloadDialog.setAudioStreams(currentInfo.getAudioStreams());
+                downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex);
+
+                downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog");
+            } catch (Exception e) {
+                Toast.makeText(activity,
+                        R.string.could_not_setup_download_menu,
+                        Toast.LENGTH_LONG).show();
+                e.printStackTrace();
+            }
+    }
+
     /*//////////////////////////////////////////////////////////////////////////
     // Stream Results
     //////////////////////////////////////////////////////////////////////////*/
diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
index 3d9a3e0de..7454aaf57 100644
--- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
@@ -428,7 +428,7 @@ public class NavigationHelper {
     }
 
     public static boolean openDownloads(Activity activity) {
-        if (!PermissionHelper.checkStoragePermissions(activity)) {
+        if (!PermissionHelper.checkStoragePermissions(activity, PermissionHelper.DOWNLOADS_REQUEST_CODE)) {
             return false;
         }
         Intent intent = new Intent(activity, DownloadActivity.class);
diff --git a/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java b/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java
index a33348934..7574a9304 100644
--- a/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java
@@ -18,26 +18,26 @@ import android.widget.Toast;
 import org.schabi.newpipe.R;
 
 public class PermissionHelper {
-    public static final int PERMISSION_WRITE_STORAGE = 778;
-    public static final int PERMISSION_READ_STORAGE = 777;
+    public static final int DOWNLOAD_DIALOG_REQUEST_CODE = 778;
+    public static final int DOWNLOADS_REQUEST_CODE = 777;
 
 
-    public static boolean checkStoragePermissions(Activity activity) {
+    public static boolean checkStoragePermissions(Activity activity, int requestCode) {
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
-            if(!checkReadStoragePermissions(activity)) return false;
+            if(!checkReadStoragePermissions(activity, requestCode)) return false;
         }
-        return checkWriteStoragePermissions(activity);
+        return checkWriteStoragePermissions(activity, requestCode);
     }
 
     @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN)
-    public static boolean checkReadStoragePermissions(Activity activity) {
+    public static boolean checkReadStoragePermissions(Activity activity, int requestCode) {
         if (ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_EXTERNAL_STORAGE)
                 != PackageManager.PERMISSION_GRANTED) {
             ActivityCompat.requestPermissions(activity,
                     new String[]{
                             Manifest.permission.READ_EXTERNAL_STORAGE,
                             Manifest.permission.WRITE_EXTERNAL_STORAGE},
-                    PERMISSION_READ_STORAGE);
+                    requestCode);
 
             return false;
         }
@@ -45,7 +45,7 @@ public class PermissionHelper {
     }
 
 
-    public static boolean checkWriteStoragePermissions(Activity activity) {
+    public static boolean checkWriteStoragePermissions(Activity activity, int requestCode) {
         // Here, thisActivity is the current activity
         if (ContextCompat.checkSelfPermission(activity,
                 Manifest.permission.WRITE_EXTERNAL_STORAGE)
@@ -63,7 +63,7 @@ public class PermissionHelper {
             // No explanation needed, we can request the permission.
             ActivityCompat.requestPermissions(activity,
                     new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
-                    PERMISSION_WRITE_STORAGE);
+                    requestCode);
 
             // PERMISSION_WRITE_STORAGE is an
             // app-defined int constant. The callback method gets the
diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java
new file mode 100644
index 000000000..e3fe4a679
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java
@@ -0,0 +1,196 @@
+package org.schabi.newpipe.util;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+import android.widget.ImageView;
+import android.widget.Spinner;
+import android.widget.TextView;
+
+import org.schabi.newpipe.Downloader;
+import org.schabi.newpipe.R;
+import org.schabi.newpipe.extractor.stream.AudioStream;
+import org.schabi.newpipe.extractor.stream.Stream;
+import org.schabi.newpipe.extractor.stream.VideoStream;
+
+import java.io.Serializable;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.Callable;
+
+import io.reactivex.Single;
+import io.reactivex.android.schedulers.AndroidSchedulers;
+import io.reactivex.schedulers.Schedulers;
+import us.shandian.giga.util.Utility;
+
+/**
+ * A list adapter for a list of {@link Stream streams}, currently supporting {@link VideoStream} and {@link AudioStream}.
+ */
+public class StreamItemAdapter extends BaseAdapter {
+    private final Context context;
+
+    private StreamSizeWrapper streamsWrapper;
+    private final boolean showIconNoAudio;
+
+    public StreamItemAdapter(Context context, StreamSizeWrapper streamsWrapper, boolean showIconNoAudio) {
+        this.context = context;
+        this.streamsWrapper = streamsWrapper;
+        this.showIconNoAudio = showIconNoAudio;
+    }
+
+    public StreamItemAdapter(Context context, StreamSizeWrapper streamsWrapper) {
+        this(context, streamsWrapper, false);
+    }
+
+    public List getAll() {
+        return streamsWrapper.getStreamsList();
+    }
+
+    @Override
+    public int getCount() {
+        return streamsWrapper.getStreamsList().size();
+    }
+
+    @Override
+    public T getItem(int position) {
+        return streamsWrapper.getStreamsList().get(position);
+    }
+
+    @Override
+    public long getItemId(int position) {
+        return position;
+    }
+
+    @Override
+    public View getDropDownView(int position, View convertView, ViewGroup parent) {
+        return getCustomView(position, convertView, parent, true);
+    }
+
+    @Override
+    public View getView(int position, View convertView, ViewGroup parent) {
+        return getCustomView(((Spinner) parent).getSelectedItemPosition(), convertView, parent, false);
+    }
+
+    private View getCustomView(int position, View convertView, ViewGroup parent, boolean isDropdownItem) {
+        if (convertView == null) {
+            convertView = LayoutInflater.from(context).inflate(R.layout.stream_quality_item, parent, false);
+        }
+
+        final ImageView woSoundIconView = convertView.findViewById(R.id.wo_sound_icon);
+        final TextView formatNameView = convertView.findViewById(R.id.stream_format_name);
+        final TextView qualityView = convertView.findViewById(R.id.stream_quality);
+        final TextView sizeView = convertView.findViewById(R.id.stream_size);
+
+        final T stream = getItem(position);
+
+        int woSoundIconVisibility = View.GONE;
+        String qualityString;
+
+        if (stream instanceof VideoStream) {
+            qualityString = ((VideoStream) stream).getResolution();
+
+            if (!showIconNoAudio) {
+                woSoundIconVisibility = View.GONE;
+            } else if (((VideoStream) stream).isVideoOnly()) {
+                woSoundIconVisibility = View.VISIBLE;
+            } else if (isDropdownItem) {
+                woSoundIconVisibility = View.INVISIBLE;
+            }
+        } else if (stream instanceof AudioStream) {
+            qualityString = ((AudioStream) stream).getAverageBitrate() + "kbps";
+        } else {
+            qualityString = stream.getFormat().getSuffix();
+        }
+
+        if (streamsWrapper.getSizeInBytes(position) > 0) {
+            sizeView.setText(streamsWrapper.getFormattedSize(position));
+            sizeView.setVisibility(View.VISIBLE);
+        } else {
+            sizeView.setVisibility(View.GONE);
+        }
+
+        formatNameView.setText(stream.getFormat().getName());
+        qualityView.setText(qualityString);
+        woSoundIconView.setVisibility(woSoundIconVisibility);
+
+        return convertView;
+    }
+
+    /**
+     * A wrapper class that includes a way of storing the stream sizes.
+     */
+    public static class StreamSizeWrapper implements Serializable {
+        private static final StreamSizeWrapper EMPTY = new StreamSizeWrapper<>(Collections.emptyList());
+        private final List streamsList;
+        private long[] streamSizes;
+
+        public StreamSizeWrapper(List streamsList) {
+            this.streamsList = streamsList;
+            this.streamSizes = new long[streamsList.size()];
+
+            for (int i = 0; i < streamSizes.length; i++) streamSizes[i] = -1;
+        }
+
+        /**
+         * Helper method to fetch the sizes of all the streams in a wrapper.
+         *
+         * @param streamsWrapper the wrapper
+         * @return a {@link Single} that returns a boolean indicating if any elements were changed
+         */
+        public static  Single fetchSizeForWrapper(StreamSizeWrapper streamsWrapper) {
+            final Callable fetchAndSet = () -> {
+                boolean hasChanged = false;
+                for (X stream : streamsWrapper.getStreamsList()) {
+                    if (streamsWrapper.getSizeInBytes(stream) > 0) {
+                        continue;
+                    }
+
+                    final long contentLength = Downloader.getInstance().getContentLength(stream.getUrl());
+                    streamsWrapper.setSize(stream, contentLength);
+                    hasChanged = true;
+                }
+                return hasChanged;
+            };
+
+            return Single.fromCallable(fetchAndSet)
+                    .subscribeOn(Schedulers.io())
+                    .observeOn(AndroidSchedulers.mainThread())
+                    .onErrorReturnItem(true);
+        }
+
+        public List getStreamsList() {
+            return streamsList;
+        }
+
+        public long getSizeInBytes(int streamIndex) {
+            return streamSizes[streamIndex];
+        }
+
+        public long getSizeInBytes(T stream) {
+            return streamSizes[streamsList.indexOf(stream)];
+        }
+
+        public String getFormattedSize(int streamIndex) {
+            return Utility.formatBytes(getSizeInBytes(streamIndex));
+        }
+
+        public String getFormattedSize(T stream) {
+            return Utility.formatBytes(getSizeInBytes(stream));
+        }
+
+        public void setSize(int streamIndex, long sizeInBytes) {
+            streamSizes[streamIndex] = sizeInBytes;
+        }
+
+        public void setSize(T stream, long sizeInBytes) {
+            streamSizes[streamsList.indexOf(stream)] = sizeInBytes;
+        }
+
+        public static  StreamSizeWrapper empty() {
+            //noinspection unchecked
+            return (StreamSizeWrapper) EMPTY;
+        }
+    }
+}
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_url.xml b/app/src/main/res/layout/download_dialog.xml
similarity index 96%
rename from app/src/main/res/layout/dialog_url.xml
rename to app/src/main/res/layout/download_dialog.xml
index eef44e76b..2cdfee553 100644
--- a/app/src/main/res/layout/dialog_url.xml
+++ b/app/src/main/res/layout/download_dialog.xml
@@ -35,8 +35,8 @@
         android:layout_width="wrap_content"
         android:layout_height="wrap_content"
         android:layout_below="@+id/file_name"
-        android:layout_marginLeft="20dp"
         android:layout_marginBottom="6dp"
+        android:layout_marginLeft="20dp"
         android:gravity="left"
         android:orientation="horizontal"
         tools:ignore="RtlHardcoded">
@@ -59,12 +59,12 @@
         android:id="@+id/quality_spinner"
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
-        android:minWidth="150dp"
         android:layout_below="@+id/video_audio_group"
         android:layout_marginBottom="12dp"
         android:layout_marginLeft="20dp"
         android:layout_marginRight="20dp"
-        tools:listitem="@layout/resolutions_spinner_item"/>
+        android:minWidth="150dp"
+        tools:listitem="@layout/stream_quality_item"/>
 
     
+        android:orientation="horizontal"
+        android:paddingBottom="12dp">
 
         
-
-
-    
-
-    
-
\ No newline at end of file
diff --git a/app/src/main/res/layout/stream_quality_item.xml b/app/src/main/res/layout/stream_quality_item.xml
new file mode 100644
index 000000000..76f52a2a0
--- /dev/null
+++ b/app/src/main/res/layout/stream_quality_item.xml
@@ -0,0 +1,66 @@
+
+
+
+    
+
+    
+
+    
+
+    
+
\ No newline at end of file
diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml
index df22b47c0..b6372433e 100644
--- a/app/src/main/res/menu/main_menu.xml
+++ b/app/src/main/res/menu/main_menu.xml
@@ -1,8 +1,27 @@
 
-
+
 
-    
+    
 
+    
+
+    
+
+    
 
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 2419b8084..f22f42e95 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -180,6 +180,7 @@
     File doesn\'t exist or insufficient permission to read or write to it
     File name cannot be empty
     An error occurred: %1$s
+    No streams available to download
 
     
     Sorry, that should not have happened.
diff --git a/fastlane/metadata/android/de/full_description.txt b/fastlane/metadata/android/de/full_description.txt
new file mode 100644
index 000000000..867dc62ea
--- /dev/null
+++ b/fastlane/metadata/android/de/full_description.txt
@@ -0,0 +1 @@
+NewPipe verwendet keine Bibliotheken des Google Frameworks oder der YouTube API. Es analysiert die Webseite, um die benötigten Informationen zu erlangen. Aus diesem Grund kann die App ohne die Google Services verwendet werden. Ebenso wird kein YouTube-Konto für NewPipe benötigt und es ist FLOSS.
diff --git a/fastlane/metadata/android/de/short_description.txt b/fastlane/metadata/android/de/short_description.txt
new file mode 100644
index 000000000..201fc57e9
--- /dev/null
+++ b/fastlane/metadata/android/de/short_description.txt
@@ -0,0 +1 @@
+Eine freie, leichtgewichtige YouTube App für Android
diff --git a/fastlane/metadata/android/en-US/shot_description.txt b/fastlane/metadata/android/en-US/short_description.txt
similarity index 100%
rename from fastlane/metadata/android/en-US/shot_description.txt
rename to fastlane/metadata/android/en-US/short_description.txt